本博文主要分为两个方面,第一部分是介绍,包括三大MQ架构设计思想和主要组件;第二部分是实践,在实际工作遇到rabbitmq改造成rocketmq的例子。
架构思想和组件
一、RabbitMQ
一句话描述思想:RabbitMQ希望发消息像寄信:生产者把信交给邮局 Exchange ,信封上写地址 Routing Key ,邮局按事先登记的投递规则 Bind 进行分拣 Route ,最终把信投到目的地 Queue,消费者从自己的信箱取信。

如上图所示,RabbitMQ 由 Producer、Broker、Consumer 三个大模块组成。生产者将数据发送到 Broker,Broker 接收到数据后,将数据存储到对应的 Queue 里面,消费者从不同的 Queue 消费数据。RabbitMQ 是一个以 AMQP 协议为规范、以 Exchange 为中心路由、以 Queue 为实际存储单元、运行在 Erlang/OTP 之上的 Broker 架构;生产者不直接写队列,而是把消息交给 Exchange,由 Binding 规则决定路由到哪些 Queue。
核心分层可以理解为:
-
客户端层:Producer 和 Consumer。
-
网络接入层:TCP Connection 与 Channel 多路复用。
-
协议层:AMQP 0-9-1 命令与帧。
-
Broker 路由层:Vhost、Exchange、Binding、Routing Key。
-
存储与消费层:Queue、消息持久化、ACK、DLX。
-
集群与高可用层:元数据复制、Quorum Queue、Raft。
-
运行底座:Erlang/OTP 轻量级进程与监督树。
RabbitMQ核心组件架构
(1)Vhost:逻辑隔离单元
Vhost 相当于 RabbitMQ 内部的"命名空间"。每个 Vhost 里可以有独立的 Exchange、Queue、Binding 和权限。不同业务线可以用不同 Vhost 隔离,避免 Exchange 和 Queue 名称冲突,也便于权限控制。
架构上,Vhost 是 Exchange 和 Queue 的上一层逻辑容器。
(2)Connection 与 Channel:网络与多路复用
-
Connection:客户端与 Broker 之间的一条 TCP 连接,承载 AMQP 协议帧。
-
Channel:Connection 内部的虚拟通道,绝大多数消息操作都在 Channel 上进行。
为什么要有 Channel?因为建立 TCP 连接成本高。一个应用通常只维持少量 Connection,但可以在一条 Connection 上开多个 Channel,分别用于生产、消费、确认等操作。这是应用层的多路复用,降低了连接管理开销。
架构上可以理解为:
text
TCP Connection
├── Channel 1 → 生产消息
├── Channel 2 → 消费消息
└── Channel 3 → 管理操作
(3)Exchange:中心路由组件,不存储消息
Exchange 是 RabbitMQ 架构的灵魂。它本身不存储消息,只做一件事:接收生产者发来的消息,根据 Binding 规则和 Routing Key,决定投递到哪些 Queue。
Exchange 有四种主要类型:
| 类型 | 路由方式 | 架构含义 |
|---|---|---|
| Direct | Routing Key 与 Binding Key 完全相等 | 精确投递 |
| Fanout | 忽略 Routing Key,广播到所有绑定队列 | 无差别分发 |
| Topic | Routing Key 按 *、# 模式匹配 |
按模式订阅 |
| Headers | 按消息 Headers 键值匹配 | 多条件路由 |
架构上的关键点是:路由决策在 Broker 内部完成,生产者只负责声明 Exchange 和 Routing Key,不关心下游有多少队列。
(4)Binding:Exchange 与 Queue 的路由规则
Binding 是 Exchange 和 Queue 之间的"连接关系",本质上是一条路由规则。它告诉 Exchange:什么样的消息应该投到这个 Queue。
例如 Topic Exchange 下:
text
Binding: order.*.created → Queue: order_created_queue
Binding: order.#.failed → Queue: order_failed_queue
生产者发送 order.pay.failed,Exchange 根据 Binding 规则匹配,把消息路由到 order_failed_queue。Binding 是架构中的"规则层",Exchange 是"执行层",Queue 是"存储层"。
(5)Queue:真正的消息存储单元
RabbitMQ 中没有 Kafka 那种作为分区载体的 Topic 。数据的实际存储单元是 Queue。Queue 负责:
-
存储消息;
-
维护消息顺序;
-
向消费者投递消息;
-
处理 ACK、NACK、requeue;
-
支持 TTL、最大长度、死信等策略。
在 Erlang/OTP 内部,每个 Queue 通常对应一个 Erlang 轻量级进程,消息的入队、出队、确认等操作通过进程消息驱动。这让 RabbitMQ 可以在一台机器上承载大量队列。
(6)Consumer 与 ACK:消费确认架构
消费者从 Queue 拉取或订阅消息。RabbitMQ 采用"确认后删除"模型:
-
消费者 ACK 后,消息从 Queue 删除;
-
消费者 NACK 且 requeue=true,消息重新入队;
-
消费者 NACK 且 requeue=false,消息可能进入死信交换机;
-
消费者未 ACK 就断开,消息重新投递。
ACK 机制是 RabbitMQ 可靠消费架构的核心。它把"消息是否被成功处理"的确认权交给消费者。
消息完整流转路径
一条消息从生产到消费,在架构中经过如下路径:
text
Producer
│ 1. 建立 Connection,打开 Channel
│ 2. 声明 Exchange、Queue、Binding(可预先声明)
│ 3. Basic.Publish 到 Exchange,携带 Routing Key
▼
Exchange
│ 4. 根据 Exchange 类型和 Binding 规则做 Route
▼
Queue
│ 5. 消息入队,按策略持久化/设置 TTL/最大长度
▼
Consumer
│ 6. Basic.Consume 或 Basic.Get 拉取消息
│ 7. 业务处理
│ 8. Basic.Ack / Nack
▼
Queue 删除消息 或 死信路由
如果消息无法路由到任何 Queue,且生产者开启了 mandatory 和 Return 机制,Broker 会通过 Basic.Return 告知生产者路由失败。
可靠性架构:四层防线
RabbitMQ 的可靠性不是单点机制,而是架构上的多层配合:
-
生产者确认层:Publisher Confirm 确认消息到达 Exchange;Return 处理路由失败。
-
持久化层:Exchange durable、Queue durable、消息 delivery-mode=2,三者同时生效才能保证 Broker 重启后消息不丢。
-
消费确认层:Consumer ACK 决定消息何时从 Queue 删除。
-
失败兜底层:死信交换机 DLX 接管被拒绝、过期、超长的消息。
这四层构成了 RabbitMQ 在传统消息队列中较强的可靠性架构。
二、RocketMQ
RocketMQ 是阿里的产品,建立在Rabbitmq和kafka之上,借鉴 Kafka 的顺序日志存储思想并针对交易场景增强,希望把消息系统做成一个既能扛交易洪峰、又能保证金融级可靠的"中央交易记账中心":生产者先按 Topic 选定 MessageQueue 分片,再把携带该分片标记的消息按到达顺序记入所有 Topic 共用的总账 CommitLog,后台异步为每个 Topic/Queue 建立分户索引 ConsumeQueue,NameServer 像通讯录一样提供路由,Broker 主从像账本副本一样复制数据,用顺序写换吞吐、用索引分离换读取效率,并以事务、定时、顺序消息覆盖业务语义,最终在极致吞吐与业务可靠性之间取得平衡。

