Go大师课程(四): 避免死锁

220 阅读10分钟

Go大师课程系列将学习

这篇,学习如何使用PostgreSQL、Golang和Docker来设计一个简单的银行后端系统。分为以下三个部分

  • 潜在的问题
  • 在测试中复制死锁场景
  • 修复死锁问题

潜在的问题

这是我们在上一课中实现的转账交易代码。

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个并发的转账交易,但没有发生死锁,对吗?

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

    account1 := createRandomAccount(t)
    account2 := createRandomAccount(t)
    fmt.Println(">> before:", account1.Balance, account2.Balance)

    n := 5
    amount := int64(10)

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

    // run n concurrent transfer transaction
    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
        }()
    }

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

    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)

        // 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)

        // check balances
        fmt.Println(">> tx:", fromAccount.Balance, toAccount.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

        k := int(diff1 / amount)
        require.True(t, k >= 1 && k <= n)
        require.NotContains(t, existed, k)
        existed[k] = true
    }

    // 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-int64(n)*amount, updatedAccount1.Balance)
    require.Equal(t, account2.Balance+int64(n)*amount, updatedAccount2.Balance)
}

没错!但是,我们现有测试中的交易都做同样的事情:将钱从 转移account 1account 2。如果其中一些交易将钱从account 2转移到会怎么样account 1

为了说明这种情况下死锁是如何发生的,我在 TablePlus 中准备了 2 个事务:

-- 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 = 2 RETURNING *;
UPDATE accounts SET balance = balance + 10 WHERE id = 1 RETURNING *;

ROLLBACK;

将从1st transaction转入10美元account 1到,方法为先从的余额中account 2减去,然后添加到 的余额中。10``account 1``10``account 2

2nd transaction做相反的工作:将10美元从转移account 2到。首先从 的余额中account 1减去。然后将其添加到 的余额中。10``account 2``10``account 1

现在让我们打开终端,在 2 个并行 psql 控制台中运行这些事务。

首先,我将启动第一个 psql 控制台和BEGIN1st transaction然后我将运行它的第一个查询来10account 1的余额中减去。

替代文本

帐户立即更新。现在让我们打开另一个选项卡,启动一个新的 psql 控制台,然后BEGIN2nd transaction然后让我们运行它的第一个查询以10account 2余额中减去。

替代文本

此查询也会立即返回。现在返回1st transaction并运行其第二个查询以更新的account 2余额。

替代文本

这次,查询被阻止,因为2nd transaction也在更新相同的account 2

如果我们回到 TablePlus 并运行此查询来列出所有锁:

替代文本

我们可以看到,此update account 2查询transaction 1正在尝试获取ShareLock事务ID 911,但尚未被授予

替代文本

这是因为transaction 2已经持有ExclusiveLock相同事务 ID 的事务。因此,transaction 1必须等待transaction 2完成后才能继续。

现在,如果我们继续运行第二个查询来transaction 2更新account 1余额:

替代文本

我们将遇到死锁,因为account 1正在被 更新transaction 1,因此transaction 2还需要等待transaction 1完成才能获得此查询的结果。发生死锁是因为这两个并发事务都需要等待对方。

好的,现在让我们回滚这2笔交易,然后回到我们的简单银行项目在测试中复制这个场景。

在测试中复制死锁场景

它将与我们在上节课中编写的测试非常相似,因此我将复制该TestTransferTx函数,并将其名称更改为TestTransferTxDeadlock

这里,假设我们要运行n = 10并发交易。我们的想法是,有一桩5交易将资金从account 1汇至account 2,另一桩5交易将资金反向account 2汇至account 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)

    ...
}

在这个场景中,我们只需要检查死锁错误,我们不需要关心结果,因为它已经在另一个测试中检查过了。所以我删除了通道results,只保留了errs通道。

现在在 for 循环内部,让我们定义 2 个新变量:fromAccountIDwill beaccount1.IDtoAccountIDwill be account2.ID

但是由于我们想要一半的交易是从 汇款account 2account 1,我会检查计数器是否i为奇数(i % 2 = 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
        }()
    }
}

TransferTxParams现在在 go 协程中,我们应该将fromAccountID和的字段设置为toAccountID。然后删除该results <- result语句,因为我们不再关心结果了。

好的,现在是检查错误部分。让我们删除映射existed以及 for 循环内的所有内容,除了错误检查语句。

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

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

    ...
}

我们还想检查 2 个账户的最终更新余额。在这种情况下,有交易在和10之间转移了相同金额的资金。但由于这种情况,其中一个会将资金从转移到,另一个会将资金从转回。account 1``account 2``if i%2 == 1``5``account 1``account 2``5``account 2``account 1

因此,我们预计最终account 1和的余额account 2应该与交易前的余额相同。

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)
}

