主题
RocketMQ 架构与核心概念
一句话:NameServer 只做轻量路由,Broker 用「统一 CommitLog 顺序写 + 每队列 ConsumeQueue 索引」存消息;前者决定消息发到哪,后者决定消息怎么落盘。
适用版本:RocketMQ 4.9 / 5.3 / 5.5。NameServer、Broker、CommitLog 的模型在三个版本间没有本质变化,差异集中在主备切换与接入协议。
为什么需要
Producer 要发消息,得先知道「这个 Topic 的队列分布在哪几台 Broker 上」。如果每台 Producer 都静态写死 Broker 地址,扩容、下线、迁移都要改所有客户端配置。
所以需要一个路由注册中心。选 ZooKeeper 也能做,但路由信息变化不频繁,为它维护一个带选举、带会话、带 Watcher 的强一致集群,运维成本不划算。NameServer 的设计是:节点之间互不通信、互不同步,各自接收 Broker 注册并回答客户端查询;客户端拿到任意一份可用路由即可。
代价是路由表可能短暂不一致,但对「发消息」这件事够用——真正的一致性由 Broker 的存储与副本保证,不由路由保证。
核心机制
角色与一条消息的路径
- NameServer:无状态路由表。存
topic → QueueData、brokerName → BrokerData、cluster → brokerName 集合。 - Broker:存储与转发。同一
brokerName下brokerId=0是 Master,brokerId>0是 Slave;Slave 默认只复制不读,开slaveReadEnable=true才分担读。 - Producer / Consumer:从 NameServer 拉路由后直连 Broker,不经过 NameServer 转发。
NameServer 的「无状态」具体指什么
- NameServer 节点之间不通信,不选主,不做日志复制。
- Broker 启动时向配置里的所有 NameServer 注册;之后每 30s 发一次心跳(
brokerHeartbeatInterval),携带最新 Topic 列表和队列数量。 - NameServer 每 5s 扫描一次,超过 120s(默认)没收到心跳就剔除该 Broker 的路由。
- 客户端每 30s 重新拉一次路由(
pollNameServerInterval),失败时用本地缓存继续工作。
由此得到三条工程结论:
- NameServer 挂一台不影响服务;全挂后,存量 Producer/Consumer 靠本地缓存仍能收发,新启动的进程拿不到路由。
- Broker 宕机后,路由最长 120s 才消失。这 120s 内客户端仍可能往死 Broker 发,靠 Producer 重试和 Broker 列表规避。
- NameServer 地址是静态配置,加节点不会自动发现。要么改所有 Broker/客户端配置,要么用
rocketmq.namesrv.domain配多条 DNS A 记录。
Broker 侧的两个存储结构
- CommitLog:所有 Topic、所有 Queue 共用,按到达顺序追加,单文件默认 1 GiB 后滚动。写入是 mmap + 顺序写,机械盘也能跑出高吞吐。
- ConsumeQueue:每个 Topic 的每个 Queue 一个索引文件,每条固定 20 字节:8 字节 CommitLog 物理偏移 + 4 字节消息长度 + 8 字节 Tag hash。消费时先读索引,再按偏移读 CommitLog。
这样做的直接收益是 Topic 数量不再受「每个 Topic 一套文件」的限制;代价是读消息多一次索引查找。细节见 03-消息存储与持久化。
主备模式的演进
| 版本 | 机制 | 故障时谁来决定 |
|---|---|---|
| 4.5 之前 | 经典主从,brokerId=0/1 | 人工或脚本把 Slave 改成 Master |
| 4.5+ | DLedger(Raft) | 组内多数派自动选主,3 节点容忍 1 台故障 |
| 5.0+ | Controller 模式 | 独立 Controller(或内嵌 NameServer)选主,Broker 只上报状态 |
不要把「Slave 不会自动升主」当成 RocketMQ 的通病——那是 4.5 之前经典主从的行为。4.5+ 的 DLedger 和 5.x 的 Controller 都能自动选主,区别在部署复杂度与切换时间。详见 04-高可用与主从复制。
5.x 的 Proxy 是什么
5.x 在 Broker 之外加了一层无状态 Proxy,对外提供 gRPC/HTTP 协议,内部再走 Remoting 访问 Broker。它解决的是多语言接入和鉴权收敛:Python/Go 客户端连 Proxy 即可,不必实现 Java 版的 Remoting 协议。Proxy 不存消息,可以水平扩容。
实战场景
一、Broker 挂了,120 秒内 Producer 一直往它发
现象:一台 Broker Master 因内存故障宕机,NameServer 侧已经知道它不健康,但客户端仍有约 40% 的发送请求打向该 Broker 的 10911 端口,报 RemotingConnectException: connect failed,重试后落到别的 Broker 成功。业务侧表现为发送耗时尖刺和零星失败,持续了接近 2 分钟才彻底恢复。
排查:路由消失是“剔除”逻辑,不是“即时通知”。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| NameServer 剔除时刻 | NameServer 日志里该 Broker 的路由失效时间 | 与宕机时间差最长接近 120s(心跳 30s + 扫描 5s + 120s 阈值) |
| 客户端路由缓存 | 客户端每 30s 拉一次路由(pollNameServerInterval) | 缓存期内仍含死 Broker 的 Queue |
| 失败分布 | 发送失败按 brokerName 聚合 | 集中在单台即可确认路由未更新 |
| Producer 重试 | retryTimesWhenSendFailed(同步默认 2)与是否换 Broker | 重试换 Broker 才可能成功 |
| 发送超时 | sendMsgTimeout(默认 3000ms) | 连接超时占比高会让总耗时翻倍 |
| 是否自动选主 | 版本与部署模式(4.5 前经典主从 / DLedger / Controller) | 经典主从不自动升主,故障会持续更久 |
对症做法:
properties
# Producer:连接失败快速放弃该 Broker,重试次数按最坏总耗时校准
sendMsgTimeout=2000
retryTimesWhenSendFailed=2 # 同步发送默认 2,总尝试 3 次;会换 Broker
retryAnotherBrokerWhenNotStoreOK=truejava
// 发送失败要留证据:记录 broker、queue、耗时,便于定位是不是路由窗口
public SendResult send(Message msg) {
long start = System.nanoTime();
try {
SendResult r = producer.send(msg);
metrics.sendOk(msg.getTopic(), costMs(start));
return r;
} catch (Exception e) {
metrics.sendFail(msg.getTopic(), e.getClass().getSimpleName());
log.warn("send failed, topic={} bodyHash={}", msg.getTopic(), msg.getKeys(), e);
outbox.save(msg, e); // 关键消息落本地消息表,由后台重投
throw e;
}
}text
1. 剔除阈值(120s)和心跳周期(30s)是“误判成本”与“发现速度”的折中,
不要为了消警改成 10s:Broker 抖动时会频繁踢路由,客户端重建连接更贵。
2. 4.5 之前的经典主从确实不会自动升主,这是版本行为不是通病;
对可用性有要求的组切 DLedger 或 5.x Controller 模式。
3. 客户端侧兜底比调服务端参数更有效:失败即换 Broker、异步发送 + 本地补偿。复盘要点:判据是“单台 Broker 宕机时,业务可见失败率与恢复时长”:恢复时长应接近“路由缓存 30s + 重试耗时”而不是 120s,若明显更长就查客户端是否禁用了换 Broker 重试。把“失败按 brokerName 分布”做成常驻面板,它能区分路由问题与 Broker 自身问题。
二、NameServer 全挂:存量正常,新进程起不来
现象:机房网络维护时 3 台 NameServer 同时不可达。运行中的服务收发完全正常,运维起初以为“没有影响”;随后扩容的 6 个 Pod 全部启动失败,报 MQClientException: No route info of this topic, order-events,回滚也起不来(新实例同样拿不到路由)。
排查:NameServer 不在消息链路上,但在“冷启动”路径上。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 存量 vs 新进程 | 老 Pod 收发成功率、新 Pod 启动日志 | 只有新进程失败即命中“拿不到路由” |
| 本地路由缓存 | 客户端 topicRoute 缓存与 namesrvAddr 连通性 | 老进程靠缓存继续,缓存不会因单次失败清空 |
| 是否允许本地兜底 | Broker 侧 Topic 是否已创建 | 未创建 + 无路由 → 报 No route info |
| 地址配置形态 | namesrvAddr 是静态列表还是 rocketmq.namesrv.domain | 静态列表加节点要改所有进程 |
| NameServer 状态 | 各 NameServer 的 Broker 注册表是否一致 | 节点间不通信,注册靠 Broker 主动广播 |
NameServer 节点之间不通信、不选主,Broker 启动时向配置里的所有 NameServer 注册——所以 NameServer 的可用性取决于“Broker 能连到至少一台且客户端也能连到”,不是集群内部共识。
对症做法:
properties
# 1. 用域名多 A 记录,避免扩容 NameServer 时要改所有客户端配置
rocketmq.namesrv.domain=namesrv.internal.example.com
rocketmq.namesrv.domainSubgroup=default
# 2. 客户端显式列多台,单台不可达时自动切换
namesrvAddr=10.0.1.11:9876;10.0.1.12:9876;10.0.1.13:9876text
3. Topic 预先创建,禁止依赖 autoCreateTopicEnable(生产应关闭):
没有路由时报的 “No route info” 常常被误读为 NameServer 故障。
4. 部署顺序写成编排:NameServer → Broker → Producer/Consumer;
新实例的启动检查里加“路由可取到”,取不到就不算 ready。
5. NameServer 与 Broker 不同机部署,避免同机故障一起消失。java
// 启动自检:拿不到路由就别 ready,而不是等到第一条消息失败
@PostConstruct void assertRouteReady() throws Exception {
TopicPublishList p = producer.checkTopicPublishable("order-events");
if (!p.isStatus()) throw new IllegalStateException("route unavailable for order-events");
}复盘要点:演练要覆盖“NameServer 全挂时扩容与发布都不可用”这条事实,写进容量与故障预案:新进程启动依赖 NameServer,存量进程不依赖。判据是“路由可达性”纳入 readiness 探针,让这类失败在发布阶段暴露而不是等到第一条消息;5.x 多语言接入走 Proxy 时,Proxy 到 Broker 的路由同样依赖 NameServer,见 高可用与主从复制。
三、上线新 Topic,第一条消息必失败
现象:新建的 settle-events 上线后,每个新实例的第一条发送都抛 No route info of this topic,第二条起正常。开发把它当“偶发失败”加了重试,问题被掩盖,直到一次审计要求解释为什么允许消息重试路径存在失败。
排查:客户端路由是拉取 + 缓存,第一次发送前往往还没拿到路由。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| Topic 是否已创建 | Broker/控制台里该 Topic 是否存在 | 依赖自动创建时,第一次请求才触发创建与注册 |
autoCreateTopicEnable | Broker 配置 | 生产一般关闭,关闭后未建 Topic 直接报错 |
| 首次发送时机 | 是否在 Producer 启动后立即发送 | 路由拉取是异步周期任务(30s),启动瞬间可能为空 |
| 失败是否可复现 | 每次新实例第一条都失败 | 有固定形状即不是网络抖动 |
| 重试掩盖 | 加了重试后“看起来正常” | 掩盖真实原因,审计时说不清 |
对症做法:
text
1. Topic 由发布流程预先创建(含读写队列数、所属集群、权限),
并把 Topic 清单纳入配置仓库,禁止运行时自动创建。bash
# 预建 Topic:队列数决定消费并行上限,按峰值与消费者数定
sh mqadmin updateTopic -n 10.0.1.11:9876 -c ClusterA -t settle-events \
-r 8 -w 8 --perm 6
sh mqadmin topicRoute -n 10.0.1.11:9876 -t settle-events # 确认路由已可解析java
// 2. Producer 启动后预热路由,再做第一次发送
producer.start();
producer.fetchPublishMessageQueues("settle-events"); // 主动拉路由,失败即启动失败
// 3. 启动检查里断言 Topic 可发布,比在业务里重试更符合“失败要在启动阶段暴露”properties
# 4. Broker 侧关闭自动创建,避免脏 Topic 与不可控队列数
autoCreateTopicEnable=false
autoCreateSubscriptionGroup=false复盘要点:autoCreateSubscriptionGroup=false 同样重要——订阅组自动创建会让“消费端没上线就有人发消息”这类顺序错误静默发生。判据是“新 Topic 上线走发布单,包含队列数、权限、订阅组和告警”,上线后第一条发送不得失败;把 No route info 这个错误码单独告警,它能同时暴露 NameServer 不可达与 Topic 未建两类问题。消费者侧的负载均衡与队列数关系见 消费者模型与负载均衡。
最小上手
以 5.3 二进制包为例,本机跑通 namesrv + broker + proxy。
bash
# 0. 解压后进入目录,设置堆内存避免小机器 OOM
export ROCKETMQ_HOME=$PWD
export NAMESRV_ADDR=127.0.0.1:9876
# 1. 启动 NameServer(默认 9876)
nohup sh bin/mqnamesrv > /tmp/namesrv.log 2>&1 &
# 2. 写 broker.conf:本机实验必须显式指定 brokerIP1
cat > conf/broker-local.conf <<'EOF'
brokerClusterName = DefaultCluster
brokerName = broker-a
brokerId = 0
brokerIP1 = 127.0.0.1
listenPort = 10911
deleteWhen = 04
fileReservedTime = 72
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
storePathRootDir = /tmp/rmq-store
EOF
nohup sh bin/mqbroker -n 127.0.0.1:9876 -c conf/broker-local.conf > /tmp/broker.log 2>&1 &
# 3. 启动 Proxy(5.x,gRPC 默认 8081)
nohup sh bin/mqproxy -n 127.0.0.1:9876 > /tmp/proxy.log 2>&1 &验证:
bash
# 集群里应出现 broker-a,brokerId=0
sh bin/mqadmin clusterList -n 127.0.0.1:9876
# 自带系统 Topic + 你建的 Topic;5.x 默认可自动建 Topic
sh bin/mqadmin topicList -n 127.0.0.1:9876
# 建一个 8 队列 Topic 后再看
sh bin/mqadmin updateTopic -n 127.0.0.1:9876 -c DefaultCluster -t demo -r 8 -w 8
sh bin/mqadmin topicStatus -n 127.0.0.1:9876 -t democlusterList 的判据:同一 brokerName 下出现 0 和 1 两个 brokerId,说明主从都注册成功;只有 0 说明 Slave 没起来或 brokerName 写得不一致。
生产动作
- NameServer 至少 2 台,分机器部署。它不存数据,但新进程启动依赖它。
- Broker 必须显式设置
brokerIP1,否则多网卡机器可能注册成错误网段,客户端连不上。 brokerId与brokerName成对检查:主从的brokerName必须相同,brokerId必须不同。- 观察 120s 剔除窗口:Broker 正常下线会主动注销,路由立即更新;进程被 kill -9 才走 120s 超时。滚动发布时优先用正常停机。
- 5.x 接入优先用 Proxy + gRPC,但 Proxy 是无状态的,需要单独做副本数和负载均衡;老客户端继续直连 Broker 也不冲突。
- 排障顺序:
clusterList看注册 →topicList看路由 →topicStatus看队列 offset →consumerProgress看消费进度。
边界
- NameServer 不是强一致配置中心。不要拿它存业务配置,也不要指望它做分布式锁。
- 路由不是实时推送。客户端默认 30s 刷新,Broker 异常后最长 120s 才从路由消失;对延迟敏感的切换要靠客户端重试和 Broker 侧自动选主,不能只靠 NameServer。
- Proxy 不是 Broker 的替代。它不持久化,也不改变消息模型;关掉 Proxy,Remoting 客户端照常工作。
- 经典主从不是高可用方案。4.5 之前 Master 挂了只能人工切;要自动切必须上 DLedger 或 Controller。
- NameServer 数量增加不等于容量线性提升。它承载的是注册与查询,压力通常远小于 Broker,瓶颈一般不在它。
讲清楚
RocketMQ 的架构可以拆成「一层路由 + 一层存储」。路由层是 NameServer,节点之间不通信、不选主,Broker 每 30 秒向所有 NameServer 发心跳,120 秒没心跳就被剔除;客户端每 30 秒拉一次路由并缓存在本地,所以 NameServer 挂一台甚至全挂,存量客户端还能继续跑,只有新进程拿不到路由。
存储层是 Broker。所有 Topic 共用一份 CommitLog 顺序追加,再为每个 Queue 建一份 20 字节的 ConsumeQueue 索引,消费时先查索引再回 CommitLog 读消息。这样 Topic 数量不受文件句柄限制,代价是多一次索引查找。
高可用分三代:4.5 之前是经典主从,Master 挂了要人工切;4.5 引入 DLedger,用 Raft 多数派自动选主;5.x 引入 Controller,把选主从 Broker 里独立出来。5.x 还多了无状态 Proxy,用 gRPC 接多语言客户端。判断一个集群是否真的高可用,看它用的是哪一代,而不是看有没有 Slave。