RocketMQ vs Kafka03 - 高可用机制深度剖析

第三篇:高可用机制深度剖析

高可用这事儿,从来不是靠某一个参数"调优"就能解决的。它背后是副本协议、故障检测、自动切换、数据对齐这一整套机制。本文会从 Kafka 和 RocketMQ 的源码实现入手,把 ISR、HW/LEO、Raft、主从复制等关键路径拆开讲,同时给出真实场景下的配置策略和踩坑经验。


3.1 高可用设计理念

先理清概念:高可用(HA)的目标是"尽可能减少不可用时间"。业界常用的指标是:

  • 99.9%:年不可用时间约 8.76 小时(一般业务可接受)
  • 99.99%:年不可用时间约 52.6 分钟(金融、支付等核心系统)
  • 99.999%:年不可用时间约 5.26 分钟(运营商级别)

要做到 4 个 9,必须在架构、流程、工具三个层面同时投入:

  1. 架构层:多副本、跨机房、自动故障转移
  2. 流程层:变更审批、容量规划、故障演练
  3. 工具层:监控告警、自动化运维、回滚机制

Kafka 和 RocketMQ 在这三个维度的投入方式不太一样。

Kafka:去中心 + ISR + Controller

Kafka 的架构哲学是"去中心化":每个 Broker 都可能是某些 Partition 的 Leader,也可能是另一些 Partition 的 Follower。这种设计带来两个好处:

  1. 负载均衡天然:Leader 分散在各个 Broker,不会所有压力集中在某台机器。
  2. 扩展性强:加一台 Broker,立刻就能分担流量。

但去中心也有代价:需要一个"协调者"来管理元数据、触发 Leader 选举。这个协调者就是 Controller。

Controller 的核心职责

  • 监听 Broker 的上下线(通过 ZooKeeper session / KRaft 心跳)
  • 管理每个 Partition 的 ISR 列表
  • 在 Leader 宕机时从 ISR 中选新 Leader
  • 把元数据变更通知到所有 Broker

ISR 机制

  • ISR(In-Sync Replicas)是 Kafka 高可用的核心。只有"紧跟"Leader 的副本才能留在 ISR 中。
  • 判定标准:Follower 的 LEO(Log End Offset)与 Leader 的 LEO 差距不超过 replica.lag.time.max.ms(默认 10 秒)。
  • 写入时 Producer 配置 acks=all,只有 ISR 内的所有副本都写入成功,才算真正成功。

Kafka 的这套理念,适合"高吞吐 + 多分区 + 动态扩缩容"的场景,比如日志流、监控数据。

RocketMQ:主备明确 + 刷盘控制 + DLedger

RocketMQ 的哲学是"主备角色清晰":Master 负责读写,Slave 负责备份。这种设计简单粗暴,运维容易理解。

Master-Slave 的优势

  • 写入路径明确:Producer 只连 Master,不会分散到多台机器。
  • 切换逻辑清晰:Master 挂了,要么人工提升 Slave,要么靠 DLedger 自动选举。
  • 配置直观:SYNC_MASTER vs ASYNC_MASTER、同步刷盘 vs 异步刷盘,组合起来就是四种可靠性等级。

但主备也有限制:单 Master 的写入能力有上限。所以 RocketMQ 通常会部署多组 Master-Slave,每组承担一部分 Topic/Queue。

DLedger(4.5+)

  • 把 CommitLog 存储抽象成 Raft 日志,写入需要多数派确认。
  • Leader 故障自动选举新 Leader,不需要人工干预。
  • 适合对一致性要求高、又希望自动切换的场景(如金融核心系统)。

RocketMQ 的这套理念,适合"确定性 + 可控性 + 业务消息"的场景,比如订单、支付、库存。

两者对比

维度 Kafka RocketMQ
角色模型 去中心化,动态 Leader 主备明确,Master/Slave 或 DLedger
副本同步 ISR 动态调整,灵活但需监控 模式固定,简单但切换需规划
控制面 Controller 重,需配合 ZK/KRaft NameServer 轻,逻辑在 Broker
适合场景 日志流、高吞吐、流处理 业务消息、事务、订单

没有"谁更好",只有"谁更适合你的场景"。下面几节会深入源码和配置,把两者的高可用机制拆到底。


3.2 故障检测与恢复

故障检测分三步:感知、判定、处置。Kafka 和 RocketMQ 在每一步的实现都有差异。

Kafka 的故障检测链路

Broker 心跳与 session 管理

ZooKeeper 模式(3.0 之前)

Broker 启动时会在 ZooKeeper 创建临时节点 /brokers/ids/{brokerId}

json 复制代码
{
  "version": 4,
  "host": "192.168.1.10",
  "port": 9092,
  "timestamp": "1700000000000",
  "endpoints": ["PLAINTEXT://192.168.1.10:9092"]
}

这是个临时节点(Ephemeral),Broker 与 ZooKeeper 的 session 断开后,节点会自动删除。Controller 监听 /brokers/ids 目录,一旦节点消失,立刻触发处理。

关键参数:

  • zookeeper.session.timeout.ms:默认 18000(18 秒),session 超时时间
  • zookeeper.connection.timeout.ms:默认 18000(18 秒),连接超时时间

调优经验:

  • session 超时不要太短,否则网络抖动会频繁触发 failover。
  • 也不要太长,否则故障恢复慢。一般生产环境保持 18~30 秒。

KRaft 模式(3.0+)

