1. 分布式事务的老大难问题到底难在哪
做过微服务拆分的兄弟大概率都经历过这种场景:用户下单,订单服务写本地库成功了,库存服务扣减库存的时候网络抖了一下超时了,结果订单创建了、库存没扣,两边数据对不上。更恶心的是,这种问题往往不是必现的,测试环境跑一百遍都没事,生产环境高峰期偶尔冒出来一两条脏数据,排查起来能把人逼疯。
这就是分布式事务的经典困境。单体应用时代,一个@Transactional注解就能搞定的事情,拆成微服务之后变成了跨进程、跨数据库的协调问题。核心矛盾在于:每个服务只能保证自己本地事务的ACID,但没有任何一个服务能单独保证跨服务操作的原子性。
分布式事务的解决方案其实不少,两阶段提交(2PC)、TCC、Saga、本地消息表、最大努力通知,每种方案都有自己的适用场景和代价。但今天我要聊的是其中落地成本最低、对业务侵入最小的一种思路:基于RabbitMQ消息中间件来实现最终一致性。这套方案在很多互联网公司的交易链路里跑了多年,稳定可靠,而且不需要引入额外的TC协调器组件。
这篇文章适合谁看?如果你正在做微服务拆分,遇到了跨服务数据一致性问题,又不想引入Seata这种重量级框架,那RabbitMQ这套方案值得你花时间研究。如果你对RabbitMQ还停留在"发消息、收消息"的认知层面,这篇文章也会帮你打开新世界的大门——原来消息队列还能这么用。
注意:本文讨论的是最终一致性方案,不是强一致性。如果你的业务场景要求毫秒级强一致(比如金融核心账务),那这套方案不适合你,老老实实上TCC或者2PC。
2. 为什么选RabbitMQ而不是其他方案
2.1 几种主流分布式事务方案的横向对比
在决定用RabbitMQ之前,我先把常见的几种方案拉出来遛一遛,看看各自的优劣。
| 方案 | 一致性强度 | 业务侵入 | 性能开销 | 落地难度 | 适用场景 |
|---|---|---|---|---|---|
| 2PC/XA | 强一致 | 低 | 高 | 中 | 传统金融、数据库层面 |
| TCC | 强一致 | 极高 | 中 | 高 | 资金交易、库存扣减 |
| Saga | 最终一致 | 高 | 低 | 中 | 长流程业务编排 |
| 本地消息表 | 最终一致 | 中 | 低 | 低 | 异步解耦场景 |
| RabbitMQ消息 | 最终一致 | 低 | 低 | 低 | 异步解耦、事件驱动 |
从表格能看出来,RabbitMQ方案在业务侵入和落地难度上都有明显优势。TCC需要每个参与方实现try-confirm-cancel三个接口,开发量直接翻三倍;Saga需要写补偿逻辑,回滚路径复杂;本地消息表虽然简单,但需要额外建表、写定时任务扫表,而且消息表和业务表在同一个库,数据库压力大的时候会互相影响。
RabbitMQ方案的核心思路是:把消息的发送和本地业务操作绑定在同一个本地事务里,利用RabbitMQ的确认机制保证消息可靠投递,消费方通过幂等处理保证消息重复消费不出问题。这样就把分布式事务拆解成了两个本地事务加一个可靠消息传递,复杂度大幅降低。
2.2 RabbitMQ的可靠投递机制是这套方案的基石
很多人用RabbitMQ就是简单的basicPublish发出去就不管了,消息丢了都不知道。但要拿它来解决分布式事务,必须把RabbitMQ的可靠投递机制吃透。
RabbitMQ保证消息不丢需要三个环节都做到位:
生产者到Broker:开启publisher confirm机制。消息发送后,Broker会异步回一个ack或者nack,告诉你消息到底有没有落盘。没收到confirm的消息需要重发。这里有个坑,很多人在confirmCallback里做重发,但没考虑重发次数上限和幂等,导致消息重复或者死循环。
Broker内部:交换机、队列、消息都要持久化。durable=true的队列加上deliveryMode=2的消息,再配合镜像队列或者Quorum队列,才能保证Broker宕机后消息不丢。单节点RabbitMQ没有高可用可言,生产环境必须上集群。
Broker到消费者:关闭自动ack,改成手动ack。消费者处理完业务逻辑后再basicAck,处理失败就basicNack让消息重新入队或者进死信队列。自动ack模式下,消费者拿到消息就ack了,如果这时候进程挂了,消息就丢了。
这三个环节缺一不可。我见过太多团队只做了持久化,没开confirm,结果生产者发消息的时候网络闪断,消息根本没到Broker,业务方还以为发出去了。
2.3 最终一致性的核心:本地事务与消息发送的原子性
这套方案最精妙的地方在于,它把"发消息"这个动作和"写业务数据"这个动作放进了同一个本地事务。具体怎么做?有两种主流做法:
做法一:本地消息表。在业务库里建一张消息表,业务操作和插入消息记录在同一个本地事务里。然后有一个独立的线程或者定时任务去扫这张表,把消息投递到RabbitMQ,投递成功后更新消息状态。这种做法最可靠,但需要额外维护一张表和扫描逻辑。
做法二:RabbitMQ事务。RabbitMQ本身支持txSelect、txCommit、txRollback,可以把消息发送和数据库操作放在一个分布式事务里。但说实话,RabbitMQ的事务性能很差,吞吐量会掉一个数量级,生产环境基本没人用。
做法三:Confirm机制+补偿。先发消息,再执行本地事务,如果本地事务失败就发一条补偿消息(或者删除消息)。这种做法实现简单,但极端情况下可能消息发出去了、本地事务失败了,需要消费方做额外的校验。
实际落地中,做法一和做法三结合使用比较多。核心业务用本地消息表保证绝对可靠,非核心业务用Confirm+补偿简化实现。
3. 基于RabbitMQ的分布式事务完整实现方案
3.1 整体架构设计与消息流转路径
先把这个方案的架构图在脑子里画出来。假设我们有一个订单服务和库存服务,用户下单需要同时创建订单和扣减库存。
整个流程是这样的:
- 订单服务收到下单请求,开启本地数据库事务
- 在本地事务中插入订单记录,同时插入一条消息记录到本地消息表(状态为待发送)
- 提交本地事务
- 一个独立的投递线程扫描本地消息表,把待发送的消息投递到RabbitMQ
- RabbitMQ把消息路由到库存服务的队列
- 库存服务消费消息,执行库存扣减
- 扣减成功后手动ack,扣减失败则nack并记录日志
- 投递线程收到Broker的confirm后,更新本地消息表状态为已发送
这个流程里,订单创建和消息记录写入是原子的,消息投递和状态更新是最终一致的,库存扣减通过手动ack保证至少消费一次。整个链路的最终结果是:要么订单创建成功且库存扣减成功,要么订单创建失败且库存不会扣减。
提示:库存服务消费消息时必须做幂等处理。因为网络抖动、消费者重启等原因,同一条消息可能被投递多次。幂等方案可以用消息ID+Redis去重,或者数据库唯一索引。
3.2 本地消息表的设计与关键字段说明
本地消息表的设计直接决定了这套方案的可靠性。我一般会设计这么几个核心字段:
CREATE TABLE t_local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, message_id VARCHAR(64) NOT NULL COMMENT '全局唯一消息ID', exchange_name VARCHAR(128) NOT NULL COMMENT '交换机名称', routing_key VARCHAR(128) NOT NULL COMMENT '路由键', message_body 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 COMMENT '下次重试时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_message_id (message_id), KEY idx_status_next_retry (status, next_retry_time) );几个关键点解释一下:
message_id是全局唯一的,用UUID或者雪花算法生成。这个ID会随消息一起发到RabbitMQ,消费方用它来做幂等判断。
status字段标记消息状态。0表示待发送,投递线程只扫这个状态的数据。1表示已发送,Broker已经confirm。2表示发送失败,需要人工介入或者定时重试。
retry_count和next_retry_time配合使用,实现指数退避重试。第一次失败后1分钟重试,第二次5分钟,第三次30分钟,超过最大重试次数就标记为失败并告警。
idx_status_next_retry这个联合索引很重要。投递线程的查询条件是status=0 AND next_retry_time <= NOW(),没有这个索引的话,消息表数据量大了之后扫描会非常慢。
3.3 消息投递线程的实现细节与重试策略
投递线程是整个方案的心脏。它的职责很单一:不停地扫描本地消息表,把待发送的消息投递到RabbitMQ,根据投递结果更新消息状态。
@Component public class MessageDispatchTask { @Autowired private LocalMessageMapper messageMapper; @Autowired private RabbitTemplate rabbitTemplate; @Scheduled(fixedDelay = 1000) public void dispatch() { List<LocalMessage> messages = messageMapper.selectPendingMessages( LocalDateTime.now(), 100); for (LocalMessage msg : messages) { try { rabbitTemplate.convertAndSend( msg.getExchangeName(), msg.getRoutingKey(), msg.getMessageBody(), new MessagePostProcessor() { @Override public Message postProcessMessage(Message message) { message.getMessageProperties() .setMessageId(msg.getMessageId()); message.getMessageProperties() .setDeliveryMode(MessageDeliveryMode.PERSISTENT); return message; } } ); messageMapper.updateStatus(msg.getId(), 1); } catch (Exception e) { int newRetryCount = msg.getRetryCount() + 1; if (newRetryCount >= MAX_RETRY) { messageMapper.updateStatus(msg.getId(), 2); alertService.sendAlert("消息投递失败", msg); } else { long delay = calculateDelay(newRetryCount); messageMapper.updateRetryInfo(msg.getId(), newRetryCount, LocalDateTime.now().plusSeconds(delay)); } } } } }这段代码有几个细节值得展开说:
批量拉取:每次拉100条,避免一次拉太多导致内存溢出,也避免一次拉太少导致吞吐量不够。这个数字可以根据消息量和处理速度调整。
confirm机制:上面代码用的是同步confirm,convertAndSend会阻塞直到Broker返回confirm。如果追求更高吞吐量,可以用异步confirm,在ConfirmCallback里更新消息状态。但异步confirm的代码复杂度会高不少,需要维护一个未确认消息的映射表。
重试延迟计算:calculateDelay方法实现指数退避,比如delay = baseDelay * 2^(retryCount-1),baseDelay设为60秒。这样第一次重试等1分钟,第二次2分钟,第三次4分钟,避免短时间内大量重试把Broker打挂。
消息ID透传:通过MessagePostProcessor把本地消息表的message_id设置到RabbitMQ消息的messageId属性上,消费方可以直接从消息属性里拿到这个ID做幂等。
实操心得:投递线程最好独立部署,不要和业务服务混在一起。业务服务重启的时候投递线程也会停,消息就积压了。独立部署的话,投递线程可以一直稳定运行,不受业务发布影响。
3.4 消费端的幂等处理与手动确认机制
消费端要做两件事:保证消息不丢(手动ack)和保证重复消费不出问题(幂等)。
先看消费端的配置:
spring: rabbitmq: listener: simple: acknowledge-mode: manual prefetch: 10 retry: enabled: falseacknowledge-mode: manual开启手动ack,prefetch: 10表示每个消费者最多预取10条消息,避免消息分配不均。retry.enabled: false关掉Spring AMQP的自动重试,我们自己控制重试逻辑。
消费端的代码骨架:
@RabbitListener(queues = "inventory.deduct.queue") public void handleInventoryDeduct(Message message, Channel channel) throws IOException { String messageId = message.getMessageProperties().getMessageId(); String body = new String(message.getBody(), StandardCharsets.UTF_8); try { // 幂等检查 if (idempotentService.isProcessed(messageId)) { channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); return; } // 执行业务逻辑 InventoryDeductDTO dto = JSON.parseObject(body, InventoryDeductDTO.class); inventoryService.deduct(dto); // 标记已处理 idempotentService.markProcessed(messageId); // 手动ack channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (BusinessException e) { // 业务异常,记录日志,ack掉消息(避免无限重试) log.error("库存扣减业务异常, messageId={}", messageId, e); channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { // 系统异常,nack并重新入队 log.error("库存扣减系统异常, messageId={}", messageId, e); channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true); } }这里有几个关键决策点:
业务异常和系统异常要分开处理。业务异常(比如库存不足)重试多少次都没用,直接ack掉,记录日志人工处理。系统异常(比如数据库连接超时)可以重试,nack后重新入队。
幂等检查要在业务逻辑之前。先查Redis或者数据库,如果这个messageId已经处理过了,直接ack返回。注意幂等标记和业务操作最好在同一个本地事务里,避免业务操作成功了但幂等标记没写进去。
nack的requeue参数要慎重。basicNack(tag, false, true)会把消息重新放回队列头部,如果这条消息一直处理失败,会无限循环。更好的做法是nack到死信队列,由死信队列的消费者做延迟重试。
3.5 死信队列与延迟重试的配合使用
死信队列(DLX)是RabbitMQ处理失败消息的利器。当消息被nack且requeue=false,或者消息过期,或者队列满了,消息就会进入死信交换机,路由到死信队列。
我们的重试策略可以这样设计:
// 声明业务队列时绑定死信交换机 @Bean public Queue inventoryDeductQueue() { Map<String, Object> args = new HashMap<>(); args.put("x-dead-letter-exchange", "dlx.exchange"); args.put("x-dead-letter-routing-key", "inventory.deduct.dlq"); args.put("x-message-ttl", 30000); // 消息30秒未消费进入死信 return new Queue("inventory.deduct.queue", true, false, false, args); } @Bean public Queue inventoryDeductDlq() { return new Queue("inventory.deduct.dlq", true); }消费失败的消息进入死信队列后,有一个专门的死信消费者来处理。死信消费者可以等一段时间再重新投递到业务队列,实现延迟重试。或者直接记录到数据库,由定时任务补偿。
注意:死信队列的消息也要设置过期时间或者最大长度,否则死信消息越积越多,最后把Broker磁盘撑爆。我见过一个线上事故,就是因为死信队列没设上限,跑了一个月积了几千万条消息。
4. 生产环境踩过的坑与排查技巧
4.1 消息重复消费的三种典型场景
消息重复消费是这套方案最常见的线上问题。RabbitMQ保证的是at least once,不是exactly once,所以重复消费是必然的,只能靠幂等兜底。
场景一:消费者ack超时。消费者处理完业务逻辑,发送ack的时候网络抖动,Broker没收到ack,以为消费者挂了,把消息重新投递给其他消费者。这时候业务逻辑已经执行了一次,第二次消费就是重复。
场景二:消费者进程崩溃。消费者拿到消息,业务逻辑执行到一半,进程OOM挂了。Broker检测到连接断开,把未ack的消息重新入队。重启后消费者再次消费这条消息。
场景三:投递线程重复投递。投递线程更新消息状态为已发送后,数据库主从延迟导致下次扫描又扫到了这条消息,重复投递到Broker。
这三种场景都指向同一个结论:幂等处理是消费端的必修课,没有幂等就没有最终一致性。幂等方案的选择也有讲究,Redis去重性能好但有丢失风险(Redis挂了),数据库唯一索引最可靠但性能差一些。核心业务建议用数据库唯一索引,非核心业务可以用Redis。
4.2 消息积压的紧急处理与预防措施
消息积压是另一个高频问题。某天早上打开监控,发现队列里积了几百万条消息,消费速度远远跟不上生产速度。
紧急处理三板斧:
第一,扩容消费者。如果是消费者处理能力不足,直接加机器。但要注意,RabbitMQ的队列是独占的,一个队列只能被一个消费者消费(其实是轮询分发),加消费者不一定能线性提升消费速度。这时候需要增加队列数量,用一致性哈希或者sharding的方式把消息分散到多个队列。
第二,优化消费逻辑。看看消费端有没有慢查询、有没有不必要的远程调用、有没有可以批量处理的地方。我遇到过一个案例,消费端每条消息都去查一次用户信息,改成批量查询后消费速度提升了10倍。
第三,临时降级。如果积压太严重,可以先ack掉消息记录到数据库,等高峰期过了再慢慢处理。这种做法会丢失实时性,但能快速恢复队列健康。
预防措施方面,监控告警是必须的。队列长度超过阈值就告警,消费延迟超过阈值也告警。另外,生产端的限流也很重要,大促期间订单量暴涨,如果生产端不限流,消息瞬间涌入,消费端肯定扛不住。
4.3 消息丢失的排查思路与恢复方案
消息丢失比重复消费更可怕,因为重复消费只是多处理一次,消息丢失意味着业务数据不一致。
排查消息丢失,按照消息流转路径逐段检查:
生产端:有没有开启confirm?confirmCallback有没有正确处理nack?本地消息表的记录有没有成功写入?投递线程有没有正常运行?
Broker端:队列和消息有没有持久化?有没有配置镜像队列?Broker有没有发生过重启或者磁盘满?
消费端:有没有开启手动ack?有没有在业务逻辑执行前就ack了?有没有异常被吞掉导致消息没处理但ack了?
恢复方案取决于丢失的消息是否还能找回。如果本地消息表还有记录(状态为待发送),重新投递即可。如果本地消息表也没有记录,那就只能通过业务对账来发现不一致,然后人工补偿。
实操心得:建议每天跑一次对账任务,比对订单表和库存表的数据。发现不一致的记录,自动生成补偿消息重新投递。对账是最后一道防线,不能省。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 消息重复消费 | ack超时、消费者崩溃、投递重复 | 查消费日志、查幂等记录 | 加强幂等、用数据库唯一索引 |
| 消息积压 | 消费能力不足、消费逻辑慢 | 查队列长度、查消费耗时 | 扩容消费者、优化消费逻辑 |
| 消息丢失 | confirm未开、未持久化、自动ack | 查confirm日志、查队列属性 | 开启confirm、持久化、手动ack |
| 投递线程不工作 | 线程挂了、数据库连接池满 | 查线程状态、查数据库连接 | 独立部署、加监控告警 |
| 死信队列爆满 | 没有设置TTL或最大长度 | 查死信队列长度 | 设置TTL、定期清理死信 |
5. 几个容易忽略的细节与优化建议
5.1 消息表的分库分表与归档策略
本地消息表的数据量增长很快,一个日订单10万的服务,消息表一年就是3650万条数据。如果不做处理,查询会越来越慢。
分表策略上,可以按月份分表,每个月一张表。投递线程查询的时候只查当前月份的表,历史表只用于归档和排查。
归档策略上,已发送状态的消息保留7天就可以删除了。删除的时候要批量删,每次删1000条,避免大事务。删除操作放在凌晨低峰期执行。
-- 批量删除已发送的历史消息 DELETE FROM t_local_message WHERE status = 1 AND update_time < DATE_SUB(NOW(), INTERVAL 7 DAY) LIMIT 1000;5.2 消息体的序列化选择与版本兼容
消息体的序列化格式直接影响兼容性和性能。JSON最通用,但体积大、解析慢。Protobuf体积小、解析快,但需要维护schema,版本升级麻烦。
我的建议是:内部服务之间用JSON,跨团队或者跨语言用Protobuf。JSON的可读性好,排查问题的时候直接看消息内容就行。Protobuf虽然性能好,但排查问题的时候需要反序列化,多了一道工序。
版本兼容方面,消息体里最好带一个version字段。消费方根据version做不同的处理逻辑。新增字段要保证向后兼容,不能删除已有字段,只能标记为deprecated。
5.3 监控告警体系的最小化搭建
监控告警不需要搞得太复杂,但几个核心指标必须有:
队列长度:超过阈值告警。这个指标直接反映消费能力是否跟得上。
消息投递延迟:从消息写入本地消息表到投递到Broker的时间差。延迟过大说明投递线程有问题。
消费失败率:消费失败的消息数除以总消费消息数。失败率突增说明消费端有bug或者下游服务有问题。
死信队列长度:死信队列持续增长说明有大量消息处理失败,需要人工介入。
这些指标可以用Prometheus采集,Grafana展示,Alertmanager告警。如果团队没有这套基础设施,用RabbitMQ自带的HTTP API也能拿到这些数据,写个定时任务定期检查就行。
5.4 什么场景不适合这套方案
最后说一下这套方案的适用边界。以下几种场景不建议用RabbitMQ做分布式事务:
强一致性要求的场景。比如金融核心账务,要求毫秒级强一致,这套最终一致性方案满足不了。
同步返回结果的场景。用户下单后要立即知道库存扣减结果,这套异步方案做不到。需要配合前端的轮询或者WebSocket推送。
超长事务链路。如果一笔业务涉及十几个服务的协同,消息会经过多次转发,链路越长,出问题的概率越大,排查也越困难。
消息量极大的场景。RabbitMQ单队列的吞吐量有限,如果每秒几十万条消息,需要考虑Kafka或者Pulsar。
我在实际项目中的体会是,这套方案最适合的是交易链路中的异步解耦场景,比如下单后异步扣库存、支付成功后异步发通知、订单完成后异步算积分。这些场景对实时性要求不高,但对数据一致性有要求,正好是RabbitMQ方案的甜区。
还有一个小技巧分享:本地消息表的投递线程和业务服务的数据库连接池要分开配置。投递线程如果和业务服务共用连接池,业务高峰期连接池被占满,投递线程拿不到连接,消息就积压了。独立的数据源配置虽然麻烦一点,但能避免互相影响。