
消息必达从来不是中间件自带的默认承诺。线上跑过一段时间的人都知道,网络抖动、Broker 主从切换、消费节点 Full GC、下游依赖超时,随便一个环节掉链子,数据不一致就找上门了。本文不聊虚的,直接拆解我们在生产环境里踩过的坑、落地的方案,以及怎么用 Spring Boot 把 MQ 的"不可靠性"框死在可控范围内。
一、选型别只看吞吐量,先看清业务要什么命
现在主流就 RabbitMQ、RocketMQ、Kafka 这三家。很多团队一上来就比 TPS,其实方向就偏了。选型得先看业务对可靠性和一致性的底线在哪。
RabbitMQ 走的是 AMQP 协议,路由灵活,TTL 和死信队列支持得很成熟。订单、支付这种丢一条就要对账半天的核心链路,用它比较踏实。缺点是复杂路由和持久化写盘会拖累吞吐,万级并发以上得仔细调优连接池和预取参数。
RocketMQ 自带金融级基因,事务消息、顺序消息、延迟队列都是开箱即用的。电商大促、资金划转这种强依赖本地事务一致性的场景,它能省不少造轮子的时间。半消息回查机制虽然会吃一点 Broker CPU,但换来了架构上的简洁。
Kafka 的强项是吞吐和流处理,日志采集、行为埋点、大数据管道基本是它的自留地。早期版本丢数据是常态,现在 ISR 机制稳了很多,但原生不支持事务,路由也不精细。硬把它当业务消息总线用,后期补一致性的成本往往比一开始选对 MQ 高得多。
生产上最容易出问题的地方其实不在 MQ 本身,而在三段链路的断裂点:生产者发出去但没拿到确认,Broker 刷盘失败或脑裂,消费者处理一半 OOM 还自动 ACK 了。任何一段没做防御,重试一叠加网络延迟,重复消息和积压马上连环爆。
二、生产者端:Confirm 回调不够,本地消息表才是定海神针
2.1 开启确认与退回回调
Spring Boot 集成 RabbitMQ 时,别用默认的自动发布确认。配置得显式打开:
yaml
spring:
rabbitmq:
publisher-confirm-type: correlated
publisher-returns: true
template:
mandatory: true
correlated 模式会把 Confirm 和当前发送的 CorrelationData 绑在一起,回调里能直接拿到业务上下文。publisher-returns 配合 mandatory: true 专门抓路由失败的消息。很多团队配了 Confirm 却忘了 Return,消息发到交换机但没落到队列,直接进黑洞,查日志都查不到。
RocketMQ 的同步 SendResult 和异步 SendCallback 同理。单向 Oneway 除非是打日志,否则生产环境一律禁用。
2.2 事务消息 vs 本地消息表
RocketMQ 的事务消息确实好用,发半消息、执行本地事务、回调 Commit/Rollback,链路清晰。但要注意半消息回查是 Broker 定时扫的,高并发下回查线程池打满,会拖慢其他正常消息的持久化。
跨中间件或者不想绑定某家 MQ 的团队,本地消息表(Outbox 模式)是更稳妥的解法。逻辑不复杂:
- 业务库加张
sys_msg_outbox,字段不用多:id, biz_type, payload, status(INIT/SENDING/CONFIRMED), retry_count, create_time。 - 业务方法和插入 outbox 记录包在同一个
@Transactional里,状态写INIT。 - 后台定时任务(或者直接吃 Binlog)捞出
INIT记录往 MQ 发,发成功改SENDING。 - 拿到 Confirm 回调再刷成
CONFIRMED。失败就累加重试次数。
这套方案把分布式事务拍扁成单库本地事务,彻底断了"业务落库成功、消息静默丢失"的可能。后期换 MQ 组件,业务代码一行不用动。代价是多了一张表和一次异步轮询,但用这点存储成本换数据一致性,值。
三、消费者端:手动 ACK 是底线,重试和 DLQ 得管住阀门
3.1 关掉自动 ACK,预取量要卡死
yaml
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: manual
prefetch: 30
自动 ACK 是线上数据不一致的头号杀手。业务逻辑跑到一半依赖下游超时,连接断了,消息却被标记为已消费,DB 里没数据,MQ 里也没了。手动 ACK 配合 basicAck / basicNack 是底线。prefetch 别设太大,设到 50 甚至 100 看着吞吐上去了,其实 ACK 窗口拉得很长,节点一挂,重复消费的窗口也跟着变大。30 左右比较均衡,既能扛住瞬态延迟,又不会把未处理消息全堆在内存里。
3.2 重试得有退避,死信不是终点
无限重试等于给自己发 DDoS。Spring Retry 用起来不麻烦,关键是指定指数退避和最大次数:
java
@Bean
public RetryTemplate retryTemplate() {
RetryTemplate template = new RetryTemplate();
ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy();
backOff.setInitialInterval(1000);
backOff.setMaxInterval(16000);
backOff.setMultiplier(2.0);
template.setBackOffPolicy(backOff);
template.setRetryPolicy(new SimpleRetryPolicy(5));
return template;
}
超过 5 次直接打回死信队列(DLQ)。记得关掉 default-requeue-rejected,不然失败消息会在主队列里无限循环。DLQ 配 x-dead-letter-exchange 路由到独立队列后,必须起单独的消费 Worker。它的职责不是把消息塞回主队列,而是三件事:把错误堆栈和业务上下文持久化、打 P1 告警、提供人工干预或脚本补偿的入口。让 DLQ 消息直接回流,污染正常流量,是极其危险的操作。
四、幂等性:别指望 MQ 不重发,业务层自己得扛住
MQ 的语义就是 At-Least-Once,重发是常态。消费者代码里但凡有写库、扣库存、改状态的动作,不加幂等就是埋雷。
创建类接口最简单,直接上数据库唯一索引。uk_msg_id 或者 uk_biz_no 都行,第一次插进去,重试再撞索引直接忽略。更新类操作不能只靠唯一索引,得用状态机配合乐观锁。比如订单状态流转,更新前先验前置状态:if (status != UNPAID) return; 然后再带版本号更新。高并发下版本号冲突会多点,但总比状态被覆盖、资金对不上强。
Redis 的 SETNX 适合无持久化或者读多写少的轻量场景,但得防锁过期业务还没跑完的边界情况。一般生产上不会单靠它兜底,更多是当第一道快速拦截,底层还是落库校验。
最佳实践就一句:创建走唯一约束,更新走状态机+版本号。把幂等判断和状态更新包在一个事务里,别分步提交,否则中间态暴露出来,重试和并发会直接搅乱数据。
五、积压治理:监控看基线,降级靠预案,补偿得留后手
积压是系统失速的第一个信号。别等告警响了才去查,得提前把基线跑出来。Prometheus 抓 queue_messages_total 和 queue_messages_unacknowledged,算出 consumer_lag。告警阈值别照抄别人的,得看你们日常消费的 TPS 和平均耗时。比如平时 Lag 稳定在 200 以内,突然拉到 5000 且持续三分钟,说明下游某个依赖慢了,先打 P2 预警排查慢 SQL 或第三方接口;拉到 5 万以上且压不住,直接进 P1 流程,按预案扩容或降级。
紧急处理三板斧其实很实在:第一是横向加消费者实例,但得注意 MQ 的分区或队列数就是并发天花板,到了上限得先拆热点 Key 或者临时调大单节点消费线程;第二是逻辑降级,非核心的打标、实时推荐先关掉,同步调用改异步落库,能跳过的校验直接 skip;第三是快速丢弃,这只适用于日志埋点,核心业务数据绝对禁止。
降级只是止血,数据修复得靠补偿脚本。平时就得把业务流水表和消息消费记录做 Diff 对账的逻辑写好。积压平复后,按批次回放未处理消息,回放接口本身必须是幂等的。补偿跑完、数据拉齐,再关掉降级开关。别等出问题临时写脚本,线上没那个试错时间。
六、顺序消息与全链路追踪:牺牲吞吐换确定,没 Trace 等于盲人摸象
顺序和高吞吐天生互斥,只有资金流水、状态强依赖的场景才值得开。实现逻辑其实就三步:生产者按 order_id 或 user_id Hash 取模,固定打到同一个 Partition/Queue;Broker 端保证队列单线程顺序落盘;消费者必须单 Partition 单线程拉取。Kafka 那边得把 max.poll.records 压到 1,RocketMQ 用 MessageListenerOrderly。别在消费者里自作主张开多线程处理同一分区消息,顺序性瞬间就碎了。业务如果能接受局部乱序,直接按业务键分组并行就行,没必要死磕严格有序。
线上排查消息卡在哪,全靠全链路 Trace。OpenTelemetry 或者 SkyWalking 接进来成本不高:上游 HTTP 请求生成 trace_id 丢进 MDC,发消息时写进 Header;消费者拿到后 MDC.put 恢复上下文。日志格式固定下来,带 trace_id、msgId、处理阶段和耗时。配合 ELK 聚合,从 Request → Send → Broker → Consume → DB 的拓扑直接拉出来,瓶颈精确到方法级。没这套东西,出了生产问题只能盲猜,查一条消息能熬到半夜。
七、参数调优与线上避坑:别死记硬背,看懂权衡再下手
调优没有万能公式,全看业务特征。拿 4C8G 的 Spring Boot 节点举例,同步发送开启 Confirm 配合 10 个并发,日常能扛住 2800 TPS,延迟压在 8ms 左右,丢包率能控制在 0;切到异步发送吞吐能拉到 1.6 万,延迟掉到 2ms,但内存曲线会明显上翘,必须配限流防 OOM。消费者落库场景,并发调到 20、prefetch 卡在 30,GC 停顿控制在 150ms 内基本不会引发积压。本地消息表补偿任务批量拉 100 条、间隔 2 秒轮询,最终一致性能做到 5 个 9,代价就是端到端延迟会多 8ms 左右。
参数怎么设?prefetch 卡在 10~50,设大了 ACK 窗口长,节点挂掉重复消费范围变大;设小了频繁发 ACK 反而占带宽。消费并发数按 CPU 核数 * 2 起步,IO 密集型可以适当放宽到 * 4,但别无脑拉满,上下文切换的开销会反噬吞吐。JVM 方面,现代 JDK 默认 G1,别乱动默认参数,真要调就盯 -XX:MaxGCPauseMillis,控制在 200ms 左右,堆内存 -Xms 和 -Xmx 务必设成一样,避免运行时动态扩容触发 Full GC。操作系统层面,ulimit -n 拉到 1 万以上,tcp_keepalive 开启,连接泄露和文件描述符耗尽是隐蔽炸弹。RocketMQ 的刷盘策略,金融核心上 SYNC_FLUSH,互联网业务走 ASYNC_FLUSH,别为了保数据把吞吐腰斩。
线上踩过的坑,总结下来就几条铁律:
第一,别信自动 ACK,异常抛出来消息就丢了,必须手动确认加异常隔离。
第二,消息体别塞超过 1MB 的 payload,大文件只传 ID 或 URL,走 OSS 反查。消息体膨胀会导致网络分片重传,消费端反序列化直接 OOM。
第三,跨机房别直连,网络延迟一旦超 10ms,Confirm 回调会被严重拖慢。跨 AZ 必须走专线或者双活 Broker 集群,客户端配好路由降级。
第四,幂等是消费者的基本功。重试、网络抖动、手动重发都会带来重复,没做幂等就是定时炸弹。
第五,DLQ 必须有人盯,死信堆积到磁盘打满会阻塞主队列,独立告警和消费 Worker 是标配。
第六,消费者线程里别干阻塞活,第三方 API 超时、慢 SQL 同步查询全扔出去,用独立线程池、超时熔断或者异步编排,消费线程池被打满,整个链路就停了。
高可靠不是靠某个中间件单点撑起来的,是生产确认、本地补偿、消费幂等、死信隔离、全链追踪、监控降级这几层防线叠加出来的结果。把配置规范前置到脚手架,把重试和降级策略沉淀成内部 Starter,日常巡检盯紧 Lag 和 STW,比事后写复盘报告管用得多。系统出问题是常态,关键是把故障圈在可观测、可隔离、能自愈的边界里。代码里多写几行防御逻辑,线上就能少熬几个通宵。
🎁 福利时间
如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。
知识库地址:https://farerboy.com/
