ISR 收缩与 HW 推进:副本同步的边界条件与 UnderReplicated 排障
0. 一个凌晨告警:为什么"副本数是 3",数据却仍然不安全
先看一个实际场景。某个订单系统在凌晨的流量低谷触发了监控告警:
text
UnderReplicatedPartitions = 47
Topic: order-events Partition: 12 Leader: 1011 ISR: [1011] Replicas: [1011, 1012, 1013]
值班同学的第一反应通常是:"副本因子配置的是 3,为什么 ISR 里只剩 1 个?"更让人不安的是,这个分区仍然可以正常写入,生产者没有报错,消费者也照常消费。看起来"没事",但此时如果 1011 这台 Broker 宕机,Leader 切换只能从 ISR 里选,而 ISR 只有它自己,分区就不可用了。
这就是 Kafka 副本同步最容易被误解的地方:副本数不等于同步副本数 。配置里的 replication.factor=3 只说明"计划存在 3 份数据",而 ISR(In-Sync Replicas,同步副本集合)说明"当前哪几份数据被认为跟得上 Leader"。只有 ISR 内的副本才有资格在 Leader 故障时接管,也只有 ISR 内的副本参与高水位推进。
本文围绕三条主线展开:ISR 什么时候收缩、HW(High Watermark,高水位)什么时候推进、副本滞后怎么判定。讲完这三条线,再看 unclean.leader.election 这个"拿一致性换可用性"的开关,最后把 UnderReplicatedPartitions 从一条告警变成一套可执行的排查路径。
先记住一个最小模型:Leader 负责接收写入并记录每条消息的位置 LEO;Follower 不断拉取 Leader 的数据,追上之后就留在 ISR 里,落后太多就被踢出 ISR;HW 是 ISR 中所有副本都已同步到的位置,消费者只能读到 HW 之前的消息。
1. 把整体拆成三部分:角色、位置与集合
要理解副本同步,先要把系统里的"人"和"刻度"分清。可以把一个分区的副本体系拆成三部分来看。
第一部分是角色:每个分区有一个 Leader 和若干 Follower。Leader 处理生产者的写入请求和消费者的读取请求;Follower 的唯一职责是主动向 Leader 发起拉取(Fetch)请求,把数据写到自己本地日志,然后等待下一轮拉取。注意这里是 Follower 拉 Leader,不是 Leader 推 Follower,这个方向决定了后续很多现象。
第二部分是位置:每个副本的日志都有一个不断增长的偏移量,称为 LEO(Log End Offset,日志末端偏移)。LEO 表示"下一条要写入的消息会放在哪个位置"。Leader 有自己的 LEO,每个 Follower 也有自己的 LEO。Follower 的 LEO 落后 Leader 多少,就是它"欠"多少数据。
第三部分是集合与刻度:ISR 是一个动态集合,默认包含 Leader 和所有跟得上的 Follower。HW 是一个位置刻度,定义是"ISR 中所有副本都已经复制到的最大偏移量"。消费者只能看到 HW 之前的消息,这是 Kafka 用来避免"读到后来被回滚的数据"的关键机制。
把三者串起来看一次数据流转:
text
生产者 --> Leader(LEO=105)
|
| Follower 主动 Fetch
v
Follower A(LEO=105) Follower B(LEO=102)
| |
+----------+----------+
|
HW = min(所有 ISR 的 LEO)
若 B 仍在 ISR,则 HW = 102
这张图想帮助理解的是:HW 不是 Leader 自己的 LEO,而是被最慢的那个 ISR 副本"拖住"的刻度。
这里最容易误解的是:很多人以为 Leader 写完就算成功。实际上,是否算"已提交"(committed),取决于 HW 是否推进到了这条消息之后。如果最慢的 ISR 副本只到 102,那么 103、104、105 这三条虽然已经写进 Leader,但消费者仍然看不到。
2. ISR 收缩条件:什么时候 Follower 会被踢出去
ISR 收缩指的是某个 Follower 从 ISR 集合中被移除。它不是一个瞬时判断,而是 Leader 基于 Follower 的拉取进度持续评估的结果。
核心判定量是 replica.lag.time.max.ms,默认 30000 毫秒(30 秒)。它的含义不是"落后多少条消息",而是"Follower 最后一次成功追上 Leader LEO 的时间距今多久"。也就是说,Leader 会记录每个 Follower 最近一次"LEO 追平 Leader LEO"的时间;如果某个 Follower 超过这个时间没有再次追平,Leader 就把它从 ISR 移除。
为什么用时间而不是条数?因为消息大小差异极大。同样是落后 1000 条,小消息可能只有几百 KB,大消息可能是几 GB。用时间衡量的是"这个副本还能不能在可接受延迟内跟上",更贴近"是否可用"这个问题。历史上还有一个 replica.lag.max.messages 参数,但因为它对突发流量极不友好(正常流量尖峰也会触发收缩,造成 ISR 频繁抖动),在新版本中已经被移除。
把常见的收缩触发条件列成一张表:
| 触发条件 | 具体表现 | 典型根因 | 是否可自动恢复 |
|---|---|---|---|
| 拉取超时 | 超过 replica.lag.time.max.ms 未追平 LEO |
Follower 磁盘慢、网络抖动、GC 停顿 | 大多数情况可以 |
| 副本被下线 | Follower 所在 Broker 宕机或停机 | 机器故障、滚动重启 | 重启并追平后可恢复 |
| 新副本加入 | 新增副本初始 LEO 为 0,需要追赶 | 扩容、副本重分配 | 追平后自动进入 ISR |
| 分区 Leader 切换 | 新 Leader 的 LEO 可能高于旧 Follower | 控制器重新选主 | 追平后恢复 |
| Follower 线程卡住 | 拉取线程无法推进 | 磁盘 IO 饱和、页面缓存不足 | 取决于资源恢复 |
注意表里"新副本加入"这一行。增加副本因子或做分区重分配时,新副本一开始必然不在 ISR 里,因为它要从头复制历史数据。此时 UnderReplicatedPartitions 告警会上升,但这是预期内的收缩,不是故障。排障时必须先区分"正常追赶"和"异常掉队"。
再看一个具体的数字例子。假设 Leader LEO 已经到 100000,Follower B 因为磁盘写入慢,LEO 停在 60000,并且在 30 秒内没有继续追平。Leader 判断:B 已经无法在可接受时间内跟上,于是把 B 从 ISR 移除。移除之后,ISR 变成 [Leader, A],HW 立刻可以按 A 的进度推进,生产者的写入延迟不再被 B 拖住。
这就是 ISR 收缩的设计取舍:它牺牲了 B 的实时参与,换取了整个分区的写入可用性和较低的提交延迟。 B 并没有被淘汰,它仍在后台拉取,一旦重新追平 Leader LEO,就会重新加入 ISR。
3. HW 推进机制:为什么消费者看到的总是"慢半拍"
HW 推进是副本同步里最需要按时序理解的部分。它的规则只有一句话:HW 等于 ISR 中所有副本 LEO 的最小值。但因为 LEO 在不停变化,HW 的推进实际上是一个反复"取最小值"的过程。
先让一次写入完整走一遍。假设 ISR 最初是 [Leader, A],Leader LEO = 100,A LEO = 100,此时 HW = 100。
第一步,生产者发来一条消息,Leader 写入本地日志,LEO 变成 101。此时 A 还没拉取,仍然 100,所以 ISR 内最小值是 100,HW 保持 100。这条消息对消费者不可见。
第二步,A 发起 Fetch 请求,拉走偏移量 100 的消息,写入本地日志,A 的 LEO 变成 101。Leader 在下一轮 Fetch 响应中携带自己的 HW 信息,此时 ISR 内最小 LEO 是 101,HW 推进到 101。
第三步,消费者下一次拉取时,Leader 把 HW = 101 返回给消费者,消费者可以读到偏移量 100 的那条消息。
写成时序图更直观:
text
生产者 Leader(LEO=100) Follower A(LEO=100)
| | |
|-- produce -------->| |
| | LEO -> 101 |
| | HW 仍为 100 |
|<-- ack -------------| |
| |<---- fetch ----------|
| |----- data(offset=100)->|
| | | LEO -> 101
| |<---- fetch ----------|
| | HW -> 101 |
| |----- HW=101 --------->|
| | |
消费者 <-- fetch -------| (只能读到 < HW 的消息)
这里有一个关键细节:Follower 在拉取时,不是只拉数据,它也在同步 Leader 的 HW。Follower 收到 Leader 返回的 HW 后,会更新自己的 HW。不过 Follower 更新本地 HW 时有一个约束:取"自己当前 HW"和"Leader 返回 HW"之间的较小值,避免在 Leader 切换时出现 HW 回退。
HW 带来的结果是消费者始终落后于生产者一小段。这不是 bug,而是隔离"未提交数据"的代价。如果允许消费者读到 HW 之后的消息,一旦 Leader 故障、某个已写入但未同步的消息丢失,消费者可能已经基于这条不存在的数据做了业务处理。
但 HW 机制也有它的边界。在 Leader 切换的瞬间,新 Leader 的 HW 可能比旧 Leader 略低,导致消费者看到少量重复消息。这是 Kafka 选择"至少一次"语义的自然结果,下面讲 unclean 选举时会再次遇到这个取舍。
4. 副本滞后判定:滞后不是一个数,而是一组口径
"这个副本滞后了"是排障时最常说的话,但它至少有三种口径,混用会导致误判。
第一种是 时间滞后 ,也就是 ISR 收缩依据的 replica.lag.time.max.ms。它问的是"多久没追平"。这个口径最贴近 ISR 语义,也是判断副本是否会被踢出的直接依据。
第二种是 偏移量滞后 ,即 Leader LEO - Follower LEO。这个数只能说明"欠多少条",不能说明严重程度。因为如果生产速率是每秒 10 万条,落后 100 万条可能只是 10 秒;如果生产速率是每秒 10 条,落后 100 万条已经是 27 小时。
第三种是 字节滞后,即副本之间的日志大小差异。它和磁盘占用、恢复时间更相关,但不等于"逻辑上落后多少"。
把三种口径放进一张对比表:
| 口径 | 计算方式 | 回答的问题 | 适用场景 | 局限 |
|---|---|---|---|---|
| 时间滞后 | 距上次追平 LEO 的时长 | 会不会被踢出 ISR | 判断 ISR 收缩风险 | 不反映积压体量 |
| 偏移量滞后 | Leader LEO - Follower LEO | 逻辑上欠多少条 | 估算追赶进度 | 受消息速率影响大 |
| 字节滞后 | 日志物理大小差值 | 磁盘和网络压力 | 容量规划、恢复评估 | 与逻辑进度不完全对应 |
在 JMX 指标里,能直接观察这些量。每个分区的 kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions 给出未充分复制的分区数;kafka.server:type=ReplicaFetcherManager,name=MaxLag,clientId=Replica 给出 Follower 视角的最大滞后;kafka.log:type=Log,name=LogEndOffset,topic=*,partition=* 给出各副本的 LEO。把这些指标放在一起看,才能定位是哪个副本、落后多少、有没有在被踢出的边缘。
这里最容易误解的是:只看 UnderReplicatedPartitions 的绝对值。 一个 5000 分区的集群,47 个 UR 可能是正常的滚动重启尾巴;一个 50 分区的集群,47 个 UR 就是大面积故障。必须结合总分区数、变化趋势和持续时间判断。
5. 完整过程推演:一次 Follower 掉队到重新入列
把前面几条线索合起来,走一遍完整生命周期。假设分区 order-events-12,ISR 为 [1011, 1012, 1013],Leader 是 1011。
初始状态:1011 LEO = 500000,1012 LEO = 500000,1013 LEO = 500000,HW = 500000。所有副本同步。
阶段一,1013 所在机器磁盘 IO 抖动,拉取速度骤降。它的 LEO 停在 500000,而 1011 的 LEO 继续以每秒 2000 条的速度增长。30 秒后,1011 的 LEO 到了 560000,1013 仍未追平。Leader 判定 1013 超过 replica.lag.time.max.ms,将 1013 移出 ISR。ISR 变为 [1011, 1012]。
阶段二,ISR 收缩后,HW 不再被 1013 拖住,可以按 1012 的进度推进。假设 1012 LEO = 555000,HW 从 500000 逐步推进到 555000。消费者可以读到 555000 之前的消息,提交延迟恢复到正常水平。
阶段三,1013 的磁盘恢复,开始以更高速度追赶。它的 LEO 从 500000 逐渐上升。由于 Leader 会持续保留日志直到所有副本都复制过(受 log.retention 约束),1013 可以继续拉取。当 1013 的 LEO 追平 1011 当时的 LEO 时,Leader 把它重新加入 ISR。
阶段四,ISR 恢复为 [1011, 1012, 1013],HW 再次受三者最小值约束。如果 1013 只是偶尔抖动,ISR 会反复收缩和恢复,这就是"ISR 抖动"。抖动本身不是故障,但它说明集群存在不稳定的资源瓶颈。
可以用一张状态表速查每个阶段:
| 阶段 | ISR | HW 受谁约束 | 写入是否可用 | 风险 |
|---|---|---|---|---|
| 初始同步 | 3 个副本 | 三者最小值 | 是 | 无 |
| 1013 掉队 | 2 个副本 | 1012 | 是,但不耐再故障 | 再掉一个副本就只剩 Leader |
| 1013 追赶 | 2 个副本 | 1012 | 是 | 追赶消耗磁盘和网络 |
| 1013 归队 | 3 个副本 | 三者最小值 | 是 | 无 |
这个推演的工程含义是:ISR 收缩有时是保护机制在起作用,真正危险的是"缩到只剩 Leader"。如果 ISR 只剩 Leader,此时任何一次 Leader 故障都会让分区不可用,除非开启 unclean 选举。
6. unclean.leader.election:当 ISR 里没有可用副本
现在处理最尖锐的取舍。假设分区 ISR 只剩 Leader 1011,1011 突然宕机。ISR 里没有其他可用副本,Kafka 面临两个选择。
选择一,等待 1011 恢复。分区不可用,生产者写入失败,消费者无法读取。等 1011 回来,数据完整,但业务在这段时间内中断。
选择二,从 ISR 之外的副本里选一个当 Leader,比如 1012。1012 虽然不在 ISR 里,但它有数据,只是落后。选它当 Leader,分区立刻恢复可用,但那些 1011 已写入、1012 还没同步的消息会丢失。这就是 unclean.leader.election.enable。
这个开关默认是 false。它的语义非常明确:用一致性换可用性。开启后,Kafka 允许从非 ISR 副本中选 Leader,代价是可能丢消息;关闭时,宁可不可用也不丢数据。
properties
# 服务端配置示例(server.properties)
# 默认 false:ISR 无可用副本时,宁可分区不可用,也不从落后副本选主
unclean.leader.election.enable=false
# 若某些业务能接受丢消息、但不能接受不可用,可按 topic 覆盖
# kafka-configs.sh --alter --entity-type topics \
# --entity-name order-events \
# --add-config unclean.leader.election.enable=true
这里最容易误解的是:以为开启它只是"让集群更健壮"。 实际上它把风险从"服务不可用"转移到了"数据静默丢失"。对于订单、支付、账务类数据,静默丢失通常比短暂不可用更严重;对于日志采集、埋点、监控指标这类可容忍丢失的数据,开启它可以提升可用性。
判断时需要问三个问题:数据丢了业务能不能感知、能不能补、补的成本多大。三个问题都指向"能接受",才考虑开启。绝不能因为"告警太多"就全局打开它来压掉 UnderReplicatedPartitions。
7. 工程落地:用 Spring Kafka 观察提交与副本行为
理解了机制,接下来看怎么在 Java 工程里观察和验证。下面的示例都基于 Spring Boot 3.x、Spring Kafka 3.x,Broker 端使用 KRaft 模式。示例的公共前提是:本地启动一个单节点 KRaft Broker,监听 localhost:9092,并创建一个 3 副本的 topic。
先准备环境和 topic:
bash
# 生成集群 ID 并格式化存储目录(KRaft 模式)
KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
bin/kafka-storage.sh format -t "$KAFKA_CLUSTER_ID" -c config/kraft/server.properties
# 启动 Broker
bin/kafka-server-start.sh config/kraft/server.properties
# 创建 3 副本 topic
bin/kafka-topics.sh --bootstrap-server localhost:9092 \
--create --topic order-events \
--partitions 3 --replication-factor 3
示例一:最小可运行示例------发送消息并观察 HW 与 LEO
目标是验证"消息写入后,消费者只能读到 HW 之前的消息"。这个示例用命令行即可复现,不需要写 Java 代码。
bash
# 查看 topic 的 ISR 和 LEO
bin/kafka-topics.sh --bootstrap-server localhost:9092 \
--describe --topic order-events
# 输出示例:
# Topic: order-events Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3
# 查看分区的 LEO(需要开启 JMX 或使用 kafka-get-offsets)
bin/kafka-get-offsets.sh --bootstrap-server localhost:9092 \
--topic order-events
# order-events:0:0
# 生产 3 条消息
printf 'o-1\no-2\no-3\n' | bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 --topic order-events
# 再次查看 LEO 和 ISR
bin/kafka-get-offsets.sh --bootstrap-server localhost:9092 \
--topic order-events
# order-events:0:3
bin/kafka-topics.sh --bootstrap-server localhost:9092 \
--describe --topic order-events
# Isr: 1,2,3
关键步骤是先看 LEO,再写消息,再看 LEO。预期结果是 LEO 从 0 变成 3,ISR 保持三个副本。如果 ISR 中少了某个副本,说明该副本在拉取上出了问题。
接着人为制造一次滞后。停止一个 Follower 所在 Broker(如果是单节点无法制造,可用三节点集群),再持续生产消息,观察 ISR 变化。
bash
# 停止 Broker 3(模拟 Follower 掉线)
bin/kafka-server-stop.sh
# 持续生产消息 30 秒以上
for i in $(seq 1 200); do echo "o-$i"; done | \
bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic order-events
# 观察 ISR 收缩
bin/kafka-topics.sh --bootstrap-server localhost:9092 \
--describe --topic order-events
# Isr: 1,2 <- 3 已被移出
适用场景是理解 ISR 收缩的触发条件。容易改错的地方是:如果停止 Broker 后立刻查看,ISR 可能还没收缩,因为默认要等 30 秒。必须让时间超过 replica.lag.time.max.ms 再观察。
示例二:Spring Kafka 生产者 ack 与幂等配置
目标是对比 acks 取值对提交语义的影响。前置环境是上一节的 Broker 和 topic。
java
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class ProducerConfigExample {
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
// acks=all:等 ISR 中所有副本确认后才算成功
props.put(ProducerConfig.ACKS_CONFIG, "all");
// 开启幂等,避免重试导致重复写入
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
// 幂等要求 acks=all 且重试次数足够
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
return new DefaultKafkaProducerFactory<>(props);
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate(
ProducerFactory<String, String> producerFactory) {
return new KafkaTemplate<>(producerFactory);
}
}
关键步骤是设置 acks=all 与 enable.idempotence=true。预期结果是:当 ISR 正常时,发送正常返回;当 ISR 收缩到只剩 Leader 时,发送仍然成功但风险很高,因为此时"所有 ISR"其实只有一个副本。
最容易改错的地方是:以为 acks=all 能保证消息被三个副本接收。它保证的是"被当前 ISR 中所有副本接收"。如果 ISR 已经缩到只剩 Leader,acks=all 也只有一个副本确认。这正是必须要监控 UnderReplicatedPartitions 的原因。
示例三:用 AdminClient 定期采集未充分复制分区
目标是把 UnderReplicatedPartitions 从被动告警变成主动巡检。前置环境是 Broker 开放 JMX 或允许 AdminClient 访问。
java
import org.apache.kafka.clients.admin.AdminClient;
import org.apache.kafka.clients.admin.AdminClientConfig;
import org.apache.kafka.clients.admin.TopicDescription;
import org.apache.kafka.common.TopicPartitionInfo;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Properties;
public class UnderReplicatedChecker {
public static void main(String[] args) throws Exception {
Properties props = new Properties();
props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
try (AdminClient admin = AdminClient.create(props)) {
String topic = "order-events";
Map<String, TopicDescription> descriptions =
admin.describeTopics(Collections.singletonList(topic)).all().get();
TopicDescription desc = descriptions.get(topic);
List<TopicPartitionInfo> partitions = desc.partitions();
int underReplicated = 0;
for (TopicPartitionInfo p : partitions) {
int replicas = p.replicas().size();
int isr = p.isr().size();
boolean bad = isr < replicas;
if (bad) {
underReplicated++;
}
System.out.printf(
"partition=%d leader=%s replicas=%d isr=%d status=%s%n",
p.partition(), p.leader(), replicas, isr,
bad ? "UNDER_REPLICATED" : "OK");
}
System.out.println("underReplicatedPartitions=" + underReplicated);
}
}
}
关键步骤是用 AdminClient 获取 Topic 描述,对比 replicas 和 isr 列表长度。预期输出类似:
text
partition=0 leader=1 replicas=3 isr=3 status=OK
partition=1 leader=2 replicas=3 isr=2 status=UNDER_REPLICATED
partition=2 leader=3 replicas=3 isr=3 status=OK
underReplicatedPartitions=1
适用场景是自定义巡检脚本或接入监控。边界是:它只给出瞬时快照,无法区分"正在追赶"和"已经掉队"。生产上应配合趋势判断,例如连续 5 分钟都处于 UNDER_REPLICATED 才告警。
8. UnderReplicatedPartitions 告警:从一条指标到排查路径
现在回到开头的告警。UnderReplicatedPartitions 统计的是"ISR 大小小于副本数的分区数量"。它是一个 Broker 级指标,汇总该 Broker 作为 Leader 时管理的分区情况。
这个指标之所以重要,是因为它同时反映了两件事:数据冗余是否充足,以及故障切换能力是否完整。UR 大于 0 并不一定是故障,但持续大于 0 一定意味着集群存在未解决的资源或稳定性问题。
排查要有顺序,避免一上来就重启。下面给出一条从粗到细的路径:
text
UnderReplicatedPartitions > 0
|
v
1. 是持续还是瞬时? --瞬时--> 滚动重启/扩容尾巴,观察即可
|持续
v
2. 看是哪些 Broker 的副本掉队 -------> 定位到具体副本节点
|
v
3. 看该节点资源:CPU / 磁盘 IO / 网络 / GC
|
v
4. 看是否在追赶:Follower LEO 是否持续增长
|
v
5. 区分:追赶中(正常) vs 卡住(异常) vs 磁盘写满(严重)
每一步都对应具体命令或指标。第 2 步用 kafka-topics.sh --describe 找到哪个副本不在 ISR;第 3 步看 Broker 的 JMX 指标和系统指标;第 4 步对比 Leader 与 Follower 的 LEO;第 5 步判断是否需要扩容或迁移。
把常见现象和处置建议列成表:
| 现象 | 可能原因 | 处置建议 |
|---|---|---|
| UR 短暂升高后回落 | 滚动重启、扩容、Leader 切换 | 观察,不干预 |
| UR 持续升高,集中在某节点 | 该节点磁盘慢或网络差 | 检查磁盘、迁移副本 |
| UR 高且 ISR 只剩 Leader | 多个副本同时掉队 | 立即排查,暂停非关键变更 |
| UR 高但 Follower LEO 在增长 | 正常追赶 | 等待,必要时限流 |
| UR 高且 Follower LEO 不动 | 拉取卡住、磁盘写满 | 优先处理该 Broker |
这里最容易误解的是:把 UR 当成单一根因。 它只是一个结果指标。真正要回答的是"哪个副本、为什么没跟上、还能不能跟上"。只有能回答这三个问题,排障才算结束。
9. 设计取舍:一致性、可用性与延迟的三角
回顾整篇文章,Kafka 的副本同步其实一直在这个三角里做取舍。
选了一致性 ,就要求 ISR 收缩、HW 推进、acks=all,代价是写入延迟受最慢 ISR 副本影响,ISR 收缩时冗余下降。选了可用性 ,就可能开启 unclean.leader.election,代价是可能丢消息。选了低延迟 ,就要限制 replica.lag.time.max.ms 不要过大,代价是 ISR 更容易抖动。
没有一组配置对所有业务都最优。可行的做法是按数据重要程度分层:
| 数据类型 | acks | min.insync.replicas | unclean 选举 | 说明 |
|---|---|---|---|---|
| 订单/支付 | all | 2 | false | 优先不丢数据 |
| 业务事件 | all | 1 | false | 平衡可用与延迟 |
| 日志/埋点 | 1 或 all | 1 | 视情况 | 可容忍少量丢失 |
min.insync.replicas 需要特别说明。它规定生产者写入时,ISR 中至少要有多少个副本才算成功。如果设置成 2,当 ISR 只剩 1 个副本时,acks=all 的写入会直接失败。这是主动用不可用换取不丢数据的配置,和 unclean 选举正好相反。
10. 常见误区
第一个误区:认为副本数就是安全副本数。replication.factor=3 只说明目标状态,ISR 才说明当前状态。副本可能因为滞后被踢出 ISR。
第二个误区:认为 acks=all 一定写三份。它写的是"当前 ISR 内所有副本"。ISR 收缩后,保障随之下降。
第三个误区:认为 ISR 收缩就是故障。新副本加入、Leader 切换、滚动重启都会引起短期收缩,属于正常现象。只有持续收缩且无法恢复才需要处理。
第四个误区:用 replica.lag.max.messages 调优。该参数已在新版本移除,强行使用旧配置会导致不可预期的行为。
第五个误区:UR 告警一响就开启 unclean 选举。这会把数据丢失风险引入生产环境,而且掩盖了真正的资源问题。
第六个误区:只看 UR 总数,不看分区总数和持续时间。必须结合基线判断。
11. 生产实践建议
首先,给不同 Topic 设定差异化的 min.insync.replicas。核心业务用 2,普通业务用 1,避免一刀切。
其次,监控要同时覆盖 UR、ISR 抖动频率、Follower 滞后时间和 Broker 磁盘 IO。单一指标无法定位根因。
再次,控制单 Broker 的副本承载量。副本过多会放大磁盘和网络的争用,导致更多副本同时掉队。
然后,滚动重启和扩容要分批进行,给 ISR 恢复留出时间窗口,避免多个副本同时不在 ISR。
最后,把 log.retention 和追赶需求对齐。如果 Follower 需要长时间才能追平,而日志已被删除,它可能永远无法回到 ISR。对于大分区,适当延长保留时间或进行分区重分配。
12. 排障清单
遇到 UnderReplicatedPartitions 告警时,按下面顺序逐项确认:
- 确认总分区数和 UR 数量,判断影响面。
- 确认 UR 是持续还是瞬时,查看最近 30 分钟趋势。
- 用
kafka-topics.sh --describe找出不在 ISR 的副本和其所在 Broker。 - 检查这些 Broker 的 CPU、磁盘 IO、网络、GC 日志。
- 对比 Leader 与 Follower 的 LEO,判断是在追赶还是卡住。
- 检查磁盘是否写满、是否只读。
- 检查是否有分区重分配、扩容或滚动重启正在进行。
- 确认
min.insync.replicas与unclean.leader.election.enable的当前配置。 - 评估 ISR 是否可能缩到只剩 Leader,必要时降低生产写入或扩容。
- 记录根因和处置动作,更新告警阈值和 Runbook。
13. 面试/复盘问题
- ISR 收缩的判定依据是条数还是时间?为什么这样设计?
- HW 是如何计算的?为什么消费者只能读到 HW 之前的消息?
- Follower 更新本地 HW 时为什么要取较小值?
replication.factor=3、acks=all、min.insync.replicas=2三者分别控制什么?- 开启
unclean.leader.election后,故障切换可能带来什么后果? - ISR 只剩 Leader 时,生产者写入还会成功吗?会有什么风险?
- 如何区分"正常追赶"和"异常掉队"?
- 为什么 UR 告警不能只看绝对值?
14. 总结
把所有内容收回到一张框架图:
text
生产者 --acks--> Leader 写入本地日志(LEO 增长)
|
| Follower 主动 Fetch
v
Follower 副本(LEO 增长)
|
| Leader 评估每个 Follower 是否在
| replica.lag.time.max.ms 内追平 LEO
v
ISR 集合(动态收缩/恢复)
|
| HW = ISR 中所有副本 LEO 的最小值
v
消费者只能读到 HW 之前的消息
记住四个结论。第一,ISR 是动态集合,收缩依据是时间而非条数。第二,HW 被最慢的 ISR 副本拖住,这是提交语义的基础。第三,副本滞后要分时间、偏移量、字节三种口径看。第四,UnderReplicatedPartitions 是结果指标,排障要落到"哪个副本、为什么、能不能恢复"。
当你能把一次告警拆解成 ISR、HW、LEO 三条线的状态变化,副本同步就不再是黑盒,而是一组可以推演、可以观测、可以取舍的边界条件。
15. 参考资料
- Apache Kafka 官方文档:Replication(https://kafka.apache.org/documentation/#replication)
- Apache Kafka 官方文档:Design(https://kafka.apache.org/documentation/#design)
- Apache Kafka 官方文档:Topic Configs,含
min.insync.replicas、unclean.leader.election.enable(https://kafka.apache.org/documentation/#topicconfigs) - Apache Kafka 官方文档:Broker Configs,含
replica.lag.time.max.ms(https://kafka.apache.org/documentation/#brokerconfigs) - Apache Kafka 官方文档:Monitoring,含 UnderReplicatedPartitions 等 JMX 指标(https://kafka.apache.org/documentation/#monitoring)
- Apache Kafka 官方文档:KRaft(https://kafka.apache.org/documentation/#kraft)
- Spring for Apache Kafka 参考文档(https://docs.spring.io/spring-kafka/reference/)
- 《Kafka: The Definitive Guide》第二版,Gwen Shapira 等著,O'Reilly Media