这个问题我至少被问过二十次。早期是面试题里高频出现:"RocketMQ 为什么要自己做一个 NameServer,直接用 ZooKeeper 不香吗?"这两年随着 5.x 全面铺开,问题延伸成了"proxy、controller、container 到底有什么用,是不是为了 KPI 硬凑的组件?"
作为一个把 RocketMQ 源码翻过几遍、也在线上真金白银压过主从切换的人,我可以直接说:这几个组件没有一个是凑数的。它们的出现完全顺着 RocketMQ 自身的演进逻辑——先是 NameServer 用"极简"换可用性,然后是 5.x 用"拆"来解决 4.x 遗留的扩展性和运维痛点。
这篇文章不打算做源码逐行注释,但会把几个关键机制讲透:NameServer 为什么放弃了 ZooKeeper 那套能力、Topic 路由从 Broker 注册到客户端拿到要经过哪些环节、Proxy 解决了什么协议和消费模型问题、Controller 如何接管选主、Container 又在往哪个方向使劲。最后给一套务实的选型建议。
1. 为什么 RocketMQ 宁愿自研 NameServer,也不肯用 ZooKeeper
先给一个反直觉的事实:NameServer 是个功能少到令人发指的组件。它不持久化任何数据、不参与消息读写、节点之间不通信、不做选主、也不主动通知客户端变更。它维护的只有一张全量内存路由表,以及 Broker 的存活状态。就这么点东西,RocketMQ 选择自己写而不是直接引入 ZooKeeper,背后的原因值得展开聊。
1.1 NameServer 到底干了多少活:一张路由表的全部家当
代码里这块的核心是RouteInfoManager,里面就是几张ConcurrentHashMap:
topicQueueTable:Topic 到QueueData的映射,记录每个 Topic 在每个 Broker 上建了哪些队列brokerAddrTable:brokerName 到BrokerData的映射,里面包含集群名和主从地址clusterAddrTable:集群名到 brokerName 集合的映射brokerLiveTable:broker 地址到最近心跳时间的映射filterServerTable:过滤服务器地址表,实际用得少
NameServer 做的事情就是这几张表的新增、更新、删除。Broker 起来后向它注册,之后每隔 30 秒心跳一次;NameServer 每 10 秒扫一遍存活表,超过 120 秒没有心跳的 Broker 直接从内存里删掉。
这也是为什么 NameServer 可以水平部署多个,而且互相之间完全不用做数据同步——因为每个 NameServer 都是独立从 Broker 那边拿全量数据。少了一个 NameServer,顶多让一部分 Broker 的新注册请求失败,但客户端连到其他 NameServer 依然能拿到完整路由。
操作层面给个提醒:生产环境至少部署两个 NameServer,不要共用一个进程,最好放在不同机器或至少不同可用区。NameServer 的故障模型就是"服务本身无状态,但部署身份必须冗余"。你要是把它跟 Broker 塞在同一台物理机,机器挂了两个角色一起没,那部署两个的意义就少了一半。
1.2 不选 ZooKeeper 的三点真实原因
第一,复杂度不匹配。ZooKeeper 是一个分布式协调系统,提供的是持久化节点、临时节点、监听、分布式锁、选主这一整套能力。但消息中间件的元数据服务主要需要的是"存路由 + 探活"。你在 ZK 上能用到的最多就是临时节点和 watcher,其他高级特性都是运维负担。
更麻烦的是 ZK 的 session 机制。客户端和 ZK 之间维护一条心跳 session,一旦网络抖动导致 session 超时,临时节点就被删掉。对消息中间件来说,这会造成 Broker 在 ZK 上"反复横跳",进而触发客户端路由频繁刷新,线上表现为发送延迟毛刺。Kafka 早期版本在这上面吃过太多亏。
第二,CAP 的取舍不符合消息链路的需求。ZooKeeper 是 CP 系统,写入必须过半确认,一旦 Leader 出问题,整个集群在重新选主期间是不对外提供写服务的。对 RocketMQ 来说,NameServer 挂掉影响的是"新路由不更新",而不是"消息发不出去"——因为客户端本地已经有缓存路由,发送消息不需要实时访问 NameServer。
两者对故障的容忍方向完全相反。RocketMQ 宁可要一个偶尔不一致、但始终能读的内存表,也不要一个强一致、但抖动起来连名字服务都不可用的协调系统。
第三,依赖收敛。RocketMQ 从设计之初就走"能不加依赖就不加依赖"的路线,除了消息存储目录之外几乎不依赖任何外部系统。这一点对中小团队特别友好:部署 RocketMQ 只需要 JDK,不需要额外维护一套 ZK。你可以算算,一个三机房 ZK 集群的机器成本和维护精力,换成两个 NameServer 能省多少。
1.3 作为交换,RocketMQ 放弃了哪些"常见福利"
放弃的东西也很明确:NameServer 不提供监听器能力,客户端拿不到"路由变更推送",只能靠定时拉取;NameServer 不提供分布式锁和选主,4.x 时代的主从切换要么靠 DLedger 插件解决、要么人工介入。
这些缺失在 5.x 由 Controller 和新的客户端协议来弥补,而不是回头去把 NameServer 做成第二个 ZK。
提示:面试如果被问"NameServer 为什么不用 ZK",不建议只答"为了去依赖"。更完整的回答是:路由场景用不上 ZK 的强一致特性,CP 模型反而放大故障面,自研无状态节点可以用最小代价换取高可用。
2. 一张 Topic 路由从注册到被客户端拿走的完整链路
很多人对 NameServer 的理解停在"存路由"这三个字,但一到排查问题,细节就决定成败。我经常被问到的几个问题:为什么新建 Topic 后客户端隔一会儿才能感知?为什么 Broker 挂了消息还能继续发?为什么说 NameServer 不持久化不是 bug 而是 feature?把这条链路讲清楚,就全明白了。
2.1 Broker 注册、心跳和过期剔除:三个关键参数
Broker 启动时调用registerBrokerAll,把自身的 brokerName、brokerId、集群名、Topic 配置、主从地址发给配置的所有 NameServer。之后每 30 秒重发一次心跳。NameServer 收到请求后更新brokerLiveTable里的时间戳。
NameServer 侧有个定时任务,每 10 秒遍历一次brokerLiveTable,把超过 120 秒没有心跳的 Broker 标记为不存活并从路由表移除。这三个数字是实际生效的关键参数:
- 30 秒:Broker 心跳周期
- 10 秒:NameServer 扫描周期
- 120 秒:Broker 存活超时阈值
算一下最坏情况:一个 Broker 刚发完心跳立刻宕机,NameServer 最坏要等 120 秒才会把它从内存里删掉。也就是说,故障发生后的两分钟内,Producer 依然可能通过 NameServer 拿到这个已经挂掉的 Broker 地址,然后发送超时。
这里的常见坑是:很多人以为"Broker 挂了 NameServer 马上就知道",实际不是,这是一个准实时模型。做故障演练的时候,别掐着秒表看路由剔除——最快也要 10 秒之后扫描到,最慢要 120 秒。
另外一个细节:如果是优雅停机,Broker 会调用unregisterBroker主动注销,此时 NameServer 立刻就能摘掉路由。所以线上能优雅停机就一定优雅停机,不要动不动kill -9。
2.2 Client 的路由拉取与本地缓存:为什么新 Topic 有 30 秒延迟
Producer 和 Consumer 启动后,并不会像有些同学想的那样每条消息都查路由。客户端拿到路由后会缓存到本地,然后每隔 30 秒向 NameServer 拉取一次最新的 Topic 路由。
这就解释了两个经典现象:
- 新创建的 Topic,客户端可能要等最多 30 秒左右才能看到完整路由
- Broker 挂掉后,Producer 在一段时间内仍然会往已缓存的旧地址发消息,直到发送失败并触发路由更新
如果你要验证一个 Topic 的路由信息,直接用mqadmin工具看是最快的:
mqadmin topicRoute -n 127.0.0.1:9876 -t YourTopic这个命令返回的就是 NameServer 内存里那张topicQueueTable的视图,Broker 地址、队列 ID、读写队列数一目了然。排查"消息发到哪个队列"的问题时,先跑这个命令比看客户端日志快得多。
我建议在代码里做一层"发送失败自动重试 + 刷新路由"的机制,不要只依赖客户端默认行为。默认情况下,Producer 发送失败会重试,但重试时是拿着旧路由再试一次。如果 Broker 真挂了,要等网络超时和两轮重试之后才能等到路由刷新,这个时间窗里消费端可能会有感知。
2.3 RouteInfoManager 里那几张表到底怎么串起来
看一下 NameServer 的注册流程逻辑就清楚了。一个 Broker 注册上来时,NameServer 实际做了这几件事:
- 根据 brokerName 找到或创建
BrokerData,把主从地址写入 - 如果 brokerId 是 0(Master),把这个 brokerName 加入
clusterAddrTable对应的集群 - 遍历 Broker 上报的
TopicConfig,为每个 Topic 创建或更新topicQueueTable里的QueueData - 更新
brokerLiveTable的时间戳
客户端拿路由时,NameServer 根据 topic 查topicQueueTable,再根据QueueData里的 brokerName 去brokerAddrTable查出实际主机地址,拼成TopicRouteData返回。
这里有值得注意的一点:顺序消息依赖队列和消费者的绑定关系,而这个绑定关系是客户端根据路由表算出来的。路由表一旦变化(比如 Broker 下线导致队列数量变化),顺序消息的分区逻辑就可能重排。所以生产环境做顺序消息,一定要设计好"队列数量固定"的约束,别让自动创建 Topic 悄悄改了队列数。顺带说一句,RocketMQ 有普通消息、顺序消息、延迟消息、事务消息四种类型,顺序消息是对路由变化最敏感的那一类。
3. 5.x 的 Proxy:不是网络代理,是消息接入层
到了 5.x,最容易被误解的组件就是 Proxy。这名字太有误导性,很多人一听就以为是网络代理。实际上,RocketMQ 5.x 里的 Proxy 是一个独立的消息接入层组件,核心作用是把多种协议的客户端请求转换成 RocketMQ 内部的 remoting 协议,再转发给后端的 Broker。
3.1 为什么 5.x 要引入一套 gRPC 接入协议
RocketMQ 从 4.x 开始就有自己的私有 remoting 协议,基于 Netty 实现,性能很好,但有个问题——它是 RocketMQ 自己的方言。官方虽然陆续提供了 C++、Go、Python 客户端,但每个客户端都要实现一遍私有协议,成本高、进度慢,社区里想用其他语言接入的团队只能自己造轮子。
5.x 的做法是:对外提供标准的 gRPC 协议,用 Protobuf 定义消息和接口。这样任何支持 gRPC 的语言都能快速写客户端。Proxy 在中间做协议转换:gRPC 请求进来,转成 remoting 调 Broker,再把结果转成 gRPC 响应返回。
用大白话说,以前每个客户端语言都要学会说 RocketMQ 的方言,现在 Proxy 当翻译官:你说标准普通话(gRPC),翻译官帮你转成方言给 Broker 听。
部署上 Proxy 是无状态的,可以随意扩缩容,因为它的内存里不放业务数据,只是一个转发和调度层。这也让多语言客户端的接入成本大幅降低。
3.2 Pop 消费:Proxy 给"队列和消费者强绑定"解绑
Proxy 带来的不只是协议层面的变化,还有一个消费模型的变化——Pop 消费。
4.x 的经典消费模型里,消费者是绑定队列的:一个消费组内,一个队列同一时刻只能被一个消费者持有。这是通过 Broker 端的锁机制和客户端的心跳完成的。这个模型在队列数量均匀、消费者数量合适时没问题,但一旦消费者数量变化、或者某几个队列消息堆积特别多,就会很尴尬——消息是跟着队列走的,队列分配不均,负载就不均。
Pop 消费的思路完全不同:消费者不再锁队列了,而是向 Proxy 发起 Pop 请求,Proxy 从队列中弹出消息给消费者,消费者消费完再提交 Ack。同一个队列的消息可以被不同消费者先后取走,不再有"一个队列一个消费者"的强约束。
带来的收益是队列间负载自然均衡,消费者伸缩时不需要做复杂的 rebalance 重分配。你可以把它类比成云上的简单队列服务(Simple Queue Service,SQS)的"可见性超时"机制:消息被弹出后对其他人不可见,处理超时后重新可见,可以被其他消费者取走。
需要提醒的是:如果业务要求严格 FIFO 顺序,Pop 消费可能会破坏顺序,除非你按 sharding key 把同序消息固定到一个队列并串行处理。Pop 消费最适合的是无序或高吞吐的在线业务。
3.3 Proxy 的本地模式和集群模式到底怎么选
Proxy 有两种部署方式:
- 本地模式:每个 Broker 进程里内嵌一个 Proxy,Broker 和 Proxy 同进程,不需要额外机器。适合小规模部署,以及只想用新客户端 API 但不想增加组件的场景。
- 集群模式:Proxy 独立部署,前端挂负载均衡,客户端统一连 Proxy。适合大规模、多语言接入、需要横向扩展接入能力的场景。
我的实际建议:如果只是为了上 5.x 试试水,本地模式完全够用;如果是生产集群且团队有多语言客户端需求,或者你想要 Pop 消费、自适应负载均衡,就上独立 Proxy 集群。
另外,集群模式下 Broker 那侧就不需要暴露公网端口了,客户端只连 Proxy,安全边界更清晰。
典型的排错提醒:很多团队启用 Proxy 后发现客户端报错,第一反应是查 Broker,实际上要查的是 Proxy 和 Broker 之间的连接、以及客户端连的是不是正确的 Proxy 地址。Proxy 日志里会有很明确的协议转换报错。习惯了 remoting 协议排错的人,要先把排查思路切到 gRPC 层,检查协议版本和 Protobuf 定义是否匹配。
4. Controller:把选主能力从 Broker 里剥离出来
4.x 时代的高可用主要靠两种模式:主从异步复制 + 手动切换,或者 DLedger 模式自动切换。前者出故障要人上去改配置,后者解决了自动切换但引入了新的成本。5.x 的 Controller 就是冲着"自动主从切换 + 低性能损耗 + 更低部署成本"来的。
4.1 DLedger 模式的遗留问题:性能和复杂度两头吃
DLedger 是 RocketMQ 的 Raft 实现。在 DLedger 模式下,至少需要三个节点才能形成多数派,因为 Raft 需要过半确认。每一条消息写入都要经过 Leader 和半数以上 Follower 确认才算提交成功,这个开销直接打在消息发送链路上,对延迟敏感的业务来说影响很明显。
更麻烦的是,DLedger 把 Raft 的日志复制和 RocketMQ 的消息存储耦合在了一起。一旦某个节点日志落后太多,或者网络分区,整个 Raft 组的行为会变得很难排查。我处理过的故障里,DLedger 集群"节点都活着但选不出主"是最让人头疼的——它不像 MySQL 主从那样直观,你看到的就是一堆日志和状态不断切换。
如果你在用 4.x 且已经上了 DLedger,我建议先稳着跑,不要为了追新立刻迁移。但从零搭建新集群的话,Controller 模式是更优解。
4.2 Controller 模式怎么完成一次主从切换
Controller 模式的核心思路是:把"选主"这件事从 Broker 进程里拆出来,交给独立的 Controller 组件。Controller 自身可以部署多个,它们之间通过 Raft 保证决策一致。Broker 这边不再跑 Raft,主从之间用普通的异步复制或同步复制,写入只需要 Master 确认,性能损耗比 DLedger 小得多。
切换流程大致如下:
- Master 和 Slave 都向 Controller 注册,并持续上报心跳和复制进度
- Controller 维护每个 Broker 组的存活状态,Master 心跳超时后触发切换
- Controller 在所有存活的 Slave 中,选择复制进度最新(commitlog 位置最靠前)的 Slave 提升为新的 Master
- 新 Master 对外提供服务,Controller 更新拓扑信息,NameServer 通过 Broker 注册信息感知主从地址变化
这个流程里最核心的数据是"复制进度"。所以 Controller 模式下,如果业务对数据不丢失要求很高,主从复制要配置成同步复制(SYNC_MASTER),否则异步复制下 Master 宕机,最后几条消息大概率会丢。这是取舍,不是 bug。
4.3 Controller 和 DLedger 的选型对照
整理一张对比表,方便决策:
| 对比项 | DLedger | Controller |
|---|---|---|
| 选主实现 | Broker 内嵌 Raft | 独立 Controller 集群 |
| 存储节点数量 | 至少 3 个 Broker | 1 主 1 从即可 |
| 写入性能 | 多数派确认,损耗大 | Master 确认,接近原生 |
| 数据复制 | Raft 日志同步 | 主从异步/同步复制 |
| Controller 进程 | 无 | 至少 3 个,可内嵌 NameServer |
| 运维复杂度 | 中,节点间耦合深 | 低,角色分离清晰 |
| 适用版本 | 4.x 可用的自动切换方案 | 5.x 主推方案 |
有一点要特别注意:Controller 模式的切换依赖 Controller 集群本身的可用性。如果 Controller 全部不可用,主从切换就停了,但消息收发不受影响。所以 Controller 至少要部署三个,而且不要全部和 NameServer 塞在同一台机器上。官方支持把 Controller 内嵌到 NameServer 里启动,省进程数,但内嵌省的是机器资源,省不了故障域——内嵌情况下 Controller 和 NameServer 一起挂的概率会更高。
5. Container:Broker 的"进程瘦身"与弹性基础
如果说 Controller 解决的是"4.x 主从切换不够自动"的问题,那 Container 解决的是"Broker 进程太重、不够弹性"的问题。它也是 5.x 后续做存储计算分离和弹性消息的基础。
5.1 一个 BrokerContainer 里能装下几个逻辑 Broker
在 5.x 源码里,BrokerContainer类管理着多个BrokerController。过去一个 Broker 进程对应一个BrokerController,两者是一一绑定关系;在 Container 模式下,一个BrokerContainer进程可以加载多个BrokerController,每个逻辑 Broker 有自己独立的配置:brokerName、brokerId、store 路径、监听端口等。
理解方式:就像一台物理机上的多个 Docker 容器,每个容器是一个独立的消息 Broker,但它们共享同一个 JVM 进程和底层的机器资源。注意这里说的 Container 和 Docker 没关系,不要被名字带偏——它是进程内的逻辑容器,不是操作系统容器。
实际部署时,你可以一次拉起多个逻辑 Broker,动态注册、动态销毁,而不用像 4.x 那样为了加一个 Broker 新起一个 JVM 进程、再维护一堆启动脚本。
5.2 Container 模式想解决的核心问题
这个设计背后有三个真实痛点:
第一,资源利用率。4.x 时代为了隔离故障域,一个物理机往往只部署一个 Broker 实例。但真实场景里,如果一个 Broker 上的 Topic 流量很低,机器资源就闲置了。Container 模式下可以在一台机器上跑多个逻辑 Broker,根据每个逻辑 Broker 的流量动态调配资源,把机器资源尽量用满。
第二,弹性伸缩。传统模式加 Broker 是加进程,牵一发动全身;Container 模式下,逻辑 Broker 可以更轻量地创建和销毁,配合 Proxy 的接入层做请求调度,可以在不重启物理进程的情况下调整容量。
第三,多租户与隔离。如果要给不同业务部门提供服务,可以用逻辑 Broker 划分隔离域,每个逻辑 Broker 有独立的存储目录、独立的配置、独立的 Topic 空间。一个逻辑 Broker 出了故障,影响范围控制在它自己的域内。
5.3 目前阶段 Container 更适合哪些场景
说实话,Container 模式目前的成熟度没有 NameServer、Proxy、Controller 那么高,生产环境大规模落地还不算多。但它指向的方向很明确:消息中间件在往存储计算分离、Serverless 方向演进。
现在比较适合尝试 Container 的场景:
- K8s 上部署 RocketMQ,希望用一个 Pod 承载多个逻辑 Broker,减少 Pod 数量和资源碎片
- 需要在一个物理节点上隔离多个业务域,又不想开太多进程
- 追求弹性伸缩,配合 Proxy 的负载均衡做队列粒度调度
如果你只是传统虚拟机部署、集群规模不大,我建议先不上 Container,常规的主从加 Controller 模式足够稳。新特性可以等技术社区迭代得更成熟再引入,没必要当小白鼠。
6. 这些新组件到底要不要全上:5.x 部署的务实建议
前面把四个组件拆开讲了,最后落到最现实的问题:我们团队到底怎么选?
6.1 四种部署组合的适用场景
我自己心里有个决策框架,分享出来可以直接抄作业:
| 业务规模 | 推荐组合 | 理由 |
|---|---|---|
| 小规模、单机房、追求运维简单 | 2 个 NameServer + 1 主 1 从 Broker(异步复制) | 不引入 Controller/Proxy,故障靠人工切换,运维面最小 |
| 消息量大、主从切换要自动化 | 2~3 个 NameServer + Controller 模式主从 | 自动切换,写入性能损耗小 |
| 多语言客户端、需要 Pop 消费、海量在线业务 | 2~3 个 NameServer + 独立 Proxy 集群 + Controller 主从 | 接入层弹性、消费弹性、故障自动切换都具备 |
| 追求资源极致利用、容器化部署 | 上述组合 + BrokerContainer 模式 | 逻辑 Broker 轻量,配合 Pod 动态调度 |
一个很重要的认知:构成 5.x 完整能力的 Proxy 和 Controller 并不是非绑定关系。你可以只用 Controller 不用 Proxy,也可以反过来。它们解决的问题是正交的,别被"5.x 全家桶"的宣传带偏。
6.2 一个容易踩的坑:客户端协议与组件不匹配
最后说一个我在实际支持中遇到最多的故障模式。
很多团队升级到 5.x 服务端后,客户端还是老的 4.x 客户端,通过 remoting 协议直连 Broker。这个场景下,服务端启用 Proxy 并不会影响老客户端,但如果服务端同时开启了某些依赖 gRPC 的新特性,老客户端就用不了。
反方向的坑更隐蔽:用了 5.x 新客户端,但连接配置写的是直连 Broker 而非 Proxy,结果新客户端某些 API 调不通。5.x 新客户端默认走 gRPC 协议,你要么让它连 Proxy,要么确保 Broker 侧配置支持对应的接入方式,不能让客户端和服务端各说各话。
我建议升级前把客户端的接入方式和服务端组件画在一张图上,明确消息路径:客户端 -> Proxy(gRPC)-> Broker,还是客户端 -> Broker(remoting)。不要混合,不要想当然。我接触的项目里,至少有三起线上"发送超时"问题最终定位在协议不匹配上,而不是 Broker 性能问题。
另外,5.x 服务端是兼容 4.x 老客户端协议的,所以升级服务端不强制升级客户端。但如果你想要 5.x 的新特性,比如 Pop 消费、自适应负载均衡,就必须换新客户端。建议在一个测试环境先把新客户端和 Proxy 链路跑通,再逐步灰度,不要直接在核心业务上全量切换。
最后再分享一个体会:RocketMQ 这套设计,从 NameServer 到 proxy、controller、container,本质是在做两件事——做减法和做拆分。NameServer 是减法,把所有不必要的功能砍掉,换一个极端简单的故障模型;5.x 的 proxy 是把接入拆出来,controller 是把选主拆出来,container 是把进程拆薄。搞懂了这个演进逻辑,这几个组件的定位基本不用背。
如果哪天上手新集群,我建议先搭一个最小的 NameServer 加主从,把路由和复制跑明白,再逐步加 controller、proxy。每加一个组件之前,先想清楚它解决你哪个痛点——这样踩坑最少,也最不容易被花哨的概念带偏。