跳到正文

RocketMQ 多活与容灾架构 ​

一句话:多活不是「两个集群都写」,而是「复制 + 切流 + 校验」三件事都成立;复制定 RPO,切流定 RTO,校验决定你敢不敢切。

适用版本:4.9 / 5.3 / 5.5。开源发行版不内置跨集群复制,跨机房能力通常来自自建组件或云产品。

为什么需要 ​

单机房能扛机器故障,扛不住机房级故障。同城双活解决「一个机房挂了另一个能接」,异地灾备解决「城市级灾难能恢复」,单元化解决「超大规模下的故障隔离和就近接入」。

但这三种方案的成本和一致性差别很大。只做「双写」而不做幂等和对账,得到的不是双活,而是两份可能不一致的数据;只做异步复制而不做切流演练,故障时才发现 RTO 是小时级。

核心机制 ​

三种形态 ​

形态拓扑RPORTO成本
同城双活两机房都对等承载流量,低延迟专线同步复制可到 0;异步复制有秒级窗口分钟级(DNS/VIP/客户端切换)中
异地灾备主机房承载,异地异步复制,平时不接流量秒级到分钟级十分钟级到小时级中高(一套冷备资源)
单元化按用户/地域分片,每单元闭环单元内可 0,跨单元异步单元级故障只影响该单元高

选择顺序:先看故障域要求(机器/机房/城市),再看能接受的 RPO/RTO,最后看预算。不要反过来「先买两套集群,再想怎么用」。

复制:RocketMQ 没有开箱即用的跨集群复制 ​

需要说清楚三个边界:

  1. Controller 只做集群内选主。它管理同一 brokerName 下 Master/Slave 的切换,不做机房 A 到机房 B 的消息复制。
  2. DLedger 也只在同一 Broker 组内做 Raft 复制,不是跨集群同步工具。
  3. 开源发行版不带「复制槽」概念。复制槽是 PostgreSQL 的术语;RocketMQ 侧应说「同步位点 / 复制进度」。

跨集群复制通常有三种做法:

  • 转发消费者:在源集群消费,再发送到目标集群。实现简单,可做过滤和转换;代价是多一次消费和发送,位点要单独管理。
  • RocketMQ Connect / 连接器:用 Connect 运行时做 source → sink,能力与版本、连接器实现有关,以官方文档为准。
  • 云产品:阿里云 Global Replicator 等托管能力,底层仍是异步复制,SLA 和延迟看产品说明。

复制方式决定 RPO:

复制方式行为代价
同步复制源集群写入等待目标确认,RPO≈0跨机房 RT 直接加到发送延迟
异步复制源集群先返回,后台同步延迟低,故障时丢最后一段

跨城光纤的物理延迟无法消除,要求 RPO=0 又要低延迟,通常只能同城;异地一般接受秒级到分钟级 RPO。

切流:写入口、读入口、消费位点 ​

切流不是改一个 DNS 就完事,要同时处理三件事:

  1. 写入口:Producer 的 NameServer 地址或接入层(VIP/GSLB)切到新集群。客户端要支持热更新,不能靠重启。
  2. 读入口:Consumer 的订阅地址切换;切换前确认目标集群已有数据、位点已对齐。
  3. 消费位点:跨集群位点不能直接搬 offset(两边的 Queue 数和写入历史不同),通常按时间重置(resetOffsetByTime)或按业务位点重新投递。

切换顺序建议:先停写或降级写 → 等复制追平 → 切读 → 切写 → 校验 → 恢复流量。每一步都要有回滚点。

双写不等于双活 ​

双写的典型问题:

  • 两个集群都成功,但顺序不同,消费端看到的状态不一致。
  • 一个成功一个失败,重试后两边重复或一边缺失。
  • 两个机房同时处理同一业务键,产生并发写冲突。

如果必须双写,至少要配:

  • 单一写主:按业务键哈希固定写某个机房,另一个机房只读或只做灾备,避免同一 key 并发写。
  • 幂等:消费端按业务唯一键去重。
  • 对账:按业务键比对两边数量与关键字段,差集修复。
  • 冲突策略:版本号 / 更新时间戳 / 业务状态机;不要只依赖「后写覆盖」,时钟偏差会让 LWW 出错。

单元化 ​

按用户 ID 或地域分片,每个单元有独立的 RocketMQ 集群,单元内闭环;跨单元通过异步事件同步,不能强依赖。

