【simple-bank:2】如何避免数据库事务死锁?
结合上节食用。
使用数据库事务时最困难的事情之一就是锁和处理死锁。
根据我的经验,处理死锁的最佳方法是避免死锁。 我的意思是说,我们应该在事务中微调我们的查询,以免死锁发生,或者至少将其发生的可能性降到最低。
一、潜在的死锁场景
这是上一节中实现的汇款事务代码。
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
result.Transfer, err = q.CreateTransfer(ctx, CreateTransferParams{
FromAccountID: arg.FromAccountID,
ToAccountID: arg.ToAccountID,
Amount: arg.Amount,
})
if err != nil {
return err
}
result.FromEntry, err = q.CreateEntry(ctx, CreateEntryParams{
AccountID: arg.FromAccountID,
Amount: -arg.Amount,
})
if err != nil {
return err
}
result.ToEntry, err = q.CreateEntry(ctx, CreateEntryParams{
AccountID: arg.ToAccountID,
Amount: arg.Amount,
})
if err != nil {
return err
}
result.FromAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{
ID: arg.FromAccountID,
Amount: -arg.Amount,
})
if err != nil {
return err
}
result.ToAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{
ID: arg.ToAccountID,
Amount: arg.Amount,
})
if err != nil {
return err
}
return nil
})
return result, err
}
基本上,我们已解决了由外键约束引起的死锁问题。 但是,如果我们仔细查看代码,就会发现潜在的死锁情况。
在此交易中,我们将更新fromAccount
和toAccount
的余额。 而且我们知道它们都需要排他锁(独占锁)才能执行操作。 因此,如果有2个涉及同一对帐户的并发事务,则可能存在死锁。
我们在上一节中的测试,使用同一对帐户运行5个并发转账交易,而不会发生死锁;但是,别忘了,上节的测试中事务都做同样的事情:将资金从account 1
转到account 2
。如果其中一些事务将金额从account 2
转移到account 1
怎么办?
为了说明如何deadlock
在这种情况下可能会发生,我准备了2个事务:
-- Tx1: transfer 10rmb from account 1 to account 2
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1 RETURNING *;
UPDATE accounts SET balance = balance + 10 WHERE id = 2 RETURNING *;
ROLLBACK;
-- Tx2: transfer 10rmb from account 2 to account 1
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 2 RETURNING *;
UPDATE accounts SET balance = balance + 10 WHERE id = 1 RETURNING *;
ROLLBACK;
终端模拟:
可以看到,事务tx1
阻塞住了,因为事务tx2
也在更新相同的account2
。
如果回到Navicat并运行此查询以列出所有锁:
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;
可以看到,事务1的UPDATE account 2
试图获取事务ID1396
上的ShareLock,但是还没有被授予。
这是因为事务2已经在同一事务ID上持有ExclusiveLock。 因此,事务1必须等待事务2完成才能继续。
现在,如果我们继续运行事务2的第二次查询以更新帐户1的余额:
我们将陷入死锁,因为帐户1正在由事务1更新,因此事务2还需要等待事务1完成才能获取此查询的结果。 发生死锁是因为这两个并发事务都需要等待对方。
现在,让我们回滚这2个交易,然后回到我们的项目中,以在测试中复现这种情况。
二、在测试用例中复现死锁场景
这与我们在上一节中编写的测试非常相似,因此我复制TestTransferTx
函数,并将其名称更改为TestTransferTxDeadlock
。
在这里,假设我们要运行10个并发事务。 让5个事务从帐户1汇款到帐户2,另外5个事务从帐户2汇款到帐户1。
func TestTransferTxDeadlock(t *testing.T) {
store := NewStore(testDB)
account1 := createRandomAccount(t)
account2 := createRandomAccount(t)
fmt.Println(">> before:", account1.Balance, account2.Balance)
n := 10
amount := int64(10)
errs := make(chan error)
...
}
在这种情况下,我们只需要检查死锁错误,就不必担心结果了,因为结果已经在其他测试中进行过检查。 所以我删除了result channel,只保留了error channel。
现在,在for循环中,定义两个新变量:fromAccountID
为account1.ID
,toAccountID
为account2.ID
。
但是,由于我们希望事务的一半为帐户2向帐户1汇款,因此对 i
取模(i%2 = 1),如果取模结果为1,则fromAccountID
改为account2.ID
,toAccountID
改为account1.ID
。
func TestTransferTxDeadlock(t *testing.T) {
...
for i := 0; i < n; i++ {
fromAccountID := account1.ID
toAccountID := account2.ID
if i%2 == 1 {
fromAccountID = account2.ID
toAccountID = account1.ID
}
go func() {
_, err := store.TransferTx(context.Background(), TransferTxParams{
FromAccountID: fromAccountID,
ToAccountID: toAccountID,
Amount: amount,
})
errs <- err
}()
}
}
现在,在goroutine中,我们应该将TransferTxParams
的字段设置为fromAccountID
和toAccountID
。 然后删除该results <- result
语句,因为我们不再关心结果了。
好的,现在检查错误部分。 让我们删除现有的existed
map 以及for循环中除错误检查语句以外的所有内容。
func TestTransferTxDeadlock(t *testing.T) {
...
for i := 0; i < n; i++ {
err := <-errs
require.NoError(t, err)
}
...
}
我们还想检查这两个帐户的最终更新余额:
func TestTransferTxDeadlock(t *testing.T) {
...
// 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)
fmt.Println(">> after:", updatedAccount1.Balance, updatedAccount2.Balance)
require.Equal(t, account1.Balance, updatedAccount1.Balance)
require.Equal(t, account2.Balance, updatedAccount2.Balance)
}
好,让我们运行此测试!
我们遇到了预期的死锁错误。 让我们学习如何修复它!
三、解决死锁问题
正如在 psql 控制台中运行的示例中已经看到的那样,发生死锁的原因是由于2个并发事务更新帐户余额的顺序不同,其中事务1在account2
之前更新了account1
,而事务2在account1
之前更新了account2
。
因此,这使我们有了一个思路,即可以通过使两个事务以相同顺序更新帐户余额来避免死锁。 假设在此事务2中,我们只是将更新帐户1查询上移,而其他所有内容保持不变。
-- Tx1: transfer $10 from account 1 to account 2
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1 RETURNING *;
UPDATE accounts SET balance = balance + 10 WHERE id = 2 RETURNING *;
ROLLBACK;
-- Tx2: transfer $10 from account 2 to account 1
BEGIN;
UPDATE accounts SET balance = balance + 10 WHERE id = 1 RETURNING *; -- moved up
UPDATE accounts SET balance = balance - 10 WHERE id = 2 RETURNING *;
ROLLBACK;
现在事务1和事务2总是会先更新account1,然后才更新account2。让我们尝试在psql控制台中运行它们,看看会发生什么!
现在与之前不同,事务2查询立即被阻止,因为事务1已经持有排他锁来更新同一account1
。所以让我们回到事务1并运行其第二查询来更新account2
。
事务1结果立即返回,并且事务2仍然阻塞。 因此,我们只需提交此事务1即可释放锁。 然后轮到事务2。
我们可以看到它立即被解除阻塞,并且余额被更新为新的值。
我们可以继续运行第二个查询来更新帐户2,然后成功执行COMMIT事务2,而不会出现死锁。
好了,现在我们知道,针对死锁的最佳防御方法是通过确保我们的应用程序始终以一致的顺序获取锁来避免死锁。
所以,在我们的例子中,我们可以很容易地修改代码,以便它总是先用较小的ID更新帐户。
在这里,我们检查arg.FromAccountID
是否小于arg.ToAccountID
,然后应在toAccount
之前更新fromAccount
。 否则,toAccount
应该在fromAccount
之前更新。
func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
var result TransferTxResult
err := store.execTx(ctx, func(q *Queries) error {
...
if arg.FromAccountID < arg.ToAccountID {
result.FromAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{
ID: arg.FromAccountID,
Amount: -arg.Amount,
})
if err != nil {
return err
}
result.ToAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{
ID: arg.ToAccountID,
Amount: arg.Amount,
})
if err != nil {
return err
}
} else {
result.ToAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{
ID: arg.ToAccountID,
Amount: arg.Amount,
})
if err != nil {
return err
}
result.FromAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{
ID: arg.FromAccountID,
Amount: -arg.Amount,
})
if err != nil {
return err
}
}
return nil
})
return result, err
}
好的,现在进行此更改之后,我们希望应该消除了死锁。 让我们重新运行测试!
通过了!在日志中,我们可以看到交易前后的余额相同。