第三篇:高可用机制深度剖析
高可用这事儿,从来不是靠某一个参数"调优"就能解决的。它背后是副本协议、故障检测、自动切换、数据对齐这一整套机制。本文会从 Kafka 和 RocketMQ 的源码实现入手,把 ISR、HW/LEO、Raft、主从复制等关键路径拆开讲,同时给出真实场景下的配置策略和踩坑经验。
3.1 高可用设计理念
先理清概念:高可用(HA)的目标是"尽可能减少不可用时间"。业界常用的指标是:
- 99.9%:年不可用时间约 8.76 小时(一般业务可接受)
- 99.99%:年不可用时间约 52.6 分钟(金融、支付等核心系统)
- 99.999%:年不可用时间约 5.26 分钟(运营商级别)
要做到 4 个 9,必须在架构、流程、工具三个层面同时投入:
- 架构层:多副本、跨机房、自动故障转移
- 流程层:变更审批、容量规划、故障演练
- 工具层:监控告警、自动化运维、回滚机制
Kafka 和 RocketMQ 在这三个维度的投入方式不太一样。
Kafka:去中心 + ISR + Controller
Kafka 的架构哲学是"去中心化":每个 Broker 都可能是某些 Partition 的 Leader,也可能是另一些 Partition 的 Follower。这种设计带来两个好处:
- 负载均衡天然:Leader 分散在各个 Broker,不会所有压力集中在某台机器。
- 扩展性强:加一台 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_MASTERvsASYNC_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)
}
}
核心逻辑:
- 遍历所有 ISR 副本,检查
lastCaughtUpTimeMs(最后一次追上 Leader 的时间)。 - 如果
now - lastCaughtUpTimeMs > replica.lag.time.max.ms,踢出 ISR。 - 更新本地 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)
}
}
核心逻辑:
- 找出所有"不在 ISR 中"的副本。
- 如果某个副本的 LEO 已经追上 Leader 的 HW,说明它已经同步完成,可以加入 ISR。
- 更新 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 中。
选举步骤:
-
触发条件:
- Broker 下线(ZK session 超时 / KRaft 心跳超时)
- Leader 所在 Broker 下线
- 手动触发 Preferred Leader Election
-
选举策略:
- 从当前 ISR 列表中选择第一个可用的副本
- 如果 ISR 为空且
unclean.leader.election.enable=true,从所有副本中选择 LEO 最大的(可能丢数据) - 如果 ISR 为空且不允许 Unclean,Partition 进入离线状态
-
元数据更新:
- Controller 更新 Leader Epoch(防止脑裂)
- 通知新 Leader 开始服务
- 通知其他 Broker 更新元数据
-
通知 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。
流程:
- Broker 向 Controller 发送
ControlledShutdownRequest - Controller 把该 Broker 上所有 Leader Partition 迁移到其他副本
- 迁移完成后,Broker 关闭网络监听,等待剩余请求处理完
- 最后关闭磁盘刷盘、日志清理等后台线程
配置:
properties
# 开启优雅停机
controlled.shutdown.enable=true
# 最大重试次数
controlled.shutdown.max.retries=3
# 重试间隔
controlled.shutdown.retry.backoff.ms=5000
实战中,优雅停机能减少 Partition 不可用时间(从 10 秒降到 < 1 秒)。但如果网络有问题,可能卡在"等待 Leader 迁移"阶段,需要设置超时。
RocketMQ 的故障检测链路
Broker 心跳机制
RocketMQ 的心跳分两类:
- Broker → NameServer:每 30 秒上报路由信息
- 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 更新流程:
- Producer 发送消息到 Leader,Leader 写入本地日志,LEO 变为 101。
- Follower 通过 Fetch 请求拉取数据,写入本地日志,各自 LEO 更新。
- Follower 在下一次 Fetch 时带上自己的 LEO,Leader 据此更新 HW。
- HW 更新后,Leader 在 FetchResponse 中返回新的 HW 给 Follower,Follower 也更新本地 HW。
这个流程有个特点:HW 的更新需要两轮 Fetch。这在旧版本中会导致数据截断问题,新版本通过 Leader Epoch 修复。
Leader Epoch 机制
Leader Epoch 是 Kafka 2.5+ 引入的机制,用于解决 HW 更新滞后导致的数据截断问题。
问题场景:
- Leader A,Follower B,ISR = [A, B]
- Leader A 写入 Offset 100~102,LEO = 103,但 HW 还是 100(需要等 B 拉取)
- B 拉取数据,LEO = 103,但本地 HW 还是 100(需要等下一轮 Fetch)
- 此时 A 宕机,B 当选 Leader。但 B 的 HW 是 100,所以 B 会截断 101~102 的数据
- 如果 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(异步复制):
- Producer 发消息到 Master
- Master 写入 CommitLog,立即返回成功
- HAService 后台线程异步把 CommitLog 复制到 Slave
这种模式性能最高,但 Master 宕机会丢失"未复制的部分"。
SYNC_MASTER(同步复制):
- Producer 发消息到 Master
- Master 写入 CommitLog,等待 Slave 返回 ACK
- Slave 写入成功后返回 ACK
- 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 流程:
- Leader 收到写入请求,分配递增的 Index
- Leader 向所有 Follower 发送 AppendEntriesRequest
- Follower 写入本地日志,返回 ACK
- Leader 收到多数派 ACK 后,提交(commit)该 Entry
- 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 流程:
- Begin:Producer 向 Coordinator 注册事务
- Prepare:Producer 发送消息到各个 Partition,但标记为"事务中"
- Commit :Producer 调用
commitTransaction(),Coordinator 写入PrepareCommit状态 - Complete :Coordinator 向所有 Partition 发送
WriteTxnMarker,标记事务完成 - Cleanup:清理事务状态
Consumer 端:
Consumer 需要配置 isolation.level=read_committed,这样只会读到已提交的事务消息。
性能影响:
事务消息的性能会下降 20~30%,因为:
- 需要写入事务状态到
__transaction_state - 需要等待所有 Partition 确认
- Consumer 需要过滤未提交的消息
RocketMQ:半消息 + 本地事务 + 回查
RocketMQ 的事务消息走另一条路:先发半消息(对 Consumer 不可见),业务执行本地事务,再决定提交还是回滚。
流程:
- 发送半消息 :Producer 调用
sendMessageInTransaction(),Broker 写入半消息到特殊 TopicRMQ_SYS_TRANS_HALF_TOPIC - 执行本地事务:Producer 执行本地事务逻辑(如扣减库存、更新订单)
- 提交/回滚:根据本地事务结果,Producer 告诉 Broker 提交或回滚
- Broker 处理:提交则把半消息移到正常 Topic,回滚则删除半消息
- 回查:如果 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,业务侧的幂等是逃不掉的。几个常见手段:
- 数据库唯一索引:
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,业务直接忽略即可。
- Redis SET:
java
String key = "msg:processed:" + msgId;
if (redis.setnx(key, "1", 3600)) {
// 第一次处理
processBusiness(msg);
} else {
// 重复消息,跳过
log.warn("Duplicate message: {}", msgId);
}
- 状态机 + 乐观锁:
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)/
恢复时:
- 拷贝 CommitLog 文件到新 Broker
- 启动 Broker,自动重建 ConsumeQueue
- 验证消息可消费
这个方案的好处是简单粗暴,适合"定期全量备份"。缺点是恢复时间长(需要重建索引)。
容灾演练的关键动作
容灾方案写得再漂亮,不演练都是纸面文章。几个必做的演练项:
-
机房断电演练:
- 强制关闭一个机房的所有 Broker
- 验证另一侧集群能否自动接管流量
- 验证数据是否丢失
-
网络隔离演练:
- 用 iptables 模拟机房之间网络中断
- 验证 Raft 或主从复制的表现
- 验证 Producer/Consumer 的切换逻辑
-
全量切流演练:
- 把所有 Producer/Consumer 从 A 集群切到 B 集群
- 验证切换时间、消息丢失、重复消费等
- 验证回滚方案是否可用
演练后一定要复盘:
- 实际恢复时间 vs 预期时间
- 哪些步骤卡住了
- 监控告警是否及时
- 有没有遗漏的检查项
总结
高可用是个系统工程,涉及架构、配置、监控、流程等多个层面。Kafka 和 RocketMQ 各有优势:
Kafka:
- 去中心化 + ISR 机制,适合高吞吐、动态扩容的场景
- KRaft 模式大幅提升故障感知速度和元数据性能
- 事务消息适合流处理,但业务应用需要仔细设计
RocketMQ:
- 主备明确 + 刷盘/复制组合,适合对一致性要求高的业务
- DLedger 实现自动切换,降低运维成本
- 事务消息适合"本地事务 + 消息发送"的场景
落地建议:
- 架构选型:根据业务特点选择合适的副本模型和一致性策略
- 参数调优 :
acks、min.insync.replicas、replica.lag.time.max.ms、heartbeatBrokerInterval等要结合网络质量调整 - 监控告警:分层监控(基础/服务/业务),告警分级(P0/P1/P2)
- 容灾演练:定期演练,复盘改进,直到"一键切换"
高可用没有捷径,只有把这些基础打牢,才能在故障来临时从容应对。 ``