You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Go语言并发转账事务异常:仅首笔交易生效求助

问题排查与解决方案

针对你遇到的并发转账仅首笔成功的问题,大概率是并发事务中的丢失更新问题,结合PostgreSQL和Go的使用场景,常见原因和解决思路如下:

常见根因

  • 未对账户行加锁:并发转账时,多个事务同时读取同一账户余额,修改后写回,导致后提交的事务覆盖前一个的更新,出现丢失更新。
  • 事务隔离级别过低:默认的READ COMMITTED隔离级别在高并发下无法防止这类问题。
  • sqlc生成的更新语句未做行锁定:如果账户余额更新语句没有使用FOR UPDATE或FOR NO KEY UPDATE,无法在事务中锁定目标行。

具体排查与修复步骤

1. 检查账户更新SQL语句

打开account_sql.go(或对应的sqlc定义文件),确认更新余额的语句是否带有行锁:

  • 错误示例:
UPDATE accounts SET balance = balance + $1 WHERE id = $2
  • 正确示例(添加行锁):
UPDATE accounts SET balance = balance + $1 WHERE id = $2 FOR NO KEY UPDATE

FOR NO KEY UPDATE会锁定目标账户行,阻止其他事务修改该行,直到当前事务提交/回滚,避免并发更新冲突。

2. 验证事务逻辑

检查store.go中的转账事务流程,确保:

  • 事务开启后,所有操作(扣减余额、增加余额、创建交易记录)都在同一个事务中执行
  • 事务执行失败时正确回滚
  • 通过带锁的更新语句锁定转出和转入账户

示例正确的事务流程:

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

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

        // 扣减转出账户余额并锁定行
        _, err = q.UpdateAccountBalance(ctx, UpdateAccountBalanceParams{
            ID:     arg.FromAccountID,
            Amount: -arg.Amount,
        })
        if err != nil {
            return err
        }

        // 增加转入账户余额并锁定行
        _, err = q.UpdateAccountBalance(ctx, UpdateAccountBalanceParams{
            ID:     arg.ToAccountID,
            Amount: arg.Amount,
        })
        if err != nil {
            return err
        }

        // 创建交易记录
        result.Transfer, err = q.CreateTransfer(ctx, CreateTransferParams{
            FromAccountID: arg.FromAccountID,
            ToAccountID:   arg.ToAccountID,
            Amount:        arg.Amount,
        })
        if err != nil {
            return err
        }

        return nil
    })

    return result, err
}

3. 检查PostgreSQL锁状态

执行以下SQL查询当前锁状态,确认是否存在长期持有的锁或未提交的事务:

-- 查询等待中的锁
SELECT * FROM pg_locks WHERE NOT granted;
-- 查询空闲但未结束的事务
SELECT * FROM pg_stat_activity WHERE state = 'idle in transaction';

如果存在长期未提交的事务,会导致锁无法释放,阻塞后续操作。

4. 调整事务隔离级别(可选)

如果加行锁后仍有问题,可以尝试将事务隔离级别提升为REPEATABLE READ:

tx, err := s.db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelRepeatableRead,
})
if err != nil {
    return err
}
// 后续事务逻辑...

测试验证

修改代码后,编写并发测试用例(参考store_test.go),模拟多协程同时转账的场景,检查余额是否正确更新:

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

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

    n := 5
    amount := int64(10)

    var wg sync.WaitGroup
    wg.Add(n)

    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()

            _, err := store.TransferTx(context.Background(), TransferTxParams{
                FromAccountID: account1.ID,
                ToAccountID:   account2.ID,
                Amount:        amount,
            })
            require.NoError(t, err)
        }()
    }

    wg.Wait()

    // 校验最终余额
    updatedAccount1, err := store.GetAccount(context.Background(), account1.ID)
    require.NoError(t, err)
    require.Equal(t, account1.Balance-int64(n)*amount, updatedAccount1.Balance)

    updatedAccount2, err := store.GetAccount(context.Background(), account2.ID)
    require.NoError(t, err)
    require.Equal(t, account2.Balance+int64(n)*amount, updatedAccount2.Balance)
}

内容的提问来源于stack exchange,提问作者Sajna Sheeja

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.01 22:25:53