所以这里,updatedAccount1.Balance应该等于account1.Balance,且updatedAccount2.Balance应该等于account2.Balance

好的,让我们进行这个测试!

替代文本

正如预期的那样,我们遇到了死锁错误。让我们学习如何修复它!

修复死锁问题

正如您在我们在 psql 控制台中运行的示例中看到的,发生死锁的原因是由于两个并发事务更新账户余额的顺序不同,其中一个事务在之前transaction 1更新,而另一个事务在 之前更新。account 1``account 2``transaction 2``account 2``account 1

因此,这让我们了解如何通过使两个事务以相同的顺序更新帐户余额来避免死锁。假设transaction 2我们只是将update account 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;

因此现在transaction 1transaction 2将始终account 1在之前更新account 2。让我们尝试在 psql 控制台中运行它们,看看会发生什么!

首先,begin transaction 1对 运行其第一个查询update account 1

替代文本

然后切换到另一个控制台并begin transaction 2。还运行其第一个查询update account 1

替代文本

现在与以前不同,这次查询立即被阻止,因为transaction 1已经持有独占锁来更新相同的account 1。因此,让我们返回transaction 1并运行其第二个查询update account 2

替代文本

结果立即返回,并且transaction 2仍然被阻塞。所以我们只需COMMIT这样transaction 1做即可释放锁。然后转到事务 2。

替代文本

我们可以看到,它立即被解除了阻止,并且余额已更新为新值。

我们可以运行第二个查询update account 2,然后COMMIT transaction 2成功并且没有死锁。

好的,现在我们明白了,防止死锁的最佳方法是确保我们的应用程序始终以一致的顺序获取锁。

例如,在我们的例子中,我们可以轻松地更改我们的代码,以便它总是首先更新具有较小 ID 的帐户。

这里我们检查是否arg.FromAccountID小于,arg.ToAccountID那么fromAccount应该在之前更新toAccount。否则,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
}

好的,现在经过这一更改后,我们预计死锁应该会消失。让我们重新运行测试!

替代文本

通过了!在日志中,我们可以看到交易前后的余额相同。完美!

重构代码

在完成之前,让我们稍微重构一下代码,因为现在它看起来很长而且有些重复。为此,我将定义一个新addMoney()函数来向 2 个帐户添加资金。

它将需要几个输入:上下文、查询对象、第一个帐户的 ID、应添加到第一个帐户的金额、第二个帐户的 ID 以及应添加到第二个帐户的金额。

该函数将返回3个值:第一个账户对象、更新后的第二个账户对象和一个潜在的错误。

func addMoney(
    ctx context.Context,
    q *Queries,
    accountID1 int64,
    amount1 int64,
    accountID2 int64,
    amount2 int64,
) (account1 Account, account2 Account, err error) {
    account1, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{
        ID:     accountID1,
        Amount: amount1,
    })
    if err != nil {
        return
    }

    account2, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{
        ID:     accountID2,
        Amount: amount2,
    })
    return
}

在这个函数中,我们首先调用q.AddAcountBalance()来将 添加到 的余额amount1中。因此应该是,应该是。我们将结果保存到输出和变量中。account1``ID``accountID1``Amount``amount1``account1``err

然后我们检查是否err不为零,只需返回即可。这里因为我们使用的是命名返回变量,所以这个没有参数的返回与我们写的 基本相同return account1, account2, err。这是 Go 的一个很酷的语法特性,可以使代码更简洁。

我们做类似的事情来添加amount2account2。现在有了这个 addMoney 函数,我们可以重构我们的转账交易:

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, result.ToAccount, err = addMoney(ctx, q, arg.FromAccountID, -arg.Amount, arg.ToAccountID, arg.Amount)
        } else {
            result.ToAccount, result.FromAccount, err = addMoney(ctx, q, arg.ToAccountID, arg.Amount, arg.FromAccountID, -arg.Amount)
        }

        return err
    })

    return result, err
}

如果fromAccountID小于toAccountID,我们希望fromAccount在之前更新toAccount。所以在这里,我们调用addMoney(),传入上下文ctx,查询q,,因为资金正在流出,然后,最后arg.FromAccountID因为资金正在流入。-arg.Amount``arg.ToAccountID``arg.Amount

该函数调用的输出应分配给result.FromAccountresult.ToAccounterr

否则,如果toAccountID较小,我们要确保toAccount在之前更新fromAccount。所以我们只需复制上一个命令,但稍作修改以反转帐户顺序。

就这样!重构完成。让我们重新运行TestTransferTxDeadlock

替代文本

让我们也运行一下常规操作TestTransferTx

替代文本

也通过了!最后重新运行整个包测试:

替代文本

全部通过了!