跨服务之后,「事务」只剩下一半含义
在单体应用里,一次借阅创建只有一件事要保证:借阅记录写进去、库存扣掉,要么都成,要么都不成。本地事务就能做到,因为两个操作共享同一个连接、同一把锁、同一个提交点。
拆成两个服务之后,这个「同一个提交点」消失了。借阅服务有自己的库,库存服务有自己的库,中间隔着一层网络。此时说「分布式事务」,其实是在问三个不同的问题:
- 原子性:两个库的修改能不能同时生效、同时失效?
- 隔离性:一方改了、另一方还没改的这段时间,第三方读到什么?
- 可恢复性:进程崩溃、网络断开、超时之后,谁来把残局收拾干净?
三种主流落地,本质上是把这三个问题的答案放在不同位置:
| 方案 | 原子性靠什么 | 隔离性靠什么 | 残局谁来收 |
|---|---|---|---|
| 2PC | 协调者 + 参与者 prepare | 资源锁一直持有到提交 | 协调者,或者人工 |
| TCC | 业务层的 confirm/cancel | 预留资源,中间态对外可见 | 事务管理器重试 + 人工 |
| 最终一致 | 本地事务 + 可靠投递 | 不保证,只保证最终收敛 | 对账 + 补偿 |
下面用同一个业务场景把三种方案各走一遍:libraryd 里「创建借阅」要写借阅表,同时让库存服务把可借数量减一。这是本书构造的合成场景,不是任何真实系统的复盘。
方案一:两阶段提交
2PC 的流程很短:协调者先让所有参与者 prepare,全部回复「可以提交」之后再发 commit;任何一个回复「不行」,就全体 rollback。
放到 libraryd 上:
协调者(借阅服务)
├─ prepare loan 库:插入 loan 行,加锁,不提交
├─ prepare 库存库:inventory.qty -= 1,加锁,不提交
├─ 两边都 OK → commit 两边
└─ 任一失败 → rollback 两边
前提:两个库都得支持 XA 这类两阶段协议,并且有一个可靠的协调者。数据库层面这通常没问题,MySQL、PostgreSQL、Oracle 都有 XA 实现。
代价是所有人都知道的那几条,但值得逐条说清楚它们为什么会咬人:
| 代价 | 具体表现 | 后果 |
|---|---|---|
| 同步阻塞 | prepare 之后资源锁一直持有到 commit | 借阅高峰时库存行被跨网络锁住,其他请求排队 |
| 协调者单点 | 协调者崩溃时参与者停在 prepare | 锁不释放,只能等协调者恢复或人工介入 |
| 提交阶段不确定 | commit 发出后部分参与者没收到 | 一部分提交、一部分没提交,数据不一致 |
| 超时语义含糊 | 参与者不知道是该回滚还是该等 | 只能靠启发式决策,可能和协调者相反 |
第三条是最难受的:2PC 保证的是「正常情况下原子」,不是「任何情况下原子」。 协调者在提交阶段崩溃,参与者会长期停在 prepared 状态;此时人工介入要判断「到底该提交还是回滚」,而这个判断所需的信息恰好随协调者一起丢了。
把一次成功路径和一次协调者崩溃的路径并排摊开,能看清锁到底持有了多久:
| 时刻 | 正常路径 | 协调者在 T3 崩溃 |
|---|---|---|
| T1 | 协调者发 prepare | 同左 |
| T2 | 两个参与者加锁、写日志、回 OK | 同左 |
| T3 | 协调者收到两个 OK | 协调者崩溃,未发出 commit |
| T4 | 发 commit,参与者提交、释放锁 | 参与者停在 prepared,锁不释放 |
| T5 | 协调者记录完成 | 锁一直持有,直到协调者恢复或人工裁决 |
正常路径里锁的持有时间是「两次网络往返 + 两次本地写日志」。崩溃路径里,这个时间没有上界。生产环境里这个上界通常由 DBA 的「长时间锁等待」告警来兜底,也就是说,2PC 的最终兜底手段是人工。
还有一条常被忽略:2PC 的「强一致」是提交意义上的原子,不是读意义上的隔离。两个库各自提交的瞬间之间,仍然存在一个第三方能观察到「借阅有了、库存没减」的窗口,只是这个窗口比最终一致短得多。
方案二:TCC
TCC 把两阶段从数据库层搬到业务层,要求每个参与者提供三个接口:
try:预留资源,不真正扣减;confirm:确认使用预留的资源;cancel:释放预留的资源。
libraryd 里库存服务的三个接口可以是这样:
// 预留:可借数量减一,冻结数量加一;不改变「已借出」
boolean tryReserve(String bookId, String loanId);
// 确认:冻结数量减一,已借出加一
boolean confirm(String bookId, String loanId);
// 取消:冻结数量减一,可借数量加一
boolean cancel(String bookId, String loanId);
协调者(借阅服务)先调所有参与者的 try,全部成功再调 confirm;任何一个 try 失败,就对已经成功的那些调 cancel。
TCC 解决了 2PC 的阻塞问题:try 阶段结束就释放数据库行锁,资源以「冻结」的形式留在业务表里,不再占用数据库锁。但代价从数据库转移到了业务代码,而且是三份:
| 问题 | 触发条件 | 不处理的后果 |
|---|---|---|
| 空回滚 | cancel 先于 try 到达(try 超时但实际没执行) | 把本来没预留的资源又加了一次,库存凭空变多 |
| 悬挂 | cancel 执行完,迟到的 try 才到达 | 资源被永久冻结,再也不会有人来 confirm |
| 幂等 | confirm/cancel 被重复调用 | 重复扣减或重复释放 |
这三个问题不是理论风险,是 TCC 实现的必答题。通常的做法是给每个 try 记一条带状态的事务记录(TRYING / CONFIRMED / CANCELLED),cancel 遇到不存在的记录时插入一条「已取消」的墓碑记录来挡住迟到的 try。也就是说,TCC 的每个参与者都要自己实现一个小型事务日志,这部分工作量经常被低估。
前提:资源必须可预留。库存、额度、优惠券名额可以;「发一条通知」「调一次外部风控」这类不可逆、不可预留的动作不行。
代价:每个参与者三个接口,加上防悬挂、幂等、超时补偿,再加一个可靠的事务管理器来驱动重试。参与者数量越多,组合爆炸越严重——三个参与者就是九种中间状态。
TCC 的状态机长什么样
把每个参与者的 try/confirm/cancel 展开,会得到一张状态迁移表。这张表是 TCC 实现的真正规格,写代码之前就应该先画出来:
| 当前状态 | 收到 try | 收到 confirm | 收到 cancel |
|---|---|---|---|
| 不存在 | 建记录,置 TRYING,预留资源 | 拒绝(无记录),记一条 CONFIRMED 墓碑 | 建记录,置 CANCELLED(墓碑,挡住迟到的 try) |
| TRYING | 幂等返回成功 | 置 CONFIRMED,冻结转已用 | 置 CANCELLED,释放预留 |
| CONFIRMED | 幂等返回成功(不重复预留) | 幂等返回成功 | 拒绝(已确认,不能再取消) |
| CANCELLED | 拒绝(悬挂,明确回滚) | 拒绝 | 幂等返回成功 |
这张表里有三行是「拒绝」,而每一次拒绝都意味着一个异常分支。TCC 框架能帮你生成这些分支,但生成不了「哪种拒绝该报警、哪种拒绝该静默」的判断——那要业务来定。
再补一条实践中的经验:cancel 的墓碑记录不能设过期时间太短。如果协调者在 try 超时后 30 秒才发 cancel,而墓碑 10 秒就被清理,迟到的 try 会重新预留,资源就悬挂了。墓碑的保留期必须大于「最长重试链路的时长」,这个数只能从自己的超时配置里推,不能拍脑袋。
方案三:最终一致
第三种方案承认一件事:跨服务的强一致买不起,那就把窗口说清楚,把收敛机制做扎实。
libraryd 的写法是:借阅服务在本地事务里同时写 loan 表和 outbox 表;中继进程把 outbox 里的记录发到 library.loan.created;库存服务消费事件、扣减库存、写自己的幂等表。整个链路里没有任何跨库锁。
它的保证只有一句:如果借阅创建成功,库存最终会减一;如果中间任何一步失败,要么重试到成功,要么进入对账和补偿。 注意「最终」两个字没有被量化——这正是 4.3 要解决的问题。
前提:
- 业务能接受一段时间的中间态(借阅已创建、库存还没减);
- 每个消费者都能幂等处理重复事件;
- 有一条对账链路能发现「库变了但事件没发」这类静默失败;
- 有一个补偿出口处理对账发现的差异。
代价:
| 代价 | 说明 |
|---|---|
| 一致性窗口 | 从毫秒到分钟不等,取决于积压与消费能力 |
| 幂等成本 | 每个消费者都要有业务幂等键,不能只靠位移 |
| 对账成本 | 需要定期比对写侧、事件、读侧三方,见 4.2 |
| 补偿成本 | 差异不能自动修复时,要有明确的人工出口,见 4.3 |
| 认知成本 | 「现在数据是对的吗」这个问题没有一行 SQL 能回答 |
最后一条最容易被低估。2PC 里运维问「现在一致吗」,答案是查协调者日志;最终一致里这个问题要变成一个持续运行的对账任务。
最终一致的投递环节有三种做法
「最终一致」不是一个方案,是一族方案。它们的分歧点在同一个地方:怎么让「数据库已提交」和「事件已发出」这两件事不脱节。 主流做法有三条:
| 做法 | 机制 | 前提 | 代价 |
|---|---|---|---|
| 本地消息表(outbox) | 业务写和消息写在同一本地事务;中继轮询并发送 | 业务库与 outbox 同库同事务 | 中继有延迟;需要标记发送状态;至少一次,消费端要幂等 |
| 半消息 + 回查 | 先发一条对消费者不可见的消息,本地事务成功后再提交;失败则回查业务状态 | broker 支持半消息与回查接口 | 强绑定特定 MQ;回查接口要幂等且能查准 |
| 变更数据捕获(CDC) | 订阅数据库日志,把行变更翻译成事件 | 有可订阅的日志(如 binlog);能稳定解析 schema | 事件语义由表结构决定,容易退化成「表变更」而不是领域事件;运维链路长 |
三条都能用,但适用场景不同。outbox 最通用,缺点是中继是自研的、要自己保证「至少一次 + 幂等」;半消息最省事,缺点是换 MQ 就得重写;CDC 侵入最小,缺点在 2.1 已经说过——从表变更里反推领域事件,很容易把 UPDATE inventory SET qty = qty - 1 直接暴露成事件,下游拿到的是「一行变了」,不是「一本书被借走了」。
本书统一用 outbox,原因是它不依赖特定 broker,且「表结构即协议」,出问题可以用一条 SQL 查清楚。4.2 会把它的工程细节和兜底对账完整走一遍。
三方案的四维对照
把上面的讨论压成一张表。四个维度是选型时真正会吵架的四个:
| 方案 | 一致性强度 | 可用性 | 实现成本 | 失败模式 |
|---|---|---|---|---|
| 2PC | 提交原子;读不隔离,提交间隙可见中间态 | 最低:任一参与者不可用即整体阻塞,锁持有到提交 | 中:依赖 XA 驱动;协调者、超时、启发式回滚的运维复杂 | 协调者崩溃导致参与者长期持锁;提交阶段部分失败产生不一致,需人工判定 |
| TCC | 最终一致,但中间态是「预留」,对外可解释、可查询 | 中:try 阶段要求全体可用;confirm/cancel 可异步重试 | 高:每个参与者三个接口,外加防悬挂、空回滚、幂等与事务管理器 | try 成功而 confirm 丢失导致资源长期冻结;cancel 失败导致资源永久占用 |
| 最终一致 | 最终一致,窗口未被约束前不可承诺 | 最高:异步,单个下游故障不阻塞上游写入 | 中:outbox、幂等消费、对账、补偿四件套;组织成本高于代码成本 | 事件静默丢失(写库未写事件)、重复消费、乱序、长期不一致只能靠对账发现 |
这张表里没有「正确」的一行,只有「你的业务能承受哪一列的代价」。
为什么大多数业务选第三种
不是因为第三种更先进,而是因为另外两种的代价落在业务最不愿意付的地方。
第一,可用性和一致性强耦合。 2PC 把「所有参与者都活着」写进了提交条件。库存服务做一次发布、数据库做一次主从切换,借阅创建就整体不可用。对一个有真实流量的系统来说,这个耦合面太大了。
第二,失败模式的运维成本不对称。 2PC 和 TCC 的失败是「卡住」:资源锁不释放、预留不确认。这类故障不会自己好,必须有人判断、有人操作。最终一致的失败是「慢了」:事件还在路上,或者已经进了死信。它的大部分情况可以自动收敛,剩下的小部分进对账报告,按批次处理。能自动收敛的失败,运维成本远低于需要人工判定的失败。
第三,业务本身通常不需要强一致。 借阅创建之后,库存晚一点减一,用户感知不到;真正感知得到的是「借阅创建失败」。把强一致换成可用性,用户体验是变好的。
但有两类场景不该跟着选第三种:
| 场景 | 为什么不能只靠最终一致 | 更合适的做法 |
|---|---|---|
| 账户余额、押金冻结 | 中间态是「钱」,窗口内可能出现超额消费 | TCC(冻结额度)或账务系统内部的强一致 |
| 稀缺资源的名额抢占 | 超卖不可逆,补偿只能退钱不能退体验 | TCC 预留,或数据库唯一约束 + 乐观锁 |
判断标准不是「重不重要」,而是中间态能不能被业务解释、能不能被补偿。库存减晚了可以补;名额超卖了,补偿只能道歉。
Kafka 事务不等于分布式事务
这是选型时最常见的误解,值得单独澄清。
Kafka 的事务(transactional.id + beginTransaction/commitTransaction)解决的是**「消费一批消息、处理、再 produce 一批消息、再提交位移」这四步在 Kafka 内部的原子性**。它保证下游 read_committed 的消费者要么看到全部输出,要么一条都看不到。
它不保证你的数据库写入和消息发送原子。下面这段代码里的 jdbcUpdate 和 producer.send 仍然可能一个成功一个失败:
producer.beginTransaction();
try {
jdbcUpdate(conn, loan); // 数据库,不参与 Kafka 事务
producer.send(new ProducerRecord<>("library.loan.created", key, value));
producer.commitTransaction(); // 只对 Kafka 这一侧生效
} catch (Exception e) {
producer.abortTransaction(); // 数据库那一侧不会回滚
}
要让它真正原子,数据库必须自己参与同一个事务协议(XA),或者干脆用 outbox 把「数据库写入」和「消息发送」解耦成两步。这也解释了为什么 4.2 要花一整节讲本地消息表:在没有 XA 的世界里,outbox 是成本最低的「伪两阶段」。
Kafka 事务真正有用的地方在流处理内部:读一个主题、写另一个主题、更新状态存储,这三件事由 Kafka 自己协调。卷二第 3 章从客户端角度讲过它的边界,本卷 8.3 会从 Streams 的 processing.guarantee 角度再讲一次。
一个具体的判断:借阅与押金
抽象的对照表落到两个真实流程上,结论会清楚很多。这两个流程都在 libraryd 里,一个是借阅创建,一个是借阅时的押金冻结。
借阅创建:写 loan 表、库存减一、给预约队列发一条通知。
- 中间态是什么?「借阅有了、库存还没减」。用户看到的是「借阅成功」,库存页面上少一本书要等一个一致性窗口。
- 中间态能被业务解释吗?能,就是「系统在同步」。
- 能补偿吗?能,库存多减了就加回来,事件漏了就重发。
- 结论:最终一致。用 outbox + 事件,库存与通知各自消费。
押金冻结:从会员账户冻结 50 元,借阅归还时解冻或扣款。
- 中间态是什么?「钱冻了、借阅没建」或者「借阅建了、钱没冻」。
- 能解释吗?不能。用户会看到余额少了但没借到书,客服也无法判断这 50 元是该退还是该扣。
- 能补偿吗?技术上能,但每一次补偿都是一次账务调整,需要凭证、需要审计。
- 结论:TCC。账户服务提供
tryFreeze/confirmFreeze/cancelFreeze,冻结是一个对外可查的状态,用户能看到「冻结中」,窗口内也有明确语义。
同一个系统里两种方案并存,这不是摇摆,是按中间态的可解释性分流。判断顺序应该是:先看中间态长什么样,再看用什么技术,而不是反过来。
顺带说一个常见的过度设计:把所有跨服务调用都套上 TCC,包括「发通知」「写日志」这类不可预留的动作。这类动作的 try 无事可做,只能空转,cancel 也只能记一笔「本来要发但没发」,平白多出两倍的调用量和三倍的状态。不可预留的动作,本来就应该走事件。
选型清单
把上面的判断做成可以照着走的顺序:
- 先问中间态能不能被业务解释。 不能解释的(钱、名额),排除纯最终一致。
- 再问资源能不能预留。 能预留的,TCC 是可用性与一致性的折中;不能预留的(外部调用、通知),只能最终一致 + 补偿。
- 再问参与者数量。 参与者超过三个,TCC 的中间状态组合会失控,优先考虑把跨服务调用改成事件。
- 最后问失败能不能自动收敛。 不能自动收敛的差异,必须先设计好人工出口,再谈上线。
- 只有在参与者少、且都支持 XA、且团队有 2PC 运维经验时,才考虑 2PC。 三个条件缺一个,都不划算。
libraryd 的选择是:借阅创建走最终一致(借阅与库存),押金冻结走 TCC(账户服务预留额度),没有任何一条链路用 2PC。这个组合不是最优解,是一个团队能维护得住的解。
小结
- 跨服务之后,「事务」拆成原子性、隔离性、可恢复性三个独立问题,三种方案是把它们放在不同位置。
- 2PC 保证提交原子,但同步阻塞、协调者单点,且提交阶段崩溃会留下无法自动判定的残局。
- TCC 把锁从数据库搬到业务,代价是每个参与者三个接口,外加空回滚、悬挂、幂等三个必答题。
- 最终一致用窗口换可用性,代价是对账与补偿;它的失败大多能自动收敛,这是它被大多数业务选中的真正原因。
- Kafka 事务只覆盖 Kafka 内部的读-处理-写,不覆盖数据库;把它当分布式事务用是选型里最常见的错误。
- 判断该用哪一种,看中间态能否被业务解释、资源能否预留、参与者有几个、失败能否自动收敛。
阅读导航:上一节 3.3 投影重建与重放 | 下一节 4.2 本地消息表与对账
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。