事务保护的是一组必须一起成功的动作
数据库事务的核心不是“高级数据库功能”,而是一个很朴素的需求:几步操作要么全部成功,要么全部失败。转账时,从 A 扣钱和给 B 加钱必须一起发生;创建订单时,写订单和扣库存也必须保持一致。如果前一步成功,后一步失败,就会留下脏数据。
Go 标准库 database/sql 提供了事务 API:BeginTx、Commit、Rollback。入门阶段最重要的是掌握固定模式:开始事务,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.ExecContext、tx.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 条件和事务边界共同作用,而不是某一个神奇参数。遇到并发一致性问题时,要结合具体数据库文档和业务场景分析。
常见问题 FAQ
Q: defer tx.Rollback() 在 Commit 后执行会报错吗?
A: Commit 后的 Rollback 会返回一个可忽略的错误(通常是 sql.ErrTxDone)。这是正常行为,不用 panic。
Q: 事务里的查询会锁住表吗?
A: 取决于数据库和隔离级别。MySQL InnoDB 默认行锁,不是表锁。但大事务或大范围扫描可能升级锁粒度。
Q: 事务里能用 db.Query 吗?
A: 不能。事务里所有操作必须用 tx 对象执行,否则那条 SQL 在事务外运行,破坏原子性。
常见陷阱
tx和db混用:在事务里不小心用了db.ExecContext,导致部分操作在事务外执行。- 事务里做外部 API 调用:支付接口、发送邮件等不能包在事务里。它们不受数据库回滚影响。
- 事务太大:长时间持有事务连接,耗尽连接池,拖垮整个服务。
对比表
| 模式 | 原子性 | 适用场景 |
|---|---|---|
| 单条 SQL | 语句级 | 简单插入/更新 |
| 显式事务 | 多语句原子 | 转账、下单 |
| 存储过程 | 数据库端原子 | 复杂业务规则 |
小结
Go 事务代码的基本模式是 BeginTx、defer Rollback、使用 tx 执行所有 SQL、最后 Commit。错误要带上下文,RowsAffected 要检查,事务范围要尽量短。
事务不是万能锁,也不是越大越安全。它保护的是一组数据库操作的一致性。把边界想清楚,Go 的 database/sql 足够写出可靠的入门事务代码。关键金句是:事务只做数据库操作,没有外部调用,失败时全盘回滚。
真实项目用例
在实际团队协作中,下面是几个推荐的工作流:
代码审查清单
- 函数是否处理了所有 error 返回值
- 并发代码是否有明确的退出路径和 WaitGroup
- 用户输入是否经过校验和清洗
- 敏感配置是否通过环境变量或加密存储注入
- 测试是否覆盖了正常路径和至少一个错误路径
- 日志是否包含足够的上下文信息但不泄露敏感数据
- 接口设计是否符合最小接口原则
CI/CD 集成建议
- 每次提交前运行
go fmt ./... - CI 中运行
go vet ./...和golangci-lint run - 单元测试使用
go test -race ./...检测数据竞争 - 关键路径的 benchmark 加入回归测试
- 使用
go mod verify确保依赖完整性
性能调优检查点
- 使用 pprof 分析 CPU 和内存使用
- 关注 benchmark 的 allocs/op,减少高频路径的堆分配
- 检查数据库查询是否使用索引
- 确认外部 HTTP 调用有合理的超时设置
- 缓存热点数据,但注意缓存一致性和过期策略
面试高频考点
如果你正在准备 Go 相关面试,以下概念是高频考点:
- goroutine 和线程的区别
- channel 的缓冲和非缓冲用法
- defer 的执行顺序和与返回值的关系
- map 的并发不安全性和解决方案
- interface 的隐式实现和类型断言
- slice 的底层数组和 append 机制
- GC 的基本原理和调优参数
- context 的使用场景和超时控制
- error 的包装和 errors.Is/errors.As
- sync.Mutex vs sync.RWMutex vs atomic
掌握这些概念意味着你具备了独立开发 Go 服务的基础能力。继续在实际项目中磨练,你会越来越熟悉 Go 的工程风格和最佳实践。
常见问题(FAQ)
Q: 这个特性在实际项目中真的有用吗?
A: 是的。本文介绍的技术来源于真实后端开发场景。无论是标准库工具还是工程实践,在日常服务开发中都会反复用到。
Q: Go 版本会影响示例代码吗?
A: 本文代码主要针对 Go 1.20+ 编写。较新版本(如 1.22、1.23)的语法可能有微调,但核心概念保持不变。如有版本差异,文中会特别说明。
Q: 学习 Go 应该先学标准库还是直接上框架?
A: 强烈建议先学标准库。框架是对标准库的封装和扩展。只有理解了标准库的能力边界,才能正确选择和使用框架,也才能在框架出问题时快速定位。
Q: 代码里的错误处理为什么都是显式的 if err != nil?
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,不会隐藏在任何 try-catch 之后。习惯了之后,你会发现这种写法实际上降低了排查错误的难度。
Q: 并发相关代码怎么测试?
A: 使用 Go 内置的 -race 标志检测数据竞争:go test -race ./...。结合 sync.WaitGroup 和 context.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。
常见坑与避坑指南
- 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
- 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。
defer是一个好习惯。 - 不要忽略错误:即使
defer file.Close()可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。 - 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用
sync.WaitGroup和context管理生命周期。 - 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
- 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。
延伸阅读与实践建议
读完本文后,建议完成以下实践:
- 把文中所有示例代码在自己的机器上跑一遍
- 给示例代码补充错误分支的测试用例
- 尝试基于本文内容构建一个小型完整项目
- 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
- 订阅 Go 官方博客,关注语言演进和最佳实践更新
参考资源
- Go 官方网站:https://go.dev/
- Go 标准库文档:https://pkg.go.dev/std
- Go by Example:https://gobyexample.com/
- Effective Go:https://go.dev/doc/effective_go
- Go 常见问题:https://go.dev/doc/faq
- Go 项目实战社区案例和开源项目源码
本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。
嵌套事务
Go 的 database/sql 不支持真正的嵌套事务,但可以通过 savepoint 模拟:
func nestedTx(ctx context.Context) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `SAVEPOINT sp1`); err != nil {
return err
}
if err := riskyOperation(ctx, tx); err != nil {
tx.ExecContext(ctx, `ROLLBACK TO sp1`)
// continue with other operations
}
return tx.Commit()
}
Savepoint 能让部分操作回滚而保留之前的提交。但不同数据库的 savepoint 语法和语义有差异,使用时要确认具体文档。
连接池与事务
BeginTx 从连接池中获取一个连接,这个连接在事务期间专属于该事务。其他请求不会复用这个连接。这意味着:
- 事务过长会占用连接池资源,影响并发。
- 长时间等待外部 API(如支付网关)不应该在事务内进行。
- 事务结束后连接归还到池中,可以继续复用。
合理设置连接池大小 (db.SetMaxOpenConns) 和事务超时 (ctx.WithTimeout) 是保证系统稳定的关键。
乐观锁
对于并发更新场景,可以用数据库版本号做乐观锁:
result, err := tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance - ?, version = version + 1 WHERE id = ? AND version = ?`,
amount, id, expectedVersion,
)
affected, _ := result.RowsAffected()
if affected == 0 {
return ErrOptimisticLockFailed
}
乐观锁不阻塞读,只在写时检测冲突。适合读多写少的场景。
总结
database/sql 的事务 API 虽然简单,但已经覆盖了绝大多数业务需求。核心原则是:事务要尽快结束,不要包含 IO 或外部调用。把事务边界放在 service 层而非 handler 层,确保数据一致性由业务逻辑保障。配合 savepoint、乐观锁和连接池管理,可以构建可靠的数据访问层。
事务中的只读查询
如果事务中只有查询没有修改,可以标记为 ReadOnly 提高效率:
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelReadCommitted,
ReadOnly: true,
})
ReadOnly 事务不会获取写锁,也不会记录到 WAL(视数据库实现而定),通常并发性能更好。但要注意:标记为 ReadOnly 后如果执行写操作,数据库会报错。
事务超时
txCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
tx, err := db.BeginTx(txCtx, nil)
事务超时后 context 会被取消,后续的数据库操作都会返回 context deadline exceeded。这是一种被动超时机制——超时不主动中断正在执行的 SQL,而是让 SQL 执行完后被 cancel。MySQL 的超时可能需要配合 SET SESSION MAX_EXECUTION_TIME 等设置。
测试事务的隔离性
func TestIsolation(t *testing.T) {
tx1, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
tx2, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
tx1.ExecContext(ctx, `UPDATE accounts SET balance = 100 WHERE id = 1`)
// tx2 should block or fail depending on database
_, err := tx2.ExecContext(ctx, `UPDATE accounts SET balance = 200 WHERE id = 1`)
// test behavior
tx1.Rollback()
tx2.Rollback()
}
隔离级别测试依赖具体数据库实现,跨数据库测试时结果可能不同。单元测试中通常只对业务语义做隔离级别验证,不对数据库本身行为做断言。
总结
database/sql 的事务设计虽然简单,但覆盖了绝大多数业务场景。核心原则是尽快提交、不包外部 IO、错误时回滚。再结合 savepoint、乐观锁、连接池管理和合理的事务超时设置,可以构建出可靠且高效的数据访问层。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。