跳到正文

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 与水位 ​

概念含义
ARAssigned Replicas,分区的全部分配副本,AR = ISR + OSR
ISRIn-Sync Replicas,与 Leader 保持同步的副本集合
OSROut-of-Sync Replicas,落后超过阈值的副本
LEOLog End Offset,每个副本自己的「下一条写入位置」
HWHigh Watermark,Leader 维护,等于 ISR 中最小 LEO
LSOLast Stable Offset,事务场景下 read_committed 消费者的可见边界

因果链:

  1. Producer 写入 Leader,Leader 的 LEO 前进。
  2. Follower 主动 fetch,写本地日志,自己的 LEO 前进。
  3. Leader 取 ISR 内所有副本 LEO 的最小值作为 HW。
  4. 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=2ISR 小于 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.replicaskafka-configs.sh --describe --entity-type topics --entity-name order-events值为 1(默认)→ ISR 缩到 Leader 仍接受写入
Producer 的 acks客户端配置或 ProducerConfig.ACKS_CONFIGall 但被上面的默认值架空
是否允许脏选举unclean.leader.election.enabletrue → OSR 副本也能当 Leader,已提交数据直接缺
当时的 ISRBroker 日志 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=false
properties
# Producer 侧保持默认 3.0+ 的 acks=all,并让失败可见
acks=all
enable.idempotence=true      # 幂等 Producer,避免重试造成分区内重复
retries=5
delivery.timeout.ms=120000
text
代价要提前接受: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。

定位层取证据判定
副本是否在 fetchFollower 日志的 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 大小与解压后不同,副本侧要能容纳
磁盘与 GCiostat 的 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=2
properties
# 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 抖动排查顺序:

  1. --describe 看 Isr 是否小于 Replicas。
  2. 看 Broker 日志里的 Shrinking ISR / Expanding ISR,确认是网络、磁盘还是 GC。
  3. 看 JMX UnderReplicatedPartitions;持续大于 0 就是在丢冗余。
  4. 若 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,可靠性名存实亡。

把一门技术讲透到既懂也会用