先从连接和事务的关系说起
在 Go 里讨论事务,很多人第一反应是 GORM 的 Transaction,或者是 database/sql 的 BeginTx。但真正容易忽略的是:事务不是应用层面的概念,它绑定的是数据库连接。你在代码里拿到一个 *sql.Tx,本质上是连接池分配了一条物理连接,并且这条连接进入了事务模式。连接池把连接归还的时候,事务也就随之结束了。

写一个最基础的本地事务,通常是这样的:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err := tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - 100 WHERE id = ?", userID); err != nil {
return err
}
if err := tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + 100 WHERE id = ?", targetID); err != nil {
return err
}
return tx.Commit()
这段代码本身没有什么问题,但它隐含了一个约束:所有参与事务的 SQL 都必须使用同一个 tx,而不是回到 db 上去执行。很多团队稍不注意,就会在某个方法内部重新使用 db.Exec,结果这条 SQL 跑到了另一条连接上,事务对它完全无感。更隐蔽的情况是,这个事务因为某些代码路径迟迟没有提交,连接一直被占用,连接池不断新建连接,最终数据库连接数被打满。
嵌套事务:Go 里有这个能力吗
当业务慢慢复杂起来,Service 层会调用多个 Repository 方法,每个 Repository 又各自有事务。这时就出现了一个很自然的想法:能不能在外面再包一层事务,形成嵌套事务?
答案很直接:Go 标准库没有提供事务传播行为,*sql.Tx 上不存在开启嵌套事务的接口。如果你强行在已开启事务的连接上继续 BeginTx,行为取决于具体驱动,多数情况下会报错或者产生不可预期的状态。而如果不同方法各自通过 db.BeginTx 开事务,那它们在连接池里拿到的很可能是不同的连接,彼此之间是隔离的,根本没有嵌套关系。
这里有一个常见的认知误区:认为 GORM 的 Transaction 支持嵌套。GORM 在检测到当前上下文已经处于事务中时,会复用已有事务,而不是开启新事务。MySQL 也提供了 SAVEPOINT,可以让内层逻辑回滚到指定位置,但 SAVEPOINT 不是事务,它只是在同一个事务里打的标记。无论套了多少层,最终提交或回滚仍然由最外层那个事务统一决定。
因此在 Go 里面讨论嵌套事务,真正要解决的问题不是“怎么支持嵌套”,而是“怎么让所有数据操作共享同一个事务”。最常见的设计是让 Repository 层接收一个 DBTX 接口:
type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
func InsertOrder(ctx context.Context, db DBTX, req Order) error {
_, err := db.ExecContext(ctx, "INSERT INTO orders ...", ...)
return err
}
这个抽象虽然简单,但它把事务边界的控制权从数据访问层收回了业务层。业务层可以传入 *sql.Tx 让多个操作处于同一事务,也可以传入 *sql.DB 让操作各自独立。把这个设计做好,比讨论各种花哨的嵌套方案更重要。
系统拆分之后,本地事务为什么不够用
上面讨论的还都局限于单库情况。一旦服务拆分,库存是库存库、订单是订单库、会员是会员库,本地事务马上就失效了。道理也不复杂:事务是通过数据库连接和日志协作实现的,不同服务之间没有共享连接,也没有共享 undo log 和锁,自然无法用一个事务把多个服务的写入打包起来。
这个阶段最常见的现象是:线上出了数据不一致,第一反应是把代码里所有原本独立的操作塞进一个大的事务方法。结果发现根本没有用,因为操作的是不同的服务,事务根本覆盖不到另一方的数据库。
真正的第一步,应该是先回答两个问题:其一,有没有可能通过修改业务流程,把需要强一致的数据放到同一个数据库里?其二,如果不可能,业务能够接受最终一致吗?这两个问题想清楚了,后面选择什么方案才有意义。
分布式事务方案中,2PC 为什么很少被直接使用
在分布式事务的经典理论里,两阶段提交提供的是强一致保证。协调者先向所有参与者发送 prepare,所有参与者都回复 ready 之后,协调者再发送 commit。看起来完美,但工程实现里有几个绕不开的坎。
- prepare 之后到 commit 之前,参与者持有的锁要一直保留,长事务会把数据库资源锁死。
- 协调者是单点,一旦崩溃,所有参与者都停在 prepare 状态,靠人工干预才能恢复。
- 网络分区时,协调者无法确定参与者状态,只能阻塞等待,业务可用性受到很大影响。
这些缺陷决定了 2PC 只适合跨库写并发量低、事务时间极短的场景。在 Go 生态里,真正直接实现 2PC 的项目并不多,更多是引入 Seata 或者 DTM 这类框架,用它们的 AT 模式或 XA 模式来封装底层细节。但即便有框架,2PC 的性能瓶颈和协调者故障问题依然存在,不会因为包了一层框架就消失。
Saga 模式:用补偿取代回滚
Saga 是另一种思路。它不再尝试跨服务锁数据,而是把一个长事务拆成若干有序的本地事务,每一步都有对应的补偿动作。任何一步失败,就逆序执行之前所有步骤的补偿操作,让系统回到业务上的一致状态。
以订单链路为例:创建订单、扣减库存、增加积分,这是三个服务上的三个本地事务。如果扣库存失败,就执行创建订单的补偿,把订单标记为已取消。补偿不一定是数据上的物理还原,它只需要让业务状态达到一个正确的语义结果。
编排式 Saga 是利用一个中心协调者来驱动整个流程,所有步骤的执行状态都由协调者维护。Go 里用代码来表达一个简化版是这样的:
type SagaStep struct {
Name string
Execute func(ctx context.Context) error
Compensate func(ctx context.Context) error
}
func RunSaga(ctx context.Context, steps []SagaStep) error {
var executed []SagaStep
for _, step := range steps {
if err := step.Execute(ctx); err != nil {
for i := len(executed) - 1; i >= 0; i-- {
if cErr := executed[i].Compensate(ctx); cErr != nil {
return fmt.Errorf("step %s execute failed: %w, compensate failed: %v", step.Name, err, cErr)
}
}
return err
}
executed = append(executed, step)
}
return nil
}
这个版本省略了很多东西:没有状态持久化,没有超时控制,没有幂等处理。但核心结构已经出来了。真实项目中,补偿过程可能执行到一半应用宕机,重启后必须根据持久化记录继续补偿;补偿操作本身也可能失败,需要有重试机制和告警。也就是说,Saga 的工程重点不在编排逻辑本身,而在如何保证补偿一定会被执行到位。
另一种是协同式 Saga。它没有中心协调者,每个服务执行完本地事务后通过消息队列发布事件,下一个服务监听事件继续执行。例如订单服务发布“订单已创建”事件,库存服务消费事件后扣减库存并发布“库存已扣减”事件。协同式的好处是去中心化,扩展性好,但流程被分散在各个服务里,遇到问题很难追踪当前进度,排障成本要高不少。
五种事务方案怎么选
理论上每次做技术选型,都逃不开一致性、隔离性、可用性和实现复杂度的权衡。下面这张表是一个实践上比较认可的参考:
| 方案 | 一致性 | 隔离性 | 工程复杂度 | 适用场景 |
|---|---|---|---|---|
| 本地事务 | 强一致 | 完整 | 低 | 单服务单库,大多数业务的主路径 |
| 2PC / XA | 强一致 | 资源锁定 | 高 | 跨库短事务,并发低,一致性要求极高 |
| TCC | 最终一致 | 业务自定义 | 高 | 有明确 Try / Confirm / Cancel 阶段语义 |
| Saga 编排式 | 最终一致 | 弱 | 中 | 跨服务流程清晰,能明确定义补偿逻辑 |
| Saga 协同式 | 最终一致 | 弱 | 中高 | 事件驱动架构,异步链路长,扩展性优先 |
选 TCC 的场景在 Go 项目里相对少见,因为它的实现要求对每个业务操作都拆出 Try、Confirm、Cancel 三个子动作,业务侵入性很强。大多数团队写到最后,都会发现 Saga 这种最终一致的模式已经足够,剩下的问题通过良好的状态机和补偿设计解决。
落地建议与典型坑
在 Go 项目的事务管理实践里,一些建议基本可以沉淀成几条。
第一,事务边界应该在设计阶段就明确下来,而不是写代码时顺手决定。一个业务用例通常对应一个事务边界,比如“下单”这个用例,边界应该是从创建订单到扣减库存的完整过程,而不是每写一个 SQL 就开一个事务。边界不清晰,后面无论用什么框架都会很痛苦。
第二,不要把外部 API 调用包进本地数据库事务。这个错误在真实项目里反复出现。有人为了确保“支付成功后更新订单状态”不会出现中间态,把 HTTP 调用和数据库更新放在同一个事务里。结果第三方接口响应慢,事务长时间不提交,连接池被耗尽,整个服务失去响应。正确的做法是先提交本地事务,再调用远程接口,失败后通过本地消息表或者异步任务补偿。
第三,补偿逻辑不是重构出来的,是设计出来的。Execute 和 Compensate 往往语义完全不一样。“创建订单”的补偿是“取消订单并写入取消原因”,“扣减库存”的补偿是“回补库存并校验超卖”。这两种操作放在同一个函数里通过方向参数区分,只会让代码越来越难维护。每个步骤的补偿应当单独设计和测试。
补偿失败才是分布式事务最需要关注的点。一个补偿操作被触发后,应用可能刚好崩溃,可能网络超时,也可能下游服务暂时不可用。因此补偿必须自身具备幂等性,并且能够被后台任务重复调度,才能保证最终一致。
第四,选择分布式事务框架之前,先衡量业务数量。如果整个系统里只有一处跨服务写操作需要一致性保障,用本地消息表加定时重试就足够了。只有当你发现多条链路都存在同类问题、需要一个统一机制来管理 Saga 流程时,引入 DTM 或者 Seata 才算是收益大于成本。框架能解决流程管理和状态存储的问题,但它也会引入新的基础设施依赖,这个运维代价必须算进账里。
写在最后
事务管理在 Go 项目里的难点,从来不是 API 怎么调用,而是边界怎么划分。单库场景下,把事务边界理清楚、把 *sql.Tx 显式传递、让 Repository 层不私自开启事务,这三点一旦落实,绝大多数本地事务问题都不会出现。跨库跨服务场景下,先问自己能不能接受最终一致,能接受再谈 Saga;不能接受,就要正视 2PC 的性能和可用性代价。
Saga 是一个实用主义的方案,它用最终一致换可用性,用补偿逻辑换回滚能力。但它的前提是每一步的本地事务都足够干净、补偿操作都足够健壮。把地基打牢,再让 Saga 在上面跑,才有意义。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/812/