《Java 消息中间件·Kafka 内核与客户端》4.2 ISR 收缩与 min.insync.replicas

acks=all 不是「写完就成功」,它真正的含义是「等 ISR 里的副本都写完」。ISR 会收缩,收缩到低于 min.insync.replicas 时写入被拒。本节用三节点集群实测:杀掉一个 broker 仍能写,再杀一个就报 NOT_ENOUGH_REPLICAS,并解释这两个参数为什么要成对出现。

一个反复出现的误解

acks=all 被很多团队当成可靠性的终点:配置写上,就认为写入不会丢了。

但这条配置的完整语义是:leader 等 ISR(in-sync replicas,同步副本集合)里的所有副本都写入成功之后,才向生产者返回成功。

注意关键词是「ISR 里的所有副本」,不是「所有副本」。如果一个副本落后太多被踢出 ISR,它就不再参与这个等待。极端情况下,ISR 里只剩 leader 自己,acks=all 就退化成了 acks=1——leader 写完就返回成功,此时 leader 一挂,消息就没了。

真正拦住这种退化的,是另一个参数:min.insync.replicas。它规定「ISR 里至少要有几个副本,才允许写入」。两者必须成对出现,单独配任何一个都不到位。

这一节用三节点集群把这件事做出来,让你亲眼看到 ISR 从 3 收缩到 2、再收缩到 1 时,写入是怎么从成功变成被拒的。

ISR 是什么,怎么判定

先说清楚 ISR 不是什么东西。

ISR 不是「副本列表」。副本列表(replicas)是 topic 创建时定下的,除非重新分配,否则不变。ISR 是副本列表的一个动态子集,只包含那些「跟得上 leader」的副本。

跟得上的判定由 broker 端参数控制:

参数默认值含义
replica.lag.time.max.ms30000(30 秒)follower 超过这个时间没有发起 fetch 或没有追上 leader 的 LEO,就被移出 ISR

判定依据是时间,不是消息条数。这一点和早期版本不同:老版本有 replica.lag.max.messages,按落后的消息条数判定,后来被移除了,因为它对「突发流量」和「持续落后」区分不开。

时间窗意味着 ISR 的收缩可能有延迟,但延迟多久取决于 broker 是怎么「消失」的。这一点很多资料写错了,本机实测了三条路径:

失效方式触发机制本机实测
docker stop(SIGTERM 优雅关闭)broker 主动向 controller 注销2 秒内 ISR 从 1,2,3 变 1,3
docker pause(进程冻结,连接保持)broker.session.timeout.ms(默认 9 秒)冻结期间不变,超时后收缩
进程活着但复制跟不上replica.lag.time.max.ms(默认 30 秒)到期后收缩

也就是说:replica.lag.time.max.ms 只管第三种情况。broker 能主动告别的,立刻移出 ISR;不能告别的,等 broker session 超时。把它当成「故障发现时间」来调是常见的配置错误。4.1 节有完整的三组实测数据和命令。

还有一点:replica.lag.time.max.ms 不能在运行时动态修改。本机尝试时返回:

InvalidRequestException: Cannot update these configs dynamically: [replica.lag.time.max.ms]

要改只能写进 broker 的 server.properties 后重启。

ISR 扩张则是另一套逻辑:follower 重新追上 leader 的 LEO 之后,会自动回到 ISR。这个过程通常比收缩快,因为它由 follower 的 fetch 成功驱动。本机解冻一个被冻结的 follower 后,ISR 在一个轮询周期(2 秒)内就恢复到了三副本。

min.insync.replicas 的位置

这个参数可以配在三个层级,优先级从高到低:

  1. topic 级别(--config min.insync.replicas=2)
  2. broker 级别(min.insync.replicas)
  3. 集群默认(不设则用 broker 默认值 1)

它的作用非常直接:在写入请求进来时检查,如果 ISR 的大小小于这个值,直接拒绝,返回 NOT_ENOUGH_REPLICAS。

注意它检查的是 ISR 大小,不是副本总数。3 副本、ISR=1 时,min.insync.replicas=2 会拒绝写入;而 min.insync.replicas=1 会放行。

所以可靠性的组合是:

acks=all + min.insync.replicas=2 + replication.factor=3

三者缺一不可:

  • 只有 acks=all:ISR 缩到 1 时退化,仍可能丢。
  • 只有 min.insync.replicas=2:acks=1 时 leader 写完就返回,另外两个副本没写也照样成功。
  • 只有 3 副本:两个参数都没配,等于没保证。

实验:把 ISR 一步一步逼到 1

准备

三节点集群(k1/k2/k3),宿主机访问端口 19091/19092/19093。建一个 3 副本、3 分区的 topic,并把 min.insync.replicas 设为 2:

B=127.0.0.1:19091,127.0.0.1:19092,127.0.0.1:19093
docker exec k1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server $B \
  --create --topic loan.events --partitions 3 --replication-factor 3 \
  --config min.insync.replicas=2