核心组件架构
- NameServer:极简无状态的路由注册中心
NameServer 是 RocketMQ 架构中最"反常规"的设计。业界通常用 ZooKeeper 做注册中心,RocketMQ 却选择自研一个几百行代码的无状态组件 ,节点之间互不通信。
设计哲学:简单、无状态、最终一致
-
无状态 :NameServer 不持久化路由数据,所有路由信息都由 Broker、Producer、Consumer 定时上报并存储在内存中。
-
节点间不通信 :每个 NameServer 独立接收 Broker 的心跳(每 30 秒一次),独立维护一份路由表。不同节点之间的路由信息不要求强一致,容忍分钟级的不一致。
-
最终一致:Broker 持续向所有 NameServer 发送心跳,即使某台 NameServer 短暂落后,也会在下一轮心跳后追上。客户端随机连接任意一台 NameServer 即可获得路由,若失败则自动切换。
架构收益:部署简单(不依赖外部协调组件)、网络开销极低、单点故障影响可控(客户端可切换其他节点)。
- Broker:消息存储与高可用的核心
Broker 是 RocketMQ 数据链路的核心,负责接收消息、存储消息、维护消费进度、处理主从复制。
Master / Slave 架构
Broker 分为 Master 和 Slave:
-
Master:接收生产者写入的消息,也支持消费者拉取;
-
Slave :从 Master 同步数据,不接收生产者写入,但可以分担消费读取的压力。
一个 Master 可以对应多个 Slave,但一个 Slave 只能对应一个 Master,通过相同的 BrokerName 标识为一组。
高可用演进:从主从同步到 DLedger
传统主从模式下,Master 宕机后需要人工介入切换。RocketMQ 引入了 DLedger(基于 Raft 的一致性模块)作为可选的高可用方案:DLedger 接管 CommitLog 的写入,实现 Leader 选举、日志复制和主从自动切换。Controller 组件可以独立部署或内嵌在 NameServer 中,与 DLedger 配合完成 Master 选举。
- 存储核心:CommitLog + ConsumeQueue 的分离设计
这是 RocketMQ 高性能的根本,也是它与 Kafka、RabbitMQ 存储模型最大的差异。
CommitLog:所有 Topic 共用的顺序日志
生产者写入的消息,无论属于哪个 Topic ,都按到达顺序追加到同一个 CommitLog 文件 中。单个 CommitLog 文件默认 1GB,写满后生成下一个文件,文件名以起始偏移量命名,保证全局顺序追加。
这种"混合型存储 "的设计目的是:把所有写入压力汇聚成一条顺序写流,最大化磁盘顺序写吞吐,避免多 Topic 并发写入造成的随机 I/O。
ConsumeQueue:按 Topic + Queue 分目录的轻量索引
如果只有 CommitLog,消费者要按 Topic 查找消息就必须遍历整个日志文件,效率极低。ConsumeQueue 正是为了解决这个问题而引入的逻辑索引。
-
组织结构 :
{topic}/{queueId}/{fileName},每个 Topic 的每个 MessageQueue 对应一组 ConsumeQueue 文件。 -
定长条目 :每个条目固定 20 字节:8 字节 CommitLog 物理偏移量 + 4 字节消息长度 + 8 字节 Tag HashCode。
-
文件大小 :单个 ConsumeQueue 文件由 30 万个条目组成,约 5.72 MB,可以像数组一样随机访问。
-
异步构建 :消息写入 CommitLog 后,后台线程
ReputMessageService异步构建 ConsumeQueue,不阻塞写入主路径。
架构含义 :写入走一条"大路"(CommitLog,顺序写),读取走多条"小路"(ConsumeQueue,按需索引)。数据与索引分离,写入吞吐和读取效率得以同时兼顾。
IndexFile:辅助的 Key/时间查询索引
除了 ConsumeQueue,RocketMQ 还维护 IndexFile,提供按消息 Key 或时间区间查询消息的能力,底层是文件系统上的 HashMap 结构,单个文件约 400MB,可保存 2000 万条索引。
- Producer:路由感知与负载均衡的发送端
Producer 在发送消息前,需要完成"查路由 → 选 Broker → 选 Queue → 发送"的流程。
-
查路由:Producer 与 NameServer 集群中的一台建立长连接,定期拉取或按需查询目标 Topic 的路由信息。路由信息包含该 Topic 关联的所有 Broker(Master/Slave)以及每个 Broker 上的 Queue 分布。
-
选 Broker :优先选择 Master Broker(Slave 不接收写入),多个 Master 之间按轮询、随机或基于 Key 的哈希策略选择。
-
发送方式 :支持同步、异步、单向三种发送模式,分别适用于对可靠性、吞吐量和延迟有不同要求的场景。
- Consumer:Push/Pull 双模式与消费进度管理
Consumer 同样先向 NameServer 获取路由,然后直接与 Broker 建立连接拉取消息。
-
Push 与 Pull :RocketMQ 的"Push"模式本质上是在 Pull 之上封装了长轮询------消费者发起拉取请求,若当前无消息,Broker 会保持连接等待约 30 秒,期间有新消息立即返回,兼顾了实时性与拉取模型的主动权。
-
消费进度 :消费者按 Consumer Group 分组拉取,SDK 将消费 Offset 定时或手动提交回 Broker,实现消费进度的持久化。
- 业务特性层:在"至少一次"基础上叠加业务语义
RocketMQ 的设计选择是:内核只保证消息"至少被消费一次"(at-least-once),把幂等性下放给消费者,从而保持内核简单和高性能。在此之上,通过 Broker 和客户端的配合提供丰富的业务特性:
-
顺序消息 :生产者将同一业务 Key 的消息发送到同一个 MessageQueue ,消费者对同一队列串行消费,从而保证分区内的严格顺序。
-
事务消息 :采用两阶段提交 + 事务补偿。生产者先发送"半消息",执行本地事务后提交或回滚;若 Broker 长时间未收到确认,会回查生产者的事务状态,最终实现分布式事务的最终一致。
-
定时/延迟消息:消息写入后不立即投递,而是根据预设的延迟级别进入对应的定时队列,到期后才对消费者可见。
消息完整流转路径
text
Producer
│ 1. 初始化:连接 NameServer,获取目标 Topic 的路由表
│ 2. 选择 Master Broker 和 MessageQueue(负载均衡)
│ 3. 发送消息到 Broker
▼
Broker
│ 4. 消息顺序追加写入 CommitLog(所有 Topic 共用)
│ 4a. 同步刷盘 → 写入成功后才返回 ACK(可靠性优先)
│ 4b. 异步刷盘 → 写入 PageCache 即返回(吞吐优先)
│ 5. 后台线程异步构建 ConsumeQueue 和 IndexFile
│ ConsumeQueue 条目 → 指向 CommitLog 中的物理位置
│ 6. (若配置主从/DLedger)消息同步复制到 Slave
▼
Consumer
│ 7. 向 NameServer 查询路由(或使用本地缓存)
│ 8. 从 Broker 拉取消息:
│ 8a. 先读 ConsumeQueue → 得到 CommitLog 偏移量
│ 8b. 再根据偏移量从 CommitLog 读取消息实体
│ 9. 业务处理
│ 10. 提交消费 Offset 回 Broker
▼
├── 成功消费 → Offset 推进
└── 消费失败 → 重试(重试队列)/ 死信队列(DLQ)
关键路径节点说明:
-
第 4 步 :写入路径极短,核心就是顺序追加 CommitLog。同步刷盘保证可靠性,异步刷盘追求吞吐,通过 PageCache 和 mmap 内存映射减少 I/O 开销。
-
第 5 步 :ConsumeQueue 是异步构建的,不阻塞写入。这意味着写入时只需要关心 CommitLog,消费索引的延迟是架构上可接受的代价。
-
第 8 步 :消费路径是"先查索引,再取数据"------ConsumeQueue 提供 Topic/Queue 维度的定长索引,CommitLog 提供消息实体。两者配合,避免了在巨大日志文件中盲目遍历。
三、kafka
Kafka 希望把消息系统做成一条可持久化、可回溯、可分片的分布式"日志流水账":Topic 按 Partition 切成多本只能追加的账本,生产者按 Key 或策略选定分区,往 Leader 账本末尾顺序写;Follower 副本持续拉取同步,ISR 保证副本不丢;消费者组按 Offset 自己翻页读,读到哪里由消费者自己记录;Controller 通过 ZooKeeper 或 KRaft 管理集群元数据和 Leader 选举;用顺序写、页缓存、零拷贝、批量压缩和分区并行换取极致吞吐,并用日志保留策略让多组消费者可以独立、重复、回溯地消费同一份数据流。

