前六篇走完了生产和消费两端。这一篇进入 Kafka 的高可用核心:副本机制。

副本机制容易被简化成"多写几份"。更准确的说法是:Kafka 在每个 partition 级别维护一组副本,用 ISR(In-Sync Replicas)动态集合代替固定多数派投票,在可用性和持久性之间做出了一系列显式的折中。

本文只抓一个问题:一条消息从被 leader 接收到被所有 ISR 副本确认,中间经历了哪些步骤,以及这些步骤中的每一个参数如何影响数据安全。

副本模型

Kafka 的副本模型是 per-partition 的 leader-follower 结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Producer


┌──────────────┐
│ Partition-0
│ Leader (B-0) │ ◄── 所有读写都经过 leader
[0][1][2][3]
└──────┬───────┘
│ FetchRequest (replicaId=1)

┌──────────────┐
│ Follower │
│ (B-1) │
[0][1][2]
└──────────────┘
│ FetchRequest (replicaId=2)

┌──────────────┐
│ Follower │
│ (B-2) │
[0][1][2]
└──────────────┘

每个 partition 有一个 leader replica 和零到多个 follower replica。Producer 只向 leader 写入,consumer 默认也只从 leader 读取(自 Kafka 2.4 起,KIP-392 允许 consumer 从最近的 follower 读取,但 leader 仍然是写入的唯一入口)。

Follower 不是被动接收推送的。每个 follower 主动向 leader 发送 FetchRequest,请求体中携带 replicaId 字段标识自己是副本而非普通 consumer。Leader 根据 replicaId 区分副本 fetch 和消费者 fetch,并据此更新 ISR 状态。

ISR:动态的同步副本集合

ISR 是一个动态集合,包含 leader 自身以及所有"跟得上"的 follower。"跟得上"的判定标准是 replica.lag.time.max.ms(默认 30 秒):如果一个 follower 在这个时间窗口内没有发送 fetch 请求追上 leader 的 LEO(Log End Offset),它就会被移出 ISR。

ISR 的收缩和扩展过程:

1
2
3
4
5
6
7
8
9
10
11
12
初始状态:  ISR = {B-0, B-1, B-2}
replica.lag.time.max.ms = 30s
B-2 停止 fetch

30s 后
ISR 收缩: ISR = {B-0, B-1} ← Controller 更新元数据
所有 broker 收到 LeaderAndIsr 请求

B-2 恢复 fetch 并追上 LEO


ISR 扩展: ISR = {B-0, B-1, B-2} ← leader 将 B-2 重新加入 ISR

早期版本(0.9 之前)同时使用 replica.lag.max.messages 按消息条数判定滞后。这个参数在高吞吐场景下容易误判:一次大批量写入就可能让所有 follower 瞬间"滞后"数千条消息,导致 ISR 频繁抖动。自 0.9 版本起,Kafka 移除了消息条数判定,只保留时间窗口判定。

High Watermark 与 LEO

每个 partition 有两个关键偏移量:

  • LEO(Log End Offset):leader 日志中下一条待写入的偏移量,即已写入消息的最大偏移量加一。
  • HW(High Watermark):所有 ISR 副本都已复制到的最大偏移量。Consumer 只能读到 HW 以下的消息。
1
2
3
4
5
6
7
8
9
Leader 日志:
offset: [0] [1] [2] [3] [4] [5]
▲ ▲
HW=4 LEO=6

Follower-1: [0] [1] [2] [3] LEO=4
Follower-2: [0] [1] [2] [3] LEO=4

HW = min(Leader.LEO, Follower-1.LEO, Follower-2.LEO) = 4

HW 的更新时机:leader 在处理 follower 的 FetchResponse 时,根据所有 ISR 副本的 LEO 取最小值来推进 HW。这意味着 HW 的推进天然滞后于 leader 的写入——offset 4 和 5 的消息已经写入 leader,但 consumer 还看不到。

这个滞后是有意设计的。如果 leader 在 offset 5 写入后立刻宕机,新 leader(从 ISR 中选出)的日志截止到 offset 3,consumer 不会读到后来丢失的数据。

Leader Epoch 防日志分叉

HW 机制有一个边界场景:follower 在重启后,如果用本地 HW 截断日志,可能丢弃已经提交的数据。KIP-101 引入了 leader epoch 来解决这个问题。

Leader epoch 是一个单调递增的整数,每次 leader 切换时加一。每个 replica 本地维护一份 leader-epoch-checkpoint 文件,记录每个 epoch 对应的起始 offset。

故障恢复流程:

1
2
3
4
5
6
1. Follower 重启
2. 向 leader 发送 OffsetsForLeaderEpochRequest
携带本地最新 epoch
3. Leader 返回该 epoch 的结束 offset
4. Follower 截断到该 offset,而非本地 HW
5. 继续正常 fetch

