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 创建事务;发短信、调用支付或写消息队列则不要硬塞进数据库事务,它们需要各自的可靠投递策略。

边界确定后,外层函数只做四件事:创建 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
}

这里的 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 返回错误应该怎么重试?
先按业务主键查询最终状态,再依据幂等约束和补偿策略处理,不要对整段写入直接盲重试。
-
448 收藏
-
Golang · Go问答 | 19分钟前 | 错误处理 · 事务 · database/sql · Go问答 · 并发冲突 · Go database/sql Tx Go事务重试 Go死锁处理 Go序列化冲突 database/sql错误分类218 收藏
-
448 收藏
-
Golang · Go问答 | 1小时前 | SQL查询 · scan · database/sql · 后端排错 · Go数据库 · Go database/sql Rows Go Rows Scan字段顺序 Go SQL查询列顺序 Go rows.Columns排查 Go rows.Err错误处理359 收藏
-
Golang · Go问答 | 1小时前 | go · 数据库 · SQL NULL · Rows.Scan · 可空类型 · Go database/sql rows SQL NULL NullString NullInt64 sql.Null254 收藏
-
294 收藏
-
Golang · Go问答 | 1小时前 | 错误处理 · 文件上传 · Go问答 · 资源清理 · multipart.Reader · Go multipart.Reader 临时上传文件 上传失败清理 multipart.Part Form.RemoveAll387 收藏
-
267 收藏
-
Golang · Go问答 | 1小时前 | GO文件上传 · io.Reader · net/http · Go问答 · multipart · http.MaxBytesReader io.LimitReader Go multipart.Reader Go multipart上传大小限制 multipart.Reader超限处理262 收藏
-
493 收藏
-
289 收藏
-
206 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习