跳到正文

RocketMQ 高可用与主从复制 ​

一句话:复制策略决定 RPO(宕机最多丢多少),选主方式决定 RTO(多久恢复写入);经典主从、DLedger、Controller 是三档不同的取舍。

适用版本:4.9(经典主从 / DLedger)、5.3 / 5.5(Controller)。DLedger 从 4.5 起可用。

为什么需要 ​

单机 Broker 一挂,这个 Broker 上的 Queue 就不可写。加 Slave 能复制数据,但「主挂了谁来写」是另一个问题:

  • 只有复制、没有自动选主:RPO 可以做到 0,RTO 取决于人工发现和切换脚本,通常是分钟级。
  • 有自动选主:RTO 降到秒级,但引入选举、多数派和脑裂防护,运维复杂度上升。

所以高可用不能只说「我配了主从」,要同时给出两个数字:允许丢多少(RPO)和允许停多久(RTO)。

核心机制 ​

经典主从:复制有了,切换靠人 ​

同一 brokerName 下,brokerId=0 是 Master,brokerId>0 是 Slave。brokerRole 有三种:

brokerRole写入确认RPO适用
ASYNC_MASTERMaster 写入成功即返回,后台异步复制Master 宕机可能丢最后一段日志、通知
SYNC_MASTER等 Slave 复制到对应位置再返回已确认消息不丢支付、订单
SLAVE只复制,不接受写—从节点

一个容易误解的点:SYNC_MASTER 在 Slave 不可用时不会静默降级成异步。Broker 会返回 SLAVE_NOT_AVAILABLE;如果 Slave 活着但迟迟追不上,则等待 syncFlushTimeout(默认 5000ms)后返回 FLUSH_SLAVE_TIMEOUT。这两种情况下消息可能已经写进 Master 的 CommitLog,但发送方收到的是失败,重试可能造成重复。客户端必须把这两种状态当成「结果未知」处理。

经典主从的 Slave 默认不承担读流量,开 slaveReadEnable=true 后可以分担部分读;Master 挂掉后,需要人工把某个 Slave 的 brokerId 改为 0、brokerRole 改为 Master 并重启。这就是它的 RTO 来源。

DLedger:用多数派换自动选主 ​

4.5 起可以开启 DLedger,把 CommitLog 的写入变成 Raft 日志复制:

properties
enableDLegerCommitLog = true
dLegerGroup = broker-a
dLegerPeers = n0-127.0.0.1:40911;n1-127.0.0.1:40912;n2-127.0.0.1:40913
dLegerSelfId = n0

Raft 在这里只做三件事,不需要展开成完整教程:

  1. 选主:超过半数节点投票,同一任期最多一个 Leader。
  2. 复制:Leader 把消息作为日志条目发给 Follower,多数派确认后才算提交。
  3. 安全:新 Leader 必然拥有所有已提交日志,所以已提交消息不丢。

3 节点容忍 1 台故障(RPO=0,RTO 秒级);只剩 1 台时无法形成多数派,集群拒写。这是「用可用性换一致性」的典型:宁可停写,不让缺少已提交数据的节点当主。

Controller:把选主从 Broker 里拿出来 ​

5.0 起支持 Controller 模式:Controller 负责维护每个 Broker 组的同步状态集合(SyncStateSet)并选主,Broker 只上报状态、执行角色切换。

properties
# broker.conf
enableControllerMode = true
controllerAddr = 127.0.0.1:9878;127.0.0.2:9878;127.0.0.3:9878

Controller 可以独立部署(sh bin/mqcontroller -c controller.conf),也可以内嵌在 NameServer(enableControllerInNamesrv=true)。5.1.1 起 brokerId 由 Controller 协商分配并在本地持久化,不再需要手工写 0/1;被选为 Master 时对外报告 brokerId=0,降级后恢复自己的 ID。

默认 enableElectUncleanMaster=false:同步副本不足时不会选出缺数据的主,而是保持不可用。要接受「可能丢数据换可用」,才考虑打开,并明确这会破坏 RPO=0 的承诺。降级路径是:Controller 不可用时退回经典主从人工切换;DLedger 失去多数派时保持拒写;同步复制不可用时宁可写失败,也不静默转异步。

三种模式的取舍 ​

