后端微服务间的最终一致性:本地消息表与事务消息的实现对比 后端微服务间的最终一致性本地消息表与事务消息的实现对比一、一个下单请求同时要调库存服务和积分服务其中一个失败了怎么办这是微服务架构下最经典的分布式一致性问题。用户下了单订单服务需要做三件事在自己数据库里创建订单、调用库存服务扣减库存、调用积分服务增加积分。在一个单体应用里这三个操作包在一个数据库事务里即可——要么全成功要么全回滚。ACID 特性天然保障。但在微服务架构下这三个操作发生在三个不同的服务、三个不同的数据库中。分布式事务不再像单机事务那样理所当然。如果订单创建成功、库存扣减成功但积分增加失败——用户的积分没加上账就对不上了。反过来积分增加了但库存没扣——超卖的风险来了。强一致性方案如两阶段提交 2PC可以实现跨服务的事务原子性但代价是性能大幅下降而且在网络分区时会阻塞所有参与者。更务实的做法是接受短暂的不一致通过异步机制最终达到一致。这种思路叫做最终一致性。二、本地消息表把消息当成数据库的一行记录本地消息表的核心思路极其朴素在业务数据库里额外建一张消息表。业务操作和消息记录放在同一个本地事务里。订单创建成功的同时一条需要通知库存服务的消息也写入了消息表。只要本地事务提交成功订单和消息就一起持久化了。之后的事情交给一个独立的定时任务。这个任务定期扫描消息表中待发送状态的记录逐条发送给消息队列。发送成功后更新消息状态为已发送。如果发送失败网络故障、MQ 宕机消息状态保持待发送下一个扫描周期会重新尝试。这套方案的优点是零外部依赖——不需要支持事务消息的消息队列任何支持 ACK 的 MQ 都能用。缺点是引入了一个定时扫描的延迟在最坏情况下消息要等到下一个扫描周期才能发出去带来秒级的延迟。对积分增加这类对实时性要求不高的场景来说这个延迟完全可接受。三、事务消息RocketMQ 的一步到位方案如果觉得定时扫描的方式太土可以用 RocketMQ 的事务消息机制。它把本地事务 消息发送打包成一个原子操作不需要额外的消息表。工作流程是这样的生产者先发送一条半消息half message到 Broker这条消息对消费者不可见。生产者执行本地事务比如创建订单。根据本地事务的结果生产者向 Broker 发送 commit 或 rollback。如果 Broker 长时间没收到确认生产者宕机了Broker 会主动回查生产者的本地事务状态。事务消息消除了本地消息表的定时扫描延迟但引入了新的复杂度生产者必须实现回查接口。当 Broker 回查时生产者要能判断对应的本地事务是否已经提交。这要求业务代码中保留查询事务状态的逻辑比如根据订单 ID 去数据库查这条订单存在不存在。/** * 使用 RocketMQ 事务消息实现最终一致性 * * 设计要点 * 1. 半消息确保消费者不会提前消费 * 2. 回查接口是兜底机制处理生产者宕机的情况 * 3. 本地事务中只做关键业务操作减少事务持有时间 */ Component public class OrderTransactionProducer { Resource private TransactionMQProducer producer; /** * 发送订单创建的事务消息 */ public void sendOrderCreatedMessage(Order order) { Message message new Message( ORDER_TOPIC, OrderCreated, JSON.toJSONBytes(order) ); // 发送事务消息。executeLocalTransaction 回调中执行本地事务 TransactionSendResult result producer.sendMessageInTransaction( message, order.getOrderId() // 用订单 ID 作为事务关联标识 ); if (result.getLocalTransactionState() LocalTransactionState.ROLLBACK_MESSAGE) { throw new RuntimeException(订单创建失败事务回滚); } } } /** * 事务监听器实现本地事务执行 回查 */ Component public class OrderTransactionListener implements TransactionListener { Resource private OrderService orderService; /** * 执行本地事务 * * 这里只做核心操作创建订单 扣减库存的预占 * 其他非关键操作放在事务外异步处理 */ Override public LocalTransactionState executeLocalTransaction( Message msg, Object arg) { String orderId (String) arg; try { // 在本地事务中创建订单 orderService.createOrderInTransaction(orderId, msg); // 事务成功通知 Broker 可以投递消息给消费者了 return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { log.error(订单创建失败, orderId{}, orderId, e); // 事务失败消息也丢弃 return LocalTransactionState.ROLLBACK_MESSAGE; } } /** * 回查本地事务状态 * * Broker 在以下情况会回调此方法 * 1. executeLocalTransaction 返回了 UNKNOW * 2. 生产者宕机没有返回任何状态 * * 这里的逻辑必须幂等且快速因为 Broker 可能在短时间内多次回查 */ Override public LocalTransactionState checkLocalTransaction( MessageExt msg) { String orderId msg.getKeys(); // 查数据库这笔订单创建成功了吗 Order order orderService.getByOrderId(orderId); if (order ! null) { // 订单存在 → 事务成功 → 消息可以投递 return LocalTransactionState.COMMIT_MESSAGE; } else { // 订单不存在 → 事务失败 → 消息丢弃 return LocalTransactionState.ROLLBACK_MESSAGE; } } }回查逻辑是事务消息中最容易出错的地方。需要特别注意回查可能在消息发送后的任意时间点触发包括几分钟后所以回查代码不能依赖本地事务上下文中的临时状态只能通过查询数据库等持久化存储来判断。四、两种方案的选型权衡维度本地消息表事务消息实时性秒级延迟取决于扫描间隔实时依赖只需要普通 MQ需要支持事务消息的 MQ复杂度需要维护消息表 定时任务需要实现回查接口一致性保证最终一致性最终一致性适用场景MQ 基础设施受限的团队已有 RocketMQ 等支持事务消息的中间件选哪个方案不取决于技术优越性而取决于团队的现有基础设施。如果公司已经在用 RocketMQ事务消息是更简洁的方案。如果用的是 Kafka 或 RabbitMQ不支持事务消息本地消息表就是务实的选择。还有一个容易被忽略的问题消费者的幂等性。无论用哪种方案消息都可能被重复投递网络重传、Broker 超时重试消费者必须在消费逻辑上做幂等处理。最简单的幂等手段是在消费者侧根据消息中的业务唯一 ID如订单 ID做去重判断。五、总结分布式环境下的数据一致性不可能做到和单机事务一样强。与其花大力气追逐强一致性不如接受最终一致性的现实然后选择合适的方案——本地消息表或事务消息——把不一致的窗口尽量缩短。两种方案各有适用的场景关键是理解各自的前提条件和边界限制在自己的系统约束下做出正确选择。