优点:故障只影响一个单元,扩容按单元进行,就近接入降低延迟。 代价:路由层要能按分片键找到单元;跨单元查询和统计变复杂;单元迁移需要数据搬迁和流量切换。

实战场景 ​

一、以为 Controller 会做跨机房复制 ​

现象:机房 A 因网络设备故障整体不可达 25 分钟。运维按“集群已启用 Controller 模式,具备自动选主”的预期,认为机房 B 的集群能接管;实际 B 侧集群里这些 Topic 根本没有消息,切换后消费端读到空,业务判定“消息全丢”。事后确认:两个机房的集群是两套完全独立的 RocketMQ 集群,之间没有任何复制链路。

排查:自动选主和跨集群复制是两件事。

定位层取证据判定
Controller 作用域它管理同一 brokerName 下 Master/Slave 的切换不做机房 A→B 的消息复制
DLedger 作用域Raft 复制发生在同一 Broker 组的节点之间不是跨集群同步工具
是否存在复制链路B 集群该 Topic 的消息与位点是否随 A 增长无增长即没有复制
复制实现方式是否有转发消费者 / Connect / 云产品 Global Replicator决定 RPO 与延迟模型
术语是否被误用方案文档里是否写了“复制槽”那是 PostgreSQL 的概念,RocketMQ 侧应说同步位点/复制进度

开源发行版不带跨集群复制能力,这一点必须在方案评审时写清楚,而不是靠“高可用模式已开启”推断。

对症做法:

text
跨集群复制只有三条路,按可控性选:
1. 转发消费者:在 A 消费、再发送到 B。实现简单,可过滤和转换,
   代价是多一次消费与发送,转发组位点要单独管理并监控滞后。