模式RPORTO运维成本适用
经典主从(异步)可能丢最后一段分钟级,人工低非核心业务
经典主从(同步)0(已确认消息)分钟级,人工低能接受人工切的核心业务
DLedger0秒级中,3 节点核心业务、无 Controller 条件
Controller0(默认不选脏主)秒级中高,需 Controller 集群5.x 新集群

实战场景 ​

一、SLAVE_NOT_AVAILABLE 被当成“没发出去”,结果重复发送 ​

现象:订单事件用 SYNC_MASTER + ASYNC_FLUSH。一次 Slave 重启期间,Producer 报 FLUSH_SLAVE_TIMEOUT / SLAVE_NOT_AVAILABLE,发送侧的封装把异常统一转成“未发送”,业务重新提交后又发了一遍,下游收到同一 orderId 的两条事件,产生重复履约(12 笔,经验值需校准)。

排查:这两种返回码是“结果未知”,不是“失败”。

定位层取证据判定
状态码分布发送失败按 SendStatus 分类统计FLUSH_SLAVE_TIMEOUT/SLAVE_NOT_AVAILABLE 属未知态
是否已落 CommitLog按消息体 hash/keys 在 Broker 侧查 offset已写入说明只是确认超时
超时参数syncFlushTimeout(默认 5000ms)Slave 活着但追不上,等 5s 后返回超时
Slave 状态该 Broker 组 Slave 是否在线、复制滞后Slave 不可用直接返回 SLAVE_NOT_AVAILABLE
重试是否换 BrokerProducer 重试策略换 Broker 后同一条消息可能进另一个组
下游幂等是否有业务唯一键无键则重复必然生效

SYNC_MASTER 在 Slave 不可用时不会静默降级成异步,这一点常被误读成“高可用会自动兜住”,实际表现为写失败。

对症做法:

java
// 1. 发送结果按四种状态分别处理,未知态不盲目重发
SendResult r = producer.send(msg);
switch (r.getSendStatus()) {
    case SEND_OK:            return Ack.ok(r.getMsgId());
    case FLUSH_DISK_TIMEOUT:
    case FLUSH_SLAVE_TIMEOUT:
    case SLAVE_NOT_AVAILABLE:
        outbox.markUnknown(msg, r);          // 落“待确认”,由核对任务判定是否已写入
        return Ack.pending();
    default: throw new IllegalStateException(r.getSendStatus().name());
}
text
2. 未知态的收敛靠“查询 + 幂等键”,不靠重发:
   核对任务用消息 keys/业务唯一键在 Broker 侧确认可见性,确认不到才重投。
3. 业务唯一键(orderId + eventType)在消费端做唯一索引,
   把“至少一次 + 结果未知”的语义收敛成恰好生效一次。
4. 客户端封装要区分“可重试错误”(连接失败、路由不存在)与“未知态”,
   前者重试、后者核对,这条规则比任何参数都重要。

复盘要点:判据是“同一业务键在下游只生效一次”,并给未知态占比设阈值(连续 5 分钟大于 0.1%,经验值需校准就查 Slave 与盘)。把四种 SendStatus 的差异写进客户端 SDK 文档,业务侧不应该看到统一的“发送失败”。

二、Master 宕机 40 分钟:经典主从的 RTO 在人身上 ​

现象:某台 Master 因磁盘故障宕机。集群是经典主从(SYNC_MASTER,2 节点一组),运维按手册改 Slave 的 brokerId=0、brokerRole=ASYNC_MASTER 并重启,全程 40 分钟。期间该 Broker 组拒写,Producer 端堆积、上游接口超时;恢复后又出现 6 分钟的路由生效延迟。

排查:RTO 由“人的响应速度 + 手工步骤”构成,不是由软件决定。

定位层取证据判定
是否有自动选主部署模式(经典主从 / DLedger / Controller)经典主从不自动升主,这是 4.5 之前的行为
操作耗时故障发现、决策、变更、重启各段时间戳手工步骤的总长就是 RTO
路由生效延迟NameServer 剔除旧 Master(最长 120s)+ 客户端 30s 拉路由恢复后仍有一段“旧路由”窗口
数据是否完整Slave 的 CommitLog 与 Master 最后写入位对比异步复制会缺最后一段
影响面该组承载的 Topic/Queue 与生产者数量决定是否需要提前迁移

对症做法:

