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.scala 的 maybeExpandIsr() 和 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. 参数调优 :acks、min.insync.replicas、replica.lag.time.max.ms、heartbeatBrokerInterval 等要结合网络质量调整
  3. 监控告警:分层监控(基础/服务/业务),告警分级(P0/P1/P2)
  4. 容灾演练:定期演练,复盘改进,直到"一键切换"

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

相关推荐
子兮曰3 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰3 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
爱勇宝3 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
胡写代码3 天前
别再前后端各写一套表单校验了
java·后端
大勇前进3 天前
原生 PHP 还是 Laravel?小项目到底要不要上框架
后端
yuzhi_liu3 天前
我用 LangGraph4j 实现 Multi-Agent Supervisor
后端
alsmile3 天前
Node-RED 之外,国产规则引擎的新方案:基于标准语法,Go 先行实现
后端·开源·go
大白803 天前
PHP 内存溢出排查思路:看懂报错日志,精准定位问题
后端
二月龙3 天前
PHP 接口返回统一响应封装,让前后端对接更省心
后端
盖伦发发3 天前
软件工程SOLID 五大设计原则
后端·软件工程