不再依赖 ZooKeeper,Broker 直接与 Controller 通信。Controller 内部维护 Broker 的心跳表,通过 Raft 通道定期收心跳。

关键参数:

  • broker.session.timeout.ms:默认 9000(9 秒)
  • broker.heartbeat.interval.ms:默认 2000(2 秒)

KRaft 的故障感知更快(9 秒 vs 18 秒),元数据变更也更快(不需要经过 ZooKeeper)。

ISR 收缩与扩展

ISR 的更新逻辑在 Partition.scalamaybeExpandIsr()maybeShrinkIsr() 方法中。

收缩逻辑(maybeShrinkIsr)

scala 复制代码
def maybeShrinkIsr(): Unit = {
  val now = time.milliseconds()
  val outOfSyncReplicas = inSyncReplicas.filter { replica =>
    val lastCaughtUpTimeMs = replica.lastCaughtUpTimeMs
    (now - lastCaughtUpTimeMs) > replicaLagTimeMaxMs
  }
  
  if (outOfSyncReplicas.nonEmpty) {
    val newIsr = inSyncReplicas -- outOfSyncReplicas
    updateIsr(newIsr)
    zkClient.updateIsr(topic, partition, newIsr)
  }
}

核心逻辑:

  1. 遍历所有 ISR 副本,检查 lastCaughtUpTimeMs(最后一次追上 Leader 的时间)。
  2. 如果 now - lastCaughtUpTimeMs > replica.lag.time.max.ms,踢出 ISR。
  3. 更新本地 ISR 列表,同时写入 ZooKeeper(ZK 模式)或元数据日志(KRaft)。

扩展逻辑(maybeExpandIsr)

scala 复制代码
def maybeExpandIsr(): Unit = {
  val outOfSyncReplicas = assignedReplicas -- inSyncReplicas
  val caughtUpReplicas = outOfSyncReplicas.filter { replica =>
    replica.logEndOffset >= leader.highWatermark
  }
  
  if (caughtUpReplicas.nonEmpty) {
    val newIsr = inSyncReplicas ++ caughtUpReplicas
    updateIsr(newIsr)
    zkClient.updateIsr(topic, partition, newIsr)
  }
}

核心逻辑:

  1. 找出所有"不在 ISR 中"的副本。
  2. 如果某个副本的 LEO 已经追上 Leader 的 HW,说明它已经同步完成,可以加入 ISR。
  3. 更新 ISR 列表。

ISR 抖动问题

如果网络不稳定,Follower 会频繁进出 ISR,导致:

  • ZooKeeper 写入压力大
  • Controller 频繁发送 UpdateMetadata 请求
  • 监控告警频繁触发

优化手段:

  • 调大 replica.lag.time.max.ms(从 10 秒调到 30 秒)
  • 升级到 KRaft,减少 ZooKeeper 写入
  • 使用 Cruise Control 检测慢副本,提前迁移
Leader 选举流程

Leader 宕机后,Controller 会从 ISR 中选出新 Leader。选举逻辑在 PartitionStateMachine.scala 中。

选举步骤

  1. 触发条件

    • Broker 下线(ZK session 超时 / KRaft 心跳超时)
    • Leader 所在 Broker 下线
    • 手动触发 Preferred Leader Election
  2. 选举策略

    • 从当前 ISR 列表中选择第一个可用的副本
    • 如果 ISR 为空且 unclean.leader.election.enable=true,从所有副本中选择 LEO 最大的(可能丢数据)
    • 如果 ISR 为空且不允许 Unclean,Partition 进入离线状态
  3. 元数据更新

    • Controller 更新 Leader Epoch(防止脑裂)
    • 通知新 Leader 开始服务
    • 通知其他 Broker 更新元数据
  4. 通知 Follower

    • Follower 收到 Leader 变更通知后,会截断本地日志到新 Leader 的 HW,避免数据不一致

源码关键路径

scala 复制代码
// PartitionStateMachine.scala
def electLeader(partition: TopicPartition): Unit = {
  val isr = zkClient.getInSyncReplicas(partition)
  val newLeader = if (isr.nonEmpty) {
    isr.head  // 选择 ISR 第一个
  } else if (uncleanLeaderElectionEnable) {
    allReplicas.maxBy(_.logEndOffset)  // 选择 LEO 最大的
  } else {
    -1  // 离线
  }
  
  if (newLeader >= 0) {
    val newLeaderEpoch = controllerEpoch + 1
    zkClient.updateLeaderAndIsr(partition, newLeader, isr, newLeaderEpoch)
    sendUpdateMetadataRequest(allBrokers)
  }
}

Unclean Leader Election 的风险

如果开启 unclean.leader.election.enable=true,允许落后副本上位:

  • 可能读到旧数据(LEO 小的副本成为 Leader)
  • 可能丢失数据(新 Leader 不包含旧 Leader 的部分数据)

生产环境一般禁用这个参数,宁可 Partition 暂时离线,也不要数据错乱。

Controlled Shutdown(优雅停机)

Kafka 支持优雅停机,在 Broker 下线前主动迁移 Leader。

流程

  1. Broker 向 Controller 发送 ControlledShutdownRequest
  2. Controller 把该 Broker 上所有 Leader Partition 迁移到其他副本
  3. 迁移完成后,Broker 关闭网络监听,等待剩余请求处理完
  4. 最后关闭磁盘刷盘、日志清理等后台线程

配置

