单库单表的年代,事务就是个注解的事:方法上挂个@Transactional,数据库帮你把 undo log、redo log、行锁都安排明白,出异常就回滚,干净利落。真正让人睡不着的,是服务被拆开之后——订单、库存、账户各守一个库,一次下单要跨三个服务写四次数据,中间任何一次网络抖动、任何一台机器重启,都可能留下"库存扣了钱没扣"或者"钱扣了两次"这种烂账。分布式事务这四个字,就是被这类问题硬生生逼出来的。这篇是系列的第 6 篇,前面几篇把基础概念、一致性模型、常见中间件的底层机制都铺过一遍了,这一篇做的是"把地图摊开"的活:分布式事务到底在解决什么问题,理论底座剩下多少,五条主流技术路线各自适合什么场景,以及怎么用最朴素的方式手写一个能跑起来的最终一致性方案。刚接手微服务拆分、正在被跨服务数据不一致折磨的开发者,能直接抄走一套可用的骨架;已经用过现成框架、但说不清为什么选它的老手,也能在这里把选型的判断依据重新捋一遍。
1. 从一次"钱对不上"的排查说起
先说个虚构但极其典型的场景。某电商团队把单体拆成了订单服务、库存服务、账户服务三个应用,各自一个数据库。下单主流程是这样的:订单服务创建订单,然后通过 RPC 调库存服务扣减库存,再调账户服务冻结金额。上线第一个月风平浪静,第二个月开始出现零星客诉:用户说库存显示已售罄,但自己的订单状态还是"待支付",钱也没扣。运维查日志发现,库存服务那次调用成功了,账户服务的调用因为超时被订单服务判定为失败,订单状态回滚成"已取消",但库存已经实实在在扣掉了。三个服务,三个库,没有任何一个组件能同时看到这三笔写入,也没有任何机制能把已经生效的库存扣减"撤销"回来。
1.1 本地事务的能力边界在哪里断掉
要理解分布式事务,得先看清本地事务是靠什么撑起来的。数据库之所以敢承诺 ACID,靠的是四样东西:同一个连接上下文、undo log(回滚日志)、redo log(重做日志)、以及基于锁的并发控制。这四样东西有一个共同前提——所有操作落在同一个数据库实例、同一个存储引擎的管辖范围内。事务开始时数据库记住了一个快照点,所有修改先进 undo log,提交时按顺序刷 redo log,回滚时按 undo log 逆向撤销。整个过程数据库是唯一的裁判,它说回滚就一定回滚得干净。
一旦写入跨出了这个实例,裁判就没了。库存库不知道账户库发生了什么,账户库也不认订单库的回滚指令。你写再多try-catch也没用,因为补偿逻辑不在同一个原子单元里。这就是边界的断裂点:事务的原子性依赖于单一资源管理器,资源管理器一旦分裂,原子性就必须由应用层或者额外的协调者重新建立。这句话是整个分布式事务领域的出发点,后面所有的 2PC、TCC、Saga、消息表,本质上都是在用不同的代价重建这个原子性。
1.2 分布式事务的准确定义与三类典型场景
严谨一点说,分布式事务指的是事务的参与者、资源服务器、事务管理器分布在不同的网络节点上,一次全局事务需要协调多个独立资源的行为,使其要么全部生效、要么全部失效。注意这里的关键词是"独立资源"——不是简单的"跨服务调用",而是每个资源都有自己的事务语义,彼此不共享锁、不共享日志。
落到实际工程里,触发点主要有三类。第一类是分库分表:单表数据量过亿,按用户 ID 拆成 16 个库,一次批量转账要跨库写,这就从"一个库内多表"变成了"多库多表"。第二类是微服务拆分:业务按领域切成独立应用,每个应用独享数据库,跨领域的主流程天然跨事务边界。第三类是跨系统对接:和外部机构交互,比如支付、清算、物流回传,对方系统你完全控制不了,只能靠接口约定和事后对账。这三类场景的共性在于:你无法通过"把数据放回一个库"来绕过问题,架构上已经回不去了。
提示:很多团队一开始会幻想"我把相关表放同一个库不就行了"。短期可行,但只要业务规模继续涨、团队继续拆,这个妥协迟早要还。判断标准很简单——跨团队的业务主流程,就不该指望共享数据库。
1.3 这一篇想给你的一张"路线图"
市面上的资料容易走两个极端:要么只讲 CAP、BASE 这些理论,看完还是不知道怎么落地;要么直接甩一个框架的配置,用起来能用,出了事一头雾水。这篇简介想做的是中间那层——先把理论底座讲清楚它到底约束了什么,再把五条技术路线的成本和边界摆出来,最后用一个不依赖任何重型中间件的本地消息表方案,把"最终一致性"从概念变成能跑通的代码和参数。读完你应该能回答三个问题:我这个业务到底需不需要强一致、我选的方案在故障时会退化成什么样、出了问题我该看哪几个指标。这三个问题答不上来,用再贵的框架都是碰运气。
2. 理论底座:ACID 在分布式环境下还剩下多少
很多文章讲分布式事务,上来就搬 CAP 和 BASE,但没讲清楚它们和 ACID 的关系,导致读者以为这是三套并列的理论。实际上它们回答的是不同层面的问题:ACID 描述单个事务的保证,CAP 描述分布式系统在故障时的取舍,BASE 是在 CAP 约束下对 ACID 的一种工程化放宽。搞清楚这三者的位置,选型时就不会被各种名词绕晕。
2.1 四个字母跨出单机后逐个走样
原子性(Atomicity)是最先崩的。单机靠 undo log 保证"要么全做要么全不做",跨服务之后没有全局 undo log,只能靠协调者记录全局状态、靠补偿接口反向操作。补偿和回滚有本质区别:回滚是"当作没发生过",补偿是"再做一个相反的操作",前者不留痕迹,后者会留下业务痕迹(比如一条退款记录),而且补偿本身也可能失败。
一致性(Consistency)这个词最容易被误解。ACID 里的 C 指的是"事务前后满足业务约束",比如转账前后总金额不变。它不是一个可以单独实现的机制,而是 A、I、D 共同作用的结果。跨服务之后,业务约束往往横跨多个库,数据库层面根本无从校验,只能靠应用层在某个时刻达成一致——这就引出了"最终一致"的概念。
隔离性(Isolation)的损失最隐蔽。单机靠锁和 MVCC 隔离并发事务,跨服务时,A 服务的事务看不到 B 服务未提交的中间状态,两个服务的中间状态可能互相可见,产生"脏读"式的业务问题。比如 TCC 的 Try 阶段已经冻结了余额,但订单还没确认,这时候用户查余额看到的是"可用余额减少但订单不存在",体验上就很怪。这个问题没法彻底消除,只能靠业务设计去规避。
持久性(Durability)反而是最容易保住的。每个参与方自己的库该刷盘刷盘,只要不丢自己的数据,全局持久性就有基础。真正难的是"全局提交决议"本身的持久化——协调者一旦在关键节点宕机,参与者不知道该提交还是回滚,这就是 2PC 的经典困境。
2.2 CAP 不是"三选二"这么粗暴
CAP 被讲烂了,但误读也最多。常见的错误理解是"系统可以任选两个",仿佛存在 CA、CP、AP 三种平等的系统。正确的理解是:网络分区(P)在分布式系统里不是可选项,是必然会发生的事实——网线会断、交换机重启、机房会抖。所以真正的选择只在分区发生的那一刻生效:要么牺牲一致性(继续提供服务,但可能返回旧数据),要么牺牲可用性(拒绝服务,等分区恢复)。
更关键的是,CAP 里的 C 指的是线性一致性,比 ACID 的 C 严格得多——它要求所有节点看到同一份数据的同一个版本,任何读都能读到最近一次写。这个强度对绝大多数业务来说都是过剩的。你的商品详情页晚 200 毫秒更新,用户根本感知不到;但你的支付扣款如果出现两个版本,那就是事故。所以工程上的正确姿势不是"选 CP 还是 AP",而是按业务模块分别定级:支付、账务走强一致路线,商品、评论、推荐走最终一致路线,同一个系统里两种策略共存才是常态。
2.3 BASE:工程上的体面妥协
BASE 是 Basically Available(基本可用)、Soft state(软状态)、Eventually consistent(最终一致)的缩写,它描述的是一种"允许中间态存在,但保证最终收敛"的设计哲学。这三个词拆开看很虚,落到具体设计上其实很实在。
基本可用意味着分区时允许降级:比如下单主流程保留,但积分、优惠券、推荐位这些非核心链路直接熔断返回兜底值。软状态意味着允许数据有中间状态:订单有"支付中"、账务有"处理中",这些状态对外可见但不可操作,等异步流程推进后再迁移。最终一致意味着有一个明确的收敛机制:定时对账、消息重投、补偿任务,保证在有限时间内所有副本达成一致。
我见过不少团队一上来就追求强一致,结果为了一个非核心的"浏览数 +1"上了 2PC,把整个页面的响应时间拖到几百毫秒。这种过度设计比不一致本身更伤。合理的判断顺序是:先问这个数据不一致会带来多大损失,再问业务能不能接受短暂中间态,最后才问技术方案。前两个问题的答案,往往直接决定了你根本不需要分布式事务。
3. 方案全景:五条路线与它们的适用边界
理论讲完,进正题。分布式事务的落地方案,行业里沉淀下来的主要就五条路线,它们不是互相替代的关系,而是针对不同一致性强度、不同性能要求、不同改造代价的组合。下面逐个拆,重点讲清楚每个方案在故障时会退化成什么样——这比讲它能做什么更有价值。
3.1 两阶段提交:最像事务,也最重
2PC 的模型来自 X/Open 组织定义的 DTP(Distributed Transaction Processing)参考模型,核心角色有三个:事务管理器(协调者)、资源管理器(各数据库)、应用程序。第一阶段是 prepare,协调者让所有参与者执行事务但不提交,各自把 undo/redo 写好、锁住资源,然后回报"准备好了";第二阶段是 commit 或 rollback,协调者根据所有参与者的反馈下发最终指令。
这个方案的优点是语义最接近本地事务,对业务代码几乎零侵入,主流关系型数据库都原生支持 XA 接口。缺点也很明显,而且每一个都致命。同步阻塞:prepare 阶段资源被锁住,参与者数量越多、链路越长,锁持有时间越久,高并发下容易把连接池打满。协调者单点:协调者宕机,所有参与者就卡在 prepare 状态,资源一直锁着,只能人工介入。数据不一致:第二阶段如果部分参与者收到 commit 指令、部分因为网络问题没收到,就会出现"一部分提交一部分未提交"的脑裂状态。启发式回滚:参与者在超时后自作主张回滚,而协调者可能已经决定提交,两边打架。
工程上的结论很直接:2PC 适合参与者数量少(通常不超过 3 个)、并发量不高、对强一致要求极高的场景,比如跨库的账务记账、内部核心资金调拨。它绝不适合放在高并发的交易主链路上。3PC 在 2PC 基础上加了预提交阶段和参与者超时机制,缓解了阻塞问题,但引入了新的不一致窗口,实际落地很少,了解思路即可。
3.2 TCC:把锁的控制权交给业务
TCC(Try-Confirm-Cancel)把两阶段拆成三个业务接口:Try 阶段做资源检查和预留(冻结余额、预占库存),Confirm 阶段做真正的扣减(把冻结转成实扣),Cancel 阶段做释放(解冻)。它的本质是把数据库层面的锁,换成了业务层面的资源预留,从而避免了长事务持锁,性能比 2PC 好一个量级。
代价是业务改造成本极高。每个参与方都要写三个接口,而且这三个接口必须处理三个经典难题。空回滚:Try 因为网络问题根本没到达,Cancel 却先到了,此时不能报错,要能识别"这次 Cancel 没有对应的 Try"并直接返回成功。幂等:Confirm 或 Cancel 因为重试被调用多次,结果必须一致,通常靠事务控制表加唯一键实现。悬挂:Cancel 先于 Try 执行,之后延迟的 Try 又到了,此时必须拒绝这个 Try,否则资源被预留却永远不会被 Confirm 或释放。这三个问题的标准解法是建一张事务控制表,用全局事务 ID 加分支 ID 做唯一键,每次操作前先查表判断状态。
TCC 适合金融、支付、库存扣减这类对一致性要求高、但又能接受一定改造成本的场景。不适合简单的 CRUD 业务,杀鸡用牛刀,维护三个接口的成本远大于收益。
3.3 Saga:长事务的补偿链
Saga 的思路是把一个长事务拆成一系列本地事务 T1、T2、T3……每个 Ti 都有对应的补偿操作 Ci。全部成功则完成,某一步失败则逆序执行已成功步骤的补偿。它和 TCC 的区别在于:TCC 的资源预留是显式的、需要业务改造;Saga 直接提交本地事务,靠补偿回滚,侵入性小得多。
Saga 有两种编排方式。协同式(Choreography)是每个服务完成后发事件,下一个服务监听事件继续,没有中心协调者,适合步骤少、链路简单的场景。编排式(Orchestration)是有一个中心协调器按状态机推进,逻辑集中、好排查,适合步骤多、分支复杂的场景,实际项目里用得更多。
Saga 最大的短板是缺少隔离性。因为每一步都是直接提交,中间状态对其他事务完全可见——用户可能在 T2 完成、T3 失败的窗口期看到"库存已扣但订单未确认"。缓解手段有两个:一是业务上做状态标记,让中间态不可操作;二是用"语义锁",在记录上加一个 pending 标志,其他事务读到就等待或拒绝。另外,Saga 只保证最终一致,补偿操作本身必须幂等且可重试,否则补偿失败就彻底卡住了。
3.4 可靠消息最终一致:本地消息表与事务消息
这是实际落地最多的一类方案,核心思想是把"跨服务写"变成"本地写 + 异步消息"。以本地消息表为例:业务操作和消息记录写在同一个本地事务里,要么都成功要么都失败;然后由一个独立的投递任务扫描消息表,把消息发给下游;下游消费成功后回执,投递任务把消息标记为已完成。这样跨服务的一致性就被拆成了两个本地事务加一个可靠的消息通道。
事务消息则是把这个模式下沉到消息中间件,典型机制是"半消息":生产者先发一条对消费者不可见的半消息,中间件持久化成功后回调生产者执行本地事务,生产者回报提交或回滚,中间件再决定这条消息是否投递。如果生产者在回调后宕机,中间件会主动回查生产者的本地事务状态。这个方案的优点是业务代码侵入小,缺点是强依赖消息中间件的能力,且回查接口必须实现得足够健壮。
这两者的共同前提是消费端必须幂等。消息可能重复投递,这是 at-least-once 语义的必然结果,指望中间件做到 exactly-once 是不现实的。幂等的实现方式后面会详细讲。
3.5 最大努力通知:承认不完美,靠对账兜底
最后这条路线听起来最"不靠谱",但恰恰是跨机构场景的唯一选择。最大努力通知指的是:发起方尽最大努力把结果通知给接收方,通知失败就按阶梯策略重试若干次,重试耗尽后不再强求,转而依赖定时对账来发现和修复差异。它适用于对方系统你完全控制不了的场景,比如和外部机构交互,对方只提供查询接口,你没法要求它配合事务协议。
这套方案的关键不在通知,而在对账。对账系统的设计要点是:保留完整的本地流水、按固定周期(通常是 T+1)拉取对方流水、逐笔比对、把差异分成"本地有对方无""对方有本地无""金额不符"几类,分别走补单、冲正、人工核销流程。很多团队把精力全放在通知重试上,对账做得马马虎虎,结果差异越积越多,最后根本对不平。
3.6 五条路线的选型对照表
| 方案 | 一致性强度 | 性能损耗 | 业务改造成本 | 典型适用场景 | 主要风险点 |
|---|---|---|---|---|---|
| 2PC/XA | 强一致 | 高(同步阻塞) | 低(几乎无侵入) | 跨库账务、内部资金调拨 | 协调者单点、长事务锁表 |
| TCC | 强一致(最终) | 中 | 高(三个接口) | 支付、库存预占、优惠券 | 空回滚、悬挂、幂等 |
| Saga | 最终一致 | 低 | 中(补偿接口) | 长流程订单、跨系统编排 | 无隔离性、补偿失败 |
| 本地消息表 | 最终一致 | 低 | 中(建表+投递任务) | 订单→库存→积分等异步链路 | 消息重复、积压 |
| 事务消息 | 最终一致 | 低 | 低(依赖中间件) | 已有消息中间件的中台系统 | 回查实现不健壮 |
| 最大努力通知 | 最终一致 | 极低 | 低 | 跨机构对接、外部回调 | 对账不及时导致差异累积 |
选型的判断顺序建议是:先看一致性要求是不是真的强(大多数不是),再看参与方数量和并发量(多且高就排除 2PC),再看业务改造预算(预算少就选消息类方案),最后看有没有外部不可控系统(有就只能走通知加对账)。把这四问走一遍,答案基本就唯一了。
4. 动手实现:从零搭一个本地消息表方案
理论说得再多,不如跑一遍。这一节用一个虚构的下单场景,把本地消息表方案从建表到补偿任务完整实现一遍。选它作为示例的原因是:不依赖任何重型中间件,纯靠数据库和定时任务就能跑通,最适合用来理解"最终一致"到底是怎么收敛的。代码用 Java + Spring 生态的常见写法,SQL 以 MySQL 语法为例,其他数据库自行调整。
4.1 表结构设计与索引考量
先看消息表的设计。这张表是整个方案的骨架,字段设计直接决定了后续排查的难易度。
CREATE TABLE t_outbox_message ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', msg_id VARCHAR(64) NOT NULL COMMENT '业务消息唯一ID,幂等键', biz_type VARCHAR(32) NOT NULL COMMENT '业务类型,如 ORDER_CREATED', biz_key VARCHAR(64) NOT NULL COMMENT '业务主键,如订单号', payload TEXT NOT NULL COMMENT '消息体JSON', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待投递 1已投递 2投递失败', retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', next_retry_time DATETIME NOT NULL COMMENT '下次可投递时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_msg_id (msg_id), KEY idx_status_retry (status, next_retry_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地消息表';几个设计点值得展开。msg_id定义为唯一索引,它是消费端幂等的依据,也是防止重复插入的最后一道闸。status加next_retry_time建联合索引,是因为投递任务的查询条件是status = 0 AND next_retry_time <= now(),这个索引直接决定了扫描效率。payload用 TEXT 而不是 JSON 类型,是为了兼容不同数据库和序列化方式,实际存储时建议只放必要字段,大对象(比如完整订单快照)放对象存储,消息体里存引用即可,否则消息表会膨胀得很快。
消费端的幂等表同样重要:
CREATE TABLE t_consume_record ( id BIGINT NOT NULL AUTO_INCREMENT, msg_id VARCHAR(64) NOT NULL COMMENT '消息唯一ID', consumer VARCHAR(64) NOT NULL COMMENT '消费者标识,支持同一消息多消费者', consume_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_msg_consumer (msg_id, consumer) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消费幂等记录表';加consumer字段是因为一条消息可能被多个下游消费(库存要听、积分也要听),每个下游各自幂等,互不干扰。这个细节在实际项目里很关键,只按msg_id建唯一键会导致第二个消费者永远消费失败。
4.2 生产者侧:业务写入与消息写入同事务
生产者的核心要求只有一条:业务数据和消息记录必须在同一个本地事务里完成。这是整个方案的基石,做不到这一点,后面所有机制都是空中楼阁。
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private OutboxMessageMapper outboxMapper; @Transactional(rollbackFor = Exception.class) public Long createOrder(CreateOrderCmd cmd) { // 1. 业务写库 OrderDO order = new OrderDO(); order.setOrderNo(generateOrderNo()); order.setUserId(cmd.getUserId()); order.setAmount(cmd.getAmount()); order.setStatus(OrderStatus.CREATED.getCode()); orderMapper.insert(order); // 2. 消息写库,与业务同一个事务 OutboxMessage msg = new OutboxMessage(); msg.setMsgId(UUID.randomUUID().toString().replace("-", "")); msg.setBizType("ORDER_CREATED"); msg.setBizKey(order.getOrderNo()); msg.setPayload(JsonUtil.toJson(buildPayload(order))); msg.setStatus(0); msg.setRetryCount(0); msg.setNextRetryTime(new Date()); outboxMapper.insert(msg); return order.getId(); } }这里有个容易被忽略的坑:@Transactional生效的前提是同类调用不绕过代理,且数据库引擎支持事务(MyISAM 不支持)。另外,如果业务方法内部调用了外部 RPC,那个 RPC 是不在事务里的,别把"调远程接口"和"写消息表"混在同一个事务方法中,否则远程超时会拖长本地事务持有时间。
注意:消息体里只放消费端真正需要的数据,不要图省事把整个实体序列化进去。实体字段一旦变更,历史消息反序列化就会报错,而消息表里往往躺着几天前的存量消息。
4.3 投递任务:扫描、发送、状态回写
投递任务是一个独立的定时任务,跟业务线程解耦。核心逻辑就是三步:捞待投递消息、发送、按结果更新状态。
@Component public class OutboxRelayTask { private static final int BATCH_SIZE = 200; private static final int MAX_RETRY = 8; @Scheduled(fixedDelay = 2000) public void relay() { List<OutboxMessage> list = outboxMapper.selectPending( new Date(), BATCH_SIZE); for (OutboxMessage msg : list) { try { boolean ok = mqProducer.send(msg.getBizType(), msg.getMsgId(), msg.getPayload()); if (ok) { outboxMapper.markDone(msg.getId()); } else { scheduleRetry(msg); } } catch (Exception e) { scheduleRetry(msg); } } } private void scheduleRetry(OutboxMessage msg) { int next = msg.getRetryCount() + 1; if (next > MAX_RETRY) { outboxMapper.markFailed(msg.getId()); alarmService.warn("outbox max retry exceeded: " + msg.getMsgId()); return; } // 指数退避:2^next 秒,上限 300 秒 long delaySec = Math.min((long) Math.pow(2, next), 300L); Date nextTime = new Date(System.currentTimeMillis() + delaySec * 1000); outboxMapper.updateRetry(msg.getId(), next, nextTime); } }这里的参数不是随手写的,背后有计算逻辑。fixedDelay = 2000意味着每轮结束后间隔 2 秒再开始下一轮,避免任务自己把自己压垮。BATCH_SIZE = 200是按单次扫描耗时和单表行宽估的,假设单条消息平均 2KB,200 条约 400KB,一次网络传输和数据库查询都在可控范围。如果业务峰值每秒产生 500 条消息,2 秒间隔意味着单轮最多积压 1000 条,需要跑 5 轮才能追上,平均延迟约 5 秒——这个延迟对订单类业务可以接受;如果要求亚秒级,就要缩短间隔或改成多线程分片扫描。
重试策略用指数退避,第 n 次重试等待 2^n 秒,上限 300 秒。这样做的目的是:如果是瞬时故障(比如消息中间件抖动),前几次快速重试就能恢复;如果是持续性故障(比如中间件整体不可用),退避能避免任务空转和日志刷屏。重试 8 次、按退避累加,总耗时约为 2+4+8+16+32+64+128+256 秒,接近 8 分半,超过这个时间的故障基本都需要人工介入了,所以标记为status = 2并告警。
4.4 消费端:幂等落地的三种姿势
前面反复强调幂等,具体怎么做?常见的有三种,选哪种取决于业务特点。
第一种是唯一键防重,最直接。消费前先往幂等表插一条记录,插入成功说明是第一次消费,继续执行业务;插入失败(唯一键冲突)说明已消费过,直接返回成功。这种方式实现简单,缺点是幂等表和业务表不在同一个事务时会有不一致风险,所以最好让幂等记录和业务修改同库同事务。
第二种是状态机判断,适合有明确状态流转的业务。比如库存扣减,只有状态为"未扣减"时才执行,执行时用UPDATE ... WHERE status = 0这种带条件的更新,靠数据库的行锁保证原子性,影响行数为 0 就说明已被处理过。这种方式性能好,但对状态设计要求高。
第三种是去重表加业务表合并,把幂等判断直接融进业务 SQL,比如INSERT INTO t_point_log (msg_id, ...) VALUES (?, ...)配合唯一索引,一步到位。适用于流水类业务。
三种方式没有优劣,看场景。资金类业务建议用第一种,严谨;高频计数类用第二种,轻量。
@Transactional(rollbackFor = Exception.class) public void consumeOrderCreated(String msgId, String payload) { try { ConsumeRecord record = new ConsumeRecord(); record.setMsgId(msgId); record.setConsumer("inventory"); consumeRecordMapper.insert(record); } catch (DuplicateKeyException e) { log.info("duplicate message ignored, msgId={}", msgId); return; } // 首次消费,执行真正的业务 inventoryService.deduct(parsePayload(payload)); }这段代码的关键在于:插入幂等记录和业务执行在同一个事务里。如果业务抛异常,幂等记录跟着回滚,消息重投时还能再次消费;如果业务成功,幂等记录和业务数据一起提交,下次重复投递就被挡住了。这个顺序不能反。
5. 排查实录:文档里不会写的那些问题
方案跑起来只是开始,真正消耗精力的是线上那些千奇百怪的异常。这一节把我在实际项目中反复遇到的问题整理成速查表,再挑三个典型展开讲。
5.1 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 处理方式 |
|---|---|---|---|
| 消息大量堆积,投递延迟飙升 | 消费者处理慢或下游不可用 | 看消费耗时分布、下游健康状态 | 限流降级,扩容消费者 |
| 同一条消息被消费多次 | 幂等未生效或幂等表被清理 | 检查唯一键是否建对、幂等表清理策略 | 补建唯一索引,延长幂等记录保留期 |
| 消息表持续膨胀 | 已投递消息未清理 | 检查清理任务是否执行 | 加归档任务,按时间分区 |
| 订单状态卡在中间态 | 补偿任务未覆盖该分支 | 梳理状态机,检查异常分支 | 补人工补偿入口,加告警 |
| 对账差异突然增多 | 通知重试耗尽未告警 | 检查重试日志和告警配置 | 恢复告警,补跑对账 |
| 数据库连接池被打满 | 长事务或定时任务并发过高 | 看慢查询和连接持有时间 | 拆分事务,限制任务并发 |
5.2 三个典型坑的复盘
坑一:幂等表被当作临时表定期清理。早期为了省空间,给幂等记录加了 3 天过期删除。结果某次下游故障导致消息延迟 5 天后重投,幂等记录早没了,业务被执行了第二次,积分发了两遍。这个问题的本质是幂等记录的保留期必须覆盖消息的最大可能重投周期,而消息的重投周期取决于你的重试策略和最坏故障恢复时间。保守做法是保留 30 天,用时间分区表控制存储成本,而不是直接删。
坑二:投递任务和业务事务抢锁。消息表在业务库里,投递任务高频扫描,SELECT ... FOR UPDATE或者大范围的UPDATE会和业务写入争抢行锁,导致下单接口偶发超时。解法是把投递任务的查询改成普通快照读(不加锁),更新时按主键精准更新,同时把扫描批量调小、间隔调长。更彻底的做法是把消息表独立到单独的库,或者干脆换用事务消息把投递压力转移给消息中间件。
坑三:只监控投递成功率,不监控"最终一致性"。投递成功率 99.9% 看起来很美,但如果每天产生 10 万条消息,那 100 条失败就是 100 笔业务不一致。正确的监控口径应该是业务侧的差异数:订单已支付但库存未扣减的条数、积分流水与订单数的差值。这些指标需要写专门的核对 SQL 定时跑,出问题直接告警到人。技术指标告诉你系统在不在转,业务指标才告诉你钱有没有算错。
5.3 该盯住的几个关键指标
除了通用的 QPS、RT、错误率,分布式事务场景下有几组指标必须单独建看板。积压量:待投递消息数、待消费消息数、最老消息的滞留时长,这三个能提前暴露消费能力不足。重试分布:按重试次数分桶统计,如果retry_count >= 3的消息占比突然上升,说明下游有持续性故障。中间态存量:处于"处理中"状态的业务单据数量,这个数字长期不下降就意味着补偿链路断了。对账差异率:每日对账的差异笔数占总笔数的比例,这是最终一致性的唯一硬指标。
我个人的经验是,把这四个指标放在同一个看板上,配合消息表的create_time和status做趋势图,绝大多数问题在爆发前就能看到苗头。有次就是发现"处理中"存量连续两天缓慢上涨,追下去发现是某个下游的消费线程池配置错了,处理速度只有正常值的十分之一,提前三天就处理掉了,没等到客诉。
5.4 一点收尾的经验
分布式事务这个领域,最容易犯的错不是选错方案,而是用方案的复杂度去掩盖业务设计的问题。我见过太多项目,为了一个"注册送积分"的功能上了 TCC,为了"浏览数统计"上了 2PC。真正的解法往往在业务层:能不能把强一致的需求改造成异步、能不能用状态机把中间态显式化、能不能接受短暂不一致然后用对账兜底。技术方案是最后一张牌,不是第一张。
另外一个实在的建议:不管选哪条路线,对账系统一定要先建。它是对所有分布式事务方案的最终兜底,也是你敢于选择最终一致方案的底气。很多团队把对账放到二期做,结果一期上线后差异越积越多,等到想做对账时,历史数据已经对不明白了。先有对账,再谈方案,这个顺序我踩过坑之后才真正理解。