2. RocketMQ Connect 等连接器:source → sink,能力与版本、连接器实现有关,以官方文档为准。
3. 云产品托管复制:底层仍是异步复制,SLA 与延迟看产品说明,不能当 RPO=0 使用。
java
// 转发消费者要自带滞后指标,否则复制断了没人知道
consumer.setConsumerGroup("cross-dc-relay-order-events");
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, ctx) -> {
    for (MessageExt m : msgs) {
        Message out = convert(m);                       // 保留 keys、tag、业务时间戳
        target.send(out);                               // 发送到另一机房集群
        metrics.relayLag(m.getStoreTimestamp());        // 复制滞后 = 现在 - 源存储时间
    }
    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
text
RPO 由复制方式决定,不由“有没有开高可用模式”决定:
- 同步复制:源集群写入等待目标确认,RPO≈0,代价是跨机房 RT 直接加进发送延迟;
- 异步复制:源先返回,后台同步,故障时丢最后一段。
跨城光纤的物理延迟消不掉,要求 RPO=0 又要低延迟通常只能同城;异地一般接受秒级到分钟级。

复盘要点:判据是“容灾方案里每个 Topic 都要写明复制方式、RPO、RTO 与链路实现者”,三项数字必须来自演练。选择顺序也固定:先看故障域要求(机器/机房/城市),再看能接受的 RPO/RTO,最后看预算——先买两套集群再想怎么用,几乎必然出现本次这种“有集群没数据”。

二、只改了 DNS 就切流,重复消费与漏消费同时发生 ​

现象:一次计划内切流把 Producer/Consumer 的接入地址从 A 集群换到 B 集群,5 分钟内:一部分订单事件被重复处理(下游账务两次入账),另一部分事件在 B 集群上从未出现。团队原以为“两边消息一样,切过去继续消费即可”。

排查:切流要同时处理写入口、读入口和消费位点三件事。

事项本次做法问题正确判据
写入口改 VIP/DNS 指向 B生效有 TTL 延迟,期间仍有写进 A客户端需支持热更新,不能靠重启;记录切换时间戳
读入口消费者重启到 B位点用的是 A 的 offset 语义两边 Queue 数与写入历史不同,offset 不能直接搬
消费位点未处理B 上从最新开始,A 上未消费的没搬过去按时间重置或按业务位点重新投递
数据对齐未确认B 集群该 Topic 是否已有数据、复制是否追平切读前先确认复制滞后为 0
回滚点无出问题只能再次切回,二次混乱每一步留回滚方案
bash
# 位点按时间对齐,而不是搬 offset
sh mqadmin resetOffsetByTime -n $NS_B -g account-consumer -t OrderTopic \
  -s 2026-10-26#14:00:00 -j          # 先试运行看影响范围
# 目标集群消费者全部停止后再执行;恢复消费前先确认位点落在预期区间起点

重复入账的直接来源是:消费者带着 A 集群的 offset 语义在 B 上继续读,B 的历史与 Queue 数不同,导致同一段业务事件被重新读了一遍。

对症做法:

text
1. 把切流写成编排脚本 + 检查清单,任何一步不满足就停在该步:
   复制滞后 = 0;目标集群 Topic 已创建且队列数一致;消费组订阅关系一致;位点已按时间对齐。
2. 切换期间用“双跑校验”:同一时间窗在 A/B 两侧统计各 Topic 条数与业务流水差异。
3. 消费端必须有幂等:切流本质会引入一次重复处理窗口,
   幂等键(业务单号 + 事件类型)是唯一能把窗口变成无害事件的手段。
4. DNS/TTL 不是切换机制:客户端地址热更新能力要先验证,否则“切了但没切干净”。

复盘要点:判据是“切流演练结果”:切流窗口内重复入账笔数为 0(靠幂等保证)、缺失事件数为 0(靠对账确认)。把“先停写→等追平→切读→切写→校验→恢复”做成一条不可跳步的流水线,每步留时间戳与回滚入口;每一步都要有回滚点,这一点在真实事故里比顺序更重要。

三、双写两个集群,消费端看到的状态互相矛盾 ​

现象:为了“两地都能就近消费”,库存变更消息同时写 A、B 两个集群,两个机房的消费者各读本地集群。上线后出现同一 SKU 的库存状态在两边不一致,且同一个订单的 LOCK 与 RELEASE 在两边到达顺序不同,导致“先释放后锁定”的状态倒退。

排查:双写不产生顺序保证。

定位层取证据判定
两边写入顺序同一业务键在 A/B 的存储时间与顺序网络抖动让顺序不同,属必然
消费来源两个机房的消费者是否读不同副本读不同副本 = 两个独立事件流
状态倒退下游是否有版本/时间戳判定无版本即后到覆盖
写失败半成功A 成功 B 失败时的处理无补偿则两边永久分叉
业务是否允许同一实体是否被两地同时写双活写冲突是架构问题

双写的典型问题就是“两个集群都成功,但顺序不同,消费端看到的状态不一致”。双写不等于双活。

对症做法:

text
1. 单一写入口 + 异步复制到另一集群:
   顺序在源集群确定,复制只搬运,不产生第二条独立事件流。
2. 需要两地都能写时,按业务键分片(单元化):
   同一 SKU/同一用户的路由固定落在一个单元,跨单元只传结果不传并发写。
3. 状态类消费一律带版本判定,拒绝倒退:
   UPDATE stock SET qty=?, version=? WHERE sku=? AND version < ?
4. 半成功要有收敛机制:双写失败时记录差集并定时对账,
   而不是让两边自然分叉。
java
// 单元化路由:同一业务键固定进同一单元,跨单元不并发写同一实体
String unit = unitRouter.bySeller(sellerId);          // 例:north / south
if (!unit.equals(localUnit)) {
    crossUnitForward.send(msg, unit);                 // 转交给归属单元处理
    return;
}
text
单元化的成本在“跨单元一致性”与“路由归属维护”:
单元内可以做到 RPO=0,跨单元只能异步,故障时单元级影响而非全站。
适合按用户/地域天然可分片的业务;库存这种全局争抢的资源要谨慎分片。

复盘要点:判据是“同一业务键是否只有一个写入集群”和“消费端是否能拒绝版本倒退”。这两条可以做成常态检查:按业务键抽样统计其在两个集群的写入顺序差异,以及版本倒退被拒绝的次数。三种形态的取舍(同城双活 / 异地灾备 / 单元化)要按故障域与预算选,别把“双写”当成低成本的容灾方案,见 生产故障场景处置。

最小上手 ​

本机起两个独立集群(不同端口和存储目录),用转发消费者验证「复制 + 校验」:

bash
# 集群 A:namesrv 9876,broker 10911
# 集群 B:namesrv 9877,broker 10921(broker.conf 改 listenPort / storePathRootDir)

nohup sh bin/mqnamesrv -p 9876 > /tmp/ns-a.log 2>&1 &
nohup sh bin/mqbroker -n 127.0.0.1:9876 -c conf/broker-a.conf > /tmp/b-a.log 2>&1 &

nohup sh bin/mqnamesrv -p 9877 > /tmp/ns-b.log 2>&1 &
nohup sh bin/mqbroker -n 127.0.0.1:9877 -c conf/broker-b.conf > /tmp/b-b.log 2>&1 &

转发消费者(伪代码,两个客户端分别指向 A 和 B):

java
DefaultMQPushConsumer from = new DefaultMQPushConsumer("relay-a2b");
from.setNamesrvAddr("127.0.0.1:9876");
from.subscribe("order-topic", "*");

DefaultMQProducer to = new DefaultMQProducer("relay-a2b");
to.setNamesrvAddr("127.0.0.1:9877");
to.start();

from.registerMessageListener((MessageListenerConcurrently) (msgs, ctx) -> {
    for (MessageExt m : msgs) {
        Message copy = new Message("order-topic", m.getTags(), m.getBody());
        copy.setKeys(m.getKeys());                 // 保留业务键,便于对账
        to.send(copy);                             // 生产环境要加重试与幂等
    }
    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});

校验:

bash
# 两边 Topic 的 maxOffset 应随复制推进
sh bin/mqadmin topicStatus -n 127.0.0.1:9876 -t order-topic
sh bin/mqadmin topicStatus -n 127.0.0.1:9877 -t order-topic

# 两边消费进度
sh bin/mqadmin consumerProgress -n 127.0.0.1:9876 -g order-consumer
sh bin/mqadmin consumerProgress -n 127.0.0.1:9877 -g order-consumer

# 按业务 key 抽检两边是否都有
sh bin/mqadmin queryMsgByKey -n 127.0.0.1:9876 -t order-topic -k order-12345
sh bin/mqadmin queryMsgByKey -n 127.0.0.1:9877 -t order-topic -k order-12345

生产动作 ​

  • 先定义 RPO/RTO,再选拓扑。同城同步复制可以做到 RPO=0,异地异步通常是秒级到分钟级;不要承诺做不到的数字。
  • 切流做成可重复的脚本/平台操作,并在低峰期演练。演练要覆盖:单 NameServer 挂、单 Broker 挂、机房断网、复制延迟、回滚。
  • 复制链路监控三项:同步延迟(源 maxOffset 与目标 maxOffset 的差)、消费位点差、失败重试数。
  • 对账按业务键做,不能只比消息条数;消息可能重复、可能被过滤、可能顺序不同。
  • 双写必须有单一写主或明确的分片归属,否则冲突处理会变成业务负担。
  • 切换后保留旧集群只读一段时间,便于回滚和比对;确认稳定后再下线。

边界 ​

  • Controller 不做跨机房复制。它只负责同一集群内的选主,别把高可用方案当成多活方案。
  • 「复制槽」是 PostgreSQL 术语,RocketMQ 没有这个东西;说「同步位点/复制进度」。
  • 双写不是双活。没有幂等和对账的双写,只是把单点故障换成了数据不一致。
  • 异步复制一定有 RPO 窗口,跨城延迟是物理限制,不能靠调参消除。
  • 单元化的成本被低估。路由、数据迁移、跨单元查询、运维工具都要改造,只有规模足够大才划算。
  • 多活不等于容灾演练通过。没有演练过的切换脚本,故障时大概率不可用。

讲清楚 ​

多活要同时成立三件事:复制、切流、校验。复制决定 RPO——同城同步复制可以做到不丢,异地异步复制一定有秒级到分钟级的窗口;切流决定 RTO——写入口、读入口、消费位点要一起切,跨集群不能直接搬 offset,通常按时间重置;校验决定你敢不敢切——按业务键对账,不能只比条数。

RocketMQ 本身要分清边界:Controller 只做集群内选主,DLedger 只做 Broker 组内 Raft 复制,开源版没有开箱即用的跨集群复制,也没有 PostgreSQL 那种「复制槽」。跨机房通常靠转发消费者、Connect 或云产品。双写更不等于双活,同一业务键两边并发写会冲突,必须配单一写主、幂等和对账。

形态上,同城双活追求低延迟和自动切换,异地灾备追求城市级恢复,单元化追求故障隔离,成本依次上升。选型的起点不是「上几套集群」,而是「能接受丢多少、停多久」。

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