properties 复制代码
# 开启优雅停机
controlled.shutdown.enable=true

# 最大重试次数
controlled.shutdown.max.retries=3

# 重试间隔
controlled.shutdown.retry.backoff.ms=5000

实战中,优雅停机能减少 Partition 不可用时间(从 10 秒降到 < 1 秒)。但如果网络有问题,可能卡在"等待 Leader 迁移"阶段,需要设置超时。

RocketMQ 的故障检测链路

Broker 心跳机制

RocketMQ 的心跳分两类:

  1. Broker → NameServer:每 30 秒上报路由信息
  2. Slave → Master:HAService 的长连接心跳,秒级检测

Broker 注册流程

Broker 启动时向所有 NameServer 发送注册请求,包含:

json 复制代码
{
  "brokerName": "broker-a",
  "brokerId": 0,
  "brokerAddr": "192.168.1.10:10911",
  "clusterName": "DefaultCluster",
  "haServerAddr": "192.168.1.10:10912",
  "topicConfigWrapper": {...}
}

NameServer 收到后更新路由表,并记录时间戳。后续每 30 秒 Broker 会重新发一次。

心跳超时判定

NameServer 有个定时任务 RouteInfoManager.scanNotActiveBroker(),每 10 秒扫一次:

java 复制代码
public void scanNotActiveBroker() {
    long current = System.currentTimeMillis();
    Iterator<Entry<String, BrokerLiveInfo>> it = brokerLiveTable.entrySet().iterator();
    while (it.hasNext()) {
        Entry<String, BrokerLiveInfo> entry = it.next();
        long last = entry.getValue().getLastUpdateTimestamp();
        if ((current - last) > brokerExpiredTime) {  // 默认 120 秒
            it.remove();
            onBrokerInactive(entry.getKey());
        }
    }
}

超过 120 秒没收到心跳,NameServer 把 Broker 从路由表移除。Producer/Consumer 在下次拉取路由时会感知到变化。

配置

properties 复制代码
# Broker 心跳间隔(毫秒)
heartbeatBrokerInterval=30000

# NameServer 认为 Broker 失效的时间(毫秒)
brokerExpiredTime=120000

调优建议:

  • 心跳间隔不要太长,否则故障感知慢。
  • 超时时间至少要是心跳间隔的 3 倍,防止偶发丢包。
HAService:主从复制监控

Slave 通过 HAService 与 Master 保持长连接,实时复制 CommitLog。

HAService 线程模型

Master 端:

  • AcceptSocketService:接受 Slave 连接
  • HAConnection.WriteSocketService:向 Slave 发送数据
  • HAConnection.ReadSocketService:接收 Slave 的 ACK

Slave 端:

  • HAClient:连接 Master,拉取数据并写入本地 CommitLog

复制延迟监控

Master 会记录每个 Slave 的复制进度(Slave Ack Offset),与当前 CommitLog 的最大偏移量对比,得出延迟:

java 复制代码
long masterMaxOffset = commitLog.getMaxOffset();
long slaveAckOffset = haConnection.getSlaveAckOffset();
long diff = masterMaxOffset - slaveAckOffset;

if (diff > maxTransferLag) {
    log.warn("Slave lag too large: {}MB", diff / 1024 / 1024);
}

如果延迟过大(比如 > 1GB),说明 Slave 性能跟不上或网络有问题,需要告警。

DLedger 的 Raft 选举

DLedger 是 RocketMQ 4.5+ 引入的 Raft 实现,用于替代 Master-Slave 的手动切换。

Raft 核心角色

  • Leader:负责读写,类似 Master
  • Follower:从 Leader 复制数据,类似 Slave
  • Candidate:选举中的临时角色

选举触发

  • Leader 心跳超时(默认 1 秒)
  • Follower 收不到 Leader 心跳,进入 Candidate 状态
  • 向其他节点发起投票请求

投票逻辑

java 复制代码
// DLedgerLeaderElector.java
public void handleVote(VoteRequest request) {
    if (request.getTerm() > this.term) {
        // 对方 term 更大,同意投票
        this.term = request.getTerm();
        this.votedFor = request.getCandidateId();
        return VoteResponse.ACCEPT;
    }
    
    if (request.getLedgerEndIndex() < this.ledgerEndIndex) {
        // 对方数据落后,拒绝投票
        return VoteResponse.REJECT;
    }
    
    // 其他情况看是否已投票
    if (this.votedFor == null || this.votedFor.equals(request.getCandidateId())) {
        this.votedFor = request.getCandidateId();
        return VoteResponse.ACCEPT;
    } else {
        return VoteResponse.REJECT;
    }
}

选举完成

  • 获得多数票的 Candidate 成为 Leader
  • Leader 定期发送心跳(AppendEntries)维持地位
  • 如果 Leader 挂掉,再次发起选举

DLedger 的优势是"自动切换 + 强一致",缺点是性能略低于异步复制(需要多数派确认)。

故障恢复时间对比

故障类型 Kafka (ZK) Kafka (KRaft) RocketMQ (MS) RocketMQ (DLedger)
Broker 宕机感知 18 秒 9 秒 120 秒 1 秒
Leader 选举时间 5~10 秒 2~5 秒 人工(分钟级) 自动(< 1 秒)
路由更新时间 立即 立即 120 秒内 立即
总恢复时间 20~30 秒 10~15 秒 2~5 分钟 2~3 秒