核心组件架构
- Topic 与 Partition:逻辑类别与物理分片
Kafka 中 Topic 是逻辑上的消息类别 ,而 Partition 是物理上的并行与顺序单元。
-
一个 Topic 被划分为多个 Partition,每个 Partition 是一个只能追加的日志文件。
-
Partition 内严格有序,不同 Partition 之间不保证全局顺序。
-
Partition 数量决定了一个 Topic 的最大消费并行度:同一消费者组内,一个 Partition 同一时刻只能被一个消费者消费。
-
每个 Partition 可以有多个副本(Replica),分布在不同的 Broker 上。
架构含义 :Kafka 的"分区"不是 RabbitMQ 的 Queue,也不是 RocketMQ 的 MessageQueue 的简单等价物。它同时承担了顺序写单元、并行读单元、副本复制单元和消费者分配单元四种角色。
- 日志存储:Segment + Index + 页缓存 + 零拷贝
每个 Partition 在磁盘上表现为一组日志段文件(Log Segment):
text
TopicA-P0/
├── 00000000000000000000.log 消息实体
├── 00000000000000000000.index 偏移量索引
├── 00000000000000000000.timeindex 时间索引
├── 00000000000000001000.log
└── ...
-
消息追加 :生产者发送的消息被追加到当前活跃 Segment 的末尾,顺序写磁盘。
-
索引 :
.index保存 Offset 到物理位置的稀疏索引,.timeindex支持按时间查找。 -
页缓存(Page Cache):Kafka 不把消息缓存到 JVM 堆,而是依赖操作系统页缓存。写入先进入 Page Cache,由操作系统刷盘;读取时若命中页缓存则无需磁盘 I/O。
-
零拷贝(Zero-Copy) :消费者读取时,通过
sendfile将页缓存中的数据直接送到网卡,避免"内核态 → 用户态 → 内核态"的多次拷贝。 -
批量与压缩:Producer 端批量发送并压缩,Broker 端以批为单位存储,显著降低网络和磁盘开销。
-
保留策略 :消息不因被消费而删除,而是按时间或大小保留。这是 Kafka 可回溯、多消费者组独立消费的基础。
- 副本与 ISR:Leader/Follower 复制模型
每个 Partition 的多个副本中,有一个 Leader 和若干 Follower:
-
Leader:处理生产写入和消费者读取。
-
Follower:主动向 Leader 发送 Fetch 请求拉取数据,保持同步。
-
ISR(In-Sync Replicas):与 Leader 保持同步的副本集合。落后太多的副本会被移出 ISR。
-
LEO(Log End Offset):每个副本日志末端位置。
-
HW(High Watermark):ISR 中所有副本都已同步到的位置,消费者只能读到 HW 之前的消息。
确认机制由 acks 控制:
acks |
含义 | 可靠性 |
|---|---|---|
0 |
生产者不等待 Broker 确认 | 最低,可能丢消息 |
1 |
Leader 写入成功即返回 | 中等,Leader 宕机可能丢 |
all / -1 |
ISR 中所有副本确认才返回 | 最高,配合 min.insync.replicas |
Leader 故障时,Controller 从 ISR 中选举新的 Leader,保证已提交消息不丢失。
- Controller 与元数据管理:ZooKeeper 与 KRaft 两条路线
Kafka 集群需要一个"管理者"来维护元数据和 Leader 选举:
-
ZooKeeper 模式:Broker 注册到 ZooKeeper,Controller 由 ZooKeeper 选举产生。Controller 负责 Topic 创建、分区 Leader 选举、Broker 上下线处理。
-
KRaft 模式 :Kafka 2.8 引入、3.3 后逐步生产可用。用 Controller Quorum 通过 Raft 协议选举 Active Controller,元数据以日志形式在 Controller 之间复制,不再依赖 ZooKeeper。
架构要点 :Kafka 没有 RocketMQ 的 NameServer 那样的独立路由中心。客户端通过 bootstrap.servers 连接任意 Broker,获取元数据后缓存在客户端本地,后续直接与对应 Partition 的 Leader Broker 通信。
- Producer:分区选择、批量发送与确认
Producer 的发送流程包含几个关键决策:
-
获取元数据:从 bootstrap Broker 拉取 Topic 的分区及 Leader 信息,并缓存。
-
选择 Partition:
-
指定了 Partition,直接使用;
-
指定了 Key,按
hash(key) % partition数选择; -
都没指定,按轮询或粘性分区策略选择。
-
-
批量与压缩:消息进入 RecordAccumulator,按 Partition 批量聚合,压缩后发送。
-
发送与确认 :发送到 Leader Broker,根据
acks等待确认;失败按重试策略重发。 -
幂等与事务:通过 Producer ID + Sequence Number 实现幂等写入;事务 API 支持跨分区原子写。
- Consumer Group 与 Offset:消费者组模型
Kafka 的消费模型是拉取式 + 消费者组:
-
Consumer Group:一组消费者共同消费一个 Topic,每个 Partition 在组内只分配给一个消费者。
-
Group Coordinator:某个 Broker 担任协调者,负责消费者加入组、分区分配和 Rebalance。
-
Offset :消费者记录自己消费到 Partition 的位置。Offset 提交到内部 Topic
__consumer_offsets,支持自动提交和手动提交。 -
拉取式消费:消费者主动向 Leader 发送 Fetch 请求,按 Offset 读取日志段。
-
Rebalance:消费者加入、离开或分区变化时,组内重新分配分区。
架构含义 :Kafka 不维护"消息是否被消费"的全局状态,只维护每个消费者组的 Offset。消息本身按保留策略过期,因此多个消费者组可以独立消费同一份数据,互不影响,也可以重置 Offset 回溯消费。
高吞吐的架构来源
Kafka 的极致吞吐不是单一技术,而是多项设计的叠加:
| 机制 | 作用 |
|---|---|
| 顺序追加写 | 避免随机 I/O,接近磁盘顺序写极限 |
| 操作系统 Page Cache | 绕过 JVM 堆,减少 GC 和对象开销 |
| 零拷贝 sendfile | 减少内核态与用户态拷贝 |
| 批量发送与压缩 | 降低网络请求数和带宽 |
| Partition 并行 | 多分区同时读写,水平扩展 |
| 副本流水线复制 | Follower 批量拉取,分摊复制开销 |
消息完整流转路径
text
Producer
│ 1. 通过 bootstrap.servers 连接 Broker,获取 Topic 元数据
│ 2. Partitioner 选择目标 Partition:
│ 2a. 指定 Partition → 直接使用
│ 2b. 指定 Key → hash(key) 取模
│ 2c. 无 Key → 轮询/粘性策略
│ 3. 消息进入 RecordAccumulator,按 Partition 批量聚合、压缩
│ 4. 发送到该 Partition 的 Leader Broker
▼
Broker(Leader)
│ 5. Leader 将消息批次顺序追加到当前 Segment 的 .log 文件
│ 6. 更新 LEO;后台构建 .index / .timeindex 稀疏索引
│ 7. Follower 向 Leader 发送 Fetch 请求,拉取并追加到本地副本
│ 8. ISR 内副本同步推进,HW 前移
│ 9. 根据 acks 返回确认:
│ 9a. acks=0 → 不等待
│ 9b. acks=1 → Leader 写入即返回
│ 9c. acks=all → ISR 确认后返回
▼
Consumer Group
│ 10. 消费者向 Group Coordinator 加入组,触发分区分配
│ 11. 消费者向目标 Partition 的 Leader 发送 Fetch 请求
│ 12. Broker 根据 Offset 定位 Segment 和索引,返回消息批次
│ 13. 消费者处理消息
│ 14. 提交 Offset 到 __consumer_offsets(自动或手动)
▼
├── 正常消费 → Offset 推进,下次从新位置继续
├── 消费者故障 → Rebalance,分区转给其他消费者,从已提交 Offset 继续
├── 未提交 Offset → 可能重复消费,需业务幂等
└── Leader 故障 → Controller 从 ISR 选举新 Leader,消费者切换后继续
消息队列迁移实战
Spring Cloud Stream + RabbitMQ 平滑迁移 RocketMQ 的方法论:
一套代码同时跑两种 MQ,灰度开关秒级切换,零停机、零回滚成本------这不是理想,是我们三个大型项目刚刚走完的路。
开篇:为什么写这篇文章
年初,团队因为消息中间件的稳定性问题,做出了一个决定:全线从 RabbitMQ 迁移到 RocketMQ。
听起来很简单,对吧?换一个 MQ 而已。但当你面对的是:
- 一个 单体应用 ,内含 4 个业务模块、28 个消费者 、10+ 个发送点
- 一条 不能停机的业务链路,消息断了 = 线下几百个网点的业务停摆
- 一套 跨国部署 架构,每个国家的业务链路都不一样
- 一个 Spring Boot 2.0 + Spring Cloud Finchley 的老技术栈
"换 MQ"就不是"换依赖"那么简单了。
这篇文章不讲 RocketMQ 的概念和安装------那些网上太多了。我讲的是迁移的方法论:怎么设计才能不停机、怎么灰度才能秒级回滚、怎么避免踩那些文档里不会告诉你的坑。
适合谁看:正在做或计划做 MQ 迁移的后端工程师、架构师。
第一部分:整体架构思维(总)
核心原则:双轨并行,不删旧代码
迁移的第一原则不是"怎么写新代码",而是"怎么保证随时能回去"。
┌──────────────────┐
│ 业务代码 │
│ (增删改触发发送) │
└────────┬─────────┘
│
┌────────▼─────────┐
│ 发送方法 │
└────────┬─────────┘
│
┌────────▼─────────┐
│ 灰度开关判断 │ ←── 配置中心(秒级生效)
└───┬──────────┬───┘
│ │
false ───┘ └─── true
│ │
┌────────▼──────┐ ┌─────▼─────────┐
│ 旧轨道(不动)│ │ 新轨道(新增) │
│ RabbitMQ │ │ RocketMQ │
└───────────────┘ └───────────────┘
三条铁律:
- 旧代码一行不删------不是舍不得,是回滚的命根子
- 发送点插开关------每个发送方法加一个 if/else,开关关=走老路
- 消费者两侧并存------旧消费者照常监听 Rabbit 队列,新消费者监听 Rocket topic,互不干扰
当开关打开时,消息走 Rocket 轨道 → Rabbit 队列自然断流 → 旧消费者空转(不重复消费)。出问题拨回开关 → 秒级回到 Rabbit。
消费者怎么办?
消费者不能插开关------它是被动接收的。做法是给每个活跃的旧消费者建一个镜像:
| 旧消费者(不动) | 新消费者(新增) |
|---|---|
@StreamListener + condition |
@RocketMQMessageListener + topic/group |
| 收 Rabbit 队列消息 | 收 Rocket topic 消息 |
| 业务逻辑一样 | 业务逻辑一样(原样搬入) |
发送端切到 Rocket 后,旧消费者自然收不到消息(队列断了),不会重复消费。
第二部分:关键技术决策(分)
2.1 路由模型映射------最大的思维转变
RabbitMQ 和 RocketMQ 的路由哲学完全不同,这是迁移中最需要"换脑子"的地方。
RabbitMQ 的模式:一个 Exchange 承载多种业务消息,靠 Header 或 RoutingKey 区分
Exchange: lmdm-data-change
├── header sys_network=1 → 队列A(缓存消费者)
├── header sys_staff=1 → 队列B(推送消费者)
└── header sys_customer=1 → 队列C(客户消费者)
RocketMQ 的模式:不用 tag,一个业务一个 topic
Topic: lmdm-data-change-sys_network → 消费组A
Topic: lmdm-data-change-sys_staff → 消费组B
Topic: lmdm-data-change-sys_customer → 消费组C
映射规则:
| Rabbit 概念 | Rocket 概念 | 迁移时的体现 |
|---|---|---|
| Exchange(带 header 路由) | 一族 Topic | 交换机名作前缀 + header 值作后缀 |
| Exchange(单一业务) | 一个 Topic | 加统一前缀重命名 |
| Queue(destination.group) | ConsumerGroup | 同一 topic 挂多组=每组全量收 |
condition = "headers['x']==1" |
删除 | topic 名已包含路由信息 |
| DLX/DLQ 死信 | %DLQ%组名 |
重试耗尽自动投递 |
同一个 topic 多消费组 = 广播------这是跟 Rabbit 最大的语义差异:
- Rabbit:一条消息只被 condition 匹配的那一个消费者处理(精确分发)
- Rocket:挂在这个 topic 上的每个消费组都收到全量消息(广播)
这意味着你需要防串扰:消费者 A 不该处理的消息也会进来,必须在代码里判断消息内容是否属于自己的处理范围(后面踩坑部分详述)。
2.2 生产者设计------统一收口
不要让每个发送点都直接操作 RocketMQTemplate,而是建一个统一的生产者类:
java
@ExtRocketMQTemplateConfiguration
public class RocketMessageProducer extends RocketMQTemplate {
@Resource
private ExportSendMQTemplate exportSendMQTemplate; // 独立发送模板
// 通用发送(90% 的调用走这里)
public boolean sendUniversalMessage(Message<?> message, String topic) {
SendResult result = exportSendMQTemplate.syncSend(topic, message);
// SLAVE_NOT_AVAILABLE 也算成功:消息已到 master,只是从节点没同步完
return SendStatus.SEND_OK.equals(result.getSendStatus())
|| SendStatus.SLAVE_NOT_AVAILABLE.equals(result.getSendStatus());
}
// 动态 topic 前缀发送("交换机+header=topic"的核心实现)
public boolean sendDataChange(Message<?> message, String module) {
// "lmdm-data-change-" + module 就是映射规则的代码化
SendResult result = exportSendMQTemplate.syncSend(TOPIC_PREFIX + module, message);
// ...
}
}
为什么 SLAVE_NOT_AVAILABLE 也算成功?因为消息已经到了 master 节点,只是从节点没来得及同步------消息没有丢,不应该让业务感知到"发送失败"。
2.3 发送端灰度包装------唯一允许改动的旧代码
改造旧代码的规则:只允许在发送语句之前插入 if/else,原路径一行不动。
java
// 改造前
public void sendDataChange(Map<String, Object> data, Map<String, Integer> header) {
Message message = buildMessage(data); // 组装消息
this.rabbitChannel.send(message); // 发到 Rabbit
}
// 改造后(只在 send 之前插了一段)
public void sendDataChange(Map<String, Object> data, Map<String, Integer> header) {
Message message = buildMessage(data); // 组装消息(不动)
// ↓↓↓ 插入的灰度分支 ↓↓↓
if (grayService.isEnabled("data-change-switch")) {
String json = toJSON(message); // 序列化(禁用循环引用检测)
rocketProducer.sendDataChange(json, message.getModule());
return;
}
this.rabbitChannel.send(message); // 原 Rabbit 路径(不动)
}
序列化时必须加 SerializerFeature.DisableCircularReferenceDetect,否则 fastjson 遇到重复对象会生成 $ref 占位符,消费端解析直接报错。
2.4 消费者模板------批量生产
28 个消费者听起来很多,但它们全是同一个模子的变体。理解了这个模子,剩下的就是体力活:
java
@Component
@RocketMQMessageListener(
nameServer = "${rocketmq.nameSrvAddr}", // 集群地址(从配置中心读)
topic = "${rocketmq.topic.xxx}", // 订阅哪个 topic
consumerGroup = "${rocketmq.group.xxx}", // 用哪个消费组
maxReconsumeTimes = 3, // 重试上限
consumeThreadNumber = 2) // 消费并发
public class RocketXxxConsumer implements RocketMQListener<MessageType> {
@Override
public void onMessage(MessageType payload) {
MDC.put("traceId", UUID.randomUUID().toString()); // 链路追踪
// 业务逻辑 = 原 Rabbit 消费者方法体原样搬入
// 唯一区别:message.getPayload() 变成了直接用 payload
}
}
注解占位符是启动级硬依赖 ------${rocketmq.topic.xxx} 在应用启动时必须能从配置中心解析出值,缺一个 key 应用直接起不来。这是整个迁移中最容易踩的坑(后面详述)。
2.5 配置中心------两份配置,两种性质
需要新建两个配置 namespace:
rocket.yml(连接与路由配置):
XML
rocketmq:
name-server: xxx:9876 # 集群地址
producer:
group: my-app-producer # 必须有
rocketmq-bps:
nameSrvAddr: xxx:9876
accessKey: xxx
secretKey: xxx
topic:
my-topic-a: my-topic-a # 每个消费者的占位符都必须有对应 key
my-topic-b: my-topic-b
group:
my-group-a: my-group-a
my-group-b: my-group-b
gray-switch.yml(灰度开关):
XML
gray-switch:
module-a:
main-switch:
flag: false # 主开关
sub-switch-1:
flag: false
fail-warn:
flag: false # 特殊:失败降级开关
| rocket.yml | gray-switch.yml | |
|---|---|---|
| 缺一个 key | 应用崩溃 | 不崩(默认 false) |
| 能否后补 | 不能(要重启) | 能(配置中心秒级生效) |
| 正确生成方式 | 从代码注解占位符反推 | 从代码开关调用反推 |
2.6 异常语义变化------最容易忽视的坑
Rabbit 和 Rocket 对消费失败的处理完全不同:
| Rabbit | Rocket | |
|---|---|---|
| 消费抛异常 | 可配置 requeue(无限重试)或进死信 | 最多重试 N 次后进死信 |
| 高频失败场景 | 无限重试可能拖垮系统 | 重试 N 次后停止,消息进 DLQ |
如果你有"推送外部系统失败就抛异常触发重试"的场景,迁移后需要加一个降级开关:
java
if (pushFailed) {
if (failWarnEnabled) {
log.warn("推送失败,仅告警不重试"); // 降级:只打日志
} else {
throw new RuntimeException("推送失败"); // 默认:重试→死信
}
}
2.7 时间字段反序列化兼容
如果消息体里有 LocalDateTime 字段,不同序列化器产出的格式可能不同(2026-10-19T10:00:00 / 2026-10-19T10:00:00.123 / 2026-10-19 10:00:00),需要写一个多格式兼容的反序列化器:
java
public class LocalDateTimeMultiDeserializer implements ObjectDeserializer {
private static final List<DateTimeFormatter> FORMATTERS = Arrays.asList(
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"),
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
);
@Override
public LocalDateTime deserialze(DefaultJSONParser parser, Type type, Object fieldName) {
String text = parser.parseObject(String.class);
for (DateTimeFormatter formatter : FORMATTERS) {
try { return LocalDateTime.parse(text, formatter); }
catch (Exception ignored) {}
}
throw new IllegalArgumentException("无法解析时间:" + text);
}
}
在实体的时间字段上挂 @JSONField(deserializeUsing = LocalDateTimeMultiDeserializer.class)。
第三部分:踩坑实录(用血泪换来的)
坑 1:配置中心缺 key → 启动崩溃(最高频)
现象 :应用 CrashLoopBackOff,日志 Could not resolve placeholder
根因:代码注解里有 28 个占位符,配置中心里只有 22 个 key------因为配置是从别的项目拷的模板,那个项目只有 22 个消费者。
正确做法:不要抄模板,用 grep 从代码反推:
java
# 这两条命令的输出 = 配置中心必须有的完整 key 清单
grep -rh 'topic = "' src/main/java | sed 's/.*topic\.\([^"]*\).*/\1/' | sort -u
grep -rh 'consumerGroup = "' src/main/java | sed 's/.*group\.\([^"]*\).*/\1/' | sort -u
坑 2:同 topic 广播导致 NPE
现象:消费者频繁抛 NullPointerException
根因:Rabbit 靠 condition 精确分发,Rocket 是广播------消费组 A 收到了本该给消费组 B 的消息,解析出来的对象字段全为 null。
解法 :在每个消费者开头加内容形状守卫:
java
// 如果这条消息不是给我的,关键字段会是 null → 自然跳过
if (isNull(payload.getKeyField())) return;
坑 3:旧代码被意外修改
现象:code review 发现 diff 里有大量与 MQ 无关的变更
根因:从项目 A 复制文件到项目 B 时,把 A 的注释状态、导入列表等也带了过去,导致 B 的基线被污染
解法 :改造前先 git diff 基线,改造后再 git diff 确认只有预期的 MQ 相关变更。用目标项目的基线做 diff,不要用来源项目的版本做参照
坑 4:消息积压在无消费者的 topic 上
现象:打开主开关后,Rocket 控制台显示大量 topic 有消息积压
根因:主开关会把所有子类型的消息发到对应的 topic,但其中大部分 topic 没有本应用的消费者(可能是外部系统消费,也可能根本没人消费)
解法:这属于正常现象,提前在 PROD 环境预建这些 topic(UAT 通常自动创建),并确认哪些需要外部消费方接入
第四部分:灰度上线手册(总)
上线前
- 所有灰度开关初始为 false(行为与改造前 100% 一致)
- 配置中心的 key 与代码占位符逐字核对
- 旧消费者代码确认零改动
- 现有单元测试全通过
Phase 1:部署新代码(开关全 false)
- 应用正常启动
- N 个新消费者上线(空转,没消息)
- 旧消费者照常工作
- 此时系统行为与改造前完全一致
Phase 2:逐开关灰度
按风险从低到高:
- 自产自销类(自己发自己收,最安全)
- 外发对端类(确认对端已建好消费组)
- 主开关(影响面最大,一次带动全家族)
- 外部依赖类(等对端系统切换到新 MQ 后才有消息)
每个开关打开后验证三有:
- 有日志(发送成功)
- 有消费(业务结果正确)
- 有断流(旧 MQ 队列不再进新消息)
任一异常 → 拨回开关 → 秒级回滚,无需发版
Phase 3:全量观察
- 观察消费堆积指标
- 监控死信队列
- 确认无异常后进入稳定期
总结:七条可复用的迁移原则
| # | 原则 | 为什么 |
|---|---|---|
| 1 | 双轨不删旧 | 回滚的命根子 |
| 2 | 配置从代码反推 | 抄模板必踩缺 key 的坑 |
| 3 | 消费者按模板批量生产 | 28 个消费者 = 1 个模子的变体 |
| 4 | 发送点只插分支 | 原路径一行不动 = 行为保证 |
| 5 | 灰度分批、先安全后风险 | 自产自销最安全,外部依赖最后动 |
| 6 | 每个消费组配 DLQ 监控 | 死信是发现问题的第一道防线 |
| 7 | 广播语义必须加守卫 | 这是 Rabbit→Rocket 最大的隐性差异 |
附:三个项目的量级对比
| 维度 | 项目 A(参照) | 项目 B | 项目 C |
|---|---|---|---|
| Rocket 消费者 | 30 | 22 | 28 |
| 灰度开关 | 21 | 14 | 13 |
| 配置中心 topic key | 24 | 16 | 20 |
| 配置中心 group key | ~30 | 22 | 28 |
| data-change 族 topic | 23 | 21 | 20 |
| 新增文件 | 34 | 26 | 32 |
| 修改文件 | 17 | 15 | 15 |
每个项目的数字都不同------因为消费者数=该项目自己的活跃消费者数,开关数=该项目自己的发送方法数。迁移方案必须按项目定制,不能照搬。
改造全程约 3 周(含 3 个项目、多轮代码审查、UAT 联调)。如果这篇文章帮到了你,点个赞让更多人看到。有问题欢迎评论区交流。