主题
Kafka 架构与 ISR
一句话:Kafka 把 Topic 切成有序的分区日志,再用 ISR 在「不拖慢写入」和「不丢已提交消息」之间划一条线。
适用版本:Kafka 3.9 / 4.0(KRaft)。4.0 起不再支持 ZooKeeper。
为什么需要
单机日志有两个硬伤:容量和并行度都锁死在一台机器上,机器一挂数据就没了。
拆成分区解决第一个:每个分区是一条独立追加的日志,分区数就是并行度上限。加副本解决第二个:同一分区在多个 Broker 上各存一份。
但副本一多就出现新问题——什么时候算「写成功」。等所有副本都写完,最慢的那台会拖垮整个分区;只等 Leader 写完,Leader 一挂就丢消息。ISR 就是这个矛盾的解。
核心机制
层次结构
分区数决定消费并行度,且只能增不能减。副本因子不能超过 Broker 数。
创建 Topic 时 Controller 负责分配副本:同一分区的副本不会落在同一 Broker,具体落点受 Broker 排序和随机 shift 影响,不必记公式。
ISR 与水位
| 概念 | 含义 |
|---|---|
| AR | Assigned Replicas,分区的全部分配副本,AR = ISR + OSR |
| ISR | In-Sync Replicas,与 Leader 保持同步的副本集合 |
| OSR | Out-of-Sync Replicas,落后超过阈值的副本 |
| LEO | Log End Offset,每个副本自己的「下一条写入位置」 |
| HW | High Watermark,Leader 维护,等于 ISR 中最小 LEO |
| LSO | Last Stable Offset,事务场景下 read_committed 消费者的可见边界 |
因果链:
- Producer 写入 Leader,Leader 的 LEO 前进。
- Follower 主动 fetch,写本地日志,自己的 LEO 前进。
- Leader 取 ISR 内所有副本 LEO 的最小值作为 HW。
- Consumer 只能读到
offset < HW的消息——这部分一定是 ISR 全员都有的。
关键点在 3:HW 只看 ISR,不看 OSR。慢副本被踢出去之后,HW 立刻能继续推进,写入不被拖累。
ISR 收缩的真实条件
不是「没发 fetch 请求」,而是 Follower 的 LEO 追不上 Leader 的 LEO 超过 replica.lag.time.max.ms(默认 30s)。一个 Follower 一直在 fetch,但每次只拉到旧数据,照样会被踢出 ISR。
被踢出的副本追平后会自动回到 ISR,不需要人工干预。
acks 与 min.insync.replicas 必须成对看
| 配置 | 作用 |
|---|---|
acks=all(3.0 起默认) | Leader 等 ISR 全部写入后才应答 |
min.insync.replicas=2 | ISR 小于 2 时直接拒绝写入 |
unclean.leader.election.enable=false(默认) | 只允许 ISR 内副本当选 Leader |
三者缺一不可:
- 只设
acks=all,不设min.insync.replicas(默认 1),ISR 缩到只剩 Leader 时写入仍然成功,副本等于没有。 - 设了
min.insync.replicas=2,但开了 unclean 选举,ISR 全挂后仍会选出缺数据的副本,之前的「已提交」就自相矛盾。
RF=3 的标准组合是:acks=all + min.insync.replicas=2 + unclean=false。代价是挂 2 台时该分区拒写。
Leader Epoch
0.11 之前,Follower 靠异步传播的 HW 截断日志,可能出现「新 Leader 的 LEO 比旧 Leader 的 HW 还短」,截掉已提交消息。Leader Epoch 给每次换届编号,Follower 带着自己的 epoch 请求,Leader 返回该 epoch 的起始偏移,据此精确截断;现在是默认行为。
Controller
Controller 负责分区 Leader 选举、Broker 上下线、分区重分配、Preferred Leader 选举。
- ZK 模式(≤3.9):Broker 抢
/controller临时节点,「第一个启动的 Broker 自动成为 Controller」只在这里成立。 - KRaft 模式(3.3+ 生产可用,4.0 唯一模式):Controller 节点组成 Raft quorum 选出 active controller,元数据写在
__cluster_metadata日志里。
实战场景
一、acks=all 配了,消息照样丢
现象:对账作业发现某台 Broker 宕机的 3 分钟里有 47 条“写入成功”的订单事件在集群里查不到(经验值,需校准)。Producer 侧没有任何异常,日志里 acks=all 写得清清楚楚。事后看 --describe,那个分区的 Isr 只剩 Leader 一个。
排查:acks=all 的语义是“等 ISR 全员”,而 ISR 是可以缩到只剩 Leader 的。
| 定位层 | 取证据 | 判定 |
|---|---|---|
Topic 侧的 min.insync.replicas | kafka-configs.sh --describe --entity-type topics --entity-name order-events | 值为 1(默认)→ ISR 缩到 Leader 仍接受写入 |
| Producer 的 acks | 客户端配置或 ProducerConfig.ACKS_CONFIG | all 但被上面的默认值架空 |
| 是否允许脏选举 | unclean.leader.election.enable | true → OSR 副本也能当 Leader,已提交数据直接缺 |
| 当时的 ISR | Broker 日志 Shrinking ISR to [...],JMX UnderReplicatedPartitions | 缩到 1 说明当时已经在丢冗余 |
| 恢复后的 LEO | 新 Leader 的 LEO 与旧 Leader 的 HW 比对 | 新 Leader LEO 更短 → 就是这段区间丢了 |
三者的因果是成对的:acks=all 只在 ISR 真的包含副本时才有意义;min.insync.replicas=1 让它退化成 acks=1;开了 unclean 选举,前面的“已提交”自相矛盾。
对症做法:
bash
# RF=3 的标准组合,建 Topic 时定死,事后改只影响新写入
bin/kafka-topics.sh --bootstrap-server broker:9092 --create \
--topic order-events --partitions 12 --replication-factor 3 \
--config min.insync.replicas=2 \
--config unclean.leader.election.enable=false \
--config replication.factor=3
# 已存在的 Topic 逐个补
bin/kafka-configs.sh --bootstrap-server broker:9092 --alter \
--entity-type topics --entity-name order-events \
--add-config min.insync.replicas=2,unclean.leader.election.enable=falseproperties
# Producer 侧保持默认 3.0+ 的 acks=all,并让失败可见
acks=all
enable.idempotence=true # 幂等 Producer,避免重试造成分区内重复
retries=5
delivery.timeout.ms=120000text
代价要提前接受:min.insync.replicas=2 时挂 2 台该分区拒写。
业务侧要能区分“写失败可重试”和“写失败要降级”,
拒写期间把事件落到本地消息表,别把异常吞掉。告警口径:UnderReplicatedPartitions > 0 持续 2 分钟、Isr 数量小于 Replicas 的分区数大于 0,都在丢冗余阶段就该叫人,而不是等宕机。
复盘要点:判据是“线上不存在 min.insync.replicas=1 且 acks=all 的 Topic”——这条能用脚本从 --describe 输出批量核对,纳入集群巡检。把“三件套”写进建 Topic 模板,事后补配只影响新写入、历史数据的可靠性补不回来,这一点在变更说明里要写明白。
二、ISR 每 30 秒抖一次:副本一直 fetch 却追不上
现象:一个存放大报文(压缩包索引,单条约 3 MiB)的 Topic 长期报警:UnderReplicatedPartitions 在 0 和 4 之间周期跳动,Broker 日志反复出现 Shrinking ISR 与 Expanding ISR,间隔约 30 秒。运维最初的判断是“磁盘慢了,要换盘”。
排查:ISR 收缩的真实条件不是“没发 fetch”,而是 LEO 差距超过 replica.lag.time.max.ms。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 副本是否在 fetch | Follower 日志的 fetch 请求频率 | 在 fetch 但 LEO 不前进 → 不是网络断连 |
| 单条消息大小 | kafka-run-class.sh kafka.tools.DumpLogSegments 看 record 大小 | 3 MiB > replica.fetch.max.bytes(默认 1 MiB)→ 永远拉不过去 |
| Producer/Topic 上限 | max.request.size、Topic 的 max.message.bytes | 允许写入大消息但副本拉不动,就是配置不成对 |
| 压缩因素 | compression.type 与 batch | 压缩后 batch 大小与解压后不同,副本侧要能容纳 |
| 磁盘与 GC | iostat 的 util、JMX GcTimeMsPerSecond | 停顿接近 30s 时也会踢副本,需排除 |
| 网络带宽 | 跨机架流量、NetworkTimeoutMs | 追平慢但能追平 → 带宽问题 |
本例命中第二、三行:Topic 的 max.message.bytes 放到 4 MiB,但 replica.fetch.max.bytes 还是 1 MiB,Follower 每次拉到超限就报错重试,LEO 停在原地,30 秒后被踢出;追平后又加回 ISR,循环往复。
对症做法:
properties
# Broker 侧:副本抓取上限要大于 Topic 允许的单条消息上限
replica.fetch.max.bytes=8388608 # 8 MiB
message.max.bytes=8388608
# Topic 侧成对设置,避免只放开一头
bin/kafka-configs.sh --bootstrap-server broker:9092 --alter \
--entity-type topics --entity-name blob-index \
--add-config max.message.bytes=8388608,min.insync.replicas=2properties
# Producer 侧同样成对,否则消息进不来
max.request.size=8388608
linger.ms=20
compression.type=zstd # 大报文压缩能显著降低副本与带宽压力text
比调参更稳的做法:不要把大报文塞进 Kafka。
消息里只放对象存储的引用(key + 大小 + 校验和),
超过 1 MiB 的负载改走存储 + 通知的两段式设计。追平需要时间,恢复期间 Isr 小于 Replicas 属正常;如果 min.insync.replicas=2 让写入被拒,说明当时冗余已经不够,这是配置在起作用,不是故障放大。
复盘要点:三个上限(Producer max.request.size、Topic max.message.bytes、Broker replica.fetch.max.bytes)必须在同一张配置表里核对,任何一头单独放开都会变成隐性故障。巡检脚本按 max.message.bytes > replica.fetch.max.bytes 直接找出不成对的 Topic。GC 停顿这条也要单独排除,判据是 GcTimeMsPerSecond 与收缩时间点是否重合,见 Kafka 生产踩坑。
三、扩分区之后,订单状态被旧事件覆盖
现象:订单事件 Topic 从 8 个分区扩到 16 个,当晚下游的订单状态表出现 20 余笔“状态倒退”:已支付的订单又变回待支付(经验值,需校准)。上下游都正常,日志里没有异常,只有状态机跳得奇怪。
排查:分区数变了,带 key 的消息落点也会变。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 分区路由方式 | 是否按 orderId 作 key 走默认 murmur2(key) % partitions | 分区数变化直接改变落点 |
| 同一 key 的分布 | 拉某 orderId 的全部事件,看落在哪几个分区 | 扩前后各在一个分区 → 顺序保证已断 |
| 消费端并行结构 | 每个分区一个消费者?offset 各自独立推进 | 两分区并行消费,无跨分区顺序 |
| 时间线 | 状态倒退是否恰好发生在扩容时间点之后 | 高度重合即确认 |
| 幂等/版本 | 下游写入是否带事件版本或时间戳 | 无版本则后到就覆盖 |
根因链条:ORDER_PAID 进了新分区、ORDER_CREATED 滞留在旧分区的未消费队列里,消费者并行拉取,创建事件晚于支付事件被处理,覆盖了状态。Kafka 的顺序保证只在单分区内成立,跨分区没有任何顺序可言。
对症做法:
java
// 1. 下游写入必须带版本,用状态机 + 版本比较拒绝倒退
UPDATE order_state SET status='PAID', version=?
WHERE order_id=? AND version < ?; // 影响行数 0 表示更晚的事件已到,丢弃旧事件text
2. 扩容前先确认业务是否依赖“同一 key 有序”:
依赖的话,选一个消费端完全排空(lag=0)的窗口扩容,
并在扩完前禁止 Producer 写入该 key 空间,避免同一 key 跨分区并存。
3. 更稳的方案是“新建 Topic + 双写 + 切读”,分区数在建 Topic 时按 3 年容量定,
而不是反复扩。
4. 需要“同一 key 串行且能扩分区”时,考虑 RocketMQ 的消息队列选择策略或改用
带 key 路由的中间层,见 [RocketMQ 消费者模型与负载均衡](../09_RocketMQ/05-消费者模型与负载均衡.md)。properties
# 消费端加一层顺序保护的兜底:同一 key 的事件按时间戳/版本入队再处理
# 或者按 key 做本地串行化(同 key 落同一线程),避免并行覆盖
consumer.key.partitioner.threads=8分区数只能增不能减,kafka-reassign-partitions.sh 也只能重分配副本,不能缩分区;想减只能建新 Topic 迁数据。
复盘要点:把“该 Topic 是否依赖 key 顺序”写进元数据(命名约定或 Topic 配置注释),变更评审时这一项必须回答。判据可量化:扩分区后的 24 小时内,状态倒退类异常的计数必须为 0;同时给下游加一条“拒绝低版本覆盖高版本”的断言测试,这类问题就不只靠流程挡住。
最小上手
KRaft 单机跑通,3 条命令起服务:
bash
# 1. 生成集群 ID 并格式化存储目录(Kafka 3.9 / 4.0 同)
CLUSTER_ID=$(bin/kafka-storage.sh random-uuid)
bin/kafka-storage.sh format -t "$CLUSTER_ID" -c config/kraft/server.properties
# 2. 启动(单机 combined 模式:既是 broker 也是 controller)
bin/kafka-server-start.sh config/kraft/server.properties
# 3. 另开终端,建一个 3 分区、单副本的 Topic
bin/kafka-topics.sh --bootstrap-server localhost:9092 \
--create --topic demo --partitions 3 --replication-factor 1
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic demo--describe 的输出就是 ISR 的观测入口:
Topic: demo Partition: 0 Leader: 1 Replicas: 1 Isr: 1
Topic: demo Partition: 1 Leader: 1 Replicas: 1 Isr: 1用 kafka-console-producer.sh / kafka-console-consumer.sh 收发一条消息验证链路。想看 ISR 收缩,单机 RF=1 看不出来,至少起 3 个 Broker、RF=3,停掉一台后用 watch -n 5 "kafka-topics.sh --describe --topic demo | grep Isr" 观察,约 30s 后该副本从 Isr 消失。
生产动作
建 Topic 时就把三个参数定死,事后改 min.insync.replicas 只影响新写入,历史数据的可靠性不会补回来。改 Topic 配置用 kafka-configs.sh --alter --entity-type topics --entity-name demo --add-config min.insync.replicas=2,unclean.leader.election.enable=false。
ISR 抖动排查顺序:
--describe看Isr是否小于Replicas。- 看 Broker 日志里的
Shrinking ISR/Expanding ISR,确认是网络、磁盘还是 GC。 - 看 JMX
UnderReplicatedPartitions;持续大于 0 就是在丢冗余。 - 若 Follower 一直 fetch 但追不上,查磁盘 IO 和
replica.fetch.max.bytes(默认 1 MiB)——单条消息大于它时 Follower 永远追不上。
扩分区用 kafka-topics.sh --alter --topic demo --partitions 6,只能加不能减。分区数变了,带 key 的消息落点也会变,顺序性保证随之失效;扩之前先确认业务是否依赖「同一 key 有序」。
边界
- ISR 不是 Raft。Kafka 的提交不要求多数派,只要求 ISR 全员;ISR 可以缩到 1,此时
acks=all等于acks=1。真正兜底的是min.insync.replicas。 unclean=true就是拿数据换可用。新 Leader 可能缺少已提交消息,Consumer 之前读到的 offset 会「倒退」,下游必须能接受。- RF 不能超过 Broker 数,否则建 Topic 直接失败。
- 分区不能减。要减只能建新 Topic 迁数据(
kafka-reassign-partitions.sh也只能重分配,不能减分区)。 - 和 RocketMQ 的对比要按版本说:RocketMQ 4.5+ 有 DLedger、5.0+ 有 Controller 自动选主,「Master 挂了必须人工切」是 4.5 之前的说法;它的 Slave 可读,是 Kafka 没有的能力。
- KRaft 迁移不是滚动重启。3.9 是最后一个支持 ZK 的版本,迁移要经过 bridge 模式,不能直接从 ZK 跳到 4.0。
讲清楚
Kafka 的架构可以拆成三层来看。第一层是分区:一个 Topic 切成多个分区,每个分区是一条只追加的日志,分区数决定并行度,也决定同一 key 的消息能不能有序。第二层是副本:每个分区在多个 Broker 上存多份,一份是 Leader 负责读写,其余是 Follower 主动拉取。
第三层是 ISR,也是 Kafka 最关键的设计。它要回答的问题是「什么时候算写成功」。如果等所有副本,最慢的会拖垮写入;如果只等 Leader,Leader 挂了就丢数据。Kafka 的做法是维护一个「跟得上 Leader 的副本集合」,也就是 ISR,HW 取 ISR 里最小的 LEO,Consumer 只能读到 HW 之前。慢副本超过 30 秒追不上就被踢出 ISR,HW 立刻能继续推进,所以慢副本不会影响写入。
但这套机制有个前提:副本得真的在 ISR 里。所以线上必须
acks=all、min.insync.replicas=2、unclean.leader.election.enable=false三个一起配。少配一个,ISR 就可能缩到 1,可靠性名存实亡。