RocketMQ 的 DLedger 在自动切换上有明显优势,但 Master-Slave 模式的心跳超时太长(120 秒),需要靠监控提前发现问题。


3.3 数据一致性保证

一致性是"不同副本能看到相同数据"的能力。在分布式环境下,网络延迟、节点故障都会造成数据不一致。Kafka 和 RocketMQ 通过不同的协议保证一致性。

Kafka:HW/LEO + Leader Epoch

HW(High Watermark)与 LEO(Log End Offset)

这是 Kafka 副本机制的核心概念。

LEO:日志的最新偏移量,即"写到哪儿了"。每个副本都有自己的 LEO。

HW:High Watermark,所有 ISR 副本都已确认的偏移量,即"消费者能读到哪儿"。

关系:HW <= min(所有 ISR 副本的 LEO)

举例:

  • Leader LEO = 100
  • Follower1 LEO = 98
  • Follower2 LEO = 97
  • ISR = [Leader, Follower1, Follower2]
  • 则 HW = min(100, 98, 97) = 97

Consumer 只能读到 Offset 97 之前的数据,Offset 98~100 虽然 Leader 有,但还没"多数派确认",暂时不可见。

HW 更新流程

  1. Producer 发送消息到 Leader,Leader 写入本地日志,LEO 变为 101。
  2. Follower 通过 Fetch 请求拉取数据,写入本地日志,各自 LEO 更新。
  3. Follower 在下一次 Fetch 时带上自己的 LEO,Leader 据此更新 HW。
  4. HW 更新后,Leader 在 FetchResponse 中返回新的 HW 给 Follower,Follower 也更新本地 HW。

这个流程有个特点:HW 的更新需要两轮 Fetch。这在旧版本中会导致数据截断问题,新版本通过 Leader Epoch 修复。

Leader Epoch 机制

Leader Epoch 是 Kafka 2.5+ 引入的机制,用于解决 HW 更新滞后导致的数据截断问题。

问题场景

  1. Leader A,Follower B,ISR = [A, B]
  2. Leader A 写入 Offset 100~102,LEO = 103,但 HW 还是 100(需要等 B 拉取)
  3. B 拉取数据,LEO = 103,但本地 HW 还是 100(需要等下一轮 Fetch)
  4. 此时 A 宕机,B 当选 Leader。但 B 的 HW 是 100,所以 B 会截断 101~102 的数据
  5. 如果 A 恢复后重新加入,会从 B 同步,导致 101~102 的数据永久丢失

Leader Epoch 解决方案

每次 Leader 选举,Epoch 递增。Follower 在同步前,先向 Leader 查询"我的 Epoch 对应的截断点",而不是盲目截断到 HW。

Epoch 查询流程

scala 复制代码
// Follower 向新 Leader 查询
val request = OffsetsForLeaderEpochRequest(myEpoch, myLEO)
val response = leader.offsetsForLeaderEpoch(request)

if (response.endOffset < myLEO) {
  truncateTo(response.endOffset)  // 只截断必要的部分
}

这样就避免了"HW 滞后导致的数据丢失"。Leader Epoch 信息存储在 .snapshot 文件中。

RocketMQ:CommitLog 复制 + Raft 日志

主从复制的 ACK 机制

RocketMQ 的 Master-Slave 复制相对简单:

ASYNC_MASTER(异步复制)

  1. Producer 发消息到 Master
  2. Master 写入 CommitLog,立即返回成功
  3. HAService 后台线程异步把 CommitLog 复制到 Slave

这种模式性能最高,但 Master 宕机会丢失"未复制的部分"。

SYNC_MASTER(同步复制)

  1. Producer 发消息到 Master
  2. Master 写入 CommitLog,等待 Slave 返回 ACK
  3. Slave 写入成功后返回 ACK
  4. Master 收到 ACK 后才返回 Producer 成功

这种模式可靠性高,但性能会下降 30% 左右(增加一次网络往返)。

源码核心逻辑(简化版):

java 复制代码
// HAService.java(Master 端)
public void notifyTransferSome(final long offset) {
    for (HAConnection conn : connectionList) {
        conn.getTransferService().wakeup();  // 唤醒发送线程
    }
    
    if (BrokerRole.SYNC_MASTER == brokerRole) {
        // 同步复制,等待 Slave ACK
        waitForSlaveAck(offset, syncFlushTimeout);
    }
}

Slave ACK 逻辑

java 复制代码
// HAClient.java(Slave 端)
private void dispatchReadRequest() {
    // 读取 Master 发来的数据
    long masterOffset = readSocketBuffer.getLong();
    byte[] bodyData = readSocketBuffer.getBytes();
    
    // 写入本地 CommitLog
    commitLog.appendData(masterOffset, bodyData);
    
    // 返回 ACK
    this.currentReportedOffset = masterOffset + bodyData.length;
    reportSlaveMaxOffset(currentReportedOffset);
}
DLedger 的 Raft 日志复制

DLedger 把 CommitLog 抽象成 Raft 日志(DLedger Log),每条消息对应一个 Entry。

AppendEntries 流程

  1. Leader 收到写入请求,分配递增的 Index
  2. Leader 向所有 Follower 发送 AppendEntriesRequest
  3. Follower 写入本地日志,返回 ACK
  4. Leader 收到多数派 ACK 后,提交(commit)该 Entry
  5. Leader 返回 Producer 成功

源码关键路径