初始状态

docker exec k1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server $B \
  --describe --topic loan.events

本机实测输出:

Topic: loan.events  TopicId: zxH6Wiv0R2WPrnBBylzW3A  PartitionCount: 3  ReplicationFactor: 3  Configs: min.insync.replicas=2
  Topic: loan.events  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 2,3,1  Elr:   LastKnownElr:
  Topic: loan.events  Partition: 1  Leader: 3  Replicas: 3,1,2  Isr: 3,1,2  Elr:   LastKnownElr:
  Topic: loan.events  Partition: 2  Leader: 1  Replicas: 1,2,3  Isr: 1,2,3  Elr:   LastKnownElr:

三件事值得注意:

  1. leader 是分散的:p0 在 broker 2,p1 在 broker 3,p2 在 broker 1。Kafka 会尽量把 leader 打散到不同 broker,避免单点压力集中。
  2. ISR 等于副本列表:三个副本都在线,所以 Isr 和 Replicas 一样。
  3. 多了两列:Elr 和 LastKnownElr。这是 4.x 新增的 Eligible Leader Replicas 相关字段,4.3 节会讲。

杀掉一个 broker

partition 0 的 leader 是 broker 2。停掉它:

docker stop k2
sleep 12

再 describe:

  Topic: loan.events  Partition: 0  Leader: 3  Replicas: 2,3,1  Isr: 3,1  Elr:   LastKnownElr:
  Topic: loan.events  Partition: 1  Leader: 3  Replicas: 3,1,2  Isr: 3,1  Elr:   LastKnownElr:
  Topic: loan.events  Partition: 2  Leader: 1  Replicas: 1,2,3  Isr: 1,3  Elr:   LastKnownElr:

三件事同时发生:

  • leader 切换:p0 的 leader 从 2 变成 3。p1 的 leader 本来就是 3,不变。p2 的 leader 还是 1。
  • ISR 收缩:每个分区的 ISR 都从 3 个变成 2 个,broker 2 被移出。
  • 副本列表不变:Replicas 仍然是三个,因为副本分配没有改。

此时写入应该仍然成功,因为 ISR=2,刚好等于 min.insync.replicas=2:

printf 'a\nb\nc\n' | docker exec -i k1 /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server 127.0.0.1:19091 --topic loan.events --producer-property acks=all

本机实测:命令正常返回,退出码 0。消息写进去了。

这就是「降级运行」:集群少了一个节点,但仍然满足最小副本要求,所以继续接受写入。这是设计好的行为,不是侥幸。

再杀一个 broker

现在 ISR 是 {3,1}。再停掉 broker 3:

docker stop k3
sleep 12

describe:

  Topic: loan.events  Partition: 0  Leader: 1  Replicas: 2,3,1  Isr: 1  Elr: 3  LastKnownElr:
  Topic: loan.events  Partition: 1  Leader: 1  Replicas: 3,1,2  Isr: 1  Elr: 3  LastKnownElr:
  Topic: loan.events  Partition: 2  Leader: 1  Replicas: 1,2,3  Isr: 1  Elr:   LastKnownElr:

ISR 只剩 broker 1。此时再写:

printf 'd\n' | docker exec -i k1 /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server 127.0.0.1:19091 --topic loan.events --producer-property acks=all

本机实测输出里出现了:

Error
NOT_ENOUGH_REPLICAS

写入被明确拒绝。这是正确行为:集群已经没有能力保证「至少 2 份副本」,与其接受一条不可靠的消息,不如拒绝。

恢复

docker-compose -f docker-compose.yml up -d
sleep 35

describe 回到:

  Topic: loan.events  Partition: 0  Leader: 1  Replicas: 2,3,1  Isr: 1,2,3  Elr:   LastKnownElr:
  Topic: loan.events  Partition: 1  Leader: 1  Replicas: 3,1,2  Isr: 1,2,3  Elr:   LastKnownElr:
  Topic: loan.events  Partition: 2  Leader: 1  Replicas: 1,2,3  Isr: 1,2,3  Elr:   LastKnownElr:

ISR 重新长回 3 个。注意 leader 没有自动切回去(p0 的 leader 还是 1,不是原来的 2)——这是优先副本要解决的问题,4.3 节讲。

把过程整理成一张表

时点ISRISR 大小min.insync写入 acks=all原因
初始2,3,132成功3 ≥ 2
停 k23,122成功2 ≥ 2,降级运行
再停 k3112拒绝1 < 2
全部恢复1,2,332成功3 ≥ 2

这张表就是本节的核心。它说明 acks=all 和 min.insync.replicas 不是两个独立的开关,而是一对:前者决定「等谁」,后者决定「不够就不许等」。

参数该怎么选

没有放之四海皆准的值,但有几条推理可以套。

replication.factor 至少 3。2 副本在挂掉一个之后 ISR=1,等于没有冗余;3 副本允许挂一个还保持 ISR=2。

