副本与 ISR:高可用的代价和折中
前六篇走完了生产和消费两端。这一篇进入 Kafka 的高可用核心:副本机制。
副本机制容易被简化成"多写几份"。更准确的说法是:Kafka 在每个 partition 级别维护一组副本,用 ISR(In-Sync Replicas)动态集合代替固定多数派投票,在可用性和持久性之间做出了一系列显式的折中。
本文只抓一个问题:一条消息从被 leader 接收到被所有 ISR 副本确认,中间经历了哪些步骤,以及这些步骤中的每一个参数如何影响数据安全。
副本模型
Kafka 的副本模型是 per-partition 的 leader-follower 结构:
1 | |
每个 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 | |
早期版本(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 | |
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 | |
与单纯依赖 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 | |
这组配置的含义: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 | |
输出类似:
1 | |
关闭 broker-2:
1 | |
预期输出:
1 | |
ISR 从 {0,1,2} 收缩为 {0,1}。此时 producer 仍然可以正常写入,因为 ISR 有 2 个成员,满足 min.insync.replicas=2。
继续关闭 broker-1:
1 | |
此时写入会收到错误:
1 | |
ISR 只剩 leader 自己(1 个成员),低于 min.insync.replicas=2,写入被拒绝。
实验结果映射
这个实验展示了几个内部机制的联动:
- ISR 收缩由 leader 判定。Leader 发现 broker-2 超过 replica.lag.time.max.ms 没有 fetch,将其移出 ISR,并通知 controller 更新元数据。
- min.insync.replicas 是写入时的检查。Leader 在接收到 acks=all 的写入请求时,检查当前 ISR 大小是否满足 min.insync.replicas,不满足则拒绝写入。
- 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 延迟上。
练习
- 在 3 broker 集群中创建 replication-factor=3 的 topic,分别用 acks=0、acks=1、acks=all 发送 10 万条消息,对比三种模式的吞吐量和 p99 延迟。
- 将 min.insync.replicas 设为 3(等于 replication-factor),然后关闭一个 broker,观察 producer 行为。思考:min.insync.replicas=replication-factor 在生产环境中是否合理?
- 开启 unclean.leader.election.enable,复现一次脏选举导致数据丢失的场景。提示:先让一个 follower 落后,然后让 leader 和另一个 follower 同时宕机。
- 用 kafka-log-dirs.sh 观察各副本的 LEO 和 size,验证 follower 的复制进度。
系列导航
参考资料
- Apache Kafka 官方文档 - Replication:https://kafka.apache.org/documentation/#replication
- KIP-101 - Alter Replication Protocol to use Leader Epoch rather than High Watermark for Truncation:https://cwiki.apache.org/confluence/display/KAFKA/KIP-101+-+Alter+Replication+Protocol+to+use+Leader+Epoch+rather+than+High+Watermark+for+Truncation
- KIP-392 - Allow consumers to fetch from closest replica:https://cwiki.apache.org/confluence/display/KAFKA/KIP-392%3A+Allow+consumers+to+fetch+from+closest+replica
- Neha Narkhede, Gwen Shapira, Todd Palino. Kafka: The Definitive Guide. O’Reilly. Chapter 5: Reliable Data Delivery.
- Jun Rao. How Kafka’s Storage Internals Work. Confluent Blog:https://www.confluent.io/blog/kafka-replication/