java 复制代码
// DLedgerEntryPusher.java(Leader 端)
public CompletableFuture<AppendEntryResponse> handleAppend(DLedgerEntry entry) {
    // 写入本地日志
    dLedgerStore.appendAsLeader(entry);
    
    // 向所有 Follower 推送
    List<CompletableFuture<PushEntryResponse>> futures = new ArrayList<>();
    for (String peerId : peerMap.keySet()) {
        futures.add(pushToFollower(peerId, entry));
    }
    
    // 等待多数派 ACK
    return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
        .thenApply(v -> {
            int ackCount = (int) futures.stream().filter(f -> f.join().isSuccess()).count();
            if (ackCount >= quorum) {  // quorum = (n + 1) / 2
                dLedgerStore.updateCommittedIndex(entry.getIndex());
                return AppendEntryResponse.success();
            } else {
                return AppendEntryResponse.fail();
            }
        });
}

日志对齐

Follower 接收到 AppendEntries 后,会检查 prevIndex 是否匹配:

java 复制代码
// DLedgerStore.java(Follower 端)
public AppendEntryResponse appendAsFollower(DLedgerEntry entry, long prevIndex, long prevTerm) {
    DLedgerEntry localPrev = this.get(prevIndex);
    
    if (localPrev == null || localPrev.getTerm() != prevTerm) {
        // 日志不连续或 term 不匹配,拒绝
        return AppendEntryResponse.inconsistent();
    }
    
    // 写入日志
    this.appendData(entry);
    return AppendEntryResponse.success();
}

如果日志不连续,Leader 会回退 prevIndex 重新发送,直到找到一致点。

acks 参数与 min.insync.replicas

Kafka 的 acks 参数决定了 Producer 对"写入成功"的定义。

acks=0

  • Producer 发完就返回,不等任何确认
  • 性能最高,但网络丢包、Broker 崩溃都会丢消息
  • 适合日志采集、监控数据等"丢几条无所谓"的场景

acks=1

  • Leader 写入成功就返回
  • 如果 Leader 写入后立刻宕机、数据还没复制到 Follower,会丢消息
  • 性能和可靠性的折中方案

acks=all(-1)

  • 所有 ISR 副本都写入成功才返回
  • 配合 min.insync.replicas >= 2,保证至少两个副本有数据
  • 可靠性最高,但性能会降低

min.insync.replicas 的作用

即使 acks=all,如果 ISR 只剩 1 个(Leader),写入仍然会成功。为了防止这种情况,设置 min.insync.replicas=2

properties 复制代码
# Topic 级别配置
min.insync.replicas=2

如果 ISR 少于 2 个,Producer 写入会失败(抛出 NotEnoughReplicasException),倒逼运维修复副本。

实战踩坑

某公司配置了 acks=all + min.insync.replicas=2,但在滚动升级时,ISR 只剩 1 个,导致写入全部失败。

解决办法:

  • 升级前检查所有 Partition 的 ISR 数量
  • 升级时限速,避免大量 Partition 同时迁移
  • 必要时临时调低 min.insync.replicas(但要评估风险)

Leader Epoch:防止数据截断

前面讲了 Leader Epoch 的原理,这里补充实际场景下的效果。

场景重现(旧版本,无 Leader Epoch):

ini 复制代码
时间线:
T1: Leader A (LEO=105, HW=100), Follower B (LEO=105, HW=100)
T2: A 写入 106~110,LEO=111,HW 还是 100(需等 B 拉取)
T3: A 宕机
T4: B 当选 Leader,但 B 的 HW=100,截断到 100
T5: A 恢复,从 B 同步,也截断到 100
T6: 数据 101~110 永久丢失

引入 Leader Epoch 后

ini 复制代码
T1: Epoch=5, Leader A (LEO=105)
T2: A 写入 106~110,LEO=111
T3: A 宕机,Epoch 递增为 6
T4: B 当选 Leader (Epoch=6, LEO=105)
T5: A 恢复,查询 Epoch 6 的起始 Offset,得知是 105
T6: A 截断到 105(而不是盲目截断到 HW),保留了 100~105 的数据

虽然 106~110 仍然没有被复制到 B,但至少避免了"B 截断后 A 也跟着截断"的问题。

配置

Leader Epoch 从 2.5+ 默认开启,不需要额外配置。可以通过 kafka-log-dirs.sh 查看 .snapshot 文件:

bash 复制代码
kafka-log-dirs.sh --bootstrap-server localhost:9092 \
  --describe --topic my-topic

RocketMQ:同步刷盘 + 同步复制的组合

RocketMQ 提供了"刷盘"和"复制"两个维度的配置,组合起来有四种可靠性等级。

刷盘 复制 可靠性 性能 适用场景
异步 异步 较低 最高 日志、监控
异步 同步 中等 中等 一般业务
同步 异步 中等 中等 需要单机可靠
同步 同步 最高 较低 金融、支付

源码实现(同步刷盘):

