分布式事务,说多了都是泪。单体时代一个数据库事务能解决的问题,微服务拆分后全都变成了难题。我前段时间做某个跨平台交易系统时,就撞上了订单服务、库存服务、账户服务三库分离之后的数据一致性问题。最初也考虑过引入分布式事务中间件,但评估下来发现要么需要额外部署重型组件,要么对既有业务的侵入太深,最后干脆回归了一个老方案——SpringBoot + 本地消息表,把所有跨服务的数据变更做成最终一致性,全程不依赖任何消息中间件,仅靠数据库里的表和几个异步任务就转起来了。如果你也在为分布式一致性发愁,正纠结要不要上重型框架,这篇文章值得你花十分钟看完。
1. 为什么最终一致性会成为一个问题
1.1 微服务拆分带来的"分步提交"困局
先理清问题的根源。单体应用里,一次下单操作要扣库存、写订单、加积分,这些只需要在一个数据库连接里开一个事务,要么全部提交,要么全部回滚,ACID 由数据库引擎直接保证。但服务拆开之后,订单数据在订单服务的库里,库存数据在库存服务自己的库里,积分数据又可能在另一个账户服务的库里。三个服务、三张表、三个独立的数据源,任何数据库事务都没办法同时覆盖它们。
这时候你遇到的是经典的"分步提交"问题:订单服务先写订单,再去调用库存服务扣库存,如果扣库存失败,订单数据怎么办?如果调用超时,到底是扣了还是没扣?这时候没有任何一个"全局事务"能告诉你标准答案。强一致方案比如两阶段提交,本质上就是想重新造一个跨数据库的原子性出来,但付出的代价是对数据库连接资源的长时间占用、协调器的复杂度、以及跨网络协调的脆弱性。大多数业务场景根本扛不住这种成本。
所以现实的做法是退一步:不追求任何时刻都一致,而是接受"短暂不一致、最终正确"的状态。用户下单后,库存可能延迟零点几秒才扣成功,积分可能过一会儿才到账,但只要数据最终是对的,用户无感知,业务就成立。这套思路就是最终一致性,也是本地消息表方案能立足的根本原因。
1.2 主流方案横向对比:本地消息表的位置
在做选型对比时,我整理了一张表格,把市面上常见的几种分布式事务方案放到一起看:
| 方案 | 实现复杂度 | 中间件依赖 | 对业务侵入 | 性能影响 | 适合场景 |
|---|---|---|---|---|---|
| 两阶段提交 | 高,需要全局事务协调器 | 通常依赖 | 低但锁资源严重 | 高,连接和锁占用时间长 | 极小范围强一致需求 |
| TCC | 高,每个业务要写 Try/Confirm/Cancel | 可选 | 高,业务代码要大量改造 | 中 | 金融类强管控场景 |
| 事务消息(各类MQ产品的事务消息特性) | 中 | 依赖消息中间件 | 中 | 中 | 已经有MQ基础设施的团队 |
| 本地消息表 | 低 | 无 | 低 | 低 | 内部服务协调、中小型团队 |
我自己为什么选本地消息表,核心就三条:第一,系统里没有现成的消息中间件集群,为了一个一致性问题引入一套重型基础设施,运维成本划不来;第二,业务场景是订单创建后同步触发库存扣减和积分入账,允许秒级延迟,不强求实时推送;第三,团队希望所有可靠性逻辑都看得见、摸得着,出了问题能直接用 SQL 查到现场,而不是去翻消息堆积。本地消息表完美命中这些约束。
2. 本地消息表原理拆解:把消息当作业务数据的一部分
2.1 核心思想:消息和数据在同一事务里共存亡
本地消息表方案最重要的一句话是:消息不是"发"出去的,而是"写"出去的。它把消息内容当作一条普通数据库记录,和业务数据一起写在同一个本地事务里。
拿订单通知库存来说,传统做法是订单服务先写订单记录,然后再调用库存服务接口或者往MQ里发一条"扣库存"的消息。但这里有个经典的时序问题:先写库再发消息,如果发消息失败,订单已经落库了,下游永远不知道这个订单存在;先发消息再写库,如果写库失败,消息已经发出去了,下游做出了扣减动作,这边却根本没有对应订单。业务数据和消息数据之间没有原子性,怎么调整顺序都别扭。
本地消息表的解法非常朴素:在同一个@Transactional里,既插入订单记录,也插入一条消息记录。两条SQL,同一个数据库事务,要么一起提交,要么一起回滚。这一步做完之后,消息就永远不会丢——哪怕进程在事务提交后立刻崩溃,消息也还稳稳躺在数据库里。后面所有的工作,不过是把这条已经落地的记录异步推送给下游而已。
这个思路其实有个正式名字,叫 Transactional Outbox,不少消息中间件的事务消息功能底层也是这个原理,只不过中间件把这张表藏在了自己的存储里,替你管理。我们做的是把它搬回自己的数据库,用SQL把同一件事重新实现一遍。理解这一点后,你会发现方案本身没有任何黑魔法。
2.2 最终一致性的完整闭环
从整体视角看,这套方案的处理链路分四步:
- 业务事务内同时写入业务数据和本地消息表记录。
- 一个中继任务周期扫描消息表,把"待发送"状态的消息取出来,调用下游服务或写入下游的消息接收表。
- 下游服务在处理完业务后返回成功,中继任务把消息状态更新为"已发送"。
- 如果调用失败或超时,中继任务按一定策略重试,直到下游成功;同时通过幂等措施保证重试不会造成重复处理。
这里要特别强调,所谓的"最终一致性"不是天上掉下来的,而是靠两个机制托底:一个是消息表记录永不丢失,配合重试机制保证消息必然被送达;另一个是下游必须做幂等处理,保证同一条业务消息被处理多次和一次的效果相同。这两个机制缺一不可,后面实现部分我会展开讲。
2.3 为什么说"无中间件依赖"是优点也是约束
这套方案不依赖任何消息中间件,意味着它天然没有中间件集群的部署成本、监控成本和运维成本。数据库本身大家都有,唯一新增的是一张业务表。同时,方案和底层存储绑定,链路更短,出现问题排查路径也短——消息卡在哪个状态,翻表就看出来了。
但也要承认这套方案的局限性。消息的即时推送能力不如消息中间件,因为中继任务本质是轮询,延迟取决于轮询间隔;消息的超大规模吞吐能力也有限,毕竟要通过数据库查询来搬运消息。如果业务对实时性要求极高,或者每天消息量在千万级以上,本地消息表就不是最佳选择,这时候应该把消息表作为降级方案,或者和轻量推送机制结合使用。工具没有绝对好坏,只有适不适合当前场景。
3. SpringBoot 完整落地实现:从表结构到可跑通的代码
3.1 消息表结构设计:字段定了,逻辑就定了一半
在设计表结构时,我参考了事务性消息模式的通用要求,并结合实际重试和排查的诉求做了调整。下面是完整建表语句:
CREATE TABLE tb_message_outbox ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_key VARCHAR(64) NOT NULL COMMENT '业务消息唯一键,用于幂等和去重', biz_type VARCHAR(32) NOT NULL COMMENT '业务类型,如 ORDER_CREATE、STOCK_DEDUCT', payload TEXT NOT NULL COMMENT '消息体,JSON格式', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待发送 1发送中 2已成功 3终态失败', retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', next_retry_time DATETIME NOT NULL COMMENT '下次重试时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_msg_key (msg_key), KEY idx_status_retry (status, next_retry_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;一个个字段解释一下设计的理由。
msg_key是业务消息的唯一键,通常用 UUID 生成,但也可以使用业务数据里的天然唯一标识,比如订单号加业务类型。这个字段是下游幂等的关键依据,所以必须建唯一索引,从数据库层面杜绝重复记录。
biz_type用来区分消息的业务语义,下游收到消息后可以根据这个字段分发到不同处理逻辑。
status字段是消息状态机的最小表达。我用 0 到 3 四个数字表示待发送、发送中、已成功、终态失败。需要说明的是,"发送中"这个状态在出现网络超时时可能会让消息卡住,所以我在实际项目中加了恢复机制:中继任务扫描时会同步把超过一定时间仍然处于"发送中"的记录重置回"待发送",避免消息卡死。
retry_count和next_retry_time是重试策略的载体,支持指数退避。扫描时只捞满足"状态为待发送且当前时间大于等于下次重试时间"的记录,天然把重试节奏控制住。
idx_status_retry组合索引是给中继扫描任务用的,让查询永远走索引,避免全表扫描压垮业务表。
3.2 发送方实现:让业务数据和消息同事务提交
发送方核心代码非常简单,但请务必注意@Transactional的边界问题。下面是订单创建服务的关键代码:
@Service public class OrderService { private final OrderDao orderDao; private final OutboxDao outboxDao; public OrderService(OrderDao orderDao, OutboxDao outboxDao) { this.orderDao = orderDao; this.outboxDao = outboxDao; } @Transactional(rollbackFor = Exception.class) public void createOrder(CreateOrderRequest req) { // 1. 业务数据写库,比如订单主表 Order order = new Order(); order.setUserId(req.getUserId()); order.setAmount(req.getAmount()); order.setStatus(OrderStatus.CREATED); orderDao.insert(order); // 2. 同一事务内写入本地消息表 MessageOutbox outbox = new MessageOutbox(); outbox.setMsgKey(order.getOrderNo() + "_ORDER_CREATE"); outbox.setBizType("ORDER_CREATE"); outbox.setPayload(JsonUtils.toJson(req)); outbox.setStatus(OutboxStatus.PENDING.getCode()); outbox.setNextRetryTime(LocalDateTime.now()); outboxDao.insert(outbox); } }这段代码的精髓在于:两个 insert 被同一个事务包住,任何一步失败都会整体回滚。我在实际开发中踩过的最典型坑,就是在事务还没提交就去调用远程服务或者发送消息,结果远程调用的异常把事务标红,最后业务数据和通知行为完全错乱。写出这段逻辑的时候请牢记:事务内只写库,绝对不做任何耗时操作或外部调用,发消息这类动作必须放到事务提交之后由中继任务完成。
3.3 中继任务实现:扫描、发送、回写状态
中继任务的核心职责是把消息表中的待发送记录投递给下游。我用 Spring 自带的定时任务来实现轮询扫描,核心逻辑如下:
@Component public class OutboxRelayTask { private static final int BATCH_SIZE = 100; private final OutboxDao outboxDao; private final DownstreamClient downstreamClient; @Scheduled(fixedDelay = 1000) public void relayPendingMessages() { // 1. 扫描待发送消息,每次取一批 List<MessageOutbox> pendingList = outboxDao.scanPendingMessages(BATCH_SIZE, LocalDateTime.now()); if (pendingList.isEmpty()) { return; } for (MessageOutbox message : pendingList) { sendSingleMessage(message); } } private void sendSingleMessage(MessageOutbox message) { // 2. 先置为发送中,防止本批记录被重复拉取 outboxDao.updateStatusById(message.getId(), OutboxStatus.SENDING.getCode()); try { // 3. 调用下游服务 boolean success = downstreamClient.send(message.getBizType(), message.getPayload()); if (success) { outboxDao.updateStatusById(message.getId(), OutboxStatus.SUCCESS.getCode()); } else { handleRetry(message); } } catch (Exception e) { // 4. 记录失败并计算下次重试时间 handleRetry(message); } } private void handleRetry(MessageOutbox message) { int newRetryCount = message.getRetryCount() + 1; if (newRetryCount >= MAX_RETRY_COUNT) { outboxDao.updateToTerminalFailure(message.getId(), newRetryCount); return; } LocalDateTime nextRetryTime = calcNextRetryTime(newRetryCount); outboxDao.updateRetryInfo(message.getId(), newRetryCount, nextRetryTime); } }这里有几个实现细节要重点说明。
第一,为什么先把状态置为"发送中"再发送?这是为了应对多实例部署或者服务重启场景下的重复拉取风险。但前面也提过,如果发送过程卡住导致状态永远停在"发送中",消息就死了。所以我在扫描 SQL 里加了一个条件:扫描"待发送"的记录之外,顺带把更新时间为几分钟前的"发送中"记录恢复成"待发送"。
第二,重试时间的计算必须考虑网络抖动的特点。我采用的策略是指数退避加随机抖动:第一次重试延迟 10 秒,第二次 30 秒,第三次 2 分钟,之后逐步拉大,并加上 0 到 5 秒的随机偏移,避免大量消息集中在同一时刻重试造成下游压力陡增。
第三,downstreamClient.send这个方法在真实项目里通常是 HTTP 调用或 RPC 调用,我建议你给所有远程调用加上超时时间,而且超时时间要比中继扫描间隔短,否则一个慢调用会把整批扫描拖垮。
3.4 消费方实现:幂等是最终一致性的生命线
中继任务把消息送过来之后,下游服务能不能正确消费,直接决定了这套方案是否可靠。消费方第一原则:永远假设同一条消息会被送多次,因此必须在自己的业务里做幂等处理。
我用的幂等方案是"唯一键 + 幂等记录表"。代码如下:
@RestController public class StockDeductController { private static final String DEDUCT_PREPARE_PREFIX = "STOCK_DEDUCT_"; private final IdempotentDao idempotentDao; private final StockDao stockDao; @PostMapping("/internal/stock/deduct") public Result deduct(@RequestBody StockDeductRequest req) { String idempotentKey = DEDUCT_PREPARE_PREFIX + req.getMsgKey(); // 1. 先查幂等记录,存在则直接返回成功 if (idempotentDao.exists(idempotentKey)) { return Result.success("duplicate request"); } // 2. 执行业务扣减 stockDao.deduct(req.getProductId(), req.getCount()); // 3. 写入幂等记录,标记处理完成 idempotentDao.save(idempotentKey); return Result.success(); } }注意,第 2 步和第 3 步必须放在同一个本地事务里,否则可能出现"业务扣减成功但幂等记录写入失败",下次重试又扣一次,那幂等就形同虚设了。如果消费方本身有现成的业务主表,也可以用业务主表里的唯一索引代替独立幂等表,比如库存台账流水表里针对msg_key建唯一索引,插入冲突就说明处理过了,这也是一种常见做法。
另外,幂等处理失败比业务失败更隐蔽。业务扣减失败会抛异常,中继任务会重试;但幂等记录先查后写的竞态窗口里,如果两个并发请求同时进来,可能双双通过查重,然后各自执行一次扣减。要彻底堵住这个问题,最好的办法是数据库唯一索引兜底,让并发插入时只有一个成功,另一个报重复键异常后按已处理返回。
4. 踩坑实录:重复消息、消息积压、回写失败的排查
4.1 重复消息问题:不是偶发,而是必然
很多人实现完第一版后,总觉得重复消息是"小概率事件",但实际上只要网络超时、重试机制存在,重复就是常态。我遇到过最典型的场景:中继任务调下游接口,下游业务处理成功后返回响应,但响应在网络上超时了,中继任务判定失败进入重试,下游就收到了两条一模一样的扣库存消息。
排查这种问题的思路不是堵住重试,而是确保消费端具备完善的幂等机制。我在实际项目中养成了一个习惯:本地消息表的msg_key生成时就把业务标识带进去,比如订单号加业务类型,这样下游可以直接利用这个字段建唯一索引。同时,我把幂等记录表设计成"宽表",记录写入时间、处理结果、原始消息体,方便出问题时对照排查。
4.2 消息积压与延迟问题
本地消息表方案天然有轮询延迟,我的中继任务用的是固定延迟 1 秒扫描,所以正常情况下消息从落库到送达下游最快也需要 1 秒。但如果出现极端情况,比如下游服务大面积超时,中继任务处理 100 条消息要花好几分钟,新消息就会越积越多。
对这种积压问题,我做了两个层面的优化。第一层是把发送过程改成线程池并发执行,批次内消息不再串行发送,线程数按下游接口的承接能力配置;第二层是给扫描 SQL 加上"按重试时间排序 + 限量"的逻辑,防止历史老消息饿死新消息。需要注意的是,并发发送时状态回写逻辑要处理好,避免 A 线程把 B 线程的消息状态覆盖了,我的做法是每次更新都带上当前消息 ID 和自己期望的旧状态作为WHERE条件。
4.3 消息发送成功但状态回写失败的隐蔽坑
有一个我一开始没料到的问题:中继任务调用下游成功,但随后更新消息表状态失败,导致消息停留在"待发送"状态,下次扫描又被送了一遍。这种场景在数据库偶发死锁、连接池耗尽时尤其容易出现。它和下游重复消息叠加起来,会造成同一条业务消息被推送两三次甚至更多次。
这类问题的排查经验是:不要单纯靠消息表状态来判断是否处理成功,而是结合下游的幂等记录表做对账。我会定期跑一个对账任务,把消息表中状态为"已成功"的记录和下游幂等表中最近 N 分钟内产生的记录做比对,找出"下游处理了但本地状态未更新"的差异数据,然后手工将消息状态推进到终态。这个对账任务虽然看起来笨,但它是整个最终一致性链路里兜底的那张安全网。
4.4 监控和告警:别等用户发现问题
本地消息表方案的一切可靠性都建立在消息表不能被"玩坏"的前提上,所以监控特别重要。我在消息表上挂了三个监控指标:待发送消息数量、终态失败消息数量、单条消息的平均投递延迟。
告警阈值根据业务容忍度来定。我这边订单类的消息超过 5 分钟还没有变为已成功,就触发告警;终态失败消息一旦大于 0,立即告警。告警通知到值班群后,第一步永远是查消息表,SQL 定位到具体的msg_key,再顺着链路看是下游服务挂了、还是消息体有问题、还是重试策略配得太保守。大多数问题在几分钟内就能定位,这比在消息中间件的堆积详情页里翻找日志要直观得多。
5. 一些容易被忽略的细节和经验
5.1 多实例部署时,中继任务怎么做互斥
方案上线一段时间后,我开始考虑把中继任务部署成多实例来提高可用性。这时立刻遇到一个问题:两台机器同时扫描同一批消息,怎么保证同一消息不会被同时拉走?我采用的方案是利用数据库行锁,扫描 SQL 改成SELECT ... FOR UPDATE SKIP LOCKED的写法,让同一时刻只有一台实例能拿到某条待发送消息,另一台实例自动跳过被锁的行。这个功能在主流关系型数据库的新版本里都有支持,能大幅度降低多实例竞争的处理复杂度。
5.2 消息体里最好带上版本号
消息体的设计比大多数人想象的重要。我在迭代过程中给消息体 JSON 增加了一个version字段,用于标记消息结构版本。下游消费时先看版本,老版本消息走老逻辑,新版本走新逻辑,这避免了上线过程中出现"新代码读到旧结构消息"的解析异常。如果你有灰度发布的习惯,这个字段还能帮助你按比例路由处理逻辑。
5.3 最终一致性不是"不做补偿"
最后想泼一盆冷水:本地消息表解决的是"消息可靠送达"和"下游幂等处理"这两件事,但它不能解决业务本身失败后又无法通过重试修复的问题。比如库存服务收到扣减消息,但实际库存不足导致业务失败,中继任务无论重试多少次结果都一样。这种情况下必须预留人工补偿通道,比如给终态失败消息配一个管理后台,允许人工查看消息、调整状态、触发再次投递或取消业务。我见过不少团队把终态失败消息直接扔着不管,直到用户投诉才发现数据对不上,这种教训希望大家别重演。
我个人在实操里的体会是,本地消息表方案确实"土",但它把每个环节都摆在了明面上:消息在哪、状态是什么、什么时候重试,全部可以用 SQL 查到。对于大多数内部服务协调场景,它的可靠性和可维护性远超预期。如果你后续想把方案做得更顺手,可以往两个方向扩展:一是把发送中继任务和消费幂等封装成通用 SDK 组件,让新服务接入成本降到最低;二是预留一个开关,在某个环节实时性要求变高时,把本地消息表改成双写消息中间件,业务代码不用大改。分布式一致性的方案没有银弹,但把一套基础方案吃透、用熟,比频繁换工具要可靠得多。