做消息中间件这块这些年,我最大的一个感受是:RocketMQ 的高可用架构,绝对不等于把节点多部署几份就完了。很多人一上来就搭了个多 Broker 集群,以为某个节点挂了系统会自动切换、消息一条不丢,结果真遇到磁盘写满、主备数据不同步、机房断网的时候,才发现消费堆积、消息丢失、重复消费哪一个都能让人半夜爬起来处理。
这篇文章我想把 RocketMQ 高可用架构背后的组件协作方式、主从复制与故障切换机制,以及顺序消息、事务消息、延迟消息这些高级特性的原理和实战细节一次讲透。适合正在做技术选型、准备搭建生产集群,或者已经上线运维 RocketMQ 但还没踩过足够多坑的同学。我会尽量用实际生产环境会遇到的场景来说明,少讲官话,多讲能直接用的东西。
1. RocketMQ 高可用架构的核心组件怎么协作
1.1 NameServer:为什么它敢这么“轻”
RocketMQ 有一套独立的注册中心叫 NameServer,它在架构里的地位很特殊:NameServer 之间互不通信,谁都不管谁,每个节点都保存着一份全量的路由信息。这种设计跟 ZooKeeper 那套“选主 + 分布式一致性”的思路完全不同。
NameServer 的职责说白了就两件事:第一,接收 Broker 的心跳注册,维护 Broker 的存活列表;第二,给客户端提供 Topic 路由查询。生产者发送消息之前要先问一下 NameServer“这个 Topic 在哪些 Broker 上”,拿到 Broker 地址之后,后续发送就直接走那个地址了,不再每次都问 NameServer。
这里牵扯到一个关键认知:RocketMQ 的高可用不依赖 NameServer 实时发现故障。Broker 是每 10 秒心跳一次,NameServer 默认在 120 秒内没收到心跳才会把 Broker 从路由表里摘掉。所以如果某个 Master 节点突然宕机,NameServer 不会立刻感知,生产者在它心跳超时之前,可能还会尝试往那个已经挂了的地址发消息。这个问题你以为高可用架构会处理,其实并没有。生产环境里真正解决它靠的是客户端自身的重试机制,以及底层 Dledger 模式提供的自动选主能力,这点后面细说。
NameServer 挂掉一个会怎样?不会怎样。只要集群里还活着至少一个 NameServer,新接入的客户端就能拿到路由信息;已经建立连接的客户端也不受影响,它们本地已经缓存了 Broker 地址。所以 NameServer 的高可用策略很朴素:多部署几个,比如 2 到 3 个,彼此无状态,谁挂了都不影响整体。
1.2 Broker 主从关系与复制链路
Broker 是真正存消息、读消息的地方。一个高可用集群里,Broker 按“brokerName”分组,同一个 brokerName 下可以有一个 Master 和多个 Slave,用 brokerId 区分:brokerId=0 是 Master,brokerId>0 是 Slave。这个分组概念一定要先建立清楚:不同 brokerName 之间是独立的物理节点,同一个 brokerName 下的主从才构成一条复制链路。
Broker 主从复制的核心数据载体是 CommitLog。所有 Topic 的消息都顺序追加写入同一个 CommitLog 文件,主从之间同步的也是这一段 CommitLog。Slave 启动后会主动向 Master 发起一条 HA 连接,Master 会告诉它“我已经写到哪个位点了”,然后持续把新增的日志字节流推给 Slave。这里注意一个容易被忽略的细节:Broker 主从通信默认监听端口是主 Broker 端口加 1,比如主节点 storeServerPort 是 10911,那主从 HA 通道就监听 10912。配置防火墙、压测抓包的时候,这 1 个端口很多人会漏掉。
主从之间有一个“落后字节数”的概念,Slave 复制进度跟 Master 差距过大的时候,即使开启了 Slave 读,也会在消费时被限制。这是高可用设计里很关键的一层保护:宁可让客户端读不到那个落后的 Slave,也不能让消费端读到严重滞后的数据。
1.3 CommitLog、ConsumeQueue 与读写高可用
RocketMQ 的高性能很大程度来自它的存储结构设计。消息先顺序写到 CommitLog,这是一块只追加的日志文件,写的时候利用 PageCache,顺序 IO 的速度远高于随机 IO。每个 Topic 的每个 Queue 对应一个 ConsumeQueue,它存的是消息在 CommitLog 中的“索引”,包括物理偏移量、消息长度和 Tag 的 HashCode。消费者消费时先读 ConsumeQueue,拿到偏移量再去 CommitLog 里捞真实消息。
这个“写日志 + 建索引”的分离结构,让消息从写入到可以被消费有了一点延迟,但也让大量 Topic 共享一个 CommitLog 成为可能,避免了每个 Topic 单独维护一堆文件造成的随机写开销。
从高可用角度看,CommitLog 文件是主从复制的唯一顺序基准。判断主备是否一致,不是看某个 Topic 的数据,而是看两者 CommitLog 的最大偏移量是否一致。实践中我会直接对比主从节点的MaxOffset,如果从节点明显落后,消费延迟大概率会跟着涨。
读的高可用在于slaveReadEnable=true配置。当 Slave 数据足够新时,消费者可以分流到 Slave 去读取,减轻 Master 压力;当 Master 宕机时,消费者也能从 Slave 继续消费,至少保住“可读”能力。但我要强调一个很多人在架构图上看到的错觉:官方的主从模式下,Master 挂了,Slave 不会自动变成 Master。路由里那个 brokerId=0 的角色不会自动转移到 Slave 上,生产者的写入请求还是只会发给原来的 Master。自动故障切换,在 RocketMQ 里其实是通过 Dledger 模式来实现的,不是靠主从复制本身。
2. 高可用架构里的关键决策:复制、刷盘与故障转移
2.1 主从复制:同步还是异步,怎么选
主从复制有两种模式:同步复制和异步复制。它们的差异体现在一条消息写入后多久给生产者返回成功。
同步复制(SYNC)下,Master 收到消息,先写本机 CommitLog,然后等待 Slave 也把这条消息写入 CommitLog 并返回确认,之后才对生产者返回写入成功。这能保证消息在主节点和从节点都落盘后,生产端才得到成功应答,主节点宕机后消息大概率还能从从节点找回来。
异步复制(ASYNC)下,Master 写完自己的 CommitLog 就立刻返回成功,Slave 靠后台线程自己拉取复制进度。这种模式吞吐高不少,但是存在一个时间窗口:如果 Master 还没把数据复制给 Slave 就宕机了,这部分消息就真的丢了。
这里有个容易误解的点:同步复制并不能做到绝对不丢消息。即便同步复制返回成功了,如果 Master 在收到 Slave 确认之后、生产者收到成功之前发生宕机,数据在两端都有了,那没问题;但如果 Master 在写盘完成、还没等到 Slave 确认时就宕机,这条消息只有 Master 有,Slave 没追上,生产者也会收到失败。所以同步复制降低的是丢失概率,不是归零。
我的建议是:需要强一致、丢消息代价高的链路用“同步复制 + 异步刷盘”组合,吞吐和安全取得一个平衡;对吞吐要求极高、能容忍极端场景下少量丢失的用“异步复制 + 异步刷盘”。下面这张表是四种组合的真实表现对比:
| 复制方式 | 刷盘方式 | 吞吐表现 | 风险等级 | 典型适用场景 |
|---|---|---|---|---|
| 同步复制 | 同步刷盘 | 最低 | 最低,双端落盘才返回 | 金融交易、对账类消息 |
| 同步复制 | 异步刷盘 | 中等偏下 | 较低,主从都写但本机依赖PageCache | 大多数生产集群,我推荐用 |
| 异步复制 | 同步刷盘 | 中等偏上 | 较高,Master宕机即可能丢 | 单master实验环境 |
| 异步复制 | 异步刷盘 | 最高 | 最高,机器宕机丢消息风险显著 | 日志采集、允许少量丢失 |
很多人配置时只关注复制模式,不关注刷盘模式,这其实不对。刷盘模式决定的是“本机 CommitLog 到底有没有真正写进磁盘”,复制模式决定的是“另外一台机器有没有这份数据”。两者要一起看。
2.2 消费者端故障转移:Rebalance 机制
Broker 故障时,消费者能不能自动恢复消费,靠的是 Rebalance 机制。RocketMQ 的消费组(ConsumerGroup)里所有消费者实例会协调分配 Topic 下的队列。当某个 Broker 上的队列不可用了,消费组内的存活消费者会对剩余队列重新分配,被分配到的消费者会接管这些队列的消费位点,从 CommitLog 里继续读。
这个过程看起来挺自动,实际生产里容易踩几个坑。第一个坑是重平衡触发的“停顿”:RocketMQ 的 Rebalance 在上报心跳时触发,默认有间隔,消费者在重平衡过程中会暂停部分队列的消费,如果你对实时性要求极高,能感知到几秒到十几秒的消费断档。第二个坑是位点问题:如果故障的 Broker 是一个还没把数据完整复制给 Slave 的 Master,重平衡后消费者可能从某个位点继续消费,之前只有 Master 才有的那几条消息就直接“跳过”了,从业务侧看就是消息丢了。这也是为什么我一直强调主从复制要尽量追上,不能只靠消费者端的 Rebalance 兜底。
还有一个常见问题是“分配抖动”。消费者实例频繁上下线时,队列会在实例间来回漂移,分配结果不稳定。解决思路是让客户端的实例数量保持稳定,不要在消费组里随意扩容缩容,扩容缩容尽量在流量低峰做。
2.3 对比其他消息中间件:为什么不选 ZooKeeper
经常有人问,为什么 RocketMQ 不用 ZooKeeper 当注册中心,Kafka 早期就用 ZooKeeper。我的理解是:RocketMQ 把“路由发现”和“分布式一致性”这两件事拆开了。
在 Kafka 里,Controller 的选举依赖 ZooKeeper,Broker 的元数据管理也依赖 ZooKeeper,ZooKeeper 本身就是集群高可用链条里一个核心依赖。RocketMQ 的 NameServer 只负责路由发现,而且是无状态的,它允许路由信息短暂不一致,因为客户端本身有容错和重试机制。这个设计让整个架构少了一个强一致组件,部署和运维复杂度降低了很多。
代价是:RocketMQ 官方主从模式下,Broker 层的故障转移能力比 Kafka 弱。Kafka 可以通过 Controller 把某个分区的新 Leader 推举出来,生产者消费者都感知到新的 Leader;RocketMQ 主从架构里的“从”不能自动“转正”,要自动转正就得引入 Dledger(Raft 模式)。所以准确说,RocketMQ 的架构把高可用的一部分责任从“框架自动完成”转移给了“运维设计和客户端容忍”。理解这一点,后面做集群规划才不会想当然。
3. 高级特性逐个拆解:顺序、事务、延迟与过滤
3.1 顺序消息:不是每个 Topic 都要有序
RocketMQ 的顺序消息分两种:全局顺序和分区顺序。全局顺序就是一个 Topic 下所有消息严格按照发送顺序被消费,这种只适合吞吐量极小的场景,因为所有队列都要收敛到同一个,并发基本废了。生产上 99% 的场景用的是分区顺序。
分区顺序的实现思路很直接:生产者在发送时,通过 MessageQueueSelector 把同一批需要有序的消息发送到同一个 MessageQueue。比如订单场景,同一个订单号的所有状态变更消息,都选同一个队列;消费者端用 MessageListenerOrderly 消费,这个监听器保证同一个队列的消息串行处理。
很多人用顺序消息会忽略两个关键细节。第一,MessageQueueSelector 选队列时,要保证“业务标识”稳定,比如用订单号对队列数量取模或者用订单号的哈希,不能每次随机选,否则顺序就乱了。第二,消费端把一条消息消费失败后,如果走重试,重试消息会被放入重试队列,可能在下一条正常消息之后才被再次投递,顺序消息就乱了。所以顺序消息的消费逻辑里,宁愿抛出异常让整个队列暂时阻塞,也不要只把当前消息退回去继续消费后面的。官方提供的 MessageListenerOrderly 会阻塞队列等待重试,这点用起来要心里有数。
顺序消息还有一个隐藏问题:Rebalance 会导致队列从一个消费者实例转移到另一个实例,转移过程中该队列的消费是暂停的,不会乱序,但会出现消费“卡顿”。如果你的业务希望做到无缝不中断,需要额外做本地的状态缓存或者补偿机制。
3.2 事务消息:本地事务与消息发送的最终一致
RocketMQ 的事务消息是很多分布式事务方案里的主角。它解决的核心问题是:本地数据库操作和消息发送这两件事,怎么保证要么都成功、要么都失败。
它的执行流程是:
- 生产者发送一条“半消息”到 Broker,这条消息对消费者不可见,住在事务消息专用主题里。
- 生产者执行本地事务,比如操作数据库。
- 本地事务执行成功,生产者向 Broker 提交(Commit),半消息变为可消费消息;本地事务失败,则回滚(Rollback),消息被删除。
- 如果本地事务执行期间生产者进程挂了,Broker 迟迟等不到 Commit 或 Rollback,就会触发事务回查。Broker 会反过来问生产者“你那条半消息对应的本地事务到底怎么样了”,生产者通过实现 TransactionListener 的 callback 告诉它结果。
这里有一个经常被误解的点:事务消息不是分布式事务里的“两阶段提交”,它没有全局协调者,也不是强一致。它本质是一个“最终一致性”方案:消息可能延迟可见,但最终会到达;事务状态不确定时会通过回查来收敛。所以那些要求“数据库刚提交、消息立刻能被消费”的业务场景,其实不适合事务消息,因为正常情况下也存在消息延迟可见的窗口。
写事务消息的生产者时,需要实现两个方法:executeLocalTransaction和checkLocalTransaction。第一个方法里做本地事务,第二个方法里回查本地事务状态。我踩过的坑是:很多人只在 executeLocalTransaction 里做了各种判断,结果进程重启后 checkLocalTransaction 没有正确的幂等逻辑,直接返回 UNKNOW,导致半消息反复被回查,永远变不回可消费状态,消费端发现消息迟迟不来。回查接口一定要做成幂等的、可快速返回的,不要在里面跑重活。
3.3 延迟消息:18 个延迟级别隐藏的架构细节
延迟消息是定时任务的常见基础设施。RocketMQ 支持多个延迟级别,默认从 1 秒到 2 小时,一共 18 档,设置 message.setDelayTimeLevel 的时候只能选固定档位,不能直接写任意秒数。你发送一个延迟级别为 3 的消息,它会在 10 秒后变成可消费状态。
延迟消息的实现原理,是在 Broker 内部维护了一个特殊的主题SCHEDULE_TOPIC_XXXX。延迟消息写入时并不直接进目标队列,而是先存到这个内部主题,按照延迟级别落在不同的队列。Broker 有定时任务不断扫描这些延迟队列,把到期的消息重新投递到真实的目标 Topic。
这个机制带来的一个特性是:延迟消息的实际延迟时间不是绝对精确的,它取决于 Broker 端调度扫描的频率和当前队列的消息量。业务上如果需要秒级精确的定时,比如“30 秒后必须执行”,延迟消息是可以满足的;但如果要求毫秒级精准,那要评估一下,或者用外部定时任务系统实现。
实际运维中,延迟消息最容易出的问题有两个。一个是大规模延迟消息堆积时,内部延迟队列会成为热点,触发写入性能下降;另一个是 Broker 重启期间,延迟队列的“当前时间”和调度的状态需要恢复,如果恰好大量消息同时到期,会看到消费端瞬间收到一波集中消息。这种突刺对下游系统的冲击,做容量评估时要算进去。
3.4 消息过滤:Tag 还是 SQL92
RocketMQ 的消息过滤有两种:Tag 过滤和 SQL92 属性过滤。Tag 过滤是在 ConsumeQueue 索引里存了 Tag 的 HashCode,消费者订阅指定 Tag 时,Broker 端直接比对索引里的 HashCode,命中后才会把完整消息拉给客户端。这个过滤发生在 Broker 端,能减少网络传输,但缺点是 HashCode 碰撞可能带来误命中,所以客户端收到消息后还会再精确比对一次 Tag,确保不会消费到别的 tag。
SQL92 属性过滤则是在发送时给消息附加自定义属性,消费者订阅时写类似age >= 18 AND gender = 'male'的表达式,Broker 会取消息属性来匹配。它灵活很多,但开销也大,因为 Broker 要为每条消息做属性提取和表达式计算。实际使用的经验是:能用 Tag 就优先用 Tag,Tag 实在无法满足复杂条件,再上 SQL92。而且 SQL92 过滤只有在客户端和 Broker 版本都支持时才生效,如果 Consumer 端传了 Tag,同时又想用 SQL92,需要确认版本兼容性,不然会静默失效。
3.5 消息轨迹与回溯消费
消息轨迹是一个容易被忽视但生产环境很有用的高级特性。开启 traceTopic 后,Broker 会记录消息从生产到消费的完整链路数据,包括生产时间、存储位置、消费时间、消费结果。排查“消息到底发没发出去”“为什么某条消息没被消费”的时候,轨迹数据往往比日志更直接。
消息回溯则是针对消费位点的操作。RocketMQ 支持通过控制台或命令行按时间重置消费位点,比如把某个消费组的位点调回到 2 小时前,重新消费一批消息。这个能力在数据修复、逻辑变更后补消费的时候非常实用。要注意的是,重置位点影响的是消费组维度,如果这个消费组同时被多个业务共用,重置会串影响,所以生产规范里一个消费组最好只对应一条业务链路。
4. 高可用集群部署与配置实战
4.1 集群架构建议:别再搞单主单从了
我见过不少团队生产环境用的是“单主单从”,理由是“主挂了还有从能读,足够了”。实际上单主单从的高可用能力非常有限:Master 挂了,Slave 不会自动转正,写入直接中断;Dledger 模式至少需要 3 个节点才能正常工作;两个相同 brokerName 的主从节点如果放在同一个机房同一排机柜,断电就是全挂。所以我给大多数业务场景的建议是:至少 2 主 2 从,4 主 4 从更好。
2 主 2 从的部署形态是:两个不同的 brokerName(比如 broker-a 和 broker-b),每个 brokerName 下各有一个 Master 和至少一个 Slave。Topic 的队列会在 broker-a 和 broker-b 之间分布,一条消息要么写在 broker-a 上,要么写在 broker-b 上,两个 Master 分摊写压力,任何一个 Master 不可用,另一个还能继续接收写入。这样才谈得上真正的高可用。
机房层面,Master 和它的 Slave 尽量不要在同一个小故障域,比如跨机柜、跨交换机;有条件的话跨机房部署主从,配合 Raft 模式做同城双活。
4.2 broker 配置核心参数逐个解读
Broker 的高可用配置集中在 broker.properties 里。我挑几个实际部署时最容易配错的参数说明一下:
| 参数名 | 作用与建议 | 踩坑点 |
|---|---|---|
| brokerClusterName | 集群名,同一集群保持一致 | 不同集群不能混用,否则路由串 |
| brokerName | 同一主从组内保持一致 | 全局唯一,不能两个主从组共用 |
| brokerId | 0 表示 Master,1 以上表示 Slave | 同 brokerName 下不能重复 |
| brokerRole | SYNC_MASTER / ASYNC_MASTER / SLAVE | Master 和 Slave 必须匹配角色 |
| flushDiskType | SYNC_FLUSH 或 ASYNC_FLUSH | 新版本参数名格式要注意 |
| slaveReadEnable | true 允许消费端读 Slave | 不配的话 Slave 只复制不服务读 |
| fileReservedTime | 日志文件保留时间,默认 72 小时 | 太短回溯能力受限,太长磁盘压力大 |
| autoCreateTopicEnable | 不建议生产环境开 true | 会自动创建主题导致路由混乱 |
| messageDelayLevel | 自定义延迟级别 | 修改后与原值不一致会出错 |
| storePathRootDir | CommitLog 存储根目录 | 一定要用独立的高性能磁盘 |
其中我觉得最影响高可用行为的是brokerRole和flushDiskType的组合,其次是slaveReadEnable。很多团队把 Slave 配置好之后,没开 slaveReadEnable,结果主节点一压满,消费延迟就蹭蹭涨,从节点资源全浪费了。
4.3 部署与主从切换验证步骤
部署一套 2 主 2 从集群的收尾验证顺序,我一般这样走:
- 启动两个 NameServer,分别用
nohup sh mqnamesrv &拉起,然后看日志确认端口监听成功。 - 依次启动 broker-a 的 Master、broker-a 的 Slave、broker-b 的 Master、broker-b 的 Slave,启动命令里通过
-c指向各自的配置文件,通过-n指向 NameServer 地址。 - 用
mqadmin clusterList -n 地址检查集群状态,重点看每组的 Master 和 Slave 是否都在线,brokerId 是否正确。 - 用
mqadmin topicRoute -t 测试Topic -n 地址确认路由中每个队列落在哪个 Broker 上,读写队列数量是否符合预期。 - 向测试 Topic 灌一批消息,关掉一个 Master,观察生产者是否能通过重试写入另一个 Master,消费者是否能从对应的 Slave 继续消费。
关于“主从切换验证”,我要提醒一句:官方主从模式下,主挂了之后,写入不会切到原来的 Slave。如果你在验证时发现生产者还在报错,不要以为配置错了,这正是这个模式的设计。想真正实现“Master 挂了自动选出新 Master”,要把这三个节点跑在 Dledger 模式下。
4.4 Dledger 模式:真正能自动选主的高可用方案
Dledger 是 RocketMQ 内置的基于 Raft 协议的日志复制模式。部署时不再区分 Master 和 Slave,而是每个副本都配置相同的 brokerName,brokerId 分别设置为 0、1、2,三副本之间通过 Raft 选出 Leader。Leader 负责写入,Follower 负责复制和同步,Leader 宕机后剩余节点自动选举出新的 Leader,生产者不用改任何配置就能继续往新 Leader 写。
Dledger 模式的优点显而易见:自动选主、数据一致性更好、不用人工干预故障切换。代价是写入路径多了 Raft 协商的开销,吞吐会低于纯异步复制;另外 Raft 要求多数派存活,2 副本没法工作,至少要 3 副本,所以部署成本更高。
选型上我的建议是:如果业务对消息可靠性要求很高、且能接受略低的写性能,用 Dledger 三副本;如果业务量大、对吞吐要求极高、能容忍极端情况下少量丢失,用经典的“同步复制 Master + 异步刷盘”配置。两者我都跑过,没有绝对的好,只有适不适合。
5. 常见故障与排查实录
5.1 消费堆积严重,先别调线程数
消费堆积是生产环境最高频的问题。很多人一看堆积就急着把消费者线程数从 20 调到 40、80,其实线程数再高,瓶颈如果在下游接口或者数据库,只会造成下游被打爆。
正确的排查路径是先看消费组在控制台上的 Consumer Lag,确认堆积发生在哪些队列;再查消费者的consumeThreadMin和consumeThreadMax是否合理,消费逻辑里有没有同步等待外部 RPC 的环节。RocketMQ 的消费端默认是一个队列一个线程的顺序吞吐模型,一个队列分配到一个消费者实例时,如果消费逻辑慢,加线程未必有用,应该更快的是优化单条消息的处理耗时,或者给 Topic 增加队列数再配上足够多的消费者实例。
我见过一个典型的坑:消费者里调用了第三方 HTTP 接口,接口超时时间设置得很长,导致一批消息卡在等待上,后面的消息全部堆积。把超时缩短、加上合理的失败重试和降级之后,堆积自然就消了。所以排查堆积时,优先看消费耗时,再看线程资源,别一上来就无限加线程。
5.2 主从切换后消息丢失:问题往往在复制落后
有朋友反馈过这样一个现象:某个 Master 突然宕机,Slave 还在,消费者自动切到 Slave 读,看起来一切正常,但业务方发现有一批消息没有消费到。查下来原因是这个 Slave 复制进度本身落后了很多,Master 后面的消息还没来得及复制过去就崩了。
官方主从模式下,这种情况几乎是无法完全避免的,我们能做的是尽量缩小风险窗口:
- 把复制模式配成 SYNC_MASTER,让消息在返回成功之前双端确认。
- 监控主从落后字节数,一旦发现 Slave 持续落后,及时排查磁盘 IO、网络抖动。
- 不要让 Slave 承担过多读压力,导致它复制进度跟不上,用
slaveReadEnable=true的同时,要考虑给 Slave 多分一些非实时性的读流量。
补偿手段上,如果消息内容还能从业务数据库找回,优先用业务数据补发;否则只能接受这部分消息的丢失,通过事务消息或对账机制发现缺口后拉起重放。
5.3 顺序消息乱序:源头多数在消费端
顺序消息乱序,生产者端原因很少,大部分是消费端没按规范来。最典型的问题是用普通MessageListener消费有序消息,并发线程同时处理同一个队列,顺序自然就乱了。换成MessageListenerOrderly是最基本的修复。
另一个隐蔽原因是消费失败重试导致的乱序。一条消息消费失败后会被丢到重试队列,而正常消息还在继续往前消费,等重试消息再次投递时,它已经排到了后面。对于严格有序的场景,正确的做法是:消费失败时阻塞当前队列,等消息重试成功或人工介入,不要直接跳过继续消费后续消息。
还有一个场景是 Rebalance 期间的队列转移。某个队列从消费者 A 转移到消费者 B,如果 A 还有一部分消息没消费完,B 从位点继续读,对业务来说可能出现一段数据的“先暂停、后继续”,严格来说不算乱序,但如果业务的缓存状态没处理好,看起来就像乱序。我的建议是:有序消息消费端尽量保持实例稳定,不要频繁上下线。
5.4 事务消息一直收不到:回查链路出了问题
事务消息卡住的表象是:本地事务已经执行成功了,Broker 也一直没收到 Commit,消费端就永远等不到那条消息。这种问题十有八九出在回查逻辑上。
检查顺序是这样的:先看生产者进程有没有异常重启过,如果执行本地事务后进程在返回结果之前挂了,那 Broker 只能靠回查来确认状态。再看checkLocalTransaction有没有真正去查库、查状态,而不是直接返回 UNKNOW。最后看回查次数有没有达到上限,达到上限后 RocketMQ 会默认把消息丢弃或者按配置处理。
实际生产中,我会在事务消息的回查逻辑里加一层“人工兜底”的考虑:不依赖回查单一路径,业务侧做一个定时任务扫描本地事务表和半消息表的对比,发现不一致就主动触发补偿。这样即便回查链路有 bug,数据最终也能收敛。
5.5 延迟消息不触发:检查级别和集群时间
延迟消息到点不触发,最常见的原因是消息的延迟级别设置错了,或者客户端版本跟 Broker 端配置的 messageDelayLevel 不一致。比如 Broker 端如果自定义过 messageDelayLevel,序列化后客户端传的 level 值可能对应不上真实的时间。
还有一类很隐蔽的原因:集群里多个 Broker 的机器时间不一致。延迟消息的到期判断依赖 Broker 本机时间,如果一个 Broker 的时间比真实时间慢了几十秒,消息触发就会晚;如果快了,消息会提前。所以生产环境务必保证所有节点用 NTP 同步时间,这个细节越小越容易被忽略,但它会让延迟消息和定时任务的行为都变得不可预期。
5.6 高频错误码速查
| 错误码/日志 | 含义 | 处理建议 |
|---|---|---|
| SLAVE_NOT_AVAILABLE | 同步复制模式下 Slave 不可用 | 检查 Slave 是否在线、HA 端口连通性 |
| SERVICE_NOT_AVAILABLE | Broker 服务不可用 | 先看 CPU、磁盘、线程池队列是否打满 |
| FLUSH_DISK_TIMEOUT | 刷盘超时 | 检查磁盘 IO、PageCache 压力 |
| MASTER_NOT_AVAILABLE | 写入时找不到 Master | 确认 brokerId=0 的节点是否存活 |
| OFFSET_ILLEGAL | 消费位点非法 | 重置消费位点或清理 OffsetStore |
| TOPIC_NOT_EXIST | 路由里找不到主题 | 确认 Topic 创建状态和 NameServer 路由 |
排错顺序我一般固定:先从日志里找关键错误码,再判断是磁盘、网络、线程池哪个层面的问题,最后看监控曲线确认时间点前后的异常。很多问题并不需要去翻源码,先把监控平台利用起来,效率会高很多。
结尾:几个运维层面的实在体会
前阵子我又在测试环境演练了一遍故障切换:直接 kill 掉一个 Master 进程,观察生产者的报错、消费者的重平衡、Slave 的复制状态。说实话,这种场景你在工位上模拟过多少次都不嫌多,因为只有真正跑过,你才知道配置文件和监控项在故障时刻到底会给出什么表现。
我个人运维 RocketMQ 最大的体会是:高可用架构的可靠性不完全取决于组件本身,更多取决于你对故障发生之后行为的理解。比如知道 Slave 不会自动转正,就不会在 Master 挂掉时指望那台 Slave 继续接收写入;知道同步复制也会存在极小概率的丢消息窗口,就会在设计对账任务时留一手;知道 Rebalance 会造成消费暂停,就会在业务层对高可用系统中的“短暂不可用”做好容错。
最后再分享一个小技巧:给集群里的每台 Broker 都提前配上“落后者字节数”和“Consumer Lag 总量”两个监控项,超过阈值就告警。很多主从切换后的丢消息事故,在发生之前都会先呈现为“Slave 落后字节数持续增长”,这个信号比磁盘容量告警要早得多。把监控前置一步,很多事故根本走不到人肉救火那一步。