前阵子接了一个订单系统的重构,表面上逻辑并不复杂:用户下单、扣库存、生成订单,最后再发一条通知。麻烦在于服务拆完之后数据库也跟着拆了,订单、库存、账户落在各自独立的库里。以前放在一个数据库里能靠单库事务解决的问题,现在被拆成三个独立事务。结果就是线上时不时出现“库存扣了但订单没生成”、“订单生成了但库存没扣”这类数据不一致。排查的时候两边对数据、跑对账脚本、人工补单,一个月能折腾好几回。
分布式事务这个话题,每个做微服务的后端迟早都要面对。标题里列的这四种方案——本地消息表、事务消息、SAGA、TCC——就是我在这类项目里反复用过、也反复踩坑之后想拿出来说清楚的东西。这篇文章不跟你扯抽象理论,我会把每种方案的原理、Go 代码怎么落地、实际运行中会遇到什么问题,以及最后怎么选型,一次讲透。适合正在做微服务拆分、被跨库数据一致性搞得头疼的 Go 后端开发者阅读。不管你项目里已经用了 go-zero、kratos,还是自己搭了一套纯 Go 微服务,这些思路都能直接套用。
1. 分布式事务为什么绕不开:一个“下单扣库存”场景的真实困境
1.1 从一次订单与库存对不上的事故说起
先说个我实际经历过的场景。当时线上有个下单接口,逻辑是:订单服务调用库存服务扣减库存,然后写入订单记录。表面看每一步都成功了,但偶尔会出现一种很隐蔽的情况——库存服务那边扣减成功,订单服务这边因为网络超时返回了失败,前端提示用户下单失败,但用户实际已经被扣了库存。
这就是典型的“多个服务各自成功,但整体不一致”。如果你只在单个服务内做重试,问题依然存在:重试可能会导致重复扣减,不重试则数据永远对不上。当时我们紧急写了脚本去扫两边数据,手工把库存补回来,过程非常痛苦。更麻烦的是,这类问题在流量小的时候出现频率低,一旦赶上大促流量上来,问题会成批出现,靠人工兜底根本不现实。
我复盘的时候发现,问题的根子不在某一段业务代码质量上,而在架构本身:事务边界被服务拆分和数据库拆分切碎了。原来单库事务里“要么全成功、要么全失败”的保证,在分布式环境下已经不成立了。
1.2 多个本地事务合不到一个事务的本质原因
先搞清楚一件事:事务的 ACID 特性,比如原子性、持久性,都是针对单个数据库实例而言的。你在同一个 MySQL 库里面,可以靠BEGIN和COMMIT把多张表操作包在一个事务里,要么全成要么全不成。
但微服务拆分之后,扣库存和写订单发生在两个数据库实例,甚至可能是异构存储(一个在 MySQL,一个在 Redis)。这时候你没有一种魔法可以跨库开启同一个事务——全局事务协调要解决的核心问题是:多个参与者各自执行本地事务,最后如何达成一个双方都认可的最终结论。
这个问题接近于分布式共识,但又有本质区别。比如传统 XA 两阶段提交可以做到强一致,它的问题是协调者单点阻塞、锁资源时间长,在互联网高并发场景下代价太高。所以业界才衍生出标题里的这些“柔性事务”方案:不追求所有节点同时一致,而是接受中间状态,通过补偿、异步、重试、对账等方式让数据最终达到一致。
我的建议是,动手写代码之前先建立这个心智模型:分布式事务方案没有一种能在一致性、可用性、性能、成本四个维度同时做到满分,你做的每一种选择都是在做权衡。
1.3 先分清你的业务到底要强一致还是最终一致
这是我在很多项目里发现的最容易被忽略的一步。很多团队一上来就问我:“我们用 TCC 是不是最稳?”但当我问他业务场景是什么时,他往往回答不出来。
分布式一致性可以分两个级别:强一致和最终一致。强一致意味着任何时刻读到的数据都是最新且全局统一的,典型就是转账、支付、资金冻结这类。最终一致则是允许数据在一段时间内不一致,但最终会收敛到一致状态,典型就是下单后异步发送通知、积分累计、优惠券发放。
你要做的第一件事,就是判断自己业务到底属于哪一类。如果你做的是普通订单流、物流状态、通知触达,那最终一致完全够用,没必要上 TCC;如果你做的是账户扣款、余额变动这类资金操作,那必须考虑强一致或者至少是带隔离性的强最终一致。这个判断直接决定下文的方案选型方向,所以我把它放在最前面讲。
2. 本地消息表:看似“土”但就是好用的落地派方案
2.1 核心思想:业务操作和消息写入必须同库同事务
本地消息表,也有人叫 Outbox 模式,其实思路特别简单:你在执行业务操作的那个数据库里,额外建一张消息表。业务数据和待发送消息在同一个本地事务里一起写入,事务提交之后,消息记录就一定存在。然后再由一个异步任务把消息表里的消息投递给下游系统,投递成功后更新消息状态。
这个方案的关键点在于“同库同事务”。为什么这一步是灵魂?你对比一下常见错误写法就明白了。很多新手写成:先去做业务操作(比如扣库存),成功之后再去调用 MQ 发送消息。这里面有个窗口期:业务已经提交,但消息还没发出去,或者发送时网络断了。这时候下游系统永远收不到通知,两边数据就开始不一致。本地消息表把消息的落库和业务操作放在同一个事务里,消息要么和业务一起成功,要么一起失败,直接把那个窗口期消灭掉。
2.2 Go 落地:一个极简的 Outbox 实现
我平时写这套方案,表结构一般是这样的:
CREATE TABLE outbox_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(64) NOT NULL COMMENT '业务唯一ID,用于下游幂等', biz_type VARCHAR(32) NOT NULL COMMENT '业务类型,如 ORDER_CREATED', payload JSON NOT NULL COMMENT '消息载荷', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待投递 1-已投递 2-失败', retry_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_next_retry (status, next_retry_at) ) ENGINE=InnoDB;业务写入时,关键是把它包在同一个事务里:
func CreateOrder(ctx context.Context, req *CreateOrderRequest) error { return db.WithTx(ctx, func(tx *sql.Tx) error { // 1. 执行核心业务:写入订单 if err := createOrder(tx, req); err != nil { return err } // 2. 同一个事务里写一条待发送消息 payload := buildPayload(req) if err := insertOutboxMessage(tx, req.OrderID, "ORDER_CREATED", payload); err != nil { return err } return nil }) }投递端用一个定时任务或者后台 worker 扫描status=0的记录。我这里用的是轮询加指数退避重试:
func PollAndSend(ctx context.Context) { for { messages := loadPendingMessages(ctx, batchSize) if len(messages) == 0 { time.Sleep(1 * time.Second) continue } for _, msg := range messages { err := sendToMQ(ctx, msg) if err != nil { markFailed(ctx, msg) continue } markDone(ctx, msg) } } }消息发送成功后,就把status置为 1。如果连续重试多次失败,就置为 2 并转入死信处理流程,人工或者对账系统介入。整个过程不复杂,核心就是一张表、一个 worker、一个幂等处理逻辑,跑起来非常稳。
2.3 我踩过的坑:重复投递、顺序错乱、消息积压
本地消息表看着简单,真正用起来还是有几个坑,我一个个说。
第一个坑是重复投递。如果你的投递服务部署了多个实例,每个实例都去扫同一张消息表,同一个消息可能被两个实例同时取出、同时投递。下游如果没有做幂等,就等着收重复数据吧。我曾经就因为这个原因,订单服务收到两条一模一样的创建消息,导致用户收到两条短信。解决办法有三个:一是保证投递任务只运行在单个实例上;二是扫数据时加FOR UPDATE行锁;三是本质上所有下游接口必须按biz_id做幂等,这是兜底方案,无论如何都要做。
第二个坑是消息顺序错乱。本地消息表天然不保证顺序,如果你一个订单有多条业务消息(比如先创建后支付),两条消息可能被两个 worker 线程并发拿走后乱序投递。下游如果依赖顺序处理就会出问题。解决方案是让同一个biz_id的消息固定走同一个投递队列,或者干脆在消息里带上业务时间戳,下游做顺序校验。
第三个坑是消息积压。如果你一小时才扫一次表,那最终一致性的收敛时间就是小时级。业务上如果不能容忍,就要提高扫描频率,或者改为推模式。我在一个项目里把扫描间隔压到 200ms,配合批量拉取,整体效果很好。另外要注意死信处理不能只靠告警,必须有定时任务把失败超限的消息捞出来重放或者转人工。
这套方案最舒服的一点是:你不需要引入额外中间件,只要有一个数据库就能跑。很多团队在项目初期不引 MQ 或者不想引入太多组件时,我都会推荐先上这种方案。
3. 事务消息:把“本地消息表”搬进中间件之后的体验差异
3.1 事务消息怎么解决“先发消息还是先做业务”的悖论
RocketMQ 的事务消息,本质上是把本地消息表逻辑下沉到消息中间件里,由中间件来保证“本地事务执行”和“消息发送”的一致性。它的核心机制有三个关键词:半消息、本地事务、回查。
我先说流程,很简单:生产者先发送一条半消息(Half Message)到 Broker,半消息的特点是暂时不可见,下游消费者无法消费它;然后生产者执行本地事务;执行完根据本地事务结果,向 Broker 提交 Commit 或者 Rollback;如果是 Commit,这条消息变成可见状态,下游可以消费;如果是 Rollback,Broker 把消息删除。
这里就解决了那个著名的悖论:先发消息再执行本地事务,消息发了事务失败怎么整?先执行本地事务再发消息,事务成功消息没发出去怎么办?事务消息通过“先发给中间件一个预备信号,再把业务结果告诉中间件”来解决。
万一生产者执行本地事务之后突然宕机了、没来得及通知 Broker 呢?Broker 会定期回调生产者做回查,问它:“你那条业务到底成功没有?”生产者的回查接口根据本地事务执行记录回答 Commit 还是 Rollback。
这段机制你可以理解为:RocketMQ 自己在内部维护了一个“消息表”,然后它还提供了回查机制帮你确认业务状态。这不就是我在第二章写的本地消息表吗?区别只是实现位置不同。
3.2 Go 接入 RocketMQ 事务消息的代码流程
Go 这边接入 RocketMQ 事务消息,通常用rocketmq-client-go。用起来的核心是实现一个TransactionListener,里面要做两件事:执行本地事务、提供回查。
type OrderTransactionListener struct { db *sql.DB } func (l *OrderTransactionListener) ExecuteLocalTransaction(msg *rmq.Message) rmq.LocalTransactionState { // 解析消息体里的业务参数 req := parseCreateOrderRequest(msg.Body) // 执行本地事务:创建订单、插入业务流水 err := executeLocalBusiness(l.db, req) if err != nil { return rmq.RollbackTransaction } return rmq.CommitTransaction } func (l *OrderTransactionListener) CheckLocalTransaction(msg *rmq.Message) rmq.LocalTransactionState { // 回查逻辑:根据业务ID查数据库,判断本地事务是否已提交 orderID := parseOrderID(msg) exists, err := isOrderExists(l.db, orderID) if err != nil || !exists { return rmq.RollbackTransaction } return rmq.CommitTransaction }发送半消息的话,普通SendMessageSync不行,得用事务消息的发送方式:
txProducer := rocketmq.NewTransactionProducer(listener, opts...) err := txProducer.Start()然后业务代码里这样写:
err := txProducer.SendMessageInTransaction(context.Background(), msg)这里我提醒一下,回查接口里的逻辑一定要轻量,并且要能根据业务 ID 快速查到本地事务结果。我当时在回查接口里直接查了 MySQL 里的事务日志表,没有引入复杂逻辑,Broker 回查频率高,但压力还在可控范围。
3.3 和本地消息表的差异,别被中间件包装迷惑
很多文章会把事务消息吹成独立于本地消息表的“另一种方案”,实际用的时候你会发现它俩在思想上是一样的。但工程落地上的体验差异还是有的,我整理下:
| 维度 | 本地消息表 | 事务消息 |
|---|---|---|
| 消息表维护 | 自己建表,自己管状态 | 中间件内部处理,无需建表 |
| 投递机制 | 自己写 worker、定时任务 | 中间件天然支持消息消费 |
| 事务状态回查 | 自己实现失败补偿机制 | 中间件自动回查,需实现回调接口 |
| 引入成本 | 仅依赖数据库 | 需要部署 RocketMQ,团队要会运维 |
| 吞吐能力 | 受数据库性能限制 | 由 MQ 集群承载,吞吐更高 |
| 故障场景 | 数据库挂掉无法投递 | MQ 挂掉影响发送,但消息存储可靠性更高 |
我的经验是:如果团队里已经有 RocketMQ,或者本来就要上消息队列,那事务消息是更省心的选择。如果不想引中间件、没有专职运维团队,那本地消息表反而更可控。两个都可以达成最终一致,别被概念忽悠——事务消息只是把“你本来要写的那张表”换成了中间件内部的实现。
4. SAGA:适合长流程的补偿式编排,但别把它当成万能药
4.1 两种编排方式:事件编排和命令编排,Go 项目里我更推荐后者
SAGA 的核心思想是:把一个长事务拆成一串本地事务,每个本地事务对应一个正向操作,同时为它预留一个补偿操作。如果中间某一步失败,就逆序执行前面各步的补偿操作,把系统拉回初始状态。它不要求在同一个时刻所有操作都可见,而是靠“补偿”来还原。
SAGA 有两种编排模式。事件编排(Choreography)是指各服务通过消息互相通知,A 做完发消息给 B,B 做完发消息给 C,哪个失败由谁发补偿消息。这种模式服务间耦合很低,但流程是隐式的,出问题时你很难从代码里一眼看清整个链路。命令编排(Orchestration)是有一个 SAGA 协调器,集中编排每一步该调谁、失败该补偿谁。流程显式、便于监控和恢复。
我在 Go 项目里更推荐命令编排。原因很简单:长流程业务一旦失败,排查链路已经很困难,再没有一张全局状态图,真的会死人。集中式协调器虽然多一个组件,但它能记录每一步的执行状态,也方便实现断点恢复。
4.2 用 Go 写一个简单的 Saga 执行器
我自己写过的最小可用版本,核心数据结构其实就两个:步骤列表和补偿步骤。
type SagaStep struct { Name string Action func(ctx context.Context) error Compensation func(ctx context.Context) error } func RunSaga(ctx context.Context, steps []SagaStep) error { for i, step := range steps { if err := step.Action(ctx); err != nil { // 逆序执行补偿 for j := i - 1; j >= 0; j-- { if cErr := steps[j].Compensation(ctx); cErr != nil { // 补偿失败,记录日志,交给人工或者重试任务处理 log.Errorf("compensate step %s failed: %v", steps[j].Name, cErr) } } return err } } return nil }这只是一个极简执行器,生产环境还有两个必须补上的点。第一是状态持久化,你要有一张saga_instance表,记录当前执行到哪个步骤、处于什么状态(运行中、成功、补偿中、补偿完成)。这样如果协调器本身挂了,重启之后可以从断点继续执行,而不是从头再来。第二是每一步的执行和补偿都要幂等,因为重试机制会反复调用。
我还建议把每一步的入参和结果都记录下来,作为审计日志。这一步当时我偷懒没做,后来出了问题排查很久,只能翻业务日志一点点拼,真的非常痛苦。
4.3 SAGA 真正难的是补偿逻辑,不是执行器
SAGA 框架逻辑写起来很快,但一家公司真正使用 SAGA 时,成本往往花在补偿代码上。你以为补偿就是把正向操作“反过来执行一次”,但现实远没有这么简单。
第一,补偿必须幂等。比如正向操作是“扣减积分”,补偿是“加回积分”,但如果同一个补偿被重试了两次,积分就多加了一次。这时候要对每个操作绑定业务唯一 ID,在加积分接口里按 ID 去重。
第二,补偿可能失败。比如补偿需要调用的下游服务也挂了。这时候你不能放弃,要有重试机制,并且最终要给人工留一个操作入口。
第三,SAGA 缺少隔离性。我举一个实际例子:一个 SAGA 流程里,步骤 A 扣减库存,步骤 B 写订单,步骤 C 发物流。如果步骤 A 已经执行了但 B 还没执行,此时另一个请求来查库存,它看到的是扣减后的值——这个值在 A 还没提交事务时是看不到的(取决于本地事务隔离级别),所以其实 SAGA 的各个本地事务是各自可见、逐步提交的。如果业务需要临时数据不可见,你必须自己加状态字段或者额外锁来弥补。
我在实际使用中的体感是:SAGA 适合那些步骤很多、周期较长,并且中间状态可以容忍的业务。比如旅游产品预订:订机票、订酒店、订接机,每一步都是独立的供应商系统,无法做统一回滚,只能逐层补偿。
5. TCC:从理论到 Go 实现的 Try/Confirm/Cancel 三阶段
5.1 TCC 的三阶段到底是干嘛的:冻结、确认、解冻
TCC 是这几套方案里业务侵入最强的,也是唯一能给你提供一定隔离性的方案。它把一个事务分成三个阶段:Try(资源预留)、Confirm(确认执行)、Cancel(取消并释放预留资源)。
我拿账户转账来举例。假设从账户 A 转 100 块到账户 B。用 TCC 写的话:Try 阶段先把 A 账户的 100 元冻结起来,不动 B 账户;Confirm 阶段做真正扣减 A、增加 B;Cancel 阶段解冻 A 的预扣金额。
这里的关键是 Try 阶段不真正扣钱,只是“锁住”。这样做的好处是:同一笔钱可以被多个 TCC 事务同时“预占”,但只有最终 Confirm 的那一笔会生效,其他事务发现预留不到就直接失败。这种隔离性 SAGA 是给不了的。SAGA 就是一锤子买卖:扣了就是扣了,错了再补回来;TCC 则是先把资源锁住,最终一次性落实。
TCC 的代价也很明确:每个参与事务的业务接口都要写三套逻辑,而且这三套逻辑都要正确处理并发、幂等、异常顺序。业务代码量几乎是原来的三倍。
5.2 Go 里的 TCC 接口与协调器最小实现
Go 里要实现 TCC,第一步就是把每个参与资源抽象成一个接口:
type TCCBranch interface { Try(ctx context.Context, txID string, params map[string]interface{}) error Confirm(ctx context.Context, txID string) error Cancel(ctx context.Context, txID string) error }协调器管理一个全局事务,事务状态至少要有:TRYING、CONFIRMING、CANCELLING、FINISHED。全局事务 ID(txID)会传递给每个分支,用来标识这是一次完整的 TCC 事务。
协调器的核心逻辑是:
func ExecuteTCC(ctx context.Context, txID string, branches []TCCBranch) error { // 1. 依次调用 Try for _, branch := range branches { if err := branch.Try(ctx, txID); err != nil { // 2. Try 失败,逆序执行 Cancel cancelAll(ctx, txID, branches) return err } } // 3. 全部 Try 成功,依次 Confirm for _, branch := range branches { if err := branch.Confirm(ctx, txID); err != nil { // Confirm 失败:进入补偿流程,多数场景要转人工 return err } } return nil }生产级的 TCC 协调器一定不要把状态存在内存里,要落到数据库。我用的表结构包括全局事务表(tx_id、status)和分支事务表(tx_id、branch_id、status),每次阶段变更都更新状态。这样协调器可以安全重启。
5.3 空回滚、悬挂、幂等:TCC 的三个老坑
TCC 工程落地最麻烦的是三个边界问题,我把它们单独拿出来说。
第一个是空回滚。一个分支的 Cancel 被调用时,对应的 Try 可能根本没执行过。比如协调器调 Try 时网络超时了,它认为失败然后直接调 Cancel,但实际 Try 请求可能到了服务端但响应丢了,或者明确失败了。Cancel 面对一个从未 Try 过的分支,你绝不能直接执行“解冻”操作,而是要识别出“没有冻结记录”,直接返回成功。我在实现里给每个分支事务记录加了一个状态位,Cancel 之前必须查这个记录,没有就按成功处理。
第二个是悬挂。一个分支的 Try 请求被延迟了很久才到达,而 Cancel 已经被先执行了。这时候 Try 如果继续执行“冻结”操作,就会产生一个永远不会被释放的悬挂资源。解决办法就是我在分支事务表里记录 Cancel 是否已经执行过,Try 执行前先查一遍,如果已 Cancel 就拒绝执行 Try。
第三个是幂等。Confirm 和 Cancel 都可能因为网络重试被调用多次。所以每个分支的三个方法都要根据事务记录状态做幂等控制,比如同一 txID 已经 Confirm 过,再收到 Confirm 就直接返回成功。
我经历过一次严重的线上事故,就是因为没做好悬挂处理:订单服务重试导致一个冻结操作在取消之后才执行,那笔钱在账户里被冻结了一个星期,直到对账脚本发现异常。从那以后,我在 TCC 方案里对分支事务状态的管理看得比什么都重。
6. 四大方案选型对照:按业务容忍度选,而不是按技术流行度
6.1 一张表看清四个方案的核心差异
做选型之前,先把四个方案的硬指标放在一起看。
| 维度 | 本地消息表 | 事务消息 | SAGA | TCC |
|---|---|---|---|---|
| 一致性级别 | 最终一致 | 最终一致 | 最终一致 | 业务层面强一致 |
| 隔离性 | 无 | 无 | 无 | 有资源预留 |
| 实现成本 | 低 | 中 | 中高 | 高 |
| 业务侵入 | 中 | 中 | 中 | 高,接口三倍逻辑 |
| 对中间件依赖 | 无 | 依赖 MQ | 可自研 | 可自研 |
| 适用场景 | 异步通知、订单流转 | 异步通知、高吞吐 | 长流程、多步骤跨系统 | 资金、库存预占 |
| 故障恢复难度 | 低 | 中 | 中 | 高 |
| 吞吐影响 | 小 | 小 | 中 | 较大,锁资源 |
这张表是给团队做技术评审用的。你会发现本地消息表和事务消息在指标上非常接近,差别只在运维偏好;SAGA 和 TCC 差距明显,TCC 的隔离性换取的是沉重的业务侵入。
6.2 Go 生态里应该手写还是引框架
写到这里,肯定有人问:Go 生态里有没有现成框架可以直接用?有,常用的是dtm和seata-go。DTM 是目前 Go 社区比较活跃的分布式事务框架,支持消息表、SAGA、TCC、事务消息等模式,go-zero、kratos 这类微服务框架可以很方便地集成。Seata 是从 Java 生态移植过来的 Go 实现,支持 AT、TCC、SAGA 模式,但 Go 的落地成熟度我体感比 DTM 略低一点。
但这不意味着你项目一上来就该引框架。我的判断标准很简单:如果业务里只有一两个场景需要分布式事务,且流程短,手写本地消息表或者自己包一个简单的 Saga 执行器就够了。如果业务里分布式事务场景很多、各种模式穿插,那引 DTM 这类框架才划算,它能帮你省掉大量事务管理代码。
引入框架也有隐性成本:你要理解它的协调器模型、状态存储方式、分布式锁和事务表设计,出问题时要能读它的源码。我见过不少团队把 DTM 引进来,最后因为不了解内部状态机,出问题也只能干瞪眼。
6.3 我的选择顺序和建议
我自己做选型时有一套固定流程,建议你参考。第一,先问业务能否接受最终一致。如果不能接受,直接看 TCC;如果能接受,继续下一步。第二,系统里是否已有 MQ 基础设施。有就用事务消息,没有就用本地消息表。第三,流程是不是很长、涉及多个系统且难以回滚。是,想清楚再上 SAGA。第四,有没有资源竞争、资金风险这类必须隔离的场景。有,上 TCC。
这个顺序排下来,你会发现大部分互联网业务最终都落在本地消息表或事务消息上,这是好事,因为复杂度最低。SAGA 和 TCC 不是不用,而是要等到确切的业务理由出现再用,而不是为了技术时髦去用。
最后分享一个我在实际项目中反复验证的经验:无论你选了哪个方案,有下三件事不能省——幂等、重试、对账。幂等保证重复调用不产生副作用,重试保证临时故障有机会被自动恢复,对账保证即使整个链路出现漏网之鱼也能被事后发现。我做的所有项目,即便上了 TCC,也仍然保留了每天一次的对账脚本。分布式事务只是把问题从“必然发生”变成了“偶尔发生”,对账兜底才是让这个“偶尔”彻底可控的终极手段。