min.insync.replicas = 副本数 − 1。3 副本配 2,5 副本配 4。这样允许一个副本离线而不影响写入,两个同时离线就停止写入。

不要配 min.insync.replicas = 副本数。那意味着任何一个副本掉线,写入立刻中断。除非你的业务宁可停写也不能接受降级。

不要配 1。那等于放弃了整个机制,acks=all 退化成 acks=1。

有一组常见的错配值得单独说:acks=all + min.insync.replicas=1。它看起来是两个参数都配了,实际上第二个不起作用。ISR 缩到 1 时照样写,照样可能丢。检查配置时要把这两个值放在一起看。

应用层该做什么

Kafka 拒绝写入时抛的是 NotEnoughReplicasException(或 NotEnoughReplicasAfterAppendException)。这两个异常的处理方式完全不同:

异常含义处理
NotEnoughReplicasException请求到达时 ISR 就不够,消息没写进去可以重试,或降级到本地落库
NotEnoughReplicasAfterAppendExceptionleader 自己写成功了,但等不到其他副本确认消息可能已经在 leader 上,重试会重复

第二个异常是最麻烦的:你不知道消息到底算不算发出去。此时如果盲目重试,会产生重复;如果放弃,可能丢。这也是卷二要用幂等消费兜底的原因之一。

一个务实的处理原则是:遇到这类异常,先停止发送并告警,而不是无限重试。因为重试解决不了「集群只剩一个副本」这个事实,只会把生产者的内存耗光——RecordAccumulator 的缓冲区会被未发送成功的批次填满,max.block.ms 到期后 send() 开始抛 TimeoutException,故障从 broker 扩散到应用线程。

和其它参数的关系

ISR 的行为还受另外两个参数影响,配置时容易漏:

unclean.leader.election.enable(默认 false)。它决定「ISR 全部离线时,是否允许一个不在 ISR 里的副本当 leader」。允许的话集群能恢复写入,但会丢数据。这个开关和 min.insync.replicas 是同一枚硬币的两面:前者管「最坏情况下能不能活」,后者管「不够好时不许写」。4.3 节会展开。

replica.lag.time.max.ms(默认 30000)。它决定「副本活着但跟不上」这条路径上 ISR 收缩有多快。调小会让这类滞后更快被踢出,但更容易因短暂抖动误判;调大会让降级更迟钝,坏副本在 ISR 里滞留更久。注意它不影响 broker 停机场景——那由 broker.session.timeout.ms 控制,默认 9 秒。

怎么观测

日常运维需要盯的与 ISR 相关的指标有三个:

指标含义什么时候看
UnderReplicatedPartitionsISR 小于副本数的分区数大于 0 持续超过几分钟就要查
IsrShrinksPerSec / IsrExpandsPerSecISR 收缩与扩张速率频繁抖动说明网络或磁盘有问题
OfflinePartitionsCount没有 leader 的分区数大于 0 意味着该分区完全不可用

UnderReplicatedPartitions > 0 是最常见的告警。它说明有副本跟不上,但不说明是哪一个、为什么跟不上。定位要靠 --describe 看具体分区,再看 broker 日志里的 Shrinking ISR 记录。14.1 节会给一套完整的排查顺序。

一个容易被忽略的细节

ISR 的判定在 leader 上做,判定结果通过元数据传播给 controller 和客户端。这意味着:

  • 如果你看的是 follower 的视角,可能和 leader 的判定不一致;
  • ISR 变化不是瞬时的,它需要一轮元数据传播;
  • 客户端的 --describe 看到的是它从集群拿到的元数据快照,可能滞后几十毫秒到几秒。

所以做故障实验时,sleep 一下再 describe 是必要的。上面实验里的 sleep 12 不是随便写的——它要大于一轮心跳加一轮 ISR 判定的时间。

本节判据

读完这一节,你应该能回答三个问题:

  1. acks=all 在 ISR=1 时等于什么? 答:等于 acks=1,因为「所有 ISR 副本」就剩 leader 自己。
  2. 为什么要同时配 min.insync.replicas? 答:它把「ISR 太小」从一个隐性退化变成一个显式拒绝。
  3. NotEnoughReplicasException 和 NotEnoughReplicasAfterAppendException 的区别? 答:前者消息没写进去,后者 leader 可能已经写了,重试会重复。

如果有一个答不上来,回到上面的实验表再看一遍。这三个问题的答案,决定了你在生产环境遇到写入失败时,是重试、是降级、还是停下来告警。

上一节讲 LEO 与 HW,说明消费者能读到什么;这一节讲 ISR 与 min.insync,说明生产者能写成什么。下一节回到选举:当 leader 真的挂掉,新 leader 是怎么选出来的,以及「不干净选举」到底在牺牲什么。

阅读导航:上一节 4.1 副本、LEO 与高水位 | 下一节 4.3 Leader 选举与不干净选举

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收