一个反复出现的误解
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.ms | 30000(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 的位置
这个参数可以配在三个层级,优先级从高到低:
- topic 级别(
--config min.insync.replicas=2) - broker 级别(
min.insync.replicas) - 集群默认(不设则用 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:
三件事值得注意:
- leader 是分散的:p0 在 broker 2,p1 在 broker 3,p2 在 broker 1。Kafka 会尽量把 leader 打散到不同 broker,避免单点压力集中。
- ISR 等于副本列表:三个副本都在线,所以
Isr和Replicas一样。 - 多了两列:
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 节讲。
把过程整理成一张表
| 时点 | ISR | ISR 大小 | min.insync | 写入 acks=all | 原因 |
|---|---|---|---|---|---|
| 初始 | 2,3,1 | 3 | 2 | 成功 | 3 ≥ 2 |
| 停 k2 | 3,1 | 2 | 2 | 成功 | 2 ≥ 2,降级运行 |
| 再停 k3 | 1 | 1 | 2 | 拒绝 | 1 < 2 |
| 全部恢复 | 1,2,3 | 3 | 2 | 成功 | 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 就不够,消息没写进去 | 可以重试,或降级到本地落库 |
NotEnoughReplicasAfterAppendException | leader 自己写成功了,但等不到其他副本确认 | 消息可能已经在 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 相关的指标有三个:
| 指标 | 含义 | 什么时候看 |
|---|---|---|
UnderReplicatedPartitions | ISR 小于副本数的分区数 | 大于 0 持续超过几分钟就要查 |
IsrShrinksPerSec / IsrExpandsPerSec | ISR 收缩与扩张速率 | 频繁抖动说明网络或磁盘有问题 |
OfflinePartitionsCount | 没有 leader 的分区数 | 大于 0 意味着该分区完全不可用 |
UnderReplicatedPartitions > 0 是最常见的告警。它说明有副本跟不上,但不说明是哪一个、为什么跟不上。定位要靠 --describe 看具体分区,再看 broker 日志里的 Shrinking ISR 记录。14.1 节会给一套完整的排查顺序。
一个容易被忽略的细节
ISR 的判定在 leader 上做,判定结果通过元数据传播给 controller 和客户端。这意味着:
- 如果你看的是 follower 的视角,可能和 leader 的判定不一致;
- ISR 变化不是瞬时的,它需要一轮元数据传播;
- 客户端的
--describe看到的是它从集群拿到的元数据快照,可能滞后几十毫秒到几秒。
所以做故障实验时,sleep 一下再 describe 是必要的。上面实验里的 sleep 12 不是随便写的——它要大于一轮心跳加一轮 ISR 判定的时间。
本节判据
读完这一节,你应该能回答三个问题:
acks=all在 ISR=1 时等于什么? 答:等于acks=1,因为「所有 ISR 副本」就剩 leader 自己。- 为什么要同时配
min.insync.replicas? 答:它把「ISR 太小」从一个隐性退化变成一个显式拒绝。 NotEnoughReplicasException和NotEnoughReplicasAfterAppendException的区别? 答:前者消息没写进去,后者 leader 可能已经写了,重试会重复。
如果有一个答不上来,回到上面的实验表再看一遍。这三个问题的答案,决定了你在生产环境遇到写入失败时,是重试、是降级、还是停下来告警。
上一节讲 LEO 与 HW,说明消费者能读到什么;这一节讲 ISR 与 min.insync,说明生产者能写成什么。下一节回到选举:当 leader 真的挂掉,新 leader 是怎么选出来的,以及「不干净选举」到底在牺牲什么。
阅读导航:上一节 4.1 副本、LEO 与高水位 | 下一节 4.3 Leader 选举与不干净选举
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。