与单纯依赖 HW 截断相比,leader epoch 把截断决策从"本地记忆"变成了"向当前 leader 确认",避免了两个 follower 各自按旧 HW 截断后日志不一致的问题。

acks 与 min.insync.replicas 的配合

Producer 的 acks 参数控制写入确认的强度:

acks 值 含义 持久性 延迟
0 不等待任何确认 可能丢消息 最低
1 leader 写入本地日志后确认 leader 宕机可能丢 中等
all (-1) 等待所有 ISR 副本确认 ISR 全部确认才返回 较高

acks=all 的持久性保证取决于 ISR 的大小。如果 ISR 缩减到只剩 leader 自己,acks=all 退化为 acks=1。

min.insync.replicas 是这个问题的兜底参数:它规定了 acks=all 时 ISR 的最小成员数。当 ISR 成员数低于 min.insync.replicas,producer 写入会收到 NotEnoughReplicasException。

典型的生产配置:

1
2
3
4
5
6
# broker 端
default.replication.factor=3
min.insync.replicas=2

# producer 端
acks=all

这组配置的含义:3 个副本,至少 2 个在 ISR 中才允许写入,写入需要等待所有 ISR 成员确认。容忍 1 个 broker 宕机,宕机第 2 个后拒绝写入而非丢数据。

Unclean Leader Election

当 ISR 中所有副本都不可用时,Kafka 面临一个选择:

  • 等待 ISR 中的某个副本恢复(可能等很久,分区不可写不可读)。
  • 从 OSR(Out-of-Sync Replicas)中选一个副本当 leader(立刻恢复服务,但可能丢数据)。

unclean.leader.election.enable 控制这个行为。默认值从 Kafka 0.11.0 起改为 false,即默认不允许脏选举。

数据丢失的原理:OSR 副本的日志落后于原 leader,它成为新 leader 后,落后的那部分数据永久丢失。已经消费过这些数据的 consumer 也无法重新消费。

选择脏选举意味着把可用性置于持久性之上。对于日志类、指标类等可容忍少量数据丢失的场景,开启脏选举可以避免分区长时间不可用。

实验:观察 ISR 变化与写入失败

启动一个 3 broker 的 Kafka 集群,创建一个 replication-factor=3、min.insync.replicas=2 的 topic:

1
2
3
4
5
6
7
8
9
# 创建 topic
kafka-topics.sh --bootstrap-server localhost:9092 \
--create --topic isr-test \
--partitions 1 --replication-factor 3 \
--config min.insync.replicas=2

# 查看初始 ISR
kafka-topics.sh --bootstrap-server localhost:9092 \
--describe --topic isr-test

输出类似:

1
Topic: isr-test  Partition: 0  Leader: 0  Replicas: 0,1,2  Isr: 0,1,2

关闭 broker-2:

1
2
3
4
5
6
# 停止 broker-2
kafka-server-stop.sh # 在 broker-2 节点执行

# 等待 30 秒后再次查看
kafka-topics.sh --bootstrap-server localhost:9092 \
--describe --topic isr-test

预期输出:

1
Topic: isr-test  Partition: 0  Leader: 0  Replicas: 0,1,2  Isr: 0,1

ISR 从 {0,1,2} 收缩为 {0,1}。此时 producer 仍然可以正常写入,因为 ISR 有 2 个成员,满足 min.insync.replicas=2。

继续关闭 broker-1:

1
2
3
4
5
6
# 停止 broker-1

# 尝试写入
kafka-console-producer.sh --bootstrap-server localhost:9092 \
--topic isr-test \
--producer-property acks=all

此时写入会收到错误:

1
2
ERROR [Producer clientId=console-producer] ... NotEnoughReplicasException:
Messages are rejected since there are fewer in-sync replicas than required.

ISR 只剩 leader 自己(1 个成员),低于 min.insync.replicas=2,写入被拒绝。

实验结果映射

这个实验展示了几个内部机制的联动:

  1. ISR 收缩由 leader 判定。Leader 发现 broker-2 超过 replica.lag.time.max.ms 没有 fetch,将其移出 ISR,并通知 controller 更新元数据。
  2. min.insync.replicas 是写入时的检查。Leader 在接收到 acks=all 的写入请求时,检查当前 ISR 大小是否满足 min.insync.replicas,不满足则拒绝写入。
  3. Replicas 列表不变。Replicas 是 topic 创建时分配的静态副本集,不因 broker 宕机而改变。ISR 是 Replicas 的动态子集。

恢复 broker-1 和 broker-2 后,它们会自动从 leader fetch 数据,追上 LEO 后被重新加入 ISR。这个过程不需要人工干预。

