跳到正文

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 的「无状态」具体指什么 ​

  1. NameServer 节点之间不通信,不选主,不做日志复制。
  2. Broker 启动时向配置里的所有 NameServer 注册;之后每 30s 发一次心跳(brokerHeartbeatInterval),携带最新 Topic 列表和队列数量。
  3. NameServer 每 5s 扫描一次,超过 120s(默认)没收到心跳就剔除该 Broker 的路由。
  4. 客户端每 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=true
java
// 发送失败要留证据:记录 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:9876
text
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 是否存在依赖自动创建时,第一次请求才触发创建与注册
autoCreateTopicEnableBroker 配置生产一般关闭,关闭后未建 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 demo

clusterList 的判据:同一 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。

把一门技术讲透到既懂也会用