java 复制代码
// CommitLog.java
public PutMessageResult putMessage(MessageExtBrokerInner msg) {
    // 写入 MappedFile
    AppendMessageResult result = mappedFile.appendMessage(msg);
    
    // 同步刷盘
    if (FlushDiskType.SYNC_FLUSH == flushDiskType) {
        GroupCommitService service = this.groupCommitService;
        service.putRequest(new GroupCommitRequest(result.getWroteOffset() + result.getWroteBytes()));
        
        CompletableFuture<FlushStatus> flushFuture = service.waitForFlush(syncFlushTimeout);
        FlushStatus flushStatus = flushFuture.get();
        
        if (flushStatus != FlushStatus.FLUSH_OK) {
            return new PutMessageResult(PutMessageStatus.FLUSH_DISK_TIMEOUT, result);
        }
    }
    
    // 同步复制
    if (BrokerRole.SYNC_MASTER == brokerRole) {
        HAService ha = this.haService;
        if (!ha.isSlaveOK(result.getWroteOffset() + result.getWroteBytes())) {
            return new PutMessageResult(PutMessageStatus.SLAVE_NOT_AVAILABLE, result);
        }
        
        CompletableFuture<PutMessageStatus> replicaFuture = 
            ha.waitForSlave(result.getWroteOffset() + result.getWroteBytes(), syncFlushTimeout);
        PutMessageStatus status = replicaFuture.get();
        
        if (status != PutMessageStatus.PUT_OK) {
            return new PutMessageResult(PutMessageStatus.FLUSH_SLAVE_TIMEOUT, result);
        }
    }
    
    return new PutMessageResult(PutMessageStatus.PUT_OK, result);
}

关键点:

  • 同步刷盘和同步复制都是阻塞等待的(通过 CountDownLatch 或 CompletableFuture)
  • 超时时间默认 5 秒(syncFlushTimeout),可调整
  • 如果超时,返回失败状态,Producer 需要重试

DLedger 的日志提交

DLedger 模式下,写入流程变为:

java 复制代码
// DLedgerCommitLog.java
public CompletableFuture<PutMessageResult> asyncPutMessage(MessageExtBrokerInner msg) {
    // 构造 DLedger Entry
    DLedgerEntry entry = encodeToDLedgerEntry(msg);
    
    // Append 到 DLedger(自动触发 Raft 复制)
    return dLedgerServer.appendAsLeader(entry)
        .thenApply(response -> {
            if (response.getCode() == DLedgerResponseCode.SUCCESS) {
                return new PutMessageResult(PutMessageStatus.PUT_OK, ...);
            } else {
                return new PutMessageResult(PutMessageStatus.SERVICE_NOT_AVAILABLE, ...);
            }
        });
}

DLedger 内部会处理 Raft 复制、多数派确认、日志对齐等逻辑,业务侧只需要配置副本数量和超时时间。


3.4 消息重复与幂等性

高可用 = 重试 + 多副本,重复消息不可避免。Kafka 和 RocketMQ 都提供了一些手段减少重复,但端到端的幂等还是要业务自己保证。

Kafka:幂等 Producer + 事务消息

幂等 Producer(PID + Sequence Number)

Kafka 0.11+ 引入了幂等 Producer,解决"网络重传导致重复"的问题。

开启方式

properties 复制代码
# Producer 配置
enable.idempotence=true

开启后,Producer 会被分配一个唯一的 PID(Producer ID),每条消息带上递增的 Sequence Number。Broker 端会缓存最近的 Sequence,重复消息会被直接丢弃。

源码逻辑(Broker 端):

scala 复制代码
// Partition.scala
def checkDuplicates(producerId: Long, sequence: Int): Boolean = {
  val lastSeq = producerStateManager.lastSequence(producerId)
  
  if (sequence <= lastSeq) {
    // 重复消息,返回成功但不写入
    return true
  }
  
  if (sequence != lastSeq + 1) {
    // 序号不连续,说明有消息丢失,报错
    throw new OutOfOrderSequenceException(...)
  }
  
  producerStateManager.updateSequence(producerId, sequence)
  return false
}

限制

  • 只能保证单个 Producer 实例、单个 Partition 的幂等
  • 跨 Partition 或多个 Producer 实例不保证
  • Producer 重启后 PID 会变,无法跨会话去重
事务消息(Transactional Producer)

Kafka 的事务消息通过两阶段提交(2PC)实现 Exactly Once。

使用方式

java 复制代码
Properties props = new Properties();
props.put("transactional.id", "my-transactional-id");
props.put("enable.idempotence", "true");

KafkaProducer<String, String> producer = new KafkaProducer<>(props);

producer.initTransactions();

try {
    producer.beginTransaction();
    producer.send(new ProducerRecord<>("topic1", "msg1"));
    producer.send(new ProducerRecord<>("topic2", "msg2"));
    producer.commitTransaction();
} catch (Exception e) {
    producer.abortTransaction();
}

事务协调器(Transaction Coordinator)

Kafka 内部有个 __transaction_state Topic,存储事务状态:

yaml 复制代码
TransactionId -> {
  producerId: 12345,
  producerEpoch: 0,
  state: Ongoing | PrepareCommit | PrepareAbort | CompleteCommit | CompleteAbort,
  partitions: [topic1-0, topic2-1],
  txnStartTimestamp: 1700000000
}

2PC 流程

  1. Begin:Producer 向 Coordinator 注册事务
  2. Prepare:Producer 发送消息到各个 Partition,但标记为"事务中"
  3. Commit :Producer 调用 commitTransaction(),Coordinator 写入 PrepareCommit 状态
  4. Complete :Coordinator 向所有 Partition 发送 WriteTxnMarker,标记事务完成
  5. Cleanup:清理事务状态

Consumer 端

Consumer 需要配置 isolation.level=read_committed,这样只会读到已提交的事务消息。

性能影响

