【simple-bank:1】事务锁&如何处理死锁?

数据库:postgreSQL,请结合https://github.com/shisuizhe/simple-bank食用。

上一节中,我们已经实现了简单的汇款事务,但是,未实现更新账户余额部分(store.go store_test.go),因为这很复杂,必须要仔细处理并发事务以避免死锁。

因此,在这节中,我们将实现此功能,以了解有关数据库锁以及如何处理死锁情况的更多信息。

一、测试驱动开发

今天,我将使用另一种实现方法,即测试驱动开发(TDD)。 这个想法是:我们首先编写测试以使当前代码中断。 然后,我们逐步改进代码,直到测试通过。

这是上一节中进行的测试:

store_test.go

func TestTransferTx(t *testing.T) {
	store := NewStore(testDB)

	account1 := createRandomAccount(t)
	account2 := createRandomAccount(t)

	n := 5
	amount := int64(10)

	errs := make(chan error) 				// 接收错误
	results := make(chan TransferTxResult) 	// 接收结果
    
	for i := 0; i < n; i++ {
		go func() {
			result, err := store.TransferTx(context.Background(), TransferTxParams{
				FromAccountID: account1.ID,
				ToAccountID:   account2.ID,
				Amount:        amount,
			})

			errs <- err
			results <- result
		}()
	}

	for i := 0; i < n; i++ {
		err := <-errs
		require.NoError(t, err)

		result := <-results
		require.NotEmpty(t, result)

		// check transfer
		transfer := result.Transfer
		require.NotEmpty(t, transfer)
		require.Equal(t, account1.ID, transfer.FromAccountID)
		require.Equal(t, account2.ID, transfer.ToAccountID)
		require.Equal(t, amount, transfer.Amount)
		require.NotZero(t, transfer.ID)
		require.NotZero(t, transfer.CreatedAt)

		_, err = store.GetTransfer(context.Background(), transfer.ID)
		require.NoError(t, err)

		// check entries
		fromEntry := result.FromEntry
		require.NotEmpty(t, fromEntry)
		require.Equal(t, account1.ID, fromEntry.AccountID)
		require.Equal(t, -amount, fromEntry.Amount)
		require.NotZero(t, fromEntry.ID)
		require.NotZero(t, fromEntry.CreatedAt)

		_, err = store.GetEntry(context.Background(), fromEntry.ID)
		require.NoError(t, err)

		toEntry := result.ToEntry
		require.NotEmpty(t, toEntry)
		require.Equal(t, account2.ID, toEntry.AccountID)
		require.Equal(t, amount, toEntry.Amount)
		require.NotZero(t, toEntry.ID)
		require.NotZero(t, toEntry.CreatedAt)

		_, err = store.GetEntry(context.Background(), toEntry.ID)
		require.NoError(t, err)

        // TODO: check accounts' balance
	}
}

它创建5个go例程来执行5个并发转帐事务,其中每个事务将从帐户1到帐户2转帐相同的金额。然后遍历结果列表以检查创建的转帐和输入对象。

现在完成此测试,我们需要检查输出帐户及其余额。

让我们从检测account开始。首先是fromAccount钱从哪个账户转出来。我们检查它不能为空。它ID应该等于account1.ID。类似的,toAccount钱转进哪个账户。帐户对象不能为空。它ID应该等于account2.ID

func TestTransferTx(t *testing.T) {
    ...

    // check results
    for i := 0; i < n; i++ {
        ...

        // check accounts
        fromAccount := result.FromAccount
        require.NotEmpty(t, fromAccount)
        require.Equal(t, account1.ID, fromAccount.ID)

        toAccount := result.ToAccount
        require.NotEmpty(t, toAccount)
        require.Equal(t, account2.ID, toAccount.ID)

        // TODO: check accounts' balance
    }
}

接下来,我们将检查帐户的余额。 我们计算account1.BalancefromAccount.Balance之间的差值diff1diff1是从帐户1取出的金额。

同样,我们计算toAccount.Balanceaccount2.Balance之间的差值diff2diff2是进入帐户2的金额。

