Go SQL 事务入门:把转账、订单和库存写得更可靠

本文用转账和订单扣库存示例讲解 Go database/sql 事务的基本模式,包括 BeginTx、defer Rollback、Commit 和上下文传播。

事务保护的是一组必须一起成功的动作

数据库事务的核心不是“高级数据库功能”,而是一个很朴素的需求:几步操作要么全部成功,要么全部失败。转账时,从 A 扣钱和给 B 加钱必须一起发生;创建订单时,写订单和扣库存也必须保持一致。如果前一步成功,后一步失败,就会留下脏数据。

Go 标准库 database/sql 提供了事务 API:BeginTxCommitRollback。入门阶段最重要的是掌握固定模式:开始事务,defer Rollback 兜底,所有 SQL 走 tx,最后 Commit

这篇文章用两个例子讲事务写法。

基本事务骨架

func DoInTx(ctx context.Context, db *sql.DB) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	// use tx.QueryContext / tx.ExecContext here

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit tx: %w", err)
	}
	return nil
}

defer tx.Rollback() 即使在 Commit 成功后也会执行,但通常会返回一个可忽略的错误。它的作用是保证中途任何 return 都能回滚。

事务里一定要使用 tx.ExecContexttx.QueryContext,不要不小心用回 db.ExecContext,否则那条 SQL 不在事务里。

转账示例

func Transfer(ctx context.Context, db *sql.DB, fromID, toID int64, cents int64) error {
	if cents <= 0 {
		return fmt.Errorf("amount must be positive")
	}

	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	result, err := tx.ExecContext(ctx,
		`UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?`,
		cents, fromID, cents,
	)
	if err != nil {
		return fmt.Errorf("decrease balance: %w", err)
	}

	affected, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("check decrease result: %w", err)
	}
	if affected != 1 {
		return fmt.Errorf("insufficient balance")
	}

	if _, err := tx.ExecContext(ctx,
		`UPDATE accounts SET balance = balance + ? WHERE id = ?`,
		cents, toID,
	); err != nil {
		return fmt.Errorf("increase balance: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit transfer: %w", err)
	}
	return nil
}

扣款 SQL 里加了 balance >= ?,并检查影响行数。这能避免余额不足时扣成负数。真实金融系统还要考虑更多一致性、审计和幂等问题,但这个例子已经展示了事务基本结构。

事务里不要做慢外部调用

不要这样:

tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()

tx.ExecContext(ctx, `INSERT INTO orders ...`)
callPaymentProvider()
tx.ExecContext(ctx, `UPDATE orders ...`)
tx.Commit()

外部支付接口可能很慢,事务会长时间占用数据库连接和锁。更好的设计通常是先创建订单,再通过明确状态机和回调处理支付结果。事务只包住必须一起提交的数据库操作。

事务越短越好。它应该保护一致性,不应该把网络调用、文件上传、发送邮件这类不受数据库控制的动作包进去。

把事务边界放在 Service 层

事务通常不应该散落在 HTTP handler 里。Handler 负责 HTTP,Service 负责业务流程,Store 负责 SQL。一个创建订单流程可以这样组织:

type OrderService struct {
	db *sql.DB
}

func (s *OrderService) CreateOrder(ctx context.Context, input CreateOrderInput) error {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	if err := insertOrder(ctx, tx, input); err != nil {
		return err
	}
	if err := decreaseStock(ctx, tx, input.SKU, input.Quantity); err != nil {
		return err
	}

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit order: %w", err)
	}
	return nil
}

Store 函数接收一个能执行 SQL 的接口:

type Execer interface {
	ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
}

这样它既可以接收 *sql.DB,也可以接收 *sql.Tx。业务流程决定是否开启事务,底层 SQL 函数只负责执行语句。这个边界在项目变大后会很有用。

测试事务代码

事务代码至少要测失败回滚。可以用测试数据库,也可以把 Store 抽象成接口做单元测试。入门阶段最关键的是确保失败时不会继续提交。比如扣库存失败后,订单不应该存在。

不要只测成功路径。事务的价值恰恰在失败路径里体现。

隔离级别先了解,不要乱调

BeginTx 的第二个参数可以传事务选项:

tx, err := db.BeginTx(ctx, &sql.TxOptions{
	Isolation: sql.LevelReadCommitted,
	ReadOnly:  false,
})

不同数据库对隔离级别的支持和默认值不完全一样。入门阶段不要为了“更安全”随便把隔离级别调到最高。更高隔离可能带来更多锁等待和性能问题,也不一定解决你的业务 bug。

多数业务可以先使用数据库默认隔离级别,并通过正确 SQL 条件、唯一约束、行锁或乐观锁保护关键规则。比如扣库存时检查库存数量:

UPDATE products
SET stock = stock - ?
WHERE sku = ? AND stock >= ?

然后检查 RowsAffected。这比单纯依赖应用层先查库存再更新更可靠。

事务正确性往往来自数据库约束、SQL 条件和事务边界共同作用,而不是某一个神奇参数。遇到并发一致性问题时,要结合具体数据库文档和业务场景分析。

小结

Go 事务代码的基本模式是 BeginTxdefer Rollback、使用 tx 执行所有 SQL、最后 Commit。错误要带上下文,RowsAffected 要检查,事务范围要尽量短。

事务不是万能锁,也不是越大越安全。它保护的是一组数据库操作的一致性。把边界想清楚,Go 的 database/sql 足够写出可靠的入门事务代码。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页