跨库扣款那笔账对不上的时候,我盯着两张表看了整整一个下午。A 库的余额已经减了,B 库的流水迟迟没落,中间那台应用服务器的日志里只留下一行含糊的超时。这不是什么高深的 bug,就是两阶段提交在真实世界里最典型的翻车姿势——参与者收到 Prepare 之后把行锁住了,协调者在等第二个参与者的投票时连接断了,于是锁一直挂着,直到连接池的回收线程把它清掉,事务既没提交也没回滚。很多团队第一次接触分布式事务,都是从这种"钱扣了但没到账"或者"订单生成了但库存没减"的场景开始的。两阶段提交(2PC)是这类问题最古老也最标准的答案,它把一次跨节点的事务拆成"先问一遍大家能不能干"和"再统一说干还是不干"两步,用协议的方式换取了原子性。但协议只保证逻辑上的正确,不保证工程上的舒服。这篇内容我会把 2PC 从协议骨架、最小实现、真实缺陷到改良路线完整走一遍,适合已经写过业务代码、准备把单库事务扩展到多数据源的开发者,也适合正在做技术选型、想搞清楚 XA 到底该不该上的架构同学。读完你至少能做到两件事:自己手写一个能跑通的两阶段提交协调者,以及在选型会上说清楚它到底会把你坑在哪里。
1. 从一次跨库扣款对不上账说起
1.1 事故现场:锁住的行与没落地的流水
先把场景还原清楚。某支付类业务系统做了一次拆分,账户余额表放在 A 库,交易流水表放在 B 库,中间通过一个事务协调组件串联。正常流量下一切安好,大促当天数据库连接数打满,监控上出现了一批"事务悬挂"的告警。表现是:A 库的余额行被SELECT ... FOR UPDATE锁住,B 库那边完全没动静,应用侧的线程卡在等待响应上,超时时间设的是 30 秒,但连接池的最大等待只有 5 秒,于是线程池先被耗尽,后面所有的请求全部排队。
这个链条里最要命的不是超时本身,而是超时之后没人做决策。参与者在 Prepare 阶段做完资源预留、写完 undo 日志,就进入了"我准备好了但我在等指令"的状态。这个状态下它不能自己提交,也不能自己回滚——因为另一边的参与者可能已经拿到了 Commit 指令。协议把决策权完全交给了协调者,而协调者一旦失联,参与者只能干等。这就是 2PC 被称为"阻塞式协议"的根本原因,它的正确性建立在"协调者总会在有限时间内给出答复"这个假设上,而分布式系统最不缺的就是打破假设的时刻。
1.2 原子性承诺背后到底要付出什么
单库事务里,原子性由数据库引擎自己兜底,redo/undo 日志一写,崩溃恢复的时候自己就能重放。跨库之后,两个引擎互相不认识,谁也不知道对方做了什么,这时候就需要一个第三方把"要么全做、要么全不做"这件事翻译成两个引擎都能听懂的语言。
2PC 的翻译方式是:先让所有参与者做一遍"预演",把要做的事都做一半——资源占住、日志写好、锁加上,但就是不提交;等所有人都表态"我能干"之后,再统一下达"正式执行"的指令。这个设计的巧妙之处在于,预演阶段任何一个人说不行,所有人都可以安全地回退,因为真正的数据还没落盘。代价是预演阶段占住的资源在整个过程中都不能释放,这段时间就是纯粹的性能损耗。你可以把它理解成一场多方签约:所有人先把自己的章盖在一份待生效的合同上,等最后一位签完,合同才正式生效;如果中途有人反悔,那份盖了章的草稿必须全部作废。草稿在桌上摊着的时间里,谁都不能用这张桌子。
1.3 谁该用 2PC,谁最好绕开
我在选型会上被问得最多的一句话是"我们到底该不该上 XA"。判断标准其实不复杂,看三个维度:事务跨度、并发量、数据源类型。如果一次事务只涉及两个同构的关系库、QPS 在几百以内、对强一致有硬性要求(比如资金账务),那 2PC 是合适的,XA 接口成熟、语义清晰、出问题也好排查。反过来,如果事务里掺了缓存、消息队列、文件存储这些不支持 XA 的资源,硬套 2PC 只会得到一个"半吊子分布式事务"——数据库部分能回滚,缓存和消息回滚不了,最终还是要靠补偿逻辑兜底,那还不如一开始就走柔性事务路线。
还有一个容易被忽略的点:2PC 的锁粒度是数据库行锁或表锁,不是业务锁。这意味着持锁时间直接等于"两次网络往返 + 参与者内部处理时间"。在跨机房部署的场景下,一次 RTT 可能就是几十毫秒,两轮下来锁的持有时间轻松过百毫秒,热点行上的并发会瞬间塌方。所以真正上 2PC 的系统,通常会把事务范围压到极小——两三个参与方、单条记录更新、不做复杂查询,把"事务"当成纯粹的写入协调工具,而不是业务编排工具。
2. 两阶段提交的协议骨架与最小实现
2.1 阶段一 Prepare:参与者到底"准备"了什么
很多人以为 Prepare 只是"检查一下能不能干",实际上它做的事情远比检查重。以 XA 规范为例,参与者收到xa_start之后开启一个本地事务,执行完业务 SQL,然后收到xa_end,接着是xa_prepare。这一步数据库会做三件事:把所有修改写入 redo 日志并刷盘、生成 undo 信息、保留事务所持有的所有锁。也就是说,Prepare 成功返回的那一刻,数据在物理层面已经"可以提交"了,只差一条 commit 记录。
这个细节非常关键,因为它决定了 2PC 的故障恢复能力。如果参与者在 Prepare 之后、Commit 之前崩溃,重启后通过xa_recover能把那些处于 prepared 状态的事务捞出来,交给协调者重新决策。这也是为什么 2PC 要求参与者必须支持事务日志的持久化——没有持久化日志的存储介质根本没法参与两阶段提交,Redis 这种内存数据库想接入就得自己实现一套 WAL,成本很高,所以大部分团队会直接放弃让它参与强一致事务。
2.2 阶段二 Commit 与 Rollback:决策一旦下发就不可撤回
阶段二的逻辑看起来很简单:全票通过就发 Commit,有一票否决就发 Rollback。但真正的难点在于决策是不可撤回的。协调者一旦把 Commit 写进自己的日志,就必须保证所有参与者最终都能收到并执行,哪怕要重试一百次。这时候如果某个参与者一直不可达,协调者只能不断地重发,直到它恢复。
这里有个常见的误解:有人觉得"参与者没响应就可以当作失败处理,直接回滚"。这在阶段一是对的,阶段二是绝对错误的。因为阶段二里,其他参与者可能已经提交了,你对这个参与者回滚,数据就彻底不一致了。所以正确的处理方式是无限重试 + 人工介入,把所有"卡住"的事务暴露到运维平台上,让人来判断是继续推还是走补偿。我在实际项目里见过最粗暴的做法是设一个最大重试次数,超了就告警,然后把事务状态标记成"待人工处理",这至少比静默失败强。
2.3 参与者状态机:把每一种中间态都列出来
写 2PC 实现之前,先把状态机画清楚,不然代码里全是 if-else 补丁。下面这张表是我在某次重构时整理的,参与者侧的完整状态迁移:
| 当前状态 | 触发事件 | 下一状态 | 需要做的事 |
|---|---|---|---|
| INIT | 收到 Prepare | PREPARED | 开本地事务、执行 SQL、写 undo 日志、持锁 |
| INIT | 收到 Rollback | ABORTED | 直接返回成功(空回滚) |
| PREPARED | 收到 Commit | COMMITTED | 提交本地事务、释放锁、删 undo 日志 |
| PREPARED | 收到 Rollback | ABORTED | 回滚本地事务、释放锁、删 undo 日志 |
| PREPARED | 协调者超时 | PREPARED | 保持不动,等恢复流程查询 |
| COMMITTED | 重复收到 Commit | COMMITTED | 幂等返回成功 |
| ABORTED | 重复收到 Rollback | ABORTED | 幂等返回成功 |
| COMMITTED | 收到 Rollback | COMMITTED | 拒绝执行,记录异常日志 |
| ABORTED | 收到 Commit | ABORTED | 拒绝执行,记录异常日志 |
表里最后两行是重点。已提交的事务收到回滚指令,必须拒绝并且大声报警,因为这意味着协调者的日志丢了或者被回滚了,是严重的正确性问题。而 ABORTED 收到 Commit 同样要拒绝,不过这种情况更常见于网络重传导致的乱序,需要靠事务 ID 和版本号去重。
2.4 一份能跑的最小协调者实现
下面这段 Java 伪代码是我从一个内部 demo 里抽出来的核心逻辑,去掉了框架依赖,只看协议流程。参与者通过 HTTP 暴露 prepare/commit/rollback 三个接口,协调者负责编排和日志落盘。
public class TwoPhaseCoordinator { private final TxLogRepository logRepo; // 事务日志,落库 private final List<ParticipantClient> participants; public TxResult execute(String txId, List<BranchOp> ops) { // 0. 先写一条全局事务记录,状态 TRYING logRepo.insert(new TxLog(txId, TxStatus.TRYING, now())); // 1. 阶段一:逐个询问 List<ParticipantClient> voted = new ArrayList<>(); for (int i = 0; i < participants.size(); i++) { ParticipantClient p = participants.get(i); try { Vote v = p.prepare(txId, ops.get(i)); if (v != Vote.YES) { rollbackAll(txId, voted); // 有人反对,回滚已投票的 logRepo.updateStatus(txId, TxStatus.ABORTED); return TxResult.rollback("participant " + i + " voted no"); } voted.add(p); } catch (Exception e) { // 超时或网络异常,一律视为 NO,但必须记录 logRepo.markParticipantError(txId, i, e.getMessage()); rollbackAll(txId, voted); logRepo.updateStatus(txId, TxStatus.ABORTED); return TxResult.rollback("prepare failed: " + e.getMessage()); } } // 2. 决策点:先落日志,再发指令 logRepo.updateStatus(txId, TxStatus.COMMITTING); boolean allOk = true; for (ParticipantClient p : participants) { allOk &= commitWithRetry(p, txId); } logRepo.updateStatus(txId, allOk ? TxStatus.COMMITTED : TxStatus.NEED_MANUAL); return allOk ? TxResult.commit() : TxResult.manual("partial commit"); } private boolean commitWithRetry(ParticipantClient p, String txId) { for (int r = 0; r < MAX_RETRY; r++) { try { if (p.commit(txId)) return true; } catch (Exception ignore) { } sleep(backoff(r)); } return false; // 交给人工,绝不回滚 } }代码里最值得注意的两个位置:一是决策点必须先写日志再发指令,顺序反了就会出现"参与者已提交、协调者重启后不记得自己做过决定"的灾难;二是commitWithRetry失败后返回NEED_MANUAL而不是回滚,这守住了阶段二的不可撤回原则。另外rollbackAll只回滚已经投过 YES 票的参与者,没投票的不用管,因为它根本没执行过 Prepare,天然就是干净状态。
3. 实现过程中最容易翻车的四个细节
3.1 超时判定:把"未知"当成"失败"是灾难的起点
第一版实现里我把 Prepare 的超时时间设成 3 秒,超时就当失败处理,直接触发全局回滚。压测的时候一切正常,线上跑到第三天出了一次诡异的数据不一致:A 库回滚了,B 库却提交了。查了很久才定位到原因——B 库的 Prepare 请求其实已经成功执行了,只是响应在路上堵了 3 秒以上,协调者判定超时发起了回滚,但回滚指令到达 B 的时候,B 又恰好收到了之前某次重试带来的 Commit(重试策略写错了,把 Prepare 重试写成了 Commit 重试),于是它提交了。
这个坑的教训是:超时只代表"结果未知",不代表"失败"。正确的处理是把超时的事务标记为PREPARING_UNKNOWN,然后通过一个异步的对账任务去参与者那边查询实际状态(XA 的xa_recover就是干这个的),拿到确定结果之后再决策。如果是自研协议,参与者必须提供一个"按 txId 查询本地分支状态"的接口,这是整个协议能自愈的前提。
3.2 事务日志表设计:几个字段没加,后面全是坑
日志表看着简单,字段设计错了后面改起来很痛苦。我踩过的具体问题是:一开始只存了全局 txId 和状态,没有存每个分支的 txId,导致恢复的时候没法定位到具体是哪个参与者卡住了。后来补了分支表,结构大概是这样:
CREATE TABLE tx_branch_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tx_id VARCHAR(64) NOT NULL, -- 全局事务ID branch_id VARCHAR(64) NOT NULL, -- 分支ID,通常是 参与者标识+序号 participant VARCHAR(128) NOT NULL, -- 参与者地址或标识 status TINYINT NOT NULL, -- 0 INIT 1 PREPARED 2 COMMITTED 3 ABORTED 4 UNKNOWN retry_count INT DEFAULT 0, error_msg VARCHAR(512), create_time DATETIME(3) NOT NULL, update_time DATETIME(3) NOT NULL, UNIQUE KEY uk_tx_branch (tx_id, branch_id), KEY idx_status_time (status, update_time) );uk_tx_branch这个唯一索引是幂等的基石,重复的 Prepare 请求打到参与者,插入冲突就说明这个分支已经处理过了,直接返回上次的结果。idx_status_time是给定时补偿任务用的,扫"超过 N 秒还停在 UNKNOWN 或 PREPARED"的记录。还有一个隐藏字段容易被忘掉:版本号或者状态机的迁移方向,没有它就没法区分"从来没准备过"和"准备过又回滚了",而这两种情况在悬挂处理里的应对方式完全不同。
3.3 悬挂事务:Cancel 跑到 Try 前面的诡异场景
这个问题严格说属于 TCC,但 2PC 里也有类似形态。协调者在发出 Prepare 之后自己挂了,恢复流程判定这次事务失败,发起了 Rollback,参与者正常回滚。结果过了几秒,原来那个 Prepare 请求因为网络抖动又到了,参与者老老实实执行,把资源又锁上了——这就是典型的悬挂。资源锁在一个已经回滚的事务上,永远不会有人来释放它。
解决办法是在参与者侧维护一份"已完结事务 ID"的墓碑记录,Prepare 进来先查这个表,命中就直接拒绝。墓碑记录不能马上删,至少要保留到"超过任何可能的网络重传窗口",实践中一般设 24 小时。我见过一个系统把墓碑表设成 7 天 TTL,代价是每天多几百万行记录,但换来的是再没出现过悬挂问题,这笔账是划算的。
3.4 幂等与重试:每一次重发都得是安全的
2PC 的每一次指令都可能被重发,所以参与者侧的三个接口必须全部幂等。实现方式不难,关键是别偷懒:用(tx_id, branch_id)做唯一键,接口进来先查状态,已经是终态就直接返回成功,处于中间态就按状态机推进,不存在就新建。看起来是个模板代码,但真正写的时候很多人会漏掉"重复 Prepare"这一种,觉得 Prepare 不可能重发——实际上协调者超时重试的时候,第一个 Prepare 可能还在路上,这就是标准的重发场景。
重试策略本身也要讲究。我的经验是指数退避 + 抖动,初始间隔 100ms,上限 10s,加上 ±20% 的随机抖动,避免所有重试在同一时刻打到同一个参与者形成脉冲。另外要给重试设一个总时长上限(比如 5 分钟),超了就转人工,而不是无限重试,否则一个坏掉的分支能把整个补偿线程池拖死。
4. 六个绕不开的缺陷,逐条拆解
4.1 同步阻塞:锁持有时间等于协议往返时间
这是最根本的性能问题。参与者从 Prepare 成功到收到 Commit 或 Rollback 之间,所有被触及的行都处于锁定状态,其他事务想改这些行只能排队。这个窗口的长度等于:协调者处理时间 + 网络往返 + 参与者内部 commit 时间,跨机房场景下百毫秒级很正常。热点账户上,一把锁挂 100ms 意味着同一行的并发能力被压到 10 TPS,这跟单库事务的性能完全不是一个量级。
有人会说那就缩小事务范围、减少参与方。这确实有效,但只能缓解不能消除。真正的出路是把 2PC 限制在极少数真的需要强一致的场景,比如账户扣款、库存扣减这类核心写操作,其他一致性要求稍低的流程走补偿。我在一个项目里做过统计,把 2PC 的使用比例从全部写操作降到 8% 之后,整体 P99 延迟降了将近一半,剩下的 8% 靠资源隔离单独部署扛住。
4.2 协调者单点:它挂了,所有人都干等
协调者在协议里是绝对的中心,它挂了整个事务就悬在半空。虽然可以通过日志恢复重建协调者状态,但恢复期间所有处于 PREPARED 状态的参与者都在持锁等待,这段时间里相关的数据行全是不可写的。恢复得越快损失越小,所以我一般会把协调者做成无状态的、日志和状态全部外置到高可用存储,重启后从日志里重新加载待决策事务,接着往下推。协调者本身的选主可以复用现成的共识组件,没必要自己造。
更麻烦的是协调者脑裂。两个实例同时认为自己负责同一个事务,各自发出不同的决策,参与者就会收到矛盾的指令。防护手段是决策前先在日志存储上做一次 CAS,把状态从 COMMITTING 改成 COMMITTED,谁改成功谁才发指令,另一个实例发现自己改不动就立刻放弃。这个 CAS 是整个协议里最关键的一次原子操作,绝对不能省。
4.3 数据不一致窗口:阶段二的部分失败
理论上 2PC 不会产生数据不一致,但工程实现上总会有一段窗口。比如协调者给三个参与者发 Commit,前两个成功,第三个网络断了,重试了几十次都不行,最后转人工。这段时间里前两个已经提交、第三个没提交,数据在物理上就是不一致的,只是协议承诺"最终会一致"。这个窗口的长度取决于故障恢复的速度,而不是协议本身。如果人工介入要两个小时,那数据就可能不一致两个小时。
降低这个窗口的手段有三条:一是把重试做好,绝大多数网络抖动能在秒级恢复;二是建一个自动对账任务,定期扫 NEED_MANUAL 状态的事务,尝试自动补齐;三是在业务侧做好读补偿,比如查询的时候同时比对两个数据源,发现不一致就走"以主为准 + 异步修复"的逻辑。三条都用上,窗口能压到分钟级。
4.4 参与者故障恢复的不确定性
参与者在 Prepare 之后宕机,重启后从本地日志里能恢复到 PREPARED 状态,但它不知道协调者最终做了什么决定。这时候它只能等,等协调者来查询或者来下指令。如果协调者也恰好挂了并且日志丢了(比如磁盘故障),这个事务就永远悬着了,只能靠人工去两边比对数据来判断该提交还是回滚。
这就是为什么我一直强调协调者的日志必须写到最可靠的存储上,宁可慢一点也不能丢。有团队为了性能把日志写到本地磁盘,觉得反正有副本,结果一次机房级别的故障把日志全带走了,恢复过程极其痛苦。日志的可靠性优先级应该高于其他所有指标,这是 2PC 实现里的铁律。
4.5 长事务与死锁风险叠加
2PC 的事务时长天然比单库事务长,多个分布式的长事务交叉持锁,死锁概率会明显上升。更麻烦的是,数据库自带的死锁检测只在自己内部生效,跨库的死锁它看不见也管不了,最后表现成"两边都在等对方的锁,直到超时"。这种情况靠数据库的innodb_lock_wait_timeout兜底,超时之后事务回滚,但如果回滚的时机不对,又会引出前面说的数据不一致问题。
缓解办法是给所有事务设定统一的超时预算,并且在进入 2PC 之前就把事务范围里涉及的行按固定顺序排序,减少交叉持锁的可能性。这是个很土的办法,但在实际项目里效果显著,尤其是批量处理场景,把 ID 排序之后再批量更新,死锁率能降一个数量级。
4.6 参与者数量带来的线性放大
2PC 的耗时大致等于"最慢的那个参与者"决定,参与者越多,出现慢节点的概率越高。三个参与者时,单个节点 1% 的慢请求概率会让整体慢请求概率涨到 3%;十个参与者就接近 10%。这跟可用性的串联模型是一样的,每增加一个串联节点,整体可靠性就下降一截。
所以 2PC 的参与方数量要严格控制,能合并的尽量合并。比如同一个库里的多张表更新,根本不需要拆成多个参与者,直接一个本地事务搞定。我见过最夸张的设计是一次事务协调七个库,每个库只更新一行,性能惨不忍睹,后来把这些表按业务域合并到三个库,问题迎刃而解。
5. 改良路线:谁在补哪块短板
5.1 3PC 用超时换非阻塞,代价是新的不一致可能
3PC 在 2PC 的 Prepare 前面加了一个 CanCommit 阶段,并且给每个阶段都配了超时机制。核心变化是:参与者在 PreCommit 阶段超时后,会默认执行提交,而不是一直等。这样一来协调者挂了也不会无限阻塞,参与者自己就能推进。代价是——这个"默认提交"的假设可能出错。如果协调者其实决定的是回滚,只是消息没送到,参与者自己提交了,数据就不一致了。
3PC 在理论上把阻塞问题解决了,但引入了新的不一致风险,而且多了一次网络往返。工程实践中真正用 3PC 的系统很少,大多数团队宁愿用 2PC 加补偿,或者干脆走柔性事务,因为 3PC 的不一致窗口虽然比 2PC 小,但一旦发生更难排查——2PC 卡住了至少看得见,3PC 静默提交了完全看不出来。
5.2 TCC:把锁的控制权交还给业务
TCC(Try-Confirm-Cancel)的思路是把两阶段的资源预留从数据库层搬到业务层。Try 阶段做业务层面的资源检查与预留(比如把库存从"可用"移到"冻结"),Confirm 阶段确认扣减,Cancel 阶段释放冻结。数据库上只有本地事务,不再有跨库的锁等待,性能自然好很多。
但它把复杂度转嫁给了开发者。三个接口都要自己写,幂等、防悬挂、空回滚三件事一个都不能少。空回滚是指 Cancel 先于 Try 到达(Try 请求丢包了),这时候 Cancel 必须能识别出"我根本没 Try 过"并直接返回成功;悬挂是指 Try 后于 Cancel 到达,需要靠前面说的墓碑记录来拦截。这两块逻辑写在每个业务方法的开头看起来重复,但绝对不能抽成通用框架就完事——不同业务的资源预留语义差别很大,硬套模板迟早出事。
5.3 本地消息表与 Saga:接受最终一致,换取吞吐
如果业务能容忍秒级到分钟级的不一致,本地消息表是最省心的方案。核心思路是:本地业务操作和消息记录在同一个本地事务里落库,然后由一个投递任务扫描消息表往外发,下游消费成功之后回执。这样就把跨库问题拆成了"本地事务 + 异步消息"两个简单问题,没有分布式锁,没有协调者。
Saga 则是把长事务拆成一串本地事务,每个都配一个补偿动作,任何一步失败就倒着执行补偿。它适合流程长、步骤多的场景,比如订单履约。缺点是补偿动作本身可能失败,也要幂等,而且中间状态对外可见——用户可能看到一个"已下单但未扣库存"的瞬间状态,需要通过状态机把这种中间态显式定义出来,不让前端误判。
5.4 选型对照:把场景和方案的匹配关系摆在一起
| 方案 | 一致性强度 | 性能开销 | 实现复杂度 | 典型适用场景 |
|---|---|---|---|---|
| 2PC / XA | 强一致 | 高,锁持有时间长 | 中,有标准接口 | 资金账务、核心库存 |
| 3PC | 理论强一致,有边界风险 | 中高 | 高 | 少见,学术或特殊场景 |
| TCC | 最终一致,可控 | 低 | 高,三接口都要写 | 交易、支付、订单 |
| 本地消息表 | 最终一致 | 很低 | 低 | 通知、积分、日志类同步 |
| Saga | 最终一致 | 低 | 中高,需设计补偿 | 长流程业务编排 |
| 最大努力通知 | 弱一致 | 极低 | 低 | 对账、外部系统回调 |
这张表的用法不是对照着挑,而是先确定一致性要求,再在同一档里挑实现成本最低的。资金类业务只能在第一行里选,那就老老实实吃 2PC 的性能代价;但凡能接受最终一致,就别碰 XA,本地消息表能解决 80% 的问题。
6. 落地时的工程细节:日志、监控与演练
6.1 补偿任务的调度与限流
补偿任务是整个方案的兜底,它挂了一切都白搭。我的做法是单独起一个调度器,按状态和时间窗扫描待处理事务,分优先级:UNKNOWN 状态最高,因为它可能正在持锁;PREPARED 超时的次之;NEED_MANUAL 最低,主要做提醒。扫描的时候必须限流,单次批量大小控制在 100 以内,避免一次性拉几万条把数据库压垮。
幂等在这里同样重要。补偿任务可能被多实例并发执行,同一条事务被两个线程同时捞到,所以捞取的时候要用乐观锁或者SELECT ... FOR UPDATE SKIP LOCKED(支持的话),保证一条记录同时只有一个执行者。我踩过的坑是早期用了简单的状态更新,两个线程同时抢到同一条,都去发 Commit,虽然参与者幂等没出问题,但日志里多了一堆重复记录,排查的时候很误导人。
6.2 必须盯住的几个指标
监控不是配了大盘就完事,得有具体的判断阈值。我一般会盯这几个:Prepare 成功率低于 99% 就要看是不是某个参与者不健康;平均持锁时长超过 200ms 要排查慢 SQL 或者网络;悬挂事务数量出现非零就是告警级别,说明有事务状态异常;补偿重试次数分布的 P99突然抬升,通常意味着下游有抖动;NEED_MANUAL 堆积量持续增长,说明自动恢复能力不够,得优化重试策略。
还有一个容易被忽略的指标:事务日志表的增长速度。如果每天新增几百万行而且从不清理,几个月之后查询性能就会明显下降。归档策略要提前设计,比如已完结超过 7 天的记录转移到历史表,主表只保留活跃事务。
6.3 故障注入:不演练就等于没做
线上出问题的时候,你希望看到的是"这套流程我演练过",而不是"让我想想该怎么办"。所以我一般会在测试环境定期做几类注入:杀掉协调者进程、在 Prepare 和 Commit 之间断开参与者网络、让某个参与者返回超时、模拟日志库不可用。每一类注入都要验证三件事——事务最终状态是否正确、有没有残留的锁、补偿任务能不能自动收敛。
有一类场景特别值得演练:协调者在决策点前后崩溃。决策点之前崩溃,恢复后应该重新走一遍投票流程;决策点之后崩溃,恢复后必须把 Commit 推完。这两个行为完全相反,代码里如果日志写入和指令发送的顺序搞错了,就会在这里暴露出来。我做过一次这样的演练,结果发现日志写完但状态没更新,重启后把所有已提交的事务又投了一遍票,虽然幂等兜住了,但暴露了状态机迁移的不严谨。
7. 写在我自己的几个项目之后
真正把 2PC 用起来的团队,通常都会经历一个心态转变:一开始把它当成万能钥匙,什么跨库操作都往上套;被性能打脸之后开始收缩,只留最核心的场景;再后来会发现,2PC 的价值不在于它能让所有事都强一致,而在于它给了你一个明确的边界——边界内的事它负责,边界外的事你自己负责。把边界画清楚,比把协议实现得多优雅重要得多。
我在实际项目里最有用的一条经验是:先写测试用例,把各种异常路径都列出来,再写实现。异常路径包括 Prepare 超时、Commit 部分失败、协调者重启、参与者重复收到指令、事务悬挂,每一个都要有对应的断言。这些用例写起来枯燥,但它们是唯一能证明你的实现在异常下还站得住的东西。协议本身只有两页纸,工程实现里的坑全在这些边角料里。
最后分享一个排查技巧:线上出现事务卡住的时候,先别急着看业务日志,直接去事务日志表里按状态和时间排序,SELECT * FROM tx_branch_log WHERE status IN (0,1,4) ORDER BY update_time ASC LIMIT 50,这五十条基本就能定位到问题在哪个参与者、卡了多久、有没有在重试。绝大多数"数据不一致"的现场,答案都在这张表里。