text
1. 核心业务组切自动选主,RTO 从“人”变成“协议”:
   - DLedger:CommitLog 走 Raft,3 节点容忍 1 台故障,RPO=0、RTO 秒级;
   - Controller(5.x):Controller 维护 SyncStateSet 并选主,Broker 只上报状态,
     5.1.1 起 brokerId 由 Controller 协商分配,不再手写 0/1。
2. 仍留在经典主从的组,把手工切换写成有审计的脚本 + 每季度演练,
   并在监控里把“该组不可写时长”当成 SLO 指标。
3. 客户端侧要有兜底降级:写失败落本地消息表、由后台重投,
   而不是让上游接口等 40 分钟。
4. 变更后要验证路由收敛:NameServer 剔除旧节点最长 120s,
   客户端 30s 拉一次路由,这两段延迟计入恢复时长而不是“已恢复”。

复盘要点:三种模式的取舍要用 RPO/RTO/运维成本三个口径陈述,而不是“哪个更先进”:能接受人工切的核心业务可以留同步主从,接受不了就 DLedger 或 Controller。判据是“主宕机后自动恢复时间”,这个数字必须在演练里实测;未演练过的高可用方案不算具备高可用能力。

三、打开脏主选举后,消息静默丢失 ​

现象:为“尽快恢复写入”,运维把 enableElectUncleanMaster 改成 true。之后一次 3 节点组只剩 1 台在线时选出了 Master,写入恢复;但故障期间已确认的约 3000 条消息不见了,且对账发现“发送成功但消费端从未收到”(经验值,需校准)。

排查:RPO=0 的承诺是被配置破坏的,不是软件 bug。

定位层取证据判定
选主日志Broker/Controller 日志是否出现 unclean 选举记录命中即选出了缺数据的节点当主
同步副本集合SyncStateSet 中的副本数与最小 ack 数同步副本不足时本应保持不可用
已确认消息可见性用 msgId/keys 在新 Master 上查查不到说明数据只在旧 Master 上
配置变更审计该项变更的时间、原因、审批人故障期临时改的配置最容易遗留
DLedger 多数派是否真的失去多数派(3 剩 1 无法提交)宁可拒写也不选脏主是默认设计

DLedger 失去多数派时保持拒写、同步复制不可用时宁可写失败也不静默转异步,这些是刻意的一致性优先取舍。

对症做法:

properties
# 默认值不要改:同步副本不足时不选出缺数据的主,宁可保持不可用
enableElectUncleanMaster=false
# 需要“可用优先”的组要显式声明并在文档里写清 RPO 代价,
# 且必须配套“消息可能缺失”的对账与补偿流程
text
1. 配置分级:涉及 RPO 的参数(unclean 选举、flushDiskType、brokerRole)
   纳入变更审批白名单,禁止临时改动;改动要写回配置仓库并留撤销时间。
2. 停写期间的正确做法是缩短停写窗口(加节点、修网络、补副本),
   而不是换一条“有数据缺失风险”的可用性路径。
3. 客户端按“写失败”处理并落本地消息表,等集群恢复后重投,
   这是唯一不牺牲 RPO 的降级方式。
4. 已经发生丢失时:用上游业务流水与消息表对账补发,
   补发必须带原业务键,靠消费端幂等收敛。

复盘要点:判据是“enableElectUncleanMaster 在所有生产集群上恒为 false”,这条应该出现在巡检脚本里。另外要说准降级路径的差别:Controller 不可用时退回经典主从人工切换(RTO 变长、RPO 不变),DLedger 失去多数派时保持拒写(RPO 不变、可用性下降)——两者的代价维度不同,混用会导致预期错位,选型对比见 Kafka vs RocketMQ。

最小上手 ​

本机用两个端口模拟一主一从(4.9 写法):

bash
# master.conf
cat > conf/master.conf <<'EOF'
brokerClusterName = DefaultCluster
brokerName = broker-a
brokerId = 0
brokerRole = SYNC_MASTER
flushDiskType = ASYNC_FLUSH
brokerIP1 = 127.0.0.1
listenPort = 10911
haListenPort = 10912
storePathRootDir = /tmp/rmq-master
EOF

# slave.conf
cat > conf/slave.conf <<'EOF'
brokerClusterName = DefaultCluster
brokerName = broker-a
brokerId = 1
brokerRole = SLAVE
flushDiskType = ASYNC_FLUSH
brokerIP1 = 127.0.0.1
listenPort = 10921
haListenPort = 10922
storePathRootDir = /tmp/rmq-slave
EOF