事务消息的性能会下降 20~30%,因为:

  • 需要写入事务状态到 __transaction_state
  • 需要等待所有 Partition 确认
  • Consumer 需要过滤未提交的消息

RocketMQ:半消息 + 本地事务 + 回查

RocketMQ 的事务消息走另一条路:先发半消息(对 Consumer 不可见),业务执行本地事务,再决定提交还是回滚。

流程

  1. 发送半消息 :Producer 调用 sendMessageInTransaction(),Broker 写入半消息到特殊 Topic RMQ_SYS_TRANS_HALF_TOPIC
  2. 执行本地事务:Producer 执行本地事务逻辑(如扣减库存、更新订单)
  3. 提交/回滚:根据本地事务结果,Producer 告诉 Broker 提交或回滚
  4. Broker 处理:提交则把半消息移到正常 Topic,回滚则删除半消息
  5. 回查:如果 Broker 长时间没收到结果,会回查 Producer 本地事务状态

源码实现(发送半消息):

java 复制代码
// TransactionalMessageBridge.java
public PutMessageResult putHalfMessage(MessageExtBrokerInner msgInner) {
    // 改写 Topic 为半消息 Topic
    msgInner.setTopic(TransactionalMessageUtil.buildHalfTopic());
    msgInner.setQueueId(0);
    
    // 写入 CommitLog
    return commitLog.putMessage(msgInner);
}

回查机制

Broker 有个定时任务 TransactionalMessageCheckService,每分钟扫一次半消息:

java 复制代码
// TransactionalMessageCheckService.java
public void check() {
    // 从半消息 Topic 拉取消息
    List<MessageExt> halfMessages = pullHalfMessages();
    
    for (MessageExt msg : halfMessages) {
        if (System.currentTimeMillis() - msg.getStoreTimestamp() > checkImmunityTime) {
            // 超时未收到提交/回滚,回查 Producer
            checkProducer(msg);
        }
    }
}

回查最多 15 次(可配置),超过次数会移到死信队列。

对比

特性 Kafka 事务 RocketMQ 事务
实现方式 2PC + 事务协调器 半消息 + 回查
适用场景 多 Topic 原子写入 本地事务 + 消息发送
性能影响 20~30% 15~20%
运维复杂度 中等 较低

Kafka 的事务更适合 Kafka Streams 这种"读一个 Topic、写多个 Topic"的场景;RocketMQ 的事务更适合"扣库存 + 发消息"这种业务场景。

业务侧幂等的典型做法

无论用哪种 MQ,业务侧的幂等是逃不掉的。几个常见手段:

  1. 数据库唯一索引
sql 复制代码
CREATE TABLE `order_message` (
  `msg_id` varchar(64) PRIMARY KEY,
  `order_id` varchar(32),
  `status` tinyint,
  `create_time` datetime,
  UNIQUE KEY `uk_msg_id` (`msg_id`)
) ENGINE=InnoDB;

插入时如果 msg_id 重复,会报 DuplicateKeyException,业务直接忽略即可。

  1. Redis SET
java 复制代码
String key = "msg:processed:" + msgId;
if (redis.setnx(key, "1", 3600)) {
    // 第一次处理
    processBusiness(msg);
} else {
    // 重复消息,跳过
    log.warn("Duplicate message: {}", msgId);
}
  1. 状态机 + 乐观锁
java 复制代码
// 查询当前状态
Order order = orderMapper.selectById(orderId);

if (order.getStatus() != UNPAID) {
    // 已处理过,跳过
    return;
}

// 更新状态,带版本号
int rows = orderMapper.updateStatus(orderId, PAID, order.getVersion());
if (rows == 0) {
    // 并发冲突,重试或放弃
    throw new ConcurrentModificationException();
}

实战经验:

  • 幂等键要选对,订单号、支付流水号等天然唯一的业务主键最靠谱。
  • Redis 去重要设置过期时间,防止内存爆炸。
  • 状态机设计要严谨,避免出现"不可逆状态"的逻辑漏洞。

3.5 容灾与备份

单机房的高可用只能防"机器挂""程序挂",防不了"机房断电""网络割接"。跨机房容灾是更高层次的可用性保障。

Kafka 的跨机房方案

MirrorMaker 2.0

MirrorMaker 是 Kafka 官方的跨集群复制工具。2.0 版本重写了架构,支持双向同步、Topic 白名单、ACL 同步等功能。

工作原理

MirrorMaker 本质上是"消费源集群 + 写入目标集群"的桥接器。

css 复制代码
Source Cluster (A 机房)
    ↓ Consumer
MirrorMaker
    ↓ Producer
Target Cluster (B 机房)

配置示例

properties 复制代码
# 源集群
source.bootstrap.servers=kafka-a:9092
source.cluster.alias=cluster-a

# 目标集群
target.bootstrap.servers=kafka-b:9092
target.cluster.alias=cluster-b

# Topic 白名单
topics=order.*, payment.*
topics.blacklist=test.*

# 同步配置
sync.topic.configs.enabled=true
sync.topic.acls.enabled=true

双向同步

如果需要双活,可以部署两个 MirrorMaker,A→B 和 B→A 同时跑。但要注意:

  • 消息可能循环复制(A 写的消息被复制到 B,B 又复制回 A)
  • 需要在消息头里加标记,防止死循环

性能调优

  • tasks.max:MirrorMaker 的并行度,建议设置为源集群 Partition 数的 1~2 倍
  • offset.lag.max:复制延迟阈值,超过会触发告警
  • replication.factor:目标集群的副本数,建议与源集群一致
