3.2 分布式事务:最终一致方案

下单这个动作要写三个地方:订单分片(订单主表)、库存分片(库存表)、另有积分系统。三处在不同库甚至不同系统,没有任何单机事务能罩住它们。XA 强一致方案评估后放弃了——性能差、协调器单点、资源锁持有时间长。本节讲我们最终采用的本地消息表方案,以及它如何用对账兜底一切异常。

场景:下单链路的三次写入

下单链路的写入顺序是:先在订单分片插入订单,再在库存分片扣减库存,最后调积分服务发货增加积分。任何一步都可能失败:库存不足、积分服务超时、网络分区。业务要求是「宁可订单创建失败也不要超卖」,但「订单创建成功而积分没加」允许分钟级滞后,由对账修复。

一致性方案 一致性强度 性能 复杂度 业务适配度
XA 事务 强一致 差(长锁) 中 高但代价大
TCC 强一致 好 极高(三套接口) 中
本地消息表 最终一致 好 中 高
事务消息 最终一致 好 低 依赖消息中间件能力
不做事务 无 最好 无 低(对账兜底)

为什么放弃 XA 与 TCC

XA 方案在预发环境实测过:下单链路在三库 XA 事务下,下单耗时从 12 毫秒升到 210 毫秒,吞吐降为原来的十分之一。原因是 XA 协调阶段对三个库的行锁持有时间从毫秒级拉长到百毫秒级,高并发下锁等待直接雪崩。TCC 方案评估时发现要为每个跨库操作写 try、confirm、cancel 三套接口,订单业务涉及五种外部系统,开发量约等于把整个订单系统重写一遍。

方案 实测吞吐 开发量评估 最终裁决
XA 三库事务 100 QPS 低 性能不可行
TCC 3000 QPS 极高 成本不可行
本地消息表 2800 QPS 中 采用
事务消息 2600 QPS 低 备选,依赖 MQ 新特性

本地消息表的吞吐几乎追平 TCC,代价仅是「分钟级不一致加对账兜底」,而订单业务恰好接受这个窗口。方案选型的核心从来不是技术先进性,而是「业务能接受什么程度的不一致窗口」——回答了这个问题,方案自己就浮出来了。

方案:本地消息表加定时对账

本地消息表的核心思想:把「跨库操作」变成「本地写消息表加异步投递」。下单事务里只做两件事:写订单(订单分片)、写本地消息表(与订单同一个分片,可以走本地事务)。库存扣减由消息消费者异步执行,失败重试,成功标记。

组件 职责 关键设计
本地消息表 记录待执行跨库操作 与订单同分片,本地事务保证
消息投递器 扫描未投递消息 每 500ms 扫一批,推送到消息队列
消息消费者 执行库存扣减与积分 幂等操作,按订单号去重
对账服务 兜底不一致 每小时比订单与库存,见 3.4 节

为什么不让订单服务直接发消息到 MQ?因为「写订单成功但发消息失败」仍然存在。消息表与订单表在同一个分片同一个事务里,要么一起成功要么一起失败,这是整个方案的关键。投递器扫描与重试机制可以参考 go 实战:微服务 CPS 中的可靠消息设计,我们这里其实是在做轮询版的事务消息。

配置:消息表的表结构与投递代码

消息表的设计要点是「幂等键」与「可重投递」。幂等键是订单号加操作类型,消费者按它去重;重投递靠状态字段加重试计数:

CREATE TABLE t_local_message (
  msg_id      BIGINT AUTO_INCREMENT PRIMARY KEY,
  biz_key     VARCHAR(64)  NOT NULL,
  biz_type    VARCHAR(32)  NOT NULL,
  payload     TEXT,
  status      TINYINT      NOT NULL DEFAULT 0,
  retry_cnt   INT          NOT NULL DEFAULT 0,
  next_retry  DATETIME     NOT NULL,
  KEY idx_status_next (status, next_retry)
);

投递器的主循环很直白:取一批 status 为待投递且到时间的消息,推送到 MQ,推送成功就把 status 改成已投递。消费者处理失败则把消息标记为待重试、next_retry 按重试次数退避。重试 8 次仍失败的消息进入死信表,由人工介入。

// 下单事务内:订单与消息表同事务写入
tx.begin();
orderDao.insert(order);             // 落订单分片
msgDao.insert(buildStockMsg(order)); // 同分片消息表,本地事务罩住
tx.commit();                         // 库存由异步消费者扣减

踩坑:消息表自己变成瓶颈

运行三个月后消息表单分片累积了 8000 万行,投递器的扫描越来越慢。根因是消息表从未归档——成功投递的消息也留在表里。后来加了「已投递且超过 7 天的消息按天归档」的定时任务,表行数稳定在 2000 万行以内。消息表一定要配归档任务,否则它就是下一个大表。

第二个坑是重复消费。MQ 的 At Least Once 语义必然重复投递,消费者必须幂等。库存扣减的幂等用「订单号加唯一索引」实现:库存流水表对订单号建唯一索引,重复插入直接违反约束被吞掉。如果扣减逻辑里有多步非幂等操作,幂等设计要画评审图逐条过。

第三个坑是消息表的扫描间隙与对账窗口叠加。投递器 500ms 扫一轮、消费者处理有延迟、对账一小时一轮——一笔订单最极端的不一致窗口约 1 小时。客服系统做「下单后立即查积分」时经常落空,最后在客服页面加了「积分延迟到账」的提示,对账后自动修复。产品设计要配合技术现实,这种提示比强一致方案便宜得多。

操作清单:最终一致改造的四步流程

步骤 动作 注意事项
1 识别事务边界 列出所有跨库写操作 从代码里找事务注解
2 设计幂等键 确定消费者去重方案 唯一索引最可靠
3 建消息表 与业务表同分片建表 配归档任务
4 建对账 参照 3.4 节五步流水线 有容忍窗口

四步顺序不能颠倒:先识别边界才知道哪些操作需要异步化,幂等键设计先行才能写消费者,对账兜底最后补。跳步改造的团队通常会在第三步发现前两步的假设全错。

迁移期还有一段过渡值得记录。消息表上线时采用「双跑」策略:原有的同步调用库存接口保留,消息表异步路径同时跑,两者结果由对账比对。双跑一个月,确认异步路径稳定后才把同步调用摘掉。任何异步化改造都值得用双跑过渡,直接切换的勇气在数据库领域通常被称为「事故」。

再补一句关于告警:消息投递器的积压量与重试计数必须进监控。某次消费者挂了 20 分钟,积压了 6 万条消息,因为投递器设计成了「失败就记日志」而没有告警,直到对账报差异才发现。从那以后,「消息积压超过 1000 条」就是 P1 告警,规则简单但救命。

小结

本地消息表用一次本地事务加一个异步投递器,把跨库事务降级为最终一致。它的可靠性链条是三段:消息表罩住「写订单与写消息」的原子性、幂等消费者罩住重复投递、定时对账兜底一切漏网之鱼。选它的核心理由是业务允许分钟级不一致——如果业务不允许,TCC 或者 Saga 是唯一出路,成本高一个量级但别无选择。而选型的第一步永远是去问业务方这个问题,而不是先在技术方案表里打勾。我们当年评审会上花了二十分钟只讨论这一个问句,省下了后来整整两周的返工。

再补充一个设计要点:消息表的存储要跟它服务的业务在同一个分片。下单产生的消息与订单同片,这意味着每个分片都有自己的消息表与投递器,投递器天然分布式、无单点。有的团队把消息表集中放在一个库里,那个库立刻成为全系统的事务瓶颈——这是此方案最常见的架构错误。

延伸阅读