模式提炼

Kafka 的副本机制是一种无投票的动态副本集复制(quorum-less replication with dynamic replica set)。

与 Raft/Paxos 的多数派投票不同,Kafka 不要求"过半节点同意"。ISR 可以缩减到只剩 leader 一个节点,此时单个节点的确认就算"全部确认"。这个设计的代价是依赖 min.insync.replicas 作为外部约束来防止退化。

这种模式的优势在于灵活性:3 副本配置下,Raft 只能容忍 1 个节点故障(需要 2/3 存活),而 Kafka 在 min.insync.replicas=1 时可以容忍 2 个节点故障(只要 leader 存活就能写入)。代价是放弃了持久性保证。

在工程实践中,这种模式要求运维人员理解 min.insync.replicas 和 acks 的组合含义,并根据业务的持久性要求做出显式选择。

工程迁移表

概念 Kafka ISR Raft 多数派 RocketMQ 同步/异步复制 MySQL 半同步复制
副本集定义 ISR 动态集合 固定成员 + 多数派 Master-Slave 固定关系 Master-Slave 固定关系
写入确认 acks=all 等待 ISR 全部确认 多数派确认即提交 syncFlush + SYNC_MASTER 等待 slave 至少一个 slave 确认
成员变更 ISR 自动收缩/扩展 配置变更协议 手动运维 手动运维
容错能力(3 副本) min.insync.replicas=2 时容 1 故障 容 1 故障(需 2/3 存活) 同步复制容 0 故障,异步容 1 半同步退化后容 1
数据安全底线 min.insync.replicas Raft 协议保证 flushDiskType 配置 rpl_semi_sync_master_wait_point
脏选举 unclean.leader.election.enable 协议不允许 允许(异步复制时) 允许(异步退化时)

常见误解

ISR 是一种投票机制。ISR 不是投票。Raft 和 Paxos 需要收集多数派的投票来达成共识,投票的阈值是固定的(N/2+1)。ISR 是一个动态集合,写入确认的条件是"ISR 中所有成员都收到了",而不是"收集到了某个比例的投票"。ISR 可以缩减到 1 个成员,这在投票机制中没有对应概念。

High watermark 等于最新 offset。HW 等于 ISR 中所有副本 LEO 的最小值,始终小于或等于 leader 的 LEO。Leader 的 LEO 减去 HW 的差值就是"已写入但尚未提交"的消息数量。Consumer 只能读到 HW 以下的消息,这是 Kafka 保证消费一致性的基础。

acks=all 会显著降低吞吐量。acks=all 增加的是延迟而非降低吞吐量。Leader 在写入本地日志后不需要阻塞等待——它在后续 follower 的 FetchResponse 处理中更新 HW,然后才向 producer 返回确认。在副本分布合理、网络正常的集群中,acks=all 与 acks=1 的吞吐量差距通常在 10% 以内,主要差异体现在 p99 延迟上。

练习

  1. 在 3 broker 集群中创建 replication-factor=3 的 topic,分别用 acks=0、acks=1、acks=all 发送 10 万条消息,对比三种模式的吞吐量和 p99 延迟。
  2. 将 min.insync.replicas 设为 3(等于 replication-factor),然后关闭一个 broker,观察 producer 行为。思考:min.insync.replicas=replication-factor 在生产环境中是否合理?
  3. 开启 unclean.leader.election.enable,复现一次脏选举导致数据丢失的场景。提示:先让一个 follower 落后,然后让 leader 和另一个 follower 同时宕机。
  4. kafka-log-dirs.sh 观察各副本的 LEO 和 size,验证 follower 的复制进度。

系列导航

序号 主题
00 导读:为什么 Kafka 的核心是一根日志
01 架构总览:Broker、Controller 与元数据管理
02 日志存储:Segment、Index 与零拷贝
03 Producer 内部机制:攒批、分区与 acks
04 幂等 Producer 与序列号:消息不重不丢的第一层
05 Consumer Group 协议:分配、重平衡与静态成员
06 Offset 管理:提交、重置与消费语义
07 副本与 ISR:高可用的代价和折中
08 Controller 与 KRaft:从 ZooKeeper 到内置共识
09 Exactly-Once 与事务:跨 partition 的原子写入
10 日志压缩:把 topic 当 KV 表用
11 Kafka Streams:在日志之上构建流处理
12 Kafka Connect:标准化的数据管道
13 Schema Registry 与数据治理:给消息加上契约
14 性能模型:吞吐、延迟与调优思路
15 安全体系:认证、授权与加密
16 生产运维:集群扩缩、监控指标与故障排查
17 Kafka 与 RocketMQ:两种消息系统的设计选择

参考资料