Cluster Linking(Confluent 商用特性)

Confluent Platform 提供的 Cluster Linking 功能更强:

  • 支持实时双向同步
  • 支持 Schema Registry 同步
  • 支持细粒度的流控和限速

但这是商用版,开源版用不了。

RocketMQ 的跨机房方案

同城双活:双集群 + 双写

常见做法是部署两套 RocketMQ 集群,业务双写:

java 复制代码
// Producer 双写
void sendMessage(Message msg) {
    try {
        producerA.send(msg);  // 写 A 机房
    } catch (Exception e) {
        log.error("Send to cluster A failed", e);
    }
    
    try {
        producerB.send(msg);  // 写 B 机房
    } catch (Exception e) {
        log.error("Send to cluster B failed", e);
    }
}

问题:

  • 双写会增加延迟
  • 两个集群的 Offset 不一致,Consumer 切换时需要重新定位

优化方案:

  • 异步双写:先写主集群,成功后异步写备集群
  • 消息轨迹:通过消息 ID 关联两侧的消息,方便排查
DLedger 跨机房

如果网络延迟 < 5ms(同城专线),可以用 DLedger 把副本分布到不同机房:

properties 复制代码
# DLedger 配置
dLedgerPeers=n0-192.168.1.10:40911;n1-192.168.2.10:40911;n2-192.168.3.10:40911

n0 在 A 机房,n1 在 B 机房,n2 在 C 机房。任意机房故障,剩余两个机房仍能达成多数派。

注意事项

  • 跨机房延迟会影响 Raft 选举和日志复制,RTT > 10ms 不建议用这种方案
  • 网络抖动可能导致频繁选举,需要调整 heartBeatTimeIntervalMs(默认 1000ms)
消息备份与恢复

RocketMQ 的 CommitLog 是独立文件,可以直接拷贝到对象存储做备份:

bash 复制代码
# 定时备份脚本
rsync -avz /data/rocketmq/store/commitlog/ \
  s3://backup-bucket/commitlog/$(date +%Y%m%d)/

恢复时:

  1. 拷贝 CommitLog 文件到新 Broker
  2. 启动 Broker,自动重建 ConsumeQueue
  3. 验证消息可消费

这个方案的好处是简单粗暴,适合"定期全量备份"。缺点是恢复时间长(需要重建索引)。

容灾演练的关键动作

容灾方案写得再漂亮,不演练都是纸面文章。几个必做的演练项:

  1. 机房断电演练

    • 强制关闭一个机房的所有 Broker
    • 验证另一侧集群能否自动接管流量
    • 验证数据是否丢失
  2. 网络隔离演练

    • 用 iptables 模拟机房之间网络中断
    • 验证 Raft 或主从复制的表现
    • 验证 Producer/Consumer 的切换逻辑
  3. 全量切流演练

    • 把所有 Producer/Consumer 从 A 集群切到 B 集群
    • 验证切换时间、消息丢失、重复消费等
    • 验证回滚方案是否可用

演练后一定要复盘:

  • 实际恢复时间 vs 预期时间
  • 哪些步骤卡住了
  • 监控告警是否及时
  • 有没有遗漏的检查项

总结

高可用是个系统工程,涉及架构、配置、监控、流程等多个层面。Kafka 和 RocketMQ 各有优势:

Kafka

  • 去中心化 + ISR 机制,适合高吞吐、动态扩容的场景
  • KRaft 模式大幅提升故障感知速度和元数据性能
  • 事务消息适合流处理,但业务应用需要仔细设计

RocketMQ

  • 主备明确 + 刷盘/复制组合,适合对一致性要求高的业务
  • DLedger 实现自动切换,降低运维成本
  • 事务消息适合"本地事务 + 消息发送"的场景

落地建议

  1. 架构选型:根据业务特点选择合适的副本模型和一致性策略
  2. 参数调优acksmin.insync.replicasreplica.lag.time.max.msheartbeatBrokerInterval 等要结合网络质量调整
  3. 监控告警:分层监控(基础/服务/业务),告警分级(P0/P1/P2)
  4. 容灾演练:定期演练,复盘改进,直到"一键切换"

高可用没有捷径,只有把这些基础打牢,才能在故障来临时从容应对。 ``

相关推荐
Lear2 小时前
如何解决MySQL唯一索引与逻辑删除冲突
后端
一 乐3 小时前
游戏助手|游戏攻略|基于SprinBoot+vue的游戏攻略系统小程序(源码+数据库+文档)
数据库·vue.js·spring boot·后端·游戏·小程序
方圆想当图灵3 小时前
Nacos 源码深度畅游:注册中心核心流程详解
分布式·后端·github
逸风尊者3 小时前
开发需掌握的知识:高精地图
人工智能·后端·算法
q***31893 小时前
Spring Boot--@PathVariable、@RequestParam、@RequestBody
java·spring boot·后端
CodeAmaz3 小时前
使用责任链模式设计电商下单流程(Java 实战)
java·后端·设计模式·责任链模式·下单
IT_陈寒4 小时前
Spring Boot 3.2震撼发布:5个必知的新特性让你开发效率提升50%
前端·人工智能·后端
李慕婉学姐4 小时前
Springboot加盟平台推荐可视化系统ktdx2ldg(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面。
数据库·spring boot·后端
Victor3564 小时前
Redis(127)Redis的内部数据结构是什么?
后端