作者:江墨白-295|15 年技术团队负责人|分布式一致性与消息中间件方向 栈:Kafka 3.5.2(KRaft,3 controller 独立节点 4C8G,8 broker 16C64G NVMe)、Spring Kafka 3.1.5、Java 17(G1)、MySQL 8.0.36 场景:订单域
order-event(24 分区,RF=3,min.insync.replicas=2)、inventory-deduct(8 分区,RF=3)、audit-log(4 分区,RF=2),56 个 producer 实例 + 24 个 consumer 实例
一、现象:controller 切换只花了 2 秒,但异常窗口拖了 12.3 秒
上个月一次 controller 节点 NVMe 盘连续 3 次 IO timeout 触发 KRaft quorum leader 切换。controller 侧日志显示:quorum 选举 840ms + metadata log apply 1.2s = 总切换耗时 2.04s。按 #1004 里的经验(KRaft controller 选举 1.1s + leader election 2.7s),这个速度完全正常。
但客户端侧的异常窗口是 12.3s:
- 56 个 producer 实例集中抛出 1,437 次
UnknownTopicOrPartitionException - 24 个 consumer 实例抛出 389 次
LeaderNotAvailableException - broker 侧
MetadataRequestQPS 从正常的 180~220 飙到峰值 4,820(22 倍) - 风暴持续 12.3s 后自动恢复,无需人工干预
诡异的是:topic 一个都没删、partition 一个都没少,UnknownTopicOrPartitionException 按理不该出现。更诡异的是,这 1,437 次异常全部集中在切换后的前 8s,后 4s 是客户端重试成功后的「余波」(reconnect 排队)。
类比一下:#1488 里讲「
acks=all成功 ≠ 数据落盘」,这里的问题是「broker 应答error_code=0≠ metadata 是最新的」——合法,但过期。
二、根因:三个独立窗口叠加,且 broker 在「旧 state」窗口内仍正常应答
KRaft 模式下 controller 切换后的 metadata 传播链路是异步的,分三段:
Controller quorum commit (840ms)
→ Controller 向 8 个 broker 发 metadata update event (120~480ms,取决于 broker 数量)
→ Broker 内部 MetadataCache apply (80~200ms)
总窗口 ≈ 500ms ~ 1.5s。在这 500ms~1.5s 内,broker 的 MetadataCache 还是旧的。
关键机制(也是反直觉的核心):broker 在 MetadataCache 未更新时,不会返回 error_code,而是用旧 state 正常应答。因为旧 state 里 topic 存在、partition 存在、leader 存在——一切看起来合法,只是 leader 可能已经切走了。
客户端侧的 refresh 触发机制是拉模型 + 被动触发混合:
- 主动拉:
metadata.max.age.ms(默认 300000ms = 5min)到期后发 Metadata 请求。注意:这不是 TTL,是最大容忍过期时间。正常情况下一天可能只主动拉几次。 - 被动触发:broker 在 Metadata 响应里返回
throttle_time_ms > 0或error_code != 0时,客户端立即重试。
问题出在被动触发:controller 切换后 500ms~1.5s 内,broker 用旧 state 应答 → error_code=0 → 客户端不会触发立即刷新 → 客户端继续用旧 metadata 发请求 → 旧 leader 已不在 → 收到 UnknownTopicOrPartitionException → 此时才触发 refresh → 56 个 producer × 3 个 connection 集中发 Metadata 请求 → 风暴。
所以完整的故障链是:
Controller 切换 (2.04s)
→ Broker MetadataCache 未更新窗口 (0.5~1.5s)
→ Broker 用旧 state 应答 (error_code=0, 合法但过期)
→ 客户端不触发立即刷新 (以为 metadata 还是对的)
→ 客户端用旧 metadata 发 Produce/Fetch → 命中已切走的 leader
→ UnknownTopicOrPartitionException / LeaderNotAvailableException
→ 客户端此时才触发 metadata refresh
→ 56×3 = 168 个 connection 集中发 Metadata RPC
→ Broker MetadataRequest QPS 22x 风暴
→ 风暴持续 12.3s 后 metadata 全部刷新完毕,恢复
12.3s = 旧 state 窗口 (≈1s) + 客户端首次异常到触发 refresh (≈2s) + 168 个 connection 排队刷新 (≈8s) + 重试余波 (≈1s)。
三、具体做法:三层防御 + 监控锚点
3.1 监控:把「窗口」变成可观测指标
不要只看 controller 切换耗时——那个 2s 只是开始。真正要盯的是:
# 1. Controller 侧:metadata update latency(KIP-500 内置指标)
# 正常 < 50ms,> 200ms 说明 broker apply 在排队
kafka-metadata-quorum.sh --describe --topic __cluster_metadata
# 关注:LastAppliedOffset 与 CommittedOffset 的差值(lag)
# lag > 10 条 = broker 还在回放,此时 broker 端 metadata 是旧的
# 2. Broker 侧:MetadataRequest QPS 突增检测
# 正常 180~220,> 1000 说明客户端 refresh 风暴
# 用 Prometheus:
# kafka_server_metadata_requests_per_second 的 delta / 60s > 1000 → 告警
# 3. 客户端侧:UnknownTopicOrPartitionException 计数
# 单实例 30s 内 > 20 次 → 大概率不是 topic 真的不存在,是 metadata 过期
# Spring Kafka:
# @KafkaListener 的 errorHandler 里统计 exception 类型
核心判据:controller 切换后 5s 内,如果 UnknownTopicOrPartitionException 计数 > 0 且 broker MetadataRequest QPS > 10x 正常值,不要告警、不要重启、不要人工干预——这是正常的 metadata 传播窗口,等 15s 自动恢复。真正要告警的是:窗口超过 30s 仍未恢复(说明 broker apply 卡住了,可能是 __cluster_metadata log 回放阻塞)。
3.2 客户端:调大而不是调小 metadata.max.age.ms
反直觉但关键:我一开始的直觉是「调小 metadata.max.age.ms 让客户端更快感知 controller 切换」——方向完全反了。
- 调小(比如 300000 → 30000):客户端每 30s 主动拉一次 → 正常情况 MetadataRequest QPS 直接 ×10 → 风暴没解决,日常开销先翻倍了
- 调大(300000 → 900000):减少主动拉取频率 → 日常开销降低 → 但被动触发机制不受影响(error_code 触发立即刷新跟 max.age 无关)
正确做法:
# producer / consumer 通用
metadata.max.age.ms=900000 # 15min,减少日常主动拉取
reconnect.backoff.ms=500 # 从默认 50ms 调到 500ms,打散 refresh 风暴
reconnect.backoff.max.ms=5000 # 指数退避上限
# 关键:不要设 delivery.timeout.ms < reconnect.backoff.max.ms
# 否则重连还没完成 delivery 就超时了
delivery.timeout.ms=30000
reconnect.backoff.ms=500 是风暴的核心解药:56×3=168 个 connection 不再同一毫秒发 Metadata 请求,而是分散在 0~5000ms 内,broker 的 MetadataRequest QPS 峰值从 4,820 降到 ~600(3x 正常值,可接受)。
3.3 Broker 侧:num.metadata.cache 与 metadata.max.idle.ms
# broker server.properties
num.metadata.cache=1000 # 默认 1000,topic 数 < 500 时够用
metadata.max.idle.ms=120000 # 默认 5min,metadata 空闲多久后从 cache 驱逐
# 不要调小 metadata.max.idle.ms——它影响的是「冷 topic 的 metadata 驱逐」
# 跟 controller 切换窗口无关,调小只会增加 broker 内部 metadata 重建开销
四、踩过的坑与反直觉之处
坑 1:「调小 metadata.max.age.ms 加快感知」——方向反了
metadata.max.age.ms 是主动拉取的最大间隔,不是「感知 controller 切换的延迟」。controller 切换后 broker 的被动通知机制(error_code / throttle_time_ms)才是即时感知的通道。调小 max.age 只增加了日常 MetadataRequest 频率,对风暴毫无帮助,反而让 broker 日常负载 ×10。
坑 2:UnknownTopicOrPartitionException ≠ topic 不存在
这是最反直觉的一条。在 KRaft controller 切换窗口内,broker 用旧 metadata 应答,旧 metadata 里的 leader 可能已经切走。客户端拿着旧 leader 地址发 Produce 请求,新 leader 不认识这个请求(PID 或 partition 不匹配),返回 UnknownTopicOrPartitionException。topic 一直在,partition 一直在,只是 leader 换了。
排查时如果只看到异常名,第一反应是「topic 被误删了」——去查 topic 列表、查 controller 日志,全在。最后才发现是 metadata 传播窗口的正常现象。判据:异常是否集中在 controller 切换后 30s 内?如果是,等;如果不是,再查 topic。
坑 3:KRaft 的 metadata 复制是 quorum 多数派,不是 ISR 模型
#1004 里讲的 replica.lag.time.max.ms 是 ISR 模型(leader-follower 复制)。KRaft 的 __cluster_metadata log 是 Raft quorum(3 controller,多数派 = 2/3)。两者的故障窗口完全不同:
- ISR 模型:follower 落后 30s 内仍算「活着」,但数据可能没追上(#1004 的 82,347 条丢失)
- Raft quorum:commit 需要 2/3 确认,不存在「commit 了但没复制」的窗口——但存在「commit 了但 broker 还没 apply」的窗口(本文的 500ms~1.5s)
这两个窗口经常被混为一谈。ISR 窗口是数据丢失风险,Raft apply 窗口是metadata 过期风险。前者要调 replica.lag.time.max.ms,后者要调 reconnect.backoff.ms。
坑 4:__cluster_metadata log lag 才是真正的「窗口宽度」
controller 切换后,LastAppliedOffset(broker 已 apply 的 offset)和 CommittedOffset(quorum 已 commit 的 offset)之间的差值就是 metadata 传播窗口。正常情况下 lag < 5,切换后 lag 可能短暂飙到 20~50(broker 在回放)。lag > 100 且持续 10s 不降 = broker apply 卡住了,这时候才需要人工介入(检查 broker 的 metadata.log 是否有 IO 错误)。
# 检查 lag
kafka-metadata-quorum.sh --describe --topic __cluster_metadata
# 输出示例:
# CommittedOffset: 1,234,567
# LastAppliedOffset: 1,234,542
# Lag: 25 ← 正常,等
# Lag: 312 ← 卡住了,查 broker 日志
五、结论与可复用清单
核心结论:KRaft controller 切换后的「异常窗口」不是 controller 切换慢,而是 broker 的 metadata apply 是异步的,且 broker 在 apply 完成前仍用旧 state 正常应答(error_code=0)。客户端的被动 refresh 机制在「合法但过期」的 metadata 下不会触发,直到第一次 Produce/Fetch 失败才集中刷新——这就是风暴的根因。
可复用清单:
| 层面 | 参数 / 动作 | 值 | 目的 |
|---|---|---|---|
| 客户端 | metadata.max.age.ms | 900000(15min) | 减少日常主动拉取,不要调小 |
| 客户端 | reconnect.backoff.ms | 500 | 打散 refresh 风暴,核心解药 |
| 客户端 | reconnect.backoff.max.ms | 5000 | 指数退避上限 |
| 客户端 | delivery.timeout.ms | 30000 | 必须 > reconnect.backoff.max.ms |
| Broker | num.metadata.cache | 1000 | topic < 500 时默认够用 |
| 监控 | __cluster_metadata lag | < 10 正常,> 100 告警 | 真正的「窗口宽度」指标 |
| 监控 | MetadataRequest QPS | > 10x 正常值 = 风暴 | 区分正常切换 vs 异常卡住 |
| 监控 | 客户端 UnknownTopicOrPartitionException 计数 | 切换后 30s 内出现 = 正常 | 区分 metadata 过期 vs topic 真删 |
判断口诀:controller 切换后 30s 内的 UnknownTopicOrPartitionException + MetadataRequest 风暴 = 正常现象,等;超过 30s 未恢复 + __cluster_metadata lag > 100 = broker apply 卡住,查日志。
抛一个开放问题给大家:
KRaft 3.6 引入了 KIP-835(metadata update via RPC push),controller 切换后 broker 的 metadata 更新从「拉模型」变成「推模型」——controller 主动把新 metadata push 给 broker,而不是等 broker 来拉。如果推模型把 apply 窗口从 500ms~1.5s 压缩到 < 100ms,那客户端的 refresh 风暴是否还会发生?
我的推测是会,但窗口更短:因为客户端的被动触发机制(error_code / throttle_time_ms)仍然依赖 broker 的 Metadata 响应,而 broker 的 MetadataCache 更新仍然是异步的(只是更快了)。真正能彻底消除风暴的,可能是 KIP-835 的 metadata 版本号——broker 在响应里带 version,客户端发现 version 变了就立即刷新,而不是等 error_code 触发。
你们线上 KRaft 3.5 → 3.6 的升级计划里,有没有人专门测过 controller 切换窗口的变化? 特别是 __cluster_metadata lag 的分布——如果 P99 从 25 降到 3,那客户端侧的 reconnect.backoff.ms=500 可能就不需要了。想听听实测数据。