nohup sh bin/mqnamesrv > /tmp/ns.log 2>&1 &
nohup sh bin/mqbroker -n 127.0.0.1:9876 -c conf/master.conf > /tmp/master.log 2>&1 &
nohup sh bin/mqbroker -n 127.0.0.1:9876 -c conf/slave.conf > /tmp/slave.log 2>&1 &

检查复制状态:

bash
# 同一 brokerName 下应有 brokerId 0 和 1
sh bin/mqadmin clusterList -n 127.0.0.1:9876
# 看单台 Broker 的运行态,关注 slaveFallBehind / 主从连接
sh bin/mqadmin brokerStatus -n 127.0.0.1:9876 -b 127.0.0.1:10911
sh bin/mqadmin getBrokerRuntimeInfo -n 127.0.0.1:9876

切换演练(经典主从):

  1. 持续发送消息,记录最后一次发送成功的时间。
  2. kill Master 进程,观察 Producer 是否开始报 SLAVE_NOT_AVAILABLE 或连接失败。
  3. 把 Slave 配置改为 brokerId=0、brokerRole=SYNC_MASTER,重启。
  4. 恢复发送,记录从停写到恢复的时间,这就是实际 RTO。
  5. 检查停写窗口内客户端重试造成的重复消息,验证消费端幂等。

切换演练(DLedger / Controller):3 节点起集群,kill 当前 Master,轮询 clusterList 观察新 Master 出现的时间;再 kill 到只剩 1 节点,确认集群拒写而不是选出缺数据的主。

生产动作 ​

  • 先定 RPO,再选复制模式。要求 RPO=0 就不能用 ASYNC_MASTER;用了 SYNC_MASTER 就要接受 Slave 故障时写入失败。
  • SYNC_MASTER 必须监控 Slave 延迟。Slave 追不上会让写入等满 syncFlushTimeout 再失败,表现为发送 RT 升高、错误率上升。
  • DLedger 至少 3 节点,且跨机架/跨机器部署。3 节点放同一台物理机等于没做容灾。
  • Controller 集群独立部署 3 副本,不要和 Broker 抢资源;controllerAddr 配置全部节点,不能只写一个。
  • 不要开 enableElectUncleanMaster 来「提高可用性」,除非业务明确接受丢消息,并且下游有对账。
  • 切换后核对位点:新 Master 的 maxOffset 不应小于切换前已确认的位置;消费端从 Broker 位点继续,重复比丢失好处理。

边界 ​

  • 经典主从不等于高可用。它只解决数据副本,不解决自动切换;RTO 是人工操作时间。
  • DLedger 不是越多节点越好。5 节点要 3 个确认,写延迟更高;3 节点是常见折中。
  • Controller 只做集群内选主,不做跨机房复制。跨地域容灾见 09-多活与容灾架构。
  • Kafka 的 ISR 不是「最终一致」。ISR 是 Leader 维护的法定确认集合,acks=all 下已提交消息不会丢(前提是不开 unclean 选举且 min.insync.replicas 足够)。Raft 与 ISR 的差别在确认集合的定义,不在「强一致 vs 最终一致」这种标签。
  • 同步复制不保证消费端恰好一次。切换、重试、位点回退都会带来重复,幂等仍然必需。

讲清楚 ​

RocketMQ 的高可用要分两个指标看:RPO 和 RTO。复制模式决定 RPO——异步复制在 Master 宕机时可能丢最后一段,同步复制要求 Slave 确认后才返回,已确认的消息不丢;但同步复制在 Slave 不可用时不会偷偷降级,而是返回失败,这点必须让客户端按「结果未知」处理。

选主方式决定 RTO。4.5 之前的经典主从没有自动切换,Master 挂了要人工把 Slave 改成 Master,RTO 是分钟级。4.5 的 DLedger 用 Raft,3 节点多数派自动选主,已提交消息不丢,只剩 1 台时拒写。5.x 的 Controller 把选主独立出来,brokerId 由 Controller 分配,默认不选缺数据的脏主。

选型顺序是:先写清楚能丢多少、能停多久,再决定用异步主从、同步主从、DLedger 还是 Controller。只回答「我配了 Slave」说明不了高可用,必须能给出 RPO 和 RTO 两个数字。

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