登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go database/sql Tx把事务边界放到业务操作外层的设计方法

来源:17golang原创

时间:2026-09-15 22:31:26 215浏览 收藏

我在给“创建订单”补库存扣减时,最容易踩的坑不是 SQL 写错,而是每个仓储函数都觉得自己应该负责事务。结果是订单已经写入,库存失败后却没有统一回滚。Go database/sql 更稳妥的做法是:把一个业务用例需要的原子写入放在外层,用同一个 *sql.Tx 传给内部操作,最后由外层统一决定提交还是回滚。

官方文档:https://pkg.go.dev/database/sql

要点速览
  • 事务边界跟着业务用例走,不跟着某一条 SQL 走。
  • 仓储函数只使用传入的 Tx,错误交给外层处理。
  • Commit 失败不能简单当成“肯定没写入”,应按未知状态处理。

一、先把事务边界画在业务用例上

先列出必须一起成功的数据库动作。订单主表写入和库存扣减属于同一原子单元,就由 CreateOrder 创建事务;发短信、调用支付或写消息队列则不要硬塞进数据库事务,它们需要各自的可靠投递策略。

Go database/sql Tx 在业务用例、订单写入和库存扣减之间的静态结构说明图
图1:业务用例、Tx 与订单表/库存表之间的静态结构说明图,帮助确认事务边界属于用例外层。

边界确定后,外层函数只做四件事:创建 Tx、调用业务写入、处理错误、提交成功结果。这样评审时能直接看见“谁拥有事务”。

二、让仓储函数只接收 Tx 并返回错误

内部函数不要再调用 db.Begin,也不要自行 Commit。它们接收 *sql.Tx 和请求上下文,只执行属于自己的 SQL,并把错误返回给业务层。下面的例子把两次写入放在同一个事务对象上:

// CreateOrder 负责业务事务的生命周期,保证订单和库存一起提交。
func (s *Store) CreateOrder(ctx context.Context, orderID, sku string, qty int) error {
	// 用请求上下文创建事务,超时或取消可以传递到数据库驱动。
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	// 回滚是安全兜底;成功提交后它通常只会返回 sql.ErrTxDone。
	defer tx.Rollback()

	if err := insertOrder(ctx, tx, orderID, sku, qty); err != nil {
		return err
	}
	if err := deductStock(ctx, tx, sku, qty); err != nil {
		return err
	}
	// 提交只放在所有业务写入都成功之后。
	return tx.Commit()
}

// insertOrder 只负责订单写入,不拥有事务。
func insertOrder(ctx context.Context, tx *sql.Tx, orderID, sku string, qty int) error {
	_, err := tx.ExecContext(ctx,
		"INSERT INTO orders(id, sku, quantity) VALUES (?, ?, ?)",
		orderID, sku, qty,
	)
	return err
}

// deductStock 只负责库存写入,失败时把决定权交回 CreateOrder。
func deductStock(ctx context.Context, tx *sql.Tx, sku string, qty int) error {
	_, err := tx.ExecContext(ctx,
		"UPDATE stock SET quantity = quantity - ? WHERE sku = ? AND quantity >= ?",
		qty, sku, qty,
	)
	return err
}
Go Tx、仓储函数和 ExecContext 连接订单表与库存表的静态关系说明图
图2:Tx、仓储函数、ExecContext 与订单表/库存表之间的静态关系说明图,突出底层函数只执行 SQL 并返回错误。

这里的 defer tx.Rollback() 不是把回滚和提交并行执行,而是给提前返回留一个出口。提交成功后事务已经结束,兜底回滚不会再次撤销已提交的数据。

三、把提交与回滚写成一个清晰的出口

BeginTx 失败时还没有可用事务,直接返回即可;任一内部操作失败,函数提前返回并由 defer 回滚;只有全部写入成功才调用一次 Commit。不要在仓储函数里“顺手提交”,否则外层无法保证多个动作的原子性。

尤其要单独看待 Commit 错误:网络中断、驱动异常或数据库状态变化都可能让客户端无法确认最终结果。此时不要立即把同一组写入无条件重试,否则可能制造重复订单。应记录业务标识,查询订单和库存状态,再按业务幂等规则决定补偿。

位置允许做什么不应做什么
业务用例外层BeginTx、统一错误出口、Commit把外部慢调用放进长事务
仓储函数Tx.ExecContext、参数校验、返回 error自行 Begin、Commit 或吞掉错误
Commit 失败分支记录标识、查状态、走幂等补偿盲目重复提交整组写入

四、用边界清单复查并发与资源行为

事务越短,连接被占用的时间通常越可控。把计算、文件操作和外部 HTTP 请求放在事务外,事务内只保留必要的数据库读写。所有 SQL 使用同一个 context.Context,超时或取消后让驱动有机会终止操作;驱动是否支持取消仍需结合具体驱动文档判断。

最后做一次代码清单:是否只有用例层拥有 *sql.Tx 生命周期;是否每条 SQL 错误都返回;是否有 defer Rollback 兜底;是否把 Commit 错误记录为未知状态;提交后是否改用 *sql.DB 做普通查询。若这些答案都明确,事务边界就不会随着仓储函数继续扩散。

相关问题

为什么不用每个仓储函数自己开启事务?

因为多个写入可能需要同一原子单元。函数各自开事务时,外层无法让它们一起提交或一起回滚。

提交成功后还需要调用 Rollback 吗?

可以保留 defer 作为失败兜底;提交后事务已结束,回滚不会把已提交结果撤回,返回值也不应再被当作业务失败。

Commit 返回错误应该怎么重试?

先按业务主键查询最终状态,再依据幂等约束和补偿策略处理,不要对整段写入直接盲重试。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>