func TestTransferTx(t *testing.T) {
    ...

    // check results
    for i := 0; i < n; i++ {
        ...

        // check accounts' balance
        diff1 := account1.Balance - fromAccount.Balance
        diff2 := toAccount.Balance - account2.Balance
        require.Equal(t, diff1, diff2)
        require.True(t, diff1 > 0)
        require.True(t, diff1%amount == 0) // 1 * amount, 2 * amount, 3 * amount, ..., n * amount
    }
}

如果转账可以正常工作,那么diff1diff2应该是一样的,他们应该是正数。

另外,diff1amount应该为0,原因是,帐户1的余额将在第一笔交易后减少1 * amount,然后在第二笔交易后减少2 * amount,在第三笔交易后减少3 * amount,等等依此类推。

因此,如果我们计算 k = diff1 / amount,则k必须是整数在1到之间n,其中n是已执行事务的次数。

func TestTransferTx(t *testing.T) {
    ...

    // check results
    existed := make(map[int]bool)

    for i := 0; i < n; i++ {
        ...

        // check accounts' balance
        ...

        k := int(diff1 / amount)
        require.True(t, k >= 1 && k <= n)

        require.NotContains(t, existed, k)
        existed[k] = true
    }
}

此外,每笔事务的k必须是唯一的,这意味着对于第一笔事务转账,k应该为1,对于第二笔,k应该为2,对于第三笔,k应该为3,依此类推,直到k等于n。

为了对此进行检查,我们需要声明一个新的变量,名为map[int]bool类型。 然后在循环中,检查现有map不应包含k。 然后,将existed[k]设置为true。

最后,我们应该检查2个帐户的最终更新余额。

首先,我们通过调用store.GetAccount()从数据库中获取更新余额后的帐户1。此查询不应返回错误。 我们以相同的方式检测帐户2。

func TestTransferTx(t *testing.T) {
    ...

    // check results
    existed := make(map[int]bool)
    for i := 0; i < n; i++ {
        ...
    }

    // check the final updated balance
    updatedAccount1, err := store.GetAccount(context.Background(), account1.ID)
    require.NoError(t, err)

    updatedAccount2, err := store.GetAccount(context.Background(), account2.ID)
    require.NoError(t, err)

    require.Equal(t, account1.Balance-int64(n)*amount, updatedAccount1.Balance)
    require.Equal(t, account2.Balance+int64(n)*amount, updatedAccount2.Balance)
}

当产生n此交易后,账户1的余额必须减少n*amount

最终转账测试代码请查看本节store_test.go。

因为我们的store.go中并未实现更新账户余额部分,所以我们回去完善代码。

二、更新账户余额(错误方式)

更改帐户余额的一种简单直观的方法是,首先从数据库中获取该帐户,然后从其余额中增加或减少一些金额,然后将其更新回数据库。

但是,如果没有适当的锁定机制,这通常是错误的。 请看!

首先,我们调用q.GetAccount()获取fromAccount记录并将其分配给account1变量。如果err不是nil,我们将其返回。

store.go

func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
    var result TransferTxResult

    err := store.execTx(ctx, func(q *Queries) error {
        ...

        // move money out of account1
        account1, err := q.GetAccount(ctx, arg.FromAccountID)
        if err != nil {
            return err
        }

        result.FromAccount, err = q.UpdateAccount(ctx, UpdateAccountParams{
            ID:      arg.FromAccountID,
            Balance: account1.Balance - arg.Amount,
        })
        if err != nil {
            return err
        }
    }

    return result, err
}

如果没有错误,我们调用q.UpdateAccount()来更新此帐户的余额。 该ID应该为arg.FromAccountID,余额将更改为account1.Balance-arg.Amount,因为这笔钱转出去了。更新的帐户记录将保存到result.FromAccount

同理,我们对收钱的账户做同样的处理。

func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
    var result TransferTxResult

    err := store.execTx(ctx, func(q *Queries) error {
        ...

        // move money out of account1
        ...

        // move money into account2
        account2, err := q.GetAccount(ctx, arg.ToAccountID)
        if err != nil {
            return err
        }

        result.ToAccount, err = q.UpdateAccount(ctx, UpdateAccountParams{
            ID:      arg.ToAccountID,
            Balance: account2.Balance + arg.Amount,
        })
        if err != nil {
            return err
        }
        
        return err
    }

    return result, err
}

好了,我们完成的转账的整体流程。但是,我告诉你这是不正确的,让我们运行测试,看看效果如何!我们给测试加上打印,方便我们查看转账情况。具体打印加入哪个位置,请查看测试代码。

// 转账之前总余额
fmt.Println(">> before:", account1.Balance, account2.Balance)
// 事务转账中的余额变化
fmt.Println(">> tx:", fromAccount.Balance, toAccount.Balance)
// 转账之后总余额
fmt.Println(">> after:", updatedAccount1.Balance, updatedAccount2.Balance)

运行测试后,你可能会发现测试用例通过了。但是,别高兴的太早,多测试几次看看,测试失败了!如下图:

在日志中,我们可以看到前四个事务是正确的。到了第五个转账事务,账户2收入了10块,但是账户1竟然没减。

要了解原因,让我们看一下GetAccount查询:

db/query/account.sql

-- name: GetAccount :one
SELECT * FROM accounts
WHERE id = $1 LIMIT 1;

这只是普通的SELECT,因此不会阻止其他事务读取相同的帐户记录。

所以,有2个并发的转账事务可以获得帐户1的相同余额。这也就解释了上图中的第4、5个事务同时获取到了账户1的余额304,导致两个事务执行完毕后,本该是余额是284的,却成了294。

三、无锁查询

为了演示刚才这种情况,让我们在2个不同的终端中启动postgresql,并运行2个并行事务。

可以看到,执行第4条命令,可以获得相同的帐户记录而不会被阻止。这不是我们想要的。 因此,让我们回滚这两个事务并学习如何解决。

四、带锁查询

同上,运行2个并行事务。 但是这一次,我将在SELECT语句的末尾添加FOR UPDATE子句。

一旦我们提交了事务,另一个事务就可以继续执行了。

我们可以看到第二笔转账事务立即被解除阻止,它获得了余额为200的新更新帐户。 这正是我们想要实现的目标!

五、锁定更新帐户余额

让我们回到db/query/account.sql文件,并添加一个新查询以获取更新帐户:

-- name: GetAccountForUpdate :one
SELECT * FROM accounts
WHERE id = $1 LIMIT 1
FOR UPDATE;

使用sqlc再次生成代码,现在db/sqlc/account.sql.go文件中,生成了一个GetAccountForUpdate()函数。

const getAccountForUpdate = `-- name: GetAccountForUpdate :one
SELECT id, owner, balance, currency, created_at FROM accounts
WHERE id = $1 LIMIT 1
FOR UPDATE
`

func (q *Queries) GetAccountForUpdate(ctx context.Context, id int64) (Account, error) {
    row := q.db.QueryRowContext(ctx, getAccountForUpdate, id)
    var i Account
    err := row.Scan(
        &i.ID,
        &i.Owner,
        &i.Balance,
        &i.Currency,
        &i.CreatedAt,
    )
    return i, err
}

我们可以在汇款事务db/sqlc/store.go中使用它。在这里,要获得第一个帐户,我们调用q.GetAccountForUpdate()而不是q.GetAccount()。我们做同样的事情来获得第二个帐户。

func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
    var result TransferTxResult

    err := store.execTx(ctx, func(q *Queries) error {
        ...
        
        // move money out of account1
        account1, err := q.GetAccountForUpdate(ctx, arg.FromAccountID)
		
        // move money into account2
        account2, err := q.GetAccountForUpdate(ctx, arg.ToAccountID)
    }

    return result, err
}

让我们重新运行测试。

不幸的是,还是失败了。这次错误是死锁(deadlock detected)。所以,我们能做些什么?

不用担心,我将向您展示如何调试这种死锁情况。

六、调试死锁

为了弄清楚为什么会发生死锁,我们需要打印一些日志,以查看哪个事务正在调用哪个查询以及调用的顺序。

为此,我们必须为每个事务分配一个名称,然后通过context参数将其传递给TransferTx()函数。

现在,在测试的此for循环中,我将创建一个txName变量来存储事务的名称。 我们使用fmt.Sprintf()函数和计数器 i 创建不同的名称:tx1tx2tx3,依此类推。

然后在goroutine中,我们将传递一个带有事务名称的上下文。

func TestTransferTx(t *testing.T) {
    ...

    // 运行n个并发转移事务
    for i := 0; i < n; i++ {
        txName := fmt.Sprintf("tx%d", i+1)

        go func() {
            ctx := context.WithValue(context.Background(), txKey, txName)

            result, err := store.TransferTx(ctx, TransferTxParams{
                FromAccountID: account1.ID,
                ToAccountID:   account2.ID,
                Amount:        amount,
            })

            errs <- err
            results <- result
        }()
    }

    // check results
    ...
}

要将事务名称添加到上下文中,我们调用context.withvalue(),传入context.Background()作为其父,以及一对键值key vaule,其中value是事务名称。

context.withvalue()说明中,它说上下文键不应该是string类型或任何内置类型,以避免包之间的冲突。通常我们应该为上下文键定义一个struct{}类型的变量。

因此,我要在store.go添加一个新的txKey变量,因为稍后我们将不得不使用此键从TransferTx()函数的输入上下文中获取事务名称。

var txKey = struct{}{}

func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
    ...
}
...

现在,在TransferTx()函数中,上下文将保存事务名称。我们可以通过调用ctx.Value()来从上下文中获取txKey的值。

我们有了事务名称,就可以用它来写一些日志。让我们打印出这个事务名称和第一个操作create transfer。然后对其余的操作进行相同的操作:

func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
    var result TransferTxResult

    err := store.execTx(ctx, func(q *Queries) error {
        var err error

        txName := ctx.Value(txKey)

        fmt.Println(txName, "create transfer")
        result.Transfer, err = q.CreateTransfer(ctx, CreateTransferParams{
            FromAccountID: arg.FromAccountID,
            ToAccountID:   arg.ToAccountID,
            Amount:        arg.Amount,
        })
        if err != nil {
            return err
        }

        fmt.Println(txName, "create entry 1")
        result.FromEntry, err = q.CreateEntry(ctx, CreateEntryParams{
            AccountID: arg.FromAccountID,
            Amount:    -arg.Amount,
        })
        if err != nil {
            return err
        }

        fmt.Println(txName, "create entry 2")
        result.ToEntry, err = q.CreateEntry(ctx, CreateEntryParams{
            AccountID: arg.ToAccountID,
            Amount:    arg.Amount,
        })
        if err != nil {
            return err
        }

        // move money out of account1
        fmt.Println(txName, "get account 1")
        account1, err := q.GetAccountForUpdate(ctx, arg.FromAccountID)
        if err != nil {
            return err
        }

        fmt.Println(txName, "update account 1")
        result.FromAccount, err = q.UpdateAccount(ctx, UpdateAccountParams{
            ID:      arg.FromAccountID,
            Balance: account1.Balance - arg.Amount,
        })
        if err != nil {
            return err
        }

        // move money into account2
        fmt.Println(txName, "get account 2")
        account2, err := q.GetAccountForUpdate(ctx, arg.ToAccountID)
        if err != nil {
            return err
        }

        fmt.Println(txName, "update account 2")
        result.ToAccount, err = q.UpdateAccount(ctx, UpdateAccountParams{
            ID:      arg.ToAccountID,
            Balance: account2.Balance + arg.Amount,
        })
        if err != nil {
            return err
        }
    })

    return result, err
}

好了,现在日志已经添加好了,我们可以重新运行测试来看看它是如何运行的。

但是为了便于调试,我们不应该运行太多的并发事务。所以我要把n变成2而不是5。

func TestTransferTx(t *testing.T) {
    ...

    n := 2
    amount := int64(10)

    errs := make(chan error)
    results := make(chan TransferTxResult)

    // run n concurrent transfer transaction
    ...
}

让我们运行测试:

机器太low,2个模拟不出,加到了3个 . . .

瞧,我们还是陷入了deadlock。但这一次,我们有详细的记录。

如你所见,tx3正常,看看tx1tx2分别做了什么:

  • tx1执行create transfer

  • tx2执行create transfer

  • tx1执行create entry 1create entry 2

  • tx2执行create entry 1

  • tx1执行get account 1

  • tx2执行create entry 2get account 1

  • tx1执行update account 1

现在,我们确切地知道发生了什么。我们要做的是找出发生这种情况的原因。

七、在psql终端模拟死锁

在这里,我打开了Navicat中的simple_bank数据库。目前它有2个账户,原始余额都是100元。

我还准备了应完全按照我们在Golang代码中实现的SQL查询列表进行的转帐事务:

BEGIN;

SELECT * FROM accounts WHERE id = 1;

INSERT INTO transfers (from_account_id, to_account_id, amount) VALUES (1, 2, 10) RETURNING *;

INSERT INTO entries (account_id, amount) VALUES (1, -10) RETURNING *;
INSERT INTO entries (account_id, amount) VALUES (2, 10) RETURNING *;

SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = 90 WHERE id = 1 RETURNING *;

SELECT * FROM accounts WHERE id = 2 FOR UPDATE;
UPDATE accounts SET balance = 110 WHERE id = 2 RETURNING *;

ROLLBACK;
  • BEGIN开始事务
  • tx1、tx2分别插入一个新的从帐户1到帐户2的转账记录,金额为10
  • tx1为帐户1插入一个金额为-10的流水记录,为帐户2插入一个金额为10的流水记录
  • tx2为帐户1插入一个金额为-10的流水记录
  • tx1获取账户1信息
  • tx2为帐户2插入一个金额为10的流水记录,然后获取账户1信息
  • tx1更新账户1余额

现在,开始完全按照我们日志中的步骤进行操作:

可以看到tx1阻塞住了,它在等待tx2提交事务或回滚。

这看起来很奇怪,因为当我们从accounts表中获取一条记录时,事务2只做了两步,在transfer表中创建一条记录和在entries表中创建一条记录。为什么一个事务对一个表的insert会阻碍另一个事务select其他表?

为了确认这一点,让我们打开这个关于lock monitoringPostgres Wiki页面。

SELECT blocked_locks.pid            AS blocked_pid,
         blocked_activity.usename   AS blocked_user,
         blocking_locks.pid         AS blocking_pid,
         blocking_activity.usename  AS blocking_user,
         blocked_activity.query     AS blocked_statement,
         blocking_activity.query    AS current_statement_in_blocking_process
    FROM pg_catalog.pg_locks         blocked_locks
    JOIN pg_catalog.pg_stat_activity blocked_activity  ON blocked_activity.pid = blocked_locks.pid
    JOIN pg_catalog.pg_locks         blocking_locks 
        ON blocking_locks.locktype = blocked_locks.locktype
        AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
        AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
        AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
        AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
        AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
        AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
        AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
        AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
        AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
        AND blocking_locks.pid != blocked_locks.pid

    JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
    WHERE NOT blocked_locks.granted;

这个又长又复杂的查询允许我们查找被阻塞的查询以及阻塞它们的是什么。我们复制到Navicat中运行它。

如您所见,被阻止的语句是SELECT FROM accounts FOR UPDATE。 阻止它的是INSERT INTO entries。 因此,对这2个不同的表的查询确实会互相阻塞。

让我们更深入地了解为什么SELECT查询必须等待INSERT查询。

如果我们返回Postgres Wiki并向下滚动一点,我们将看到另一个查询,该查询将允许我们列出数据库中的所有锁。

我将略微修改此查询,因为我想查看更多信息:

SELECT
    a.datname,
    a.application_name,
    l.relation::regclass,
    l.transactionid,
    l.mode,
    l.locktype,
    l.GRANTED,
    a.usename,
    a.query,
    a.pid
FROM pg_stat_activity a
JOIN pg_locks l ON l.pid = a.pid
WHERE application_name = 'psql'
ORDER BY a.pid;
  • a.datname显示数据库名称
  • a.application_name显示锁来自哪个应用程序
  • l.relation::regclass是表的名称
  • L.transactionid 表示锁来自哪个事务的ID
  • L.mode 锁的模式
  • l.lock_type锁的类型
  • L.GRANTED告诉我们是否授予锁定
  • a.usename 是运行查询的用户名
  • a.query 是持有或试图获取锁的查询
  • a.pid正在运行事务的进程ID

如您所见,我们设置pg_state_activity表别名为a,并在进程ID列上与别名为l的pg_locks表连接。

WHERE application_name = 'psql' 表示我们获取到的锁全是来自psql控制台的,不加上的话navicat相关的也会被打印出来,不方便我们查看。

ok,让我们运行它!

现在我们可以看到,只有1个锁还没有被授予。它来自进程ID为12868SELECT FROM accounts查询。

未被授予的原因是因为它试图获取类型为transactionidShareLock,其中事务ID为1140。而该事务ID锁仅由另一个进程ID 3047通过INSERT INTO transfers查询保留。而这个事务ID锁由INSERT INTO entries查询中的另一个进程ID10212独占持有。

但是,为什么SELECT FROM accounts需要从运行INSERT INTO entries的其他事务中获取锁?

好的,如果我们看一下数据库模式,我们可以看到accountentries表之间唯一的连接是外键约束:

ALTER TABLE "entries" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id");

ALTER TABLE "transfers" ADD FOREIGN KEY ("from_account_id") REFERENCES "accounts" ("id");

ALTER TABLE "transfers" ADD FOREIGN KEY ("to_account_id") REFERENCES "accounts" ("id");

entries表的account_id关联着accounts表的id列。因此该帐户上的任何更新都将影响这个外键约束。

这就是为什么当我们选择一个帐户进行更新时,它需要获取一个锁,以防止冲突和确保数据的一致性。

话虽如此,如果现在我们继续接着刚才的操作,完成剩下的几步,会发生什么呢?

我们将会得到一个死锁,因为这个查询也必须等待来自事务1的锁,而事务1也在等待来自事务2的锁。

这就清楚地解释了死锁是如何发生的。但是如何解决呢?

八、修复死锁(不好的方式)

我们知道,死锁是由外键约束引起的,因此避免死锁的一个简单方法是删除这些约束。

让我们尝试注释掉init_schema.up中的这些语句:

-- ALTER TABLE "entries" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id");

-- ALTER TABLE "transfers" ADD FOREIGN KEY ("from_account_id") REFERENCES "accounts" ("id");

-- ALTER TABLE "transfers" ADD FOREIGN KEY ("to_account_id") REFERENCES "accounts" ("id");

然后在终端中运行make migrationdown以删除数据库中的表。 并运行make migrateup来重新创建没有外键约束的新数据表。

好了,现在如果我们再次运行测试,它将会通过,因为约束消失了,所以在SELECT FROM accounts进行更新时不需要锁。没有锁意味着没有死锁。

但是,这并不是最好的解决方案,因为我们不想放松保持数据一致的良好约束。

因此,让我们还原这些更改,运行make migrationdown,然后再次运行make migrationup来恢复这些约束。 现在测试将再次由于死锁而失败。

让我们学习一种更好的方法来解决此问题。

九、修复死锁(更好的方式)

我们已经知道,事务锁是必需的,因为Postgres担心事务1将更新account ID,这会影响entries表的外键约束。

但是,如果我们查看UpdateAccount查询,则可以看到它仅更改帐户余额。

-- name: UpdateAccount :one
UPDATE accounts
SET balance = $2
WHERE id = $1
RETURNING *;

account ID永远不会更改,因为它是accounts表的主键。

因此,如果我们可以告诉Postgres我选择这个帐户进行更新,但是不会触碰到它的主键,那么Postgres就不需要获取事务锁,因此就不会出现死锁。

幸运的是,这非常容易做到。 在GetAccountForUpdate查询中,我们不仅要说清楚SELECT FOR UPDATE,还需要说得更清楚SELECT FOR NO KEY UPDATE

-- name: GetAccountForUpdate :one
SELECT * FROM accounts
WHERE id = $1 LIMIT 1
FOR NO KEY UPDATE;

这将告诉Postgres我们不更新accounts表的键或ID列。

db/sqlc/account.sql.go

让我们再次运行测试!

它通过了!太好了!这样我们的调试和修复就完成了。

十、更新帐户余额(更好的方式)

现在,在我们结束之前,我将向您展示一个更好的方法来实现这个更新帐户余额操作。

目前,我们必须执行2个查询,以获得帐户和更新其余额:

func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
    var result TransferTxResult

    err := store.execTx(ctx, func(q *Queries) error {
        ...

        // move money out of account1
        account1, err := q.GetAccountForUpdate(ctx, arg.FromAccountID)
        if err != nil {
            return err
        }

        result.FromAccount, err = q.UpdateAccount(ctx, UpdateAccountParams{
            ID:      arg.FromAccountID,
            Balance: account1.Balance - arg.Amount,
        })
        if err != nil {
            return err
        }

        // move money into account2
        ...
    })

    return result, err
}

其实,我们可以通过仅使用一个查询直接向帐户余额中添加一定数量的金额来改进这一点。

为此,我要在db/query/account.sql文件中添加一个名为AddAccountBalance的新SQL查询。

-- name: AddAccountBalance :one
UPDATE accounts
SET balance = balance + $1
WHERE id = $2
RETURNING *;

让我们运行make sqlc以生成代码。新功能已成功添加:

const addAccountBalance = `-- name: AddAccountBalance :one
UPDATE accounts
SET balance = balance + $1
WHERE id = $2
RETURNING id, owner, balance, currency, created_at
`

type AddAccountBalanceParams struct {
	Balance int64 `json:"balance"`
	ID      int64 `json:"id"`
}

func (q *Queries) AddAccountBalance(ctx context.Context, arg AddAccountBalanceParams) (Account, error) {
	row := q.db.QueryRowContext(ctx, addAccountBalance, arg.Balance, arg.ID)
	var i Account
	err := row.Scan(
		&i.ID,
		&i.Owner,
		&i.Balance,
		&i.Currency,
		&i.CreatedAt,
	)
	return i, err
}

然而,AddAccountBalanceParams结构中的balance参数看起来有点不妥,因为我们只是向余额中添加了一些金额,而不是将账户余额更改为这个值。

所以这个参数的名称应该是Amount。我们能让sqlc帮我们做这个吗?

是的,可以!在SQL查询中,我们可以设置为sqlc.arg(amount)而不是$1,设置sqlc.arg(id)而不是​$2

-- name: AddAccountBalance :one
UPDATE accounts
SET balance = balance + sqlc.arg(amount)
WHERE id = sqlc.arg(id)
RETURNING *;

重新生成:

const addAccountBalance = `-- name: AddAccountBalance :one
UPDATE accounts
SET balance = balance + $1
WHERE id = $2
RETURNING id, owner, balance, currency, created_at
`

type AddAccountBalanceParams struct {
    Amount int64 `json:"amount"`
    ID     int64 `json:"id"`
}

func (q *Queries) AddAccountBalance(ctx context.Context, arg AddAccountBalanceParams) (Account, error) {
    row := q.db.QueryRowContext(ctx, addAccountBalance, arg.Amount, arg.ID)
    var i Account
    err := row.Scan(
        &i.ID,
        &i.Owner,
        &i.Balance,
        &i.Currency,
        &i.CreatedAt,
    )
    return i, err
}

可以看到生成的代码中arg.Balance改为了arg.Amount,这次就更好理解了。当然,如果我们不使用sqlc生成CURD代码,自己写也是可以的。

现在回到store.go文件,我将删除GetAccountForUpdate调用,并将UpdateAccount()更改为AddAccountBalance()

func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
	var result TransferTxResult

	err := store.execTx(ctx, func(q *Queries) error {
		...

		// move money out of account1
		result.FromAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{
			ID:     arg.FromAccountID,
			Amount: -arg.Amount,
		})
		if err != nil {
			return err
		}

		// move money into account2
		result.ToAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{
			ID:     arg.ToAccountID,
			Amount: arg.Amount,
		})
		if err != nil {
			return err
		}

		return nil
	})

	return result, err
}

让我们再次重新运行测试。

全都通过了,这就是关于db事务的锁和如何调试死锁的全部内容了。

posted @ 2020-09-27 19:16    阅读(224)  评论(0编辑  收藏  举报