Act 0 · 源头 / Chapter

故事从共识算法讲起:一个无处不在的角色,
为什么在 Kafka 缺席了十年

先不谈 patch,先谈一个问题:"抄一份数据但不参与投票"这个角色,凭什么别的系统都有、唯独 Apache Kafka 没有?
要理解我们做了什么,得先理解我们没做什么——我们没发明新东西,只是把共识世界的通用常识,补进了 Kafka。
Learner 遍地开花 Raft/etcd/TiKV/…五系统 唯独 Kafka 独缺 ISR 模型天生二元 答案都不给你 MRC 收费 · fork 私有 KIP-929 空占位 需求为什么真实 交易所三条硬约束 我们决定自己做 可审计 patch set
Takeaway:Act 0 只做一件事——把 observer 放回它本该在的位置。它是分布式共识世界人人都有的标准角色,Kafka 的缺席不是"没想到",而是结构使然。
Act 0 · 源头导览

为什么说这不是奇技淫巧 · Learner 是共识世界的标准角色

动机全都一样:共识要等多数派(慢),数据想多存一份(要)——把两者解耦的角色,就叫 learner / non-voter / observer。它同步全量日志,但不投票、不算 quorum、不能当选
Leader Voter · 算 quorum Voter · 算 quorum Learner · 不算 quorum commit / 确认线 只等多数 voter learner 抄得慢也不影响 commit
系统 角色名 同步全量日志 参与投票 / 算 quorum 典型动机
Raft (论文/协议) non-voting member / learner ✓ 全量 ✗ 不投票 新成员先追平再入组,避免拖慢 commit
etcd Learner (v3.4+) ✓ 全量 ✗ 不计 quorum 安全扩容,追平后 MemberPromote 转正
TiKV (Raft) Learner ✓ 全量 ✗ 不投票 副本调度过渡态 + follower read 分流
SofaJRaft Learner ✓ 异步全量 ✗ 不投票 只读副本 / 冷备,不影响写路径延迟
ZooKeeper Observer (3.3.0+) ✓ 全量 ✗ 不参与 Zab 投票 扩读吞吐、跨机房,不拖慢写多数派
Takeaway:五个系统、五个名字、一个模式——"全量同步数据、但不参与投票/不算 quorum"。这是共识世界的标准角色,不是奇技淫巧;observer 之于 Kafka,就是这一栏本该有的第六行。
Act 0 · Learner 全景

缺席的结构根源 · Kafka 的 "quorum" 叫 ISR,它把两件事捆死成了一件

别的系统早就拆开了:"同步数据""有选举/确认资格" 是两个独立维度。而 Kafka 只有一个集合 ISR,把数据同步、acks 确认、选举资格全绑在同一个成员身份上——所以 replica 只有两种活法。
别的系统 · 两个独立维度 同步数据 → 投票资格 ↑ voter learner learner = 同步满格,投票资格为零 Kafka · 一个集合 ISR 绑死两件事 in ISR 同步✓ + 确认✓ + 选举✓ (三合一,拆不开) out of ISR 掉队 / 被踢出 三样全没 活法 ① 把远端副本放进 ISR 一致性成立、byte-identical ✓ 但 HW = min(ISR 成员 LEO) acks=all 等最慢成员 → 主路径继承跨 AZ 延迟 活法 ② 踢出 ISR + 跨集群工具 主路径无感 ✓(异步) consume → re-produce 第二 offset 空间 → EOS 结构性丢失 缺的第三种 活法
Takeaway:Kafka 缺 observer 不是"忘了做",而是 ISR 模型天生二元——它把同步/确认/选举捆成一个身份。两种活法各违反交易所的一条硬约束,中间那个"同步满格、确认与选举资格为零"的点,配置空间里根本不存在。
Act 0 · 结构根源

答案早已存在,只是开源世界拿不到 · 收费 / 私有 / 空占位

业界解法统一收敛到同一个东西——第三种 replica 状态。但今天想用它的人,面对的是三分格局:
形态 可得性
Confluent MRC
Multi-Region Clusters Observers
商业产品
CP ≥ 5.4,核心卖点
闭源 · 付费
大厂内部
Learner replicas
私有 fork
各自实现
不公开
Apache Kafka 上游
KIP-929
"Observer Replicas"
wiki 提案页
正文 0 字节
占位符,非计划
Confluent 的演进印证了它是刚需:CP 5.4 先上 ZK 版 → 7.5 补 KRaft → 8.0 删 ZK——把 observer 做成了 Multi-Region Clusters 的核心能力。
cwiki.apache.org / KIP-929: Observer Replicas
KIP-929: Observer Replicas
Status: Under Discussion · Created: —
body length = 0
(页面正文为空——无 Motivation、无 Proposal、无 Design)
$ curl …/KIP-929 | body_length
→ 0 // Confluence API 验证,截至 2026-07
Takeaway:这个能力行业早已给出答案,只是开源世界一个都拿不到——买 Confluent、抄不到的私有 fork、或一个正文零字节的 wiki 页。"等上游 KIP-929"等于等一个空占位符,不是可选项。
Act 0 · 三分格局

QA · 这个能力到底为什么重要,值得动内核?

Q:不就是"多存一份备份"吗,MM2 / 多副本不够用?为什么交易所非要它?
A:因为撮合 / 账本类系统要在远端保有一份 byte-identical、RPO=0 的副本,同时主链路延迟一微秒都不能多付。这两条在 vanilla Kafka 里不可能同时满足。拆成三条硬约束看:
约束 ①
异地强一致备份
已 ack 写入 RPO=0,且 exactly-once 语义全程不断——远端那份必须能作为账本的可信副本。
约束 ②
绝不拖主链路
acks=all 不能等它、HW 不能被它牵制——远端副本进 ISR 就把跨 AZ 延迟灌进每一次确认。
约束 ③
灾难时能顶上
平时透明不可见,主力全灭时秒级晋升成正式成员、当选 leader 继续服务——不是冷备。
①②在 vanilla Kafka 里天然打架:满足 ① 要它进 ISR,进 ISR 就违反 ②;满足 ② 要它出 ISR,出 ISR 又违反 ①、且丢掉 ③。observer 是唯一同时守住三条的构造——而这个需求,所有金融 / 交易类客户画像都共通。
Takeaway:这不是"锦上添花的多一份备份",而是一个 vanilla Kafka 数学上无法同时满足的三约束交集。需求真实且普遍——所以值得、也只能,动一次内核。
Act 0 · 为什么重要

我们的选择 · 三条路里为什么走第三条

要这个能力,今天只有三条路。前两条要么付钱要么养团队,我们选了第三条——一套可审计的 patch set,并把整个过程记录成这份百科。
路线 A
买 Confluent MRC
要厂商支持合同、要 observerPromotionPolicy 自动策略 → 选它。代价:闭源、付费、绑定。
路线 B
自维护私有 fork
有内核团队长期养它 → 选它。代价:跟不上上游、无人可对照、每次升级都是一次冒险。
路线 C ← 我们
可审计的最小 patch set
今天就要、要能自己读懂每一行 → 选它。全部复用原生 ISR expand/shrink,看完这套演讲你能自己复现。
为什么走得通 · 工程立场
  • Apache-2.0:分发 patch 而非 binary,标注 modified、保留 license、不称 "Apache Kafka",compliance 面压到最小。
  • 改动极小:ZK 版 ~60 行 / KRaft combined ~115 行,全部是在原生传送带上装闸门。
  • 不发明新机制:闸门打开 = 原来的 Kafka。
原生 ISR expand/shrink 传送带 闸门 (patch) observer 被挡在 ISR 外,其余照常通过
Takeaway:我们没有发明新机制,只是在 Kafka 原有的传送带上装了一道闸门——闸门打开,它就是原来的 Kafka。这份百科,就是这条路从源头到落地的完整过程记录。
Act 0 · 三条路的决定
Act 1 · Chapter

把问题拆到不能再拆:
为什么这是状态机缺一个状态,而不是缺一个参数

交易所要的是:远端一份 byte-identical 的强一致备份,同时它绝不能拖慢撮合主链路。 在 vanilla Kafka 里,一个 replica 只有两种活法——我们先证明这两种各违反一条硬约束,再用三连 QA 关掉所有旁门。
二元状态机 两个状态各违反一条硬约束 Q1 为什么调参数解决不了? HW=min(ISR LEO) 是定义不是配置 Q2 为什么 MM2 不行? 两个 offset 空间 · EOS 结构性断 Q3 为什么必须改内核 准入 choke point 在 broker 进程内 缺失的第三状态 同步一切 · 对 acks/HW/election 不可见
导览:不是"少调了一个 knob",而是"状态机少了一个状态"——三连 QA 会逐一关掉调参、MM2、外部工具三条旁门,逼出唯一出路:最小内核 patch。
Act 1 · 问题深拆

副本只有两种活法 · 而它们各违反一条硬约束

两条硬约束缺一不可:① 强一致(已 ack 写入 RPO=0,EOS 不能断) · ② 不拖主路径(acks=all / HW 绝不等它)。 vanilla Kafka 的 replica 状态机只有两态——进 ISR 违反 ②,出 ISR 违反 ①。
Leader 低延迟 AZ · acks=all 状态 ① in-ISR 远端副本进 ISR(纯配置可达) 一致性 ✓ · byte-identical ✓ 但……它现在算"正式组员" 状态 ② out-of-ISR 踢出 replica set + MM2 跨集群搬 主路径无感 ✓ · consume→re-produce 但……数据要重新生产一遍 ✗ 违反约束 ② HW = min(ISR 成员 LEO) acks=all 要等最慢成员 跨 AZ 副本进 ISR 后 acks=all 2.38 ms ✗ 违反约束 ① 第二 offset 空间 · PID=-1 EOS 结构性丢失 实测 kill -9 → 20,000 条精确翻倍 缺失的状态 ③ · Observer 同步一切数据 · 对 acks / HW / election 完全不可见
两态各违反一条硬约束、彼此互斥——配置空间里根本没有"既强一致又不拖 HW"的点。缺的是第三个副本状态,不是某个参数值。
Act 1 · 二元状态机
QA · 第一道旁门
Q:那我把 replica.lag.time / minISR / acks 调一调,
不就行了吗?
A:不行——因为你要动的那个量,是定义,不是配置。
HW 是一个恒等式,不是旋钮
// Kafka 的 high-watermark 定义(原生, 非 patch 引入) HW = min( LEO of each replica in ISR ) // acks=all 的 ack 点 == HW 推进点 // 只要副本在 ISR 里, HW 数学上必须等它
所有你能调的参数——replica.lag.time.max.msmin.insync.replicasacks——全部只在 ISR 这个集合的定义域内做文章。
两难:无论副本在集合里还是集合外
副本 ISR 里
→ HW 必须等它 → acks=all 继承跨 AZ 延迟。违反约束 ②
副本不在 ISR 里
→ 没有任何参数能让它保有选举资格与强一致语义。违反约束 ①
要的那个点
"同步一切但对 HW 不可见"——在配置空间里根本不存在
调参调的是"ISR 里的成员怎么算",改不了"HW = min(ISR 成员 LEO)"这个恒等式本身。想让副本对 HW 不可见,只能改变它是否算 ISR 成员——那是代码,不是配置。
Act 1 · QA 为什么调参不行
QA · 第二道旁门
Q:那用 MirrorMaker 2 把数据复制到远端,不就有备份了?
A:MM2 本质是 consume→re-produce——它天生就断掉了 exactly-once,而且这是模型的必然,不是 bug
① 两个 offset 空间
目标端用自己的 producer 重新分配 offset,源 offset 与目标 offset 不可能恒等——续传只能靠"至少一次"。
② producer 会话断裂
目标端是全新 producer 会话,实测 PID=-1(默认非幂等)→ broker 无从去重。
③ txn marker 不传播
COMMIT / ABORT 控制批不被搬运;KIP-618 也只覆盖 produce 侧,穿越复制的 EOS 无解。
同一个 kill -9 · 真机对照(东京 loadgen,Act 6 详拆)
# 源 topic: 20,000 条 idempotent 写入, 源末端 offset = 20000 # 在 offset-flush 窗口内 kill -9 MM2, 重启后从头重放 目标末端 offset = 40000 distinct = 20000 每条 payload 恰好出现 2 次 前半 / 后半 CRC 序列 md5 完全相同 = 同一份源字节被 +20000 偏移再投一次 目标所有 batch producerId = -1 # 非幂等, broker 无从去重 --- 对照: 同故障下 Observer 侧 0 重复 (offset 恒等 / PID 保留 / CRC 一致) ---
差的不是参数,是模型:只要中间隔了一次"重新生产",就必然有第二个 offset 空间和 at-least-once 重放窗口。
MM2 是跨集群 mirror-the-stream 的正确工具,但它结构上给不了同集群 RPO=0 + EOS。region 内 replicate-the-log、region 间 mirror-the-stream——两个家族 compose,不 compete。
Act 1 · QA 为什么 MM2 不行
QA · 第三道旁门(也是最后一道)
Q:那用外部工具 / 客户端配置去拦,为什么也不行?
——真的非改内核不可吗?
A:因为决定"要不要把副本放进 ISR"的那个开关,只存在于 broker 进程内部——客户端、配置、外部工具全都够不着。
唯一的准入 choke point 在这里
follower fetch 追平 LEO maybeExpandIsr() canAddReplicaToIsr() ← 唯一闸门 Partition.scala @1044 AlterPartition → controller 加入 ISR return false ✗
return false → AlterPartition 请求根本不发出。这一点天然跨 ZK / KRaft 两种 controller 世界。
于是问题反转了
需要的是第三种 replica 状态:同步一切数据、对 acks / HW / election 完全不可见。这个状态的开关只在 Partition.scala 里——客户端和配置都看不到它。
// v0.1 全部改动就 8 行 (真实 patch) private def canAddReplicaToIsr(id: Int) = { // observer 永不进 ISR if (ObserverIds.isObserver(id)) return false ... // 以下原生逻辑不动 }
答案只能是最小内核 patch——问题反而变成"怎么改得足够少":只在这一个闸门上加一个谓词,其余全部复用原生 ISR expand/shrink。
调参够不着定义、MM2 隔着重新生产、外部工具够不着进程内的闸门——三条旁门全关,唯一出路是在 canAddReplicaToIsr 装一道 8 行的 gate。这就是 Act 2/3 要逐行展开的东西。
Act 1 · QA 为什么必须改内核
ACT 2  ·  机制原理 / CORE MECHANISM
这一整幕只讲三样东西:一个比喻一条因果链一个守门函数
Act 1 我们论证了"这是状态机缺一个状态"。这一幕回答: 那个状态长什么样、它的安全性凭什么成立、以及全部改动为什么能收敛到一个函数——看完你能在脑子里把 observer 的每一条性质自己推一遍。
① 记账小组比喻
主记账员 / 正式组员 / 旁听生——先把 leader、ISR、HW、observer 换成一间办公室里的四个人,后面每个机制都能映射回这张图。
② HW 因果链
HW = min(ISR 成员 LEO)。确认、可用性、选举三大机制共享同一个定义域 ISR——这是原生代码事实,不是 patch 引入的。
③ 唯一守门函数
canAddReplicaToIsr——"not in ISR" 一条前提结构性推出四条安全性质;运行期扩容的唯一闸门 v0.1 全部 8 行。
收尾一页 QA + 一页 runbook 预览: 为什么"只挡一个门"在运行期够、在全生命周期不够(诚实预告 Act 3 的迭代故事),以及三个故障场景旁听生分别怎么动。
Takeaway: 机制不是黑盒——一个比喻建立直觉,一条因果链给出定义域,一个守门函数收敛所有改动;三者串起来,observer 的安全性可以在脑内推导
Act 2 · 导览
为什么先讲比喻 · 把 leader / ISR / HW / observer 换成办公室里的四个人
主记账员 leader · 新账先写它 正式组员 (ISR) 每笔都要等他们抄完 确认线 HW = 抄得最慢的正式组员 旁听生 (observer) 照抄全部账,但不算数 确认线不等他 主记账员出事 . 数据一笔不少 也轮不到他
你想在远端机房放一个抄账员做备份。如果他是正式组员,每笔账都要等他——远,所以慢,整组被他拖住。这就是 vanilla Kafka 的困境。
我们的全部改动就一件事: 允许一种"旁听生"身份——照常抄账(数据全量同步)、确认线不等他(不拖 HW,实测 2.04–2.35 ms 快对水平)、主记账员出事也轮不到他(不参与选举)。
Takeaway: 记住这三个人,后面每一个机制、每一段代码、每一个故障场景都能映射回这张图——比喻不是简化,是这套设计的心智模型本身。
Act 2 · 记账小组比喻
这条链不是我们发明的 · HW = min(ISR 成员 LEO) 是 Kafka 的原生定义
① 确认 (acks=all) ack 点 = HW 推进点 ② 可用性 (minISR) 只数 ISR 成员个数 ③ 选举 (election) 候选只来自 ISR ISR 唯一共享定义域 in-sync replica set HW = min(LEO of ISR members)
确认: producer 拿到 ack 的那一刻,就是 HW 越过它 offset 的那一刻。ack 等的就是 HW。
可用性: min.insync.replicas 数的是 ISR 里的成员数,不是总副本数。
选举: leader 候选人只能从 ISR 里挑——ISR 外的副本没有选举资格。
Takeaway: 确认、可用性、选举三大机制共享同一个定义域 ISR——这是原生 Kafka 代码事实,patch 一个字都没改它。理解了这条链,下一页的四条安全性质你就能自己推。
Act 2 · HW 因果链
一个前提推出四条性质 · "把它挡在 ISR 外" 之后,安全性是结构性跟随的
not in ISR 唯一前提 · 单一 choke point 被 canAddReplicaToIsr 挡住 HW = min(ISR 成员 LEO) → HW 永不等它 零延迟拖累: 复制多慢都与 producer 无关 acks=all 只等 ISR 成员 → 不计 acks producer 永不等它——ack 点就是 HW 推进点 minISR 只数 ISR 成员 → 不掩盖真相 无法虚增可用性余量——真实损失可见 候选只来自 ISR → 永不当选 clean / unclean / preferred 全路径不当 leader 推导方向单向: 前提 → 性质 不需要发明任何并行机制
Takeaway: 一整页推导树 = 一条前提。所有安全论证收敛为"把副本挡在 ISR 外"这一件事——这也是 Act 5 的 8 场景实弹能全绿的根本原因: 性质是结构性跟随的,不是逐个补丁堆出来的。
Act 2 · 推导树
全部改动为什么能收敛到一个函数 · Partition.canAddReplicaToIsr,v0.1 只有 8 行
// core/src/main/scala/kafka/cluster/Partition.scala // @@ -1044,6 +1044,13 @@ (Kafka 3.7.1 真实行号) 1044 private def canAddReplicaToIsr( followerReplicaId: Int): Boolean = { // === OBSERVER PATCH v3 === observer 永不进 ISR // 列表来自动态文件(5s 缓存, 零重启) if (kafka.observer.ObserverIds .isObserver(followerReplicaId)) { return false } // === END OBSERVER PATCH === val current = partitionState !current.isInflight && !current.isr.contains(followerReplicaId) && ...
FQN 调用kafka.observer.ObserverIds)不碰 import 块——这是多版本 patch 冲突面最小化的工程手法(Act 3 展开)。
调用链: "the only gate"
follower fetch 追平 leader LEO
  ↓
updateFollowerFetchState
  ↓
maybeExpandIsr
  ↓
canAddReplicaToIsr ← 唯一准入点
  ↓ return false = 到此为止
AlterPartition 请求 根本不发出
天然跨模式: 拦截点在 broker 侧,Partition.scala ZK / KRaft 两模式共享——这是 KRaft 移植时 broker 侧三个 hook 原封不动复用的根据。
真机(东京 ap-northeast-1): 30,000 条全量同步、追平后仍 Isr: 2,3 / Replicas: 2,3,1,acks=all 保持 2.35 ms avg。
Takeaway: 在请求发出之前拦截,而不是在 controller 侧拒绝——一个 8 行的 broker 侧 gate 天然覆盖 ZK 和 KRaft 两个世界。这 8 行就是整个项目的原点。
Act 2 · 守门函数
QA · 为什么只挡一个门就够?——v0.1 假说成立的部分,与被现实攻破的部分
Q: 挡住 canAddReplicaToIsr 一个门,真的就能保证 observer 永远进不了 ISR 吗?
A(假说成立的部分): 运行期扩容,确实只有这一个门
任何"已经在跑"的分区,副本想进 ISR 必须走 follower fetch → maybeExpandIsr → canAddReplicaToIsr 这条唯一路径。挡住它,扩容请求根本发不出。

真机验证: observer 全量同步 30,000 条、追平 leader LEO,仍 Isr: 2,3 不进;同时 acks=all 稳在 2.35 ms——假说在运行期完全成立。
A(被现实攻破的部分): 还有两条不走这个门的路
① topic 创建: 新分区的 initial ISR 由 controller 直接塞入所有存活副本,完全绕过 maybeExpandIsr——observer 生下来就在 ISR 里。
② unclean election: ISR 全灭时 unclean 路径可能选出一个永不能回 ISR 的 observer → 分区锁死。
诚实的结论: 答案是"运行期够全生命周期不够"。这两条被攻破的路正是 v0.1 → v0.2 的迭代动机——它们不是 code review 发现的,是"attack your own patch"打出来的。完整的补门故事在 Act 3 代码百科逐行展开(初始 ISR 过滤 + unclean 排除)。
Takeaway: "只挡一个门"是一个可以被证伪的工程假说——它在运行期被验证、在边界被攻破;诚实地承认边界,比声称"一个门解决一切"更能给人信心。
Act 2 · QA 一个门够不够
比喻收束 · 主记账员出事时,旁听生分别怎么动——三场景 runbook 预览
场景 发生了什么 旁听生怎么动 结果
A
挂 1 AZ
RF4 拓扑,挂 1 个主力 AZ,ISR 从 3 掉到 2,仍 ≥ minISR=2 零动作——完全不用碰旁听生 写入继续,非事件(真机 300/300 acked
A′
挂 2 AZ
挂 2 个 AZ,ISR < minISR → fail-stop(宁停不丢) 停写 → 从 observer.ids 删 id → ≤10 s 进 ISR → 恢复 RPO=0(log 本就 byte-identical,晋升只改身份不追数据)
B
主力全灭
全部 ISR broker 死,旁听生是唯一幸存者 → Leader: none(永不静默接管) 比对 LEO 确认 zero-lag → 删 id + 显式 unclean election → 晋升当选 真机 9.4 s 当选 leader,服务读写
RF4 = 3 ISR + 1 observer, minISR=2 的设计哲学: 平时旁听生完全透明(不进 ISR、不算 minISR、不拖 HW、不选举),把它留给真正的灾难——丢 2+ AZ。
进出 ISR 本身是 Kafka 原生机制自动完成的;运维要做的只是"编辑一个文件"。三场景逐秒拆解 + 谁来操作,在 Act 5 生命周期展开。
Takeaway: 比喻在这里闭环——旁听生平时不存在、出事时秒级顶上;场景越严重,动作越多,但每一步都能映射回"删/加一行 id",且从不牺牲一致性(RPO=0)。
Act 2 · Runbook 预览
【Chapter】代码百科 · ~115 行逐段展开, 每一行都能回答"为什么这么写"

从这一幕起, 我们把整套 patch 摊在桌上——不是"大概改了哪里", 而是真代码、真行号、真调用链。前半场只讲两个文件: 一个决定"谁是旁听生"(ObserverIds), 一个决定"旁听生怎么被挡在 ISR 门外"(Partition)。

ObserverIds.scala 76 行 · 独立 package "谁是旁听生"——动态身份 文件 → 5s 缓存 → fail-safe Partition.scala 3 hook · broker 侧共用 "怎么挡在 ISR 门外" 晋升闸门 / 降级 / HW 后半场 → Java 侧镜像 · RCM v0.1→v0.3 两课 FQN 调用 isObserver() 本幕前半 · 五页路线 P21 · 身份从哪来 文件 · env 回退 · 格式 P22 · 缓存 / fail-safe nanoTime · volatile · 冻结 P23 · Hook #1 晋升闸门 @1044 P24 · Hook #2 降级钩子 @1312 P25 · Hook #3 HW 闸门 @1176
Takeaway: 前半场只有两个文件——一个自包含的身份对象 (ObserverIds) + 三处 broker 侧 hook (Partition)。合起来不到 90 行, 却是整个 observer 能力"运行期"的全部。娓娓道来的顺序: 先讲身份从哪来, 再讲身份怎么被用来守门。
Act 3 · 代码百科导览
ObserverIds.scala (上) · 身份从哪来——一个文件、一条回退链、一套宽容的格式
// core/src/main/scala/kafka/observer/ObserverIds.scala (new, +76) package kafka.observer // ← 独立 package, 调用点全 FQN object ObserverIds extends Logging { // ① 主来源: 文件路径, env 可覆盖, 默认 /opt/kafka/observer.ids private def filePath: Path = Paths.get(Option(System.getenv( "KAFKA_OBSERVER_IDS_FILE")) .getOrElse("/opt/kafka/observer.ids")) // ② 回退: 文件不存在 → 读 env (兼容 v1/v2 部署) // Try(_).toOption 而非 toIntOption: // 为兼容 Scala 2.12/2.13 双构建, 故意不用 private def envFallback: Set[Int] = Option(System.getenv("KAFKA_OBSERVER_BROKER_IDS")) .getOrElse("").split(",").iterator .map(_.trim).filter(_.nonEmpty) .flatMap(s => Try(s.toInt).toOption).toSet // ③ 解析文件: 注释 / 分隔符 / 非数字 全部宽容处理 private def readFromFile(): Set[Int] = { val p = filePath if (!Files.exists(p)) envFallback else Files.readAllLines(p).asScala.iterator .filterNot(_.trim.startsWith("#")) // 整行注释 .flatMap(_.split("[,\s]+")) // 逗号/空白 .map(_.trim).filter(_.nonEmpty) .flatMap(s => Try(s.toInt).toOption) // 非数字静默忽略 .toSet }
observer.ids 文件 存在? 以文件为准 env 变量 KAFKA_OBSERVER_BROKER_IDS Set[Int] 空文件 = 无 observer 文件缺失才走 优先级: 文件 > env > 空
格式为什么这么宽容?
运维半夜改文件, 容不得一个多余空格打死 broker。所以: # 整行注释、逗号空白都能分隔、非数字 token 静默忽略而非报错。3 / 3,4 / 每行一个, 三种写法等价。
为什么独立 package + 全 FQN?
调用点写全名 kafka.observer.ObserverIds.isObserver(...), 不碰任何 import 块——多版本 patch 移植时 import 区最易冲突, 不动它就把冲突面压到最小。这是"3.6→4.1 六版本零手工"的工程前提之一。
Takeaway: 身份的唯一真相是一个文本文件 (/opt/kafka/observer.ids), env 只是兼容老部署的回退。格式刻意宽容、package 刻意独立——这两个"小气"的决定, 换来的是运维安全与多版本可移植。
Act 3 · ObserverIds 身份来源
ObserverIds.scala (下) · 为什么用 nanoTime 减法、为什么无锁、为什么读失败也不死
// 5 秒 TTL 缓存: Hook #1 在 fetch 热路径上, 不能每次读盘 private val CacheTtlNanos = 5L * 1000 * 1000 * 1000 // 5s // @volatile 三字段, 无锁读 @volatile private var cachedIds: Set[Int] = Set.empty @volatile private var lastRefreshNanos: Long = 0L @volatile private var initialized: Boolean = false def current(): Set[Int] = { val now = System.nanoTime() // 减法比较, 对 nanoTime 回绕安全 (wall clock 不行) if (!initialized || now - lastRefreshNanos >= CacheTtlNanos) { try { val fresh = readFromFile() if (fresh != cachedIds) info(s"Observer id set changed: $cachedIds -> $fresh") cachedIds = fresh } catch { // 读失败(权限/IO) → 保留旧值 + WARN, 绝不外抛 case NonFatal(e) => warn(s"Failed to read observer ids, keeping $cachedIds", e) } lastRefreshNanos = now; initialized = true } cachedIds } def isObserver(brokerId: Int): Boolean = current().contains(brokerId)
Q · 为什么是 nanoTime 减法, 不是比时间戳?
System.nanoTime() 可能溢出回绕成负数, 但 now - last差值在回绕时依然正确 (二补数运算)。若改用 wall-clock, 一次 NTP 校时或闰秒就可能让缓存"永不过期"或"疯狂刷新"。热路径上的判定必须对时钟异常免疫。
Q · 三个 @volatile 无锁, 会不会读错?
最坏竞态 = 两个 fetch 线程同时判过期、并发各读一次文件。读文件是幂等的, 结果相同, 无害。省掉锁 = fetch 热路径零争用。这是"用正确性换性能"里少见的两头都赢。
Q · 文件读失败了会怎样? (刻意取舍)
NonFatal 捕获 → 保留上次缓存值 + 打 WARN, 异常绝不外抛。配置文件永远不能打死 broker——chmod 000、写入垃圾、误删, broker 都照常跑, 只是"身份名单冻结在最后一次成功读取"。晋升方向天然 fail-safe: 读不到就当它还是 observer, 挡在 ISR 外。
Takeaway: 这 30 行是整套 patch 里被推敲最多的部分——nanoTime 抗时钟回绕、volatile 无锁抗热路径争用、NonFatal 抗一切 IO 意外。三条都指向同一个信条: observer.ids 出任何问题, 代价至多是"晋升慢一点", 绝不是"broker 挂一台"
Act 3 · ObserverIds 缓存与 fail-safe
Partition.scala Hook #1 · canAddReplicaToIsr @1044——整个项目的原点, v0.1 只有它
// core/.../cluster/Partition.scala @1044 private def canAddReplicaToIsr( followerReplicaId: Int): Boolean = { + // === OBSERVER PATCH v3 === + // observer 永不进 ISR。名单来自动态文件(5s 缓存)。 + if (kafka.observer.ObserverIds.isObserver( + followerReplicaId)) { + return false + } + // === END OBSERVER PATCH === val current = partitionState !current.isInflight && !current.isr.contains(followerReplicaId) && ... }
净增 8 行, 语义只有一句
"是 observer 就 return false"。return falseAlterPartition 请求根本不会发出——拦截点在 broker 进程内, 天然跨 ZK / KRaft 两模式 (同一份 Partition.scala)。design-story 原话: "the only gate"

调用链: follower 每次 fetch 追平 leader LEO 时, leader 就会走到这里决定"要不要发起 ISR 扩容"。放行才有后续, 拦住则一切归零。

follower fetch (有流量 <500ms) updateFollowerFetchState maybeExpandIsr canAddReplicaToIsr ← 唯一闸门 isObserver? 走 5s 缓存 observer → false 正常 → 放行 AlterPartition → controller AlterPartition 根本不发出
Takeaway: v0.1 整个 patch 就这 8 行——把闸门装在"发起扩容"这个唯一 choke point 上, observer 复制得再快也进不了 ISR。这是全场所有安全性质的物理起点; 但它只管"运行期扩容", 还有两条不走这个门的路 (topic 创建直塞 / unclean 选举)——伏笔留给后半场。
Act 3 · Hook #1 晋升闸门
Partition.scala Hook #2 · getOutOfSyncReplicas @1312——净增 1 行, 借 isr-expiration 的便车
// @1312 · leader 周期任务(周期=lag.time.max.ms/2)找掉队副本 val candidateReplicaIds = current.isr - localBrokerId val leaderEndOffset = localLogOrException.logEndOffset - candidateReplicaIds.filter(replicaId => - isFollowerOutOfSync(replicaId, leaderEndOffset, ...)) + val laggingReplicaIds = candidateReplicaIds.filter( + replicaId => isFollowerOutOfSync(replicaId, ...)) + // === OBSERVER PATCH v3: 降级钩子 === + // 已在 ISR 内但被标记 observer 的副本, 视同 out-of-sync + // → 原生 shrink 流程(日志/AlterPartition/HW 重算)挤出去 + laggingReplicaIds ++ candidateReplicaIds.filter( + kafka.observer.ObserverIds.isObserver) + // === END OBSERVER PATCH ===
为什么只加一行就够?
不发明新流程, 只是往"掉队副本名单"里追加 observer。剩下的日志、AlterPartition、HW 重算全是原生 shrink 走一遍——所以降级是零重启的。
observer 态 在名单里 · 不在 ISR ISR 成员 删 id · Hook #1 放行 晋升: 删 id (Hook #1) 降级: 加回 id (Hook #2 便车)
双向开关 · 状态收敛稳定
Hook #1 + Hook #2 让 observer.ids 成为双向开关: 删 id = 晋升, 加 id = 降级。被挤出去后想回 ISR 会被 Hook #1 拦住——状态不会来回抖。真机: 降级 Isr: 2,3,1 → 2,3 约 9–12 s (搭周期任务便车比晋升多一个相位差)。
埋一个伏笔: leader 不自剔
原生 shrink 永远不 shrink leader 自己。所以"降级一个正在当 leader 的 observer"静默无效——必须先挪走 leadership 或 bounce。这条硬边界在 Act 4/5 展开。
Takeaway: 降级不是新机制, 而是"把 observer 塞进原生 isr-expiration 的掉队名单"——1 行代码搭上了 Kafka 每 lag.time.max.ms/2 就跑一次的便车, 零重启把它挤出 ISR。与 Hook #1 合成一个稳定的双向开关。
Act 3 · Hook #2 降级钩子
Partition.scala Hook #3 · maybeIncrementLeaderHW @1176——封掉那个 30 秒的理论窗口
// @1176 · HW 推进时判断"该不该等这个副本追上来" val replicaState = replica.stateSnapshot def shouldWaitForReplicaToJoinIsr: Boolean = { + // === OBSERVER PATCH v3: HW 推进永不等待 observer === + // (它也永远不会加入 ISR) + !kafka.observer.ObserverIds.isObserver(replica.brokerId) && replicaState.isCaughtUp( leaderLogEndOffset.messageOffset, currentTimeMs, replicaLagTimeMaxMs) && isReplicaIsrEligible(replica.brokerId) }
微妙点: 这是两个不同的函数
HW 推进判断用的是 isCaughtUp + isReplicaIsrEligible——和 Hook #1 的 canAddReplicaToIsr 不是同一处。挡住"能不能进 ISR"不等于挡住"HW 要不要等它"。所以要单独补这一个 && 前缀条件。

为什么存在这个窗口? 一个尚未进 ISR、但仍处在 replica.lag.time.max.ms (默认 30 s) 追赶窗口内的 observer, 理论上会被 HW 推进逻辑"等一下"——即便它永远进不了 ISR。

t=0 落后 30 s 窗口 补丁前: HW 理论上可能等 observer 经验性质 补丁后: 从第 0 秒起就不等 → 窗口清零 结构性质
从"经验性质"升级为"结构性质"
补上这个 gate 后, "永不拖 HW"不再依赖"observer 恰好总是追得上"这种经验观察, 而是代码上直接排除。真机组合验证 (Hook #1 + #3): kill observer, producer 与 ISR 完全无感——它根本不在 ack 路径上。
Takeaway: Hook #3 补的不是一个真实事故, 而是一个"理论上够得着"的 30 秒窗口。为什么值得为它单加一行? 因为百科全书式的可信度来自把每个边角都堵死——让"observer 永不拖 HW"从"我们没见它拖过"变成"代码结构上它拖不了"。
Act 3 · Hook #3 HW 闸门
Java 侧 · 为什么同一段逻辑, 我们被迫用两种语言各写一遍

Broker 侧的 ObserverIds.scala 已经能读文件、判身份了——那 KRaft 为什么还要一个 157 行的 ObserverReplicas.java? 答案不在功能, 在模块依赖方向: KRaft 的 quorum controller 活在 metadata 模块 (纯 Java), 而 metadata 不允许反向依赖 core——所以 Scala 版够不着, 只能做一个逐条对齐的纯 Java 镜像。

core (Scala) kafka.observer.ObserverIds broker 侧 · 76 行 metadata (Java) controller.ObserverReplicas KRaft controller 侧 · 157 行 metadata ↛ core 不能反向依赖 → 复用不可能 逐条对齐重写 (同格式/同缓存/同 fail-safe)
// metadata/.../controller/ObserverReplicas.java (157 行, package-private final) final class ObserverReplicas { private static final long CACHE_TTL_NANOS = 5L*1000*1000*1000; // 5s, 与 Scala 版逐字对齐 private static volatile Set<Integer> cachedIds = Collections.emptySet(); static Set<Integer> current() { // 事件线程调用, 绝不每 call 读盘 long now = System.nanoTime(); // nanoTime 减法, 对回绕安全 if (!initialized || now - lastRefreshNanos >= CACHE_TTL_NANOS) { try { cachedIds = Collections.unmodifiableSet(readFromFile()); } catch (Exception e) { // fail-safe: 保留旧值, 只 WARN, 绝不抛 log.warn("Failed to read observer ids ..., keeping {}", cachedIds, e); } lastRefreshNanos = now; initialized = true; } return cachedIds; }
// controller 侧独有的第 4 个能力: 过滤初始 ISR static List<Integer> filterInitialIsr(List<Integer> isr) { Set<Integer> observers = current(); if (observers.isEmpty()) return isr; List<Integer> filtered = /* 去掉所有 observer */; if (filtered.isEmpty()) { // 兜底: assignment 全是 observer? log.warn("All replicas in initial ISR {} are " + "observers {}; keeping original ISR " + "so the partition has a leader.", isr, observers); return isr; // 新分区必须有 leader → 宁可可用 } return filtered; }
部署注意 · 源码注释原话
"this file must be present on ALL controller nodes (the controller quorum may run on machines separate from brokers)." —— observer.ids 不只放 broker, controller 节点也得有 (拓扑 B V5 实证)。
Takeaway: 不是 KRaft 需要"更多逻辑", 而是模块边界不让 Scala 版跨过去——同一份语义, 两种语言各写一遍, 逐条对齐; 多写的唯一新能力 filterInitialIsr 就是为了堵下一页要讲的那个绕过闸门的洞。
Act 3 · Java 侧 ObserverReplicas
RCM 三处 hook · 为什么一行 return false 能锁死 7 个选举入口

ZK 版要在两个独立函数里分别拦"初始 ISR"和"unclean 选举"; KRaft 把所有选举路径收敛成一个公共谓词 LeaderAcceptor.test(int)——所以只需在它开头加一行, clean/unclean/preferred/controlled-shutdown 全被一次挡死。三处 hook 从"入口 → 兜底 → 纵深"层层收口。

// ① buildPartitionRegistration @824 — 初始 ISR 过滤 (KRaft 版 Hook #4) // 一处覆盖 createTopic(手动/自动 placement) + createPartitions 三个调用点 List<Integer> effectiveIsr = ObserverReplicas.filterInitialIsr(isr); return new PartitionRegistration.Builder() .setIsr(Replicas.toArray(effectiveIsr)) // 原为 isr .setLeader(effectiveIsr.get(0)) // 原为 isr.get(0) ...
// ③ ineligibleReplicasForIsr @1263 — AlterPartition 纵深防御 // broker 没部署闸门? controller 也把 observer 判成 INELIGIBLE if (ObserverReplicas.isObserver(brokerId)) { ineligibleReplicas.add( new IneligibleReplica(brokerId, "observer")); } else if (registration == null) { ineligibleReplicas.add(/* "not registered" */); }
// ② LeaderAcceptor.test @2279 — 一行覆盖全部选举入口 @Override public boolean test(int brokerId) { // OBSERVER PATCH: observer 永远不是合格 leader, // 覆盖 clean / unclean / preferred / // controlled-shutdown 等每一个选举入口。 if (ObserverReplicas.isObserver(brokerId)) { return false; // ← 就这一行 } if (!isAcceptableLeader.test(brokerId)) { return false; } ...
clean election unclean election preferred reassignment fence · unfence controlled shutdown recovery LeaderAcceptor .test(brokerId) false
Takeaway: V6 决定性证据——开 unclean + 双 kill, observer 是唯一存活且数据完整的副本, controller 仍返回 Leader: none 而非选它 (可用性换一致性); ineligibleReplicasForIsr 让 broker/controller 双闸门互为兜底, 任一侧漏部署都朝安全侧失败。
Act 3 · ReplicationControlManager 3 hook
迭代第一/二课 · 我们怎么亲手攻破自己"只挡一个门就够"的假说

v0.1 的假说是: ISR 准入只有 canAddReplicaToIsr 一个 choke point, 挡住它一切自动跟随。运行期确实成立——但我们把方法论定成 "attack your own patch", 于是主动找不走这个门的路, 结果一天内找到两条。

follower fetch maybeExpandIsr canAddReplicaToIsr v0.1 唯一的门 (8 行) ISR 成员 可被选举 observer 在此被 return false 挡住 ✓ 攻击① 建 topic: initializeLeaderAndIsr 直塞 绕过 maybeExpandIsr → observer 生下来就在 ISR 攻击② ISR 全灭: unclean 选举挑存活副本 选出 observer → 它永不能回 ISR → 分区死锁
第一课 · 初始 ISR 直塞绕过闸门
新 topic 走 initializeLeaderAndIsrForPartitions, controller 把 all live replicas 直接塞进 initial ISR——根本不经过 maybeExpandIsr。只 gate Hook #1 时, observer 一出生就在 ISR 里。
// 修法: PartitionStateMachine.scala @288 (ZK 侧 Hook #4) val nonObserver = liveReplicas.filterNot(ObserverIds.isObserver) val isrReplicas = if (nonObserver.nonEmpty) nonObserver else liveReplicas // 兜底: 全 observer 保留原生
第二课 · unclean 选出 observer 锁死分区
unclean.leader.election=true 且 ISR 全灭时, 原生逻辑 assignment.find(liveReplicas.contains) 会挑中 observer——可它永不能回 ISR, 分区就此死锁。修法: 宁可 Leader: none 也不丢一致性
// 修法: offlinePartitionLeaderElection @536 (ZK 侧 Hook #5) val leaderOpt = assignment.find(id => liveReplicas.contains(id) && !ObserverIds.isObserver(id)) // 加一个否定
Takeaway: 假说的诚实结论是 "运行期只有一个门, 全生命周期不止一个"——建 topic 和 unclean 选举都不走 maybeExpandIsr。这两课不是失败, 是方法论的产物: 先攻破自己, 再补门。
Act 3 · 迭代 v0.1 → v0.2
迭代第三课 + patch 全貌 · 为什么 DR 机制在灾难中途要求重启是 design smell

v0.2 用环境变量 KAFKA_OBSERVER_BROKER_IDS 定身份——改身份要重启 broker。运维审查一句话点破: "灾难中途还需要重启的 DR 机制, 本身就是 design smell"。于是 v0.3 换成文件驱动 + 5s 缓存 + fail-safe——晋升/降级从此零重启。

patch 全貌 · 一套 combined diff, 两模式通吃
文件 归属
Partition.scala 17 broker · 两模式共用
ObserverIds.scala (新) 76 broker · 两模式共用
PartitionStateMachine.scala 9 controller · ZK 侧
ObserverReplicas.java (新) 157 controller · KRaft 侧
ReplicationControlManager.java 18 controller · KRaft 侧 (3 处)
combined 合计 271+ 5 文件, 271 插入 / 6 删除
单看模式: ZK patch = 151 行 diff (Partition + PartitionStateMachine + ObserverIds); KRaft combined = 360 行 diff (再叠 ObserverReplicas + RCM)。 broker 侧 3 hunk 在两模式逐字节相同——所以是"一套 jar 两模式通吃"。
方法论 · 每一步都由一次被攻破换来
1 · locate the choke point canAddReplicaToIsr (v0.1) 2 · gate it minimally 8 行, 复用原生 expand/shrink 3 · attack your own patch → v0.2 补 2 门 (初始ISR/unclean) 4 · make it operable → v0.3 文件驱动, 零重启 5 · probe every environment → v0.5 KRaft 探针替代推理 6 · prefer structural guarantees → v0.6 ELR 零新代码 (先试图攻破)
fail-safe 的刻意取舍: observer.ids 丢失/损坏 → broker 继续跑, 保留上次缓存值 + WARN——配置文件永远不能打死 broker。每代 patch 脚本存 patches/archive/, 失败可审计。
Takeaway: ~115 行 KRaft 净增不是一次写成的, 是 v0.1→v0.6 每一步被自己攻破再补一门换来的; 方法论可直接复用于任何魔改 Kafka 的团队——locate → gate minimally → attack yourself → make it operable → probe every environment → prefer structural guarantees (但只在真机 falsification 失败后)。
Act 3 · 迭代 v0.2 → v0.3 与全貌
ACT 4  ·  ZK vs KRaft
同一个 observer,为什么改一半代码就能跨两个世界

Kafka 的秘密是:broker 侧的 Partition.scala 是 ZK 与 KRaft 两模式共用的一份代码, 而 controller 侧是两套完全独立的世界。于是我们的 3 个 broker hook「免费」跨模式生效,controller 侧才要各写一份。 这一幕从一个决定性探针讲起——用真机替代推理。

broker 侧 · Partition.scala 3 hook 两模式共用 · 逐字一致 · 免费生效 ZK controller 世界 PartitionStateMachine.scala (Scala) KRaft controller 世界 ReplicationControlManager.java (Java) combined patch · 一套 jar 两模式通吃 ↖ 拦截点在 broker · canAddReplicaToIsr 挡在 AlterPartition 发出之前,与 controller 实现无关 ↗
Takeaway:一套 observer 语义,两个 controller 世界——broker 侧共用是「免费跨模式」的结构根源,controller 侧各写一份是不得不。
Act 4 · ZK vs KRaft
探针 · 把 ZK patch 原封不动扔进 KRaft 跑,会发生什么?

在写任何一行 KRaft controller 代码之前,我们先做了一个决定性实验:用打了 ZK v0.3 patch 的同一份 3.7.1 jar, 启一个纯 KRaft 集群(东京 EC2 单机 3 节点 combined broker+controller)。用真机替代推理——看哪些 hook 活着、哪些静默失效。

Hook 位置 KRaft 下
① canAddReplicaToIsr 晋升闸门Partition.scala✅ 生效
② getOutOfSyncReplicas 降级钩子Partition.scala✅ 生效
②b maybeIncrementLeaderHW 闸门Partition.scala✅ 路径生效
③ 初始 ISR 排除PartitionStateMachine (纯 ZK)❌ 失效
④ unclean 选举排除ElectionAlgorithms (纯 ZK)❌ 失效
ObserverIds 动态文件 (5s 缓存)新增 kafka.observer 包✅ 生效
为什么 broker 侧免费? 3.7.1 与 4.0.0 的 Partition.scala没有任何 ZK 依赖分支[源码事实]。 canAddReplicaToIsr 返回 false 意味着 AlterPartition 请求根本发不出去——拦截点在 broker,与 controller 实现无关。
真机日志 · 建 RF3 topic 后无需任何操作:
# === T+0 初始 ISR:controller 侧 hook 失效 === Topic: probe-rf3 Leader: 2 Replicas: 2,3,1 Isr: 2,3,1 ← observer 3 竟在初始 ISR! # === 15~30s 后:broker 侧降级钩子把它挤出去 === [09:45:42] INFO [Partition probe-rf3-0 broker=2] Shrinking ISR from 2,3,1 to 2,1 [09:45:42] INFO ISR updated to 2,1 and version updated to 1
两个过渡态 gap(窗口 ≈ replica.lag.time.max.ms/2):
① 窗口内 acks=all 会等 observer、minISR 计数含它;
② 窗口/极端竞态下 observer 可能被选为 leader。
→ v0.5 KRaft controller patch 就是为关闭这两个 gap 而生。
Takeaway:探针把「broker 侧 3 hook 免费移植」从推断升级为真机事实,也精确划出 controller 侧 2 hook 必须补的边界——不猜,先跑。
Act 4 · ZK vs KRaft
怎么补? controller 侧各写一份——而 KRaft 版结构上更收敛

ZK 模式走 PartitionStateMachine.scala,KRaft 模式走 ReplicationControlManager.java,两条路互不干扰。 有趣的是:KRaft 版一行谓词覆盖 7 个选举入口,ZK 版做到同等覆盖要两个独立 hook

ZK controller · PartitionStateMachine.scala (两个 hook)
// @288 初始 ISR:从非 observer 中选 leader val nonObserver = liveReplicas.filterNot( ObserverIds.isObserver) val isrReplicas = if (nonObserver.nonEmpty) nonObserver else liveReplicas // 兜底 // @536 unclean 选举:也不选 observer val leaderOpt = assignment.find(id => liveReplicas.contains(id) && !ObserverIds.isObserver(id))
KRaft controller · ReplicationControlManager.java (一行覆盖 7 入口)
// @2279 LeaderAcceptor.test —— 全部选举必经 public boolean test(int brokerId) { // observer 永不是合格 leader,一行覆盖 // clean/unclean/preferred/shutdown... if (ObserverReplicas.isObserver(brokerId)) return false; ... }
combined patch · 5 files · 271 insertions(+), 6 deletions(-) —— 一次成型打在 vanilla 3.7.1 上 broker 侧(两模式共用) Partition.scala · 17 行 ObserverIds.scala · 76 行 (新) ZK controller 侧 PartitionStateMachine.scala 9 行 · 2 hook KRaft controller 侧 (metadata 模块) ObserverReplicas.java · 157 行 (新) RCM.java · 18 行 · 3 hook
Takeaway:与 Confluent MRC 演进同构——CP 5.4 先 ZK、7.5 补 KRaft、8.0 删 ZK;我们走同一条路,只是把它做成一份可审计的 combined patch。
Act 4 · ZK vs KRaft
QA · 常被追问的一题
两种模式,是不是完全等同?

语义等同——observer 定义、observer.ids 文件格式、晋升/降级 SOP、runbook 完全一致,迁移期能力无断档。 但有一张「行为差异清单」必须背下来。

① 新 topic 学习
ZK:运行中 observer 学不到新 topic assignment,需重启(已知坑)。

KRaft-native win:从共享 metadata log 学 assignment,新 topic 后 t+1s 分区目录已出现,100 条写入后与 leader 字节一致,零 workaround。
② 在任 leader 热降级
KRaft:observer 正当 leader 时写回文件,85s+ 不动——leader 不自剔、KRaft 无 ZK 式主动重选举路径。

Runbook:必须先挪 leadership 或滚动重启该 broker(follower 则 ~9~12s 热生效)。
③ 部署
ZK:只换 core 一个 jar。

KRaft:换 core + metadata + storage 三个 jar,且 observer.ids 必须到 controller 节点;晋升时先改 controller,否则 AlterPartition 被 INELIGIBLE_REPLICA 拒绝(fail-safe,只加延迟)。
一句话答复:语义等同、SOP 等同,但三处工程行为不同——最刁钻的是「在任 leader 热降级」,KRaft 下静默无效需重启。 好在这在生产极少发生:observer 平时永远不是 leader。决定性铁证见拓扑 B V6:开 unclean + kill 双 broker,唯一存活的 observer 也是 Leader: none 而非 leader=3——可用性换一致性。
Takeaway:不要说「两模式一样」——要说「语义一样、SOP 一样,三条行为差异清单,其中一条会咬人」,这才是给运维的诚实答案。
Act 4 · ZK vs KRaft
走到今天:3.6.2 → 4.1.0 六版本,一份 patch 打天下,ELR 零新代码兼容
支持矩阵 · 全部东京真机 + weekly CI 哨兵守护
Kafka 版本 ZK 模式 KRaft 模式
3.6.2 / 3.8.1 / 3.9.1✅ apply+compile
3.7.1✅ v0.3 frozen✅ v0.5 · 8/8 矩阵
4.0.0n/a (上游删 ZK)✅ v0.6 · 6/6 矩阵
4.1.0n/a✅ v0.6 · 逐字节复用
移植成本被工程化压平:3 个 broker 侧锚点跨六版本 byte-identical; 3.7.1→4.0 8/8 hunk 零手工(仅行号漂移 −13~+166);4.0→4.1 patch 逐字节一致。 2 个 ZK-only hunk 在 4.x 按计划弃用(文件已删),KRaft 等价 hunk 覆盖同语义。
ELR (KIP-966) × Observer · 结构性证明 + 双版本真机
// maybePopulateTargetElr() ELR 候选集: candidateSet = targetElr partition.isr // observer 双闸门永不进 ISR // ⇒ 永不进候选集 ⇒ 结构性永不进 ELR
kill-ISR 四步 (minISR=2, obs=3)describe
初始Isr:1,2 Elr:—
kill b2Isr:1 Elr:2 (3 不进)
再 kill b1Leader:none Elr:1,2 (3 不当选)
重启 b2Leader:2 干净选主·零丢数
v0.4 预留的 ELR 过滤代码被证明完全不需要——patch 没碰 PartitionChangeBuilder,ELR 全部行为来自原生代码,零冲突。
版本建议:要 ELR 直接上 4.1.0(默认开 + 含 KAFKA-19522 修复);4.0 保持 ELR=0 即完全规避上游「误选 fenced broker」风险。
Takeaway:一份 patch 覆盖 3.6–4.1,ELR 靠「永不进 ISR」结构性免疫——observer 与 KIP-966 是 compose 不是 compete,叠加后 RF3(2+1) 容灾语义更完整。
Act 4 · ZK vs KRaft

Act 5 · 生命周期与场景手册

整套生命周期收敛成一句话:一切状态转换 = 编辑一个文件,其余全是 Kafka 原生 ISR 机制自动完成。

/opt/kafka/observer.ids 唯一的人为 / 自动化动作 tmp+mv 原子写 · 5s TTL 缓存 晋升 · 删 id canAddReplicaToIsr 放行 → 进 ISR 降级 · 加回 id getOutOfSyncReplicas → shrink 出 ISR 谁改文件 · 三模式 人跑脚本 / 外部 daemon / daemon 检测 + 人确认 S1–S8 故障手册 全部真机注入 · 数据影响 = 0 每场景: 症状→第一条命令 进出 ISR 本身 = Kafka 原生状态机 · patch 只装闸门, 不发明机制

Takeaway: 这一幕把生命周期讲到不留死角 —— 逐秒晋升/降级、三种运维模式、S1–S8 八场故障实弹、以及"节点掉线到底谁做什么"的速查表, 而所有动作的入口只有一个文本文件。

Act 5 · 生命周期与场景

晋升 · 为什么删掉一个 id 就能秒级转正, 且零数据搬移

t = 0 文件删 id tmp + mv 原子写 t ≤ 5s ObserverIds 缓存刷新 5s TTL · nanoTime 下次 follower fetch 有流量 <500ms maybeExpandIsr → 闸门放行 原生路径 AlterPartition → controller 加入 ISR t ≈ 4–10s 完整 ISR 成员 可被选主 KRaft ~4s 真机实测 ZK ≤10s 含文件分发
# observer-promote.sh — 原子更新所有 broker 的文件 ssh $SSH_OPTS "$h" "sudo sh -c 'grep -v \"^${BROKER}\$\" $FILE \ | grep -v \"^\$\" > $FILE.tmp; mv $FILE.tmp $FILE'" # 等进 ISR: 5s 缓存 + fetch 往返, 最多轮询 60s for i in $(seq 1 12); do sleep 5 kafka-topics.sh --describe | grep -qE "Isr: [0-9,]*$BROKER" \ && exit 0 done
零重启 · 零数据搬移

observer 一直在做全量 follower 复制, 数据本来就 byte-identical(真机 baseOffset=5000、5001 条全达)—— 晋升只改"身份", 不动数据。这是与 reassignment 的本质区别: 不需要等新副本追数据。

落后 observer 自然被挡

放行只是打开闸门, 原生准入仍要求 LEO 追平。S5 真机: 追 30k 条(15MB)耗时 5.4s 才进 ISR, HW 永不回退。晋升侧无对称硬边界, 由 isCaughtUp 兜底。

Takeaway: 晋升时序完全可推导 = 5s 缓存 + 一次 fetch 往返; 之所以"秒级且安全", 是因为数据早就在那里, 闸门打开的只是选举资格, 而追平检查仍由原生 ISR 准入把守。

Act 5 · 生命周期与场景

降级 · 为什么比晋升多一个相位差, 以及两条不能碰的硬边界

t = 0 加回 id tmp + mv t ≤ 5s 缓存刷新 isr-expiration 周期便车 周期 = replica.lag.time.max.ms / 2 getOutOfSyncReplicas → 视同 out-of-sync 原生 shrink maybeShrinkIsr → AlterPartition t ≈ 9–12s 回到 observer 继续 fetch 数据 降级 = 5s 缓存 + 0~周期长度的相位差 —— 这就是 9–12s 区间的来历(非事件驱动)
硬边界一 · leader 不能被热降级

原生 shrink 路径中 leader 永不把自己移出 ISR —— 写回文件什么都不会发生(静默无效, 不报错)。必须先挪 leadership: kafka-leader-election.sh --election-type preferred 或 bounce。

硬边界二 · 降级前 ISR−{b} ≥ minISR

否则降级动作本身就把 ISR 打到 minISR 以下, 触发 NOT_ENOUGH_REPLICAS fail-stop。demote 脚本对这两条都做硬阻断(晋升脚本只做告知式检查)。

# observer-demote.sh — 两个 HARD 前置检查, 跳过直接出事故 # Pre-check 1: 不能是任何分区的 leader LEADS=$(kafka-topics.sh --describe | grep -c "Leader: $BROKER"); [ "$LEADS" -gt 0 ] && exit 1 # Pre-check 2: 对每个分区 ISR - {broker} >= min.insync.replicas (保守默认 minISR=2) awk -v b="$BROKER" '/Isr:/ { ...; if (inisr && (n-1) < 2) print }' # 命中即 abort

Takeaway: 降级慢在"搭周期任务的便车"这一个相位差上(9–12s), 而真正要背下来的是那两条硬边界 —— 降级 leader 会静默失效、降到 minISR 以下会 fail-stop, 脚本替你硬拦。

Act 5 · 生命周期与场景

QA · 进出 ISR 到底是自动还是人操作?

Q

Observer 进出 ISR,是 Kafka 自动做的,还是需要人一步步操作?

核心回答:"操作" = 编辑一个文件; 而进出 ISR 本身,是 Kafka 原生机制自动完成的(maybeExpandIsr / isr-expiration shrink)。人只负责改一行文本,剩下的传送带自己转。

谁来"编辑那个文件",有三种运维模式 —— 按你要的是确定性还是RTO来选:

模式 谁改文件 适用 代价
手动
金融推荐
人跑 observer-promote.sh / demote.sh(带前置检查) 每次 ISR 变更必须是 deliberate human decision RTO 含人的反应时间; 确定性最高
Auto daemon 外部 watchdog 每 10s 扫描 under-min-isr 亚分钟 RTO 优先于人工控制 多一个运维组件; 每个决策可审计、随时可 kill
混合
多数场景推荐
daemon dry-run(-n)检测告警 → 人确认 → 执行 自动检测 + human in the loop 检测快、执行 deliberate, 两头兼得
关键: daemon 不是内核代码

它是纯外部脚本 —— 可以被 kill、可以 dry-run、bug 的爆炸半径是零个分区。一句话: "shell 脚本 bug 的爆炸半径是 0 个分区,controller hook bug 的爆炸半径是所有分区。"为处理故障而生的功能,本身不能引入更大的故障域。

Takeaway: 别再纠结"自动 vs 手动"—— 进出 ISR 永远是 Kafka 原生自动完成; 你真正选的是"谁按下那个文件编辑按钮", 而这三种模式随时可以互换、可以并存。

Act 5 · 生命周期与场景

auto-promoter · 想自动化, 就把九条安全设计焊进脚本里

对标 Confluent observerPromotionPolicy=under-min-isr 等价语义; 但它是外部 watchdog, 与人工走完全相同的原语(底层调 promote.sh/demote.sh -y)。

# observer-auto-promoter.sh — 决策循环(实际实现) scan_once() { observers=$(observer_ids) # 单一事实源: 读第一台 broker # Phase 1: 检测 under-min-isr → 晋升一个 caught-up observer while read t p l r is; do n=$(csv_count "$is"); min=$(min_isr_of "$t") [ "$n" -ge "$min" ] && continue for b in ${r//,/ }; do cooldown_ok "$b" || continue # 300s anti-flap lag=$(replica_lag "$b" "$t" "$p") if [ "$lag" -le "$MAX_LAG" ]; then do_promote "$b"; acted=1; break # 每 scan 最多一动作 else audit "SKIP | laggy → would stall HW"; fi done done <<<"$table" # Phase 2: 恢复检测 → 只降级自己晋升过的 for b in $(state_list); do demotion_safe "$b" || continue sleep 5; demotion_safe "$b" || continue # double-check do_demote "$b"; break done }
① 默认 OFF
-e 打印状态 exit 0
② dry-run
-n 全量 trace 零变更
③ caught-up gate
offsetLag>阈值 → SKIP
④ 300s cooldown
anti-flap
⑤ 每 scan 一动作
防级联决策
⑥ 降级 double-check
相隔 5s 两次成立
⑦ scoped ownership
只降自己晋升的
⑧ audit-or-die
日志不可写则拒启动
⑨ 人工永远权威 —— 操作员要动手, 先停 daemon; 这只是一个 daemon, 不是共识系统, 只跑一个实例。
真机完整循环(v0.7, KRaft)

kill broker → scan→PROMOTE-OK 12s → 恢复 → scan→DEMOTE-OK 31s(含 5s 双确认 + preferred election 检查)。审计行 broker/controller 成对, removed=[3]=晋升 / added=[3]=降级。

Takeaway: 自动化不是把决策塞进内核, 而是把策略(under-min-isr)留在一个可 kill / 可 dry-run / 可审计的外挂脚本里 —— 九条安全设计确保它最坏也只等于一次人为误操作。

Act 5 · 生命周期与场景

S1–S8 故障手册 · 八场实弹一张表, 数据影响全部为零

全部真机注入(2026-07-20 东京 KRaft combined, 3.7.1+combined patch, replica.lag.time.max.ms=10000, minISR=2)。

场景 注入 观察 / 结论 中断 / 恢复 数据影响
S1 leader 死 kill -9 leader(非 observer) 新 leader 当选, observer 绝不当选; ISR≥minISR 写入继续 10.4s failover 0
S2 follower 死 kill -9 一个 ISR follower ISR 收缩, leader 不变, 写入不停; 重启自动回 ISR 10.3s 收缩 0
S3 observer 死 kill -9 observer ISR / leader / 延迟(2ms 50th)全不变 —— observer 本不在 ISR 零影响 0
S4 全 primary 死 kill -9 两个 primary(仅 observer 活) Leader:none(永不静默接管)→ 删 id + 显式 unclean election → 当选 9.4s 晋升 0*
S5 落后晋升 observer 落后 30k 条时晋升 追平前不进 ISR, HW 永不回退; 追平后进 ISR 5.4s 追平 0
S6 文件分裂 只清 broker 侧文件(controller 保留) controller 反复 Rejecting AlterPartition, committed ISR 全程不动 5.8s 自愈 0
S7 文件损坏 chmod 000 / 垃圾内容 / 删文件 冻结上次缓存值 + WARN, 写入 100/100 acked 冻结安全 0
S8 controller 死 kill -9 active controller 新 controller 接管执法无空窗; 新 topic 初始 ISR 仍排除 observer 3.7s 接管 0

* S4 数据影响为 0 的前提: 晋升前 observer 与 leader zero-lag(md5 已证 byte-identical)。若在落后时 unclean-elect 落后 observer, 超过其 LEO 的记录会丢 —— 见 S5 负面结论(下一页)。

Takeaway: 八场实弹覆盖了 leader/follower/observer/controller 全死法 + 文件的三种烂法, 数据影响清一色为 0; 观察那栏就是你的 runbook 期望值 —— 时间全部 ≈ replica.lag.time.max.ms, patch 不改任何 timing。

Act 5 · 生命周期与场景

精讲 S4+S5 · 全 primary 死的 runbook, 与唯一那条丢数据的通路

S4 · observer 是唯一幸存者, 仍 Leader: none

kill 两个 primary 后, Leader: none Isr: 1(仍持已死成员)—— observer 活着但永不静默接管。这正是排除机制的意义: 宁可无 leader, 不错误准入。

# S4 runbook-B: durability 优先(只读等待) # 1. 晋升前比对 LEO (pre-check 已证 md5 相同) 0d5f95…a55 data-broker2/sm-0/..log # leader 0d5f95…a55 data-broker3/sm-0/..log # observer 恒等 # 2. 删 id + 显式 unclean(ISR 仍持死成员, clean 不可能) kafka-leader-election.sh --election-type UNCLEAN → Leader: 3 (9353ms since file edit) # Branch B: ISR={3}<minISR → acks=all 写 fail, 读 OK # Branch A: 有意识地 minISR=1 → 单副本恢复写(零冗余)
S5 · 落后 observer 晋升: 追平前 HW 不动

kill observer → 灌 30k 条(15MB)→ 重启并立即晋升(最坏 race)。replica 3 追完 ~30k 条后 5383ms 才进 ISR, caught up BEFORE admission, HW never went backwards。

唯一丢数通路 = lag + 晋升 + unclean election

若对落后的已晋升副本做 unclean election, 它的 LEO 就成了真相, 超过它 LEO 的每条记录都会丢。防线是 SOP 而非代码:
晋升前用 kafka-get-offsets 逐 broker 比对 LEO。observer 若 DOWN, get-offsets 失败 → pre-check 失败 → 不许盲晋升。

为什么 S4 常规安全, S5 需要人把关: 正常灾难里 observer 一直 zero-lag(S4 md5 已证), unclean 只是形式; 危险只发生在"数据滞后"叠加"越过 LEO 选举"—— 这是 durability 与 availability 的有意识取舍点, 代码不替你决定, 但 SOP 和 pre-check 逼你先看数据。

Takeaway: 全 primary 死也永不静默接管(宁可 Leader:none), 晋升当选 9.4s 恢复; 但要记住那条唯一的丢数通路 —— 只要晋升前逐 broker 比对过 LEO, 你就永远踩不上它。

Act 5 · 生命周期与场景

精讲 S6/S7/S8 · 一切误操作都 fail-safe, 不一致只会把副本挡在门外

S6 · 文件分裂

只清 broker 侧文件, controller 仍保留 id。broker 侧闸门放行 → 提 AlterPartition → controller 反复拒绝

// controller 侧铁证 Rejecting AlterPartition ... ineligible replicas [3 (observer)] with INELIGIBLE_REPLICA

committed ISR 全程不动, 对齐 controller 文件后 5.8s 自愈

S7 · 文件损坏

chmod 000 / 灌垃圾 / 删文件三连:

// 权限拒绝: 保留旧值 WARN Failed to read observer ids ..., keeping last value Set(3) // 垃圾内容: 非数字静默忽略 banana,3 %%@@!! → Set(3)

整个故障期间写入 100/100 acked, id 集合不 flap。

S8 · controller failover

kill active controller, 新 controller 3.7s 接管, 执法无空窗:

// 新 controller 建新 topic Filtered observers [3] from initial ISR [3,1,2] -> [1,2] // preferred 选举拒选 observer

初始 ISR 过滤 + 选举排除, 换 controller 也一字不差。

结构性结论(把这一句刻进脑子)

任何不一致 / 损坏 / failover, 系统只有一个方向的错法 —— 把副本挡在 ISR 外(fail-safe), 永远不会错误准入。broker 缺闸门 controller 兜底、文件读不了就冻结旧值、controller 挂了新 controller 立刻接手。没有一条路径能让 observer "偷偷"进 ISR 或被选主。

Takeaway: 观察 S6/S7/S8 三种"环境烂掉"的姿势, 系统的错误永远偏保守一侧 —— 宁可拒绝准入、宁可冻结旧值、宁可 Leader:none, 这就是为什么灾难中误操作也不会变成数据事故。

Act 5 · 生命周期与场景

QA · 节点掉线, 到底谁做什么?

Q

半夜告警响了, 我盯着一屏日志, 第一条命令该敲什么?

答案是一张"症状 → 场景 → 第一条命令 → 然后"速查表 —— 先认症状, 再决定动不动 observer, 大多数情况什么都不用做

你看到的症状 场景 第一条命令 → 然后
Leader: none 且 observer 活着 S4 kafka-get-offsets 逐 broker 比对 LEO → 相同才删 id + 显式 unclean election 晋升
NOT_ENOUGH_REPLICAS 且 leader 活着 S1/S2 数 ISR vs minISR → 等原副本回归(自动); 确认回不来才晋升 observer
Rejecting AlterPartition 反复出现 S6 diff 所有节点 observer.ids, controller 节点先看 → 对齐文件即自愈(ISR 全程未动, 不紧急)
WARN Failed to read observer ids S7 查文件权限 / 内容 → 期间冻结旧值安全, 从容修复, 无需停写
晋升"不生效"(id 删了不进 ISR) 依次查: ① 是否落后(lag)② 文件是否所有节点一致 ③ 该 broker 是否恰好是 leader(降级方向)

最常见的真相: RF4(3 ISR + 1 observer)拓扑下丢 1 个 AZ 是非事件 —— ISR 3→2 仍 ≥minISR, 写入继续, observer 一动不动。observer 只留给丢 2+ AZ 的真正灾难。

动手前的铁律: 若开着 auto daemon, 操作员要手动动 observer 之前先停 daemon(人工 runbook 永远权威); 晋升前永远先比 LEO。

Takeaway: 把这张速查表贴在告警手册第一页 —— 认准症状对应的场景, 90% 的掉线你只需要"看一眼、什么都不做", 剩下的动作也只有"比 LEO → 改文件"这一套。

Act 5 · 生命周期与场景
Act 6 / 10 · Chapter

为什么 Exactly-Once 对 Observer 是"天然免费"的
—— EOS 守的是 produce 门口, observer 走的是搬运通道; 通道里没有门, 所以不需要钥匙

先把话说死: observer 不"实现" exactly-once, 它"保留" exactly-once —— 因为复制链路完全位于 EOS 设防面之外。这一幕用三组真机字节级证据把这句架构论断钉成事实。

EOS 设防面: producer → leader idempotent producer · transactions · read-process-write 重复只诞生于存在 re-produce 的地方 (retry / replay / offset 重分配) GATE observer 走的搬运通道: appendAsFollower 没有 produce → 没有 EOS 需要出手的场景 字节复制在构造上不可能产生重复 通道里没有门, 所以不需要钥匙 bytes 铁证一 · CRC 逐 batch CRC32C 恒等 byte-identical, 非语义等价 P46 铁证二 · 事务 read_committed 双端一致 15 committed / 3 aborted P47 对照组 · MM2 kill -9 → 精确翻倍 20,000 → 40,000 P48 行业六方案 两个结构家族 其余性质皆推论 P49
Takeaway: EOS 是 produce 侧的机制; observer 从不 produce, 所以它天生不在设防路径上 —— 这是结构性命题, 不是调参命题。
Act 6 · EOS 一致性

机制 · 一个参数 validateAndAssignOffsets=false 为什么让三件事同时成立

Follower(observer 走同一条路)落盘走 UnifiedLog.appendAsFollower() —— Kafka 原生代码, 非 patch。看懂这个函数, 就看懂了 EOS 为什么免费。

// UnifiedLog.scala (Apache Kafka 3.7.1, 原生非 patch) // observer 与普通 follower 共用此路径 def appendAsFollower(records: MemoryRecords) : LogAppendInfo = { append( records, origin = AppendOrigin.Replication, // ↓↓↓ 全部三件事的开关 validateAndAssignOffsets = false, // 源码原注释: // "we are taking the offsets // we are given" ...) }
// 对比: leader 写入路径才跑 LogValidator // —— offset 分配 / PID 冲突 / dedup 检查 // 全部只在 produce 侧执行, follower 侧不碰
① 不重分配 offset
leader 已定的 baseOffset / lastOffset 原样落盘 —— 全集群单一 offset 空间
② 跳过整个 LogValidator
follower 路径连"检查是否重复"的代码都不执行 —— 因为字节复制在构造上不可能产生重复。
③ RecordBatch header 原样透传
producerId / producerEpoch / baseSequence + 事务 COMMIT/ABORT marker, 都作为被复制字节的一部分落盘, 不解包不重写。
副本侧确定性重建
ProducerState(PID/epoch/seq 表)与 LSO 不是 leader 推送的元数据, 而是每个副本从复制来的字节确定性重建 —— 字节相同 ⇒ ProducerState 相同 ⇒ LSO 相同 ⇒ read_committed 视图恒等。
Takeaway: 只要 observer 实现复用 replica fetch 路径(appendAsFollower), EOS 语义就自动继承 —— 零额外代码、零额外验证逻辑。
Act 6 · EOS 一致性

铁证一 · 为什么 CRC 逐 batch 恒等就等于"字节没被动过"

东京 POC 集群真机(2026-07-20 01:11 UTC): topic lifecycle_test, replica-assignment 2:3:1(broker1 为 follower/observer), 5000×200B, acks=all, enable.idempotence=true。两台 broker 各跑一次 kafka-dump-log.sh, 对比输出。

Leader (broker2) Observer (broker1) not in ISR crc:3058053539 crc:3245726146 crc:3058053539 crc:3245726146 = = appendAsFollower · 逐字节复制
字段(前 5 batch 采样) Leader Observer 一致
baseOffset:0 lastOffset:77
producerId:5009 epoch:0
baseSequence:0 lastSequence:77
crc:3058053539✅ 字节级
全部 23+ batch 的 CRC 全部匹配 —— 不存在任何一个 batch 被修改过。
为什么 CRC 是铁证?
Kafka RecordBatch 的 CRC32C 覆盖从 attributes 到全部 record 内容的所有字节。两端 CRC 逐 batch 相同 = 不是"语义等价", 是逐字节恒等(byte-identical)。任何一个 offset 被重分配、任何一个 PID/seq 被重写, CRC 必然不同。
# 两台 broker 各跑一次, 对比输出 kafka-dump-log.sh --files \ /data/kafka/lifecycle_test-0/0...00.log
复现路径就是这两行 —— 任何团队在自己集群上 10 分钟可自证。
Takeaway: CRC 逐 batch 恒等把"appendAsFollower 不改字节"从设计断言变成可自证的字节级事实 —— 任何魔改绕开此路径, 就得用这套 CRC 法重新过验收。
Act 6 · EOS 一致性

铁证二 · 为什么"永不进 ISR 的 observer"上事务隔离视图仍与 leader 完全一致

真机(2026-07-20 ~02:20 UTC): topic txn_eos_test, replica-assignment 2:3:1, observer = broker1, 全程不在 ISR(Isr: 2,3) —— 这一点关键: 证明结论对"永久旁听生"同样成立。Producer transactional.id=poc-txn-1, acks=all。

写入序列(15 committed + 3 aborted)
Batch 动作 内容
1commitcommit1-0 ~ commit1-4
2commitcommit2-0 ~ commit2-4
3abortABORT-0 ~ ABORT-2
4commitcommit3-0 ~ commit3-4
# isolation.level=read_committed 分别打到 # leader(broker2) 与 observer(broker1)
Leader read_committed commit1-* (5) commit2-* (5) ABORT-* 不可见 commit3-* (5) = 15 条 abort 3 条不可见 ✅ Observer read_committed commit1-* (5) commit2-* (5) ABORT-* 不可见 commit3-* (5) = 15 条 同样顺序, 完全一致 ✅
机制
COMMIT/ABORT control batch 就是普通 RecordBatch, 随 appendAsFollower 字节复制到 observer; observer 从这些字节重建 ProducerState 并正确推进 LSO; read_committed 消费者据 LSO + abort index 过滤 —— 视图与 leader 完全一致。
Takeaway: 事务隔离语义原样保留 —— 即使 observer 永不进 ISR, 它从字节重建的 LSO 与 abort index 让 read_committed 视图与 leader 逐条恒等。
Act 6 · EOS 一致性

对照组 · 为什么同一个 kill -9, MM2 侧凭空多出 20,000 条重复

把"同一类故障"打到 consume→re-produce 复制器上(真机 2026-07-20 UTC): 源 topic mm2_src 20,000×200B, acks=all, enable.idempotence=true, 源末端 offset=20,000 distinct 20,000(源端零重复)。在 Connect offset-flush 窗口内 kill -9

04:44:57 启动目标 offset→20000 04:45:29 kill -9位点未落盘 04:45:58 重启Resetting offset→0 04:46:08offset→40000 从头全量重放 (at-least-once) 源: 20,000 条 · distinct 20,000 enable.idempotence=true · 携带真实 PID 目标: 40,000 条 · distinct 20,000 每条恰好出现 2 次 · producerId = -1 +20000
不是 MM2 的 bug, 是模型的必然
offset 空间断裂: 目标重分配 offset(0→20000 偏移)
producer 会话断裂: 源 PID 在目标变 -1(新会话, 无幂等)
txn marker 不传播: COMMIT/ABORT 不被搬到目标
恢复=at-least-once: 从上次 committed 位点重放
KIP-618 只覆盖 produce 侧幂等, 不解决 offset 空间断裂与位点丢失的重放。
字节取证: 重投段 = 原段
目标 514 batch, 前半(0..19999)257 个 / 后半(20000..39999)257 个; 两半的 CRC 序列 md5 完全相同:
2b4dad2aeb237066a660dc0ad329e7b8
⇒ 同一份源字节被以 +20000 偏移再投一次。目标所有 batch producerId=-1 ⇒ 无从去重。
对照 observer: 同一个 kill -9, 0 重复 —— 差的不是参数, 是模型。
Takeaway: consume→re-produce 家族在结构上不存在"不重放"的路径; observer 走 broker 间副本协议, 按 HW/leader epoch 精确续传, 永不重复。
Act 6 · EOS 一致性

行业六方案 · 为什么工具的一切性质由它属于哪个"结构家族"决定

家族 A · consume → re-produce(跨集群)
客户端从源消费、向目标生产。两个 offset 空间; 目标 broker 分配新 offset、看到新 producer session。
家族 B · replicate-the-log(同集群)
用原生 fetch 协议 byte-copy leader 日志。一个 offset 空间; producer state 和 txn markers 随字节一起走。
维度 MM1 MM2
KIP-382
Replicator uReplicator Brooklin Observer
/ MRC
模型 consume → re-produce(家族 A) replicate-the-log
范围 跨集群 跨集群/多系统 同集群 · 跨 AZ/站点
Offset 保持 translation ✅ 构造恒等
复制穿越 EOS 20k dup ✅ CRC 相同
Txn markers 传播 ✅ verbatim
Producer 可见延迟 无(异步) 无 · HW 不等它
2.04–2.35 ms
Failover RPO > 0(复制滞后) 已 ack 写入 = 0
额外基础设施 MM1 进程 Connect 集群 Connect(商) Helix+workers Brooklin 集群 patched jar + 1 文本文件
实践结论: 两个家族解不同问题, 是 compose 不是 compete
经验法则: region 内 replicate the log(observers); region 间 mirror the stream(MM2 / 跨集群复制工具)+ 消费端幂等。同 region 一致性关键(支付/交易/账本)—— consume→re-produce 结构上无法保 EOS, observer 是唯一让 EOS 穿越复制的构造, 因为根本没有第二个 produce 步骤。
Takeaway: 选型不看功能列表, 看结构家族 —— 一旦确定"一个 offset 空间还是两个", offset 保持 / EOS / RPO / failover 全部随之注定。
Act 6 · EOS 一致性

Act 7 · 真实结果:东京真机环境全揭底

在讲任何一个数字之前,先把摆出来 —— 所有数据来自 ap-northeast-1 三 AZ 真机,非模拟、非推算,每一条都能回溯到一份 raw evidence 文件。

ap-northeast-1 (Tokyo) · 3 AZ stretched cluster AZ-a broker 1 · leader controller 101 m7g.large · ISR 成员 -Xmx512m broker / 256m controller AZ-b broker 2 · ISR controller 102 broker 3 · ISR controller 103 AZ-c / 远端 broker 4 · OBSERVER 全量同步 · 永不进 ISR 不拖 HW · 不参与选举 loadgen: 独立 m7g.xlarge (4 vCPU / 16 GB) · EndToEndLatency 同进程发读 RTT
硬件

broker ×4 m7g.large + 专用 controller quorum ×3 (id 101–103) · loadgen m7g.xlarge,全部 Graviton3 单区就近。

软件 & 拓扑

Kafka 3.6–4.1(ZK+KRaft)· topic sm RF3=2+1、smx RF4=3+1(生产推荐)。

关键旋钮

replica.lag.time.max.ms=10000 · min.insync.replicas=2 —— 后面每个时序数字都能由这两个值推出。

Takeaway:把秤先摆出来 —— 三 AZ、m7g、lag.max=10s、minISR=2、observer 坐在最慢的第三 AZ;后面 50 多个数字全部长在这套真机上,no claim without a raw evidence file。

Act 7 · 真实结果

性能 · 把 observer 放进最慢的 AZ,acks=all 延迟为什么纹丝不动?

同一台 loadgen 跑五种布局,p50 acks=all produce 摆成一把标尺 —— observer 方案 2.04–2.35 ms 正好坐在"快对基线"上,因为 HW = min(ISR 成员 LEO),而 observer 从不在 ISR 里。

012 345 p50 acks=all produce latency (ms) — ap-northeast-1 实测 快对基线 placement group(物理下限) 1.01 ms 同 AZ RF3 2.33 ms observer 方案(最慢 AZ) 2.04–2.35 ms · 坐在基线上 3-AZ RF3(全 ISR 跨 AZ) 2.38 ms · HW 被跨 AZ 副本拖 leader 放错 AZ 5.42 ms · 每条双跳

对照的关键:配置法让跨 AZ 副本进 ISR → HW 立刻被拖到 2.38 ms;本方案让它同步全量但不进 ISR → HW 不拖,延迟塌回快对 2.04–2.35 ms。这正是 Confluent 收费、我们用 ~115 行 patch 复现的"第三态"。

S3 动态证明kill -9 observer 期间,p50 19→2 ms、p99 223→219 ms —— observer 生死对 producer 延迟连噪声级影响都没有(差值是 JVM 预热)。

Takeaway:延迟标尺一眼可读 —— observer 坐在快对基线上(2.04–2.35 ms),把它 kill 掉延迟也不动;"零拖累"不是口号,是 HW=min(ISR LEO) 这条定义的直接推论。

Act 7 · 真实结果

时序 · 每一个恢复时间,为什么都能用两个旋钮推出来?

patch 不改任何 timing:failover ≈ replica.lag.time.max.ms,晋升/降级 = 5 s 文件缓存 + native ISR 流程。全场只有三个旋钮:lag.time.max / 5 s TTL(硬编码) / scan interval

事件 实测 构成 / 主导项
observer 晋升4–10 sKRaft 4 s / ZK ≤10 s = 5 s 缓存 + 一次 fetch 往返
observer 降级 (follower)9–12 s3.7.1≈9 s / 4.0≈12.2 s = 缓存 + isr-expiration 相位
leader failover10.4 s≈ replica.lag.time.max.ms(broker 活性超时),与 vanilla 一致
controller failover3.7 sKRaft quorum 重选,broker 无感,执法无空窗(S8)
落后 observer 追平进 ISR5.4 s追 30k 条/≈15 MB,isCaughtUp 准入,HW 从不回退(S5)
文件分裂自愈5.8 s对齐 observer.ids 后 controller 停止 Rejecting,零重启(S6)
auto 端到端晋升≤14 sscan→PROMOTE-OK 12 s(daemon 再加一个 scan interval)
auto 端到端降级31 sscan→DEMOTE-OK:含 5 s 双确认 + preferred 检查 + shrink
旋钮 ① replica.lag.time.max.ms

主导所有 failover / ISR 收缩时间(此处 10 s)。它是 vanilla Kafka 的原生参数,patch 一个字都没改。

旋钮 ② 5 s 文件缓存 TTL

硬编码在 ObserverIds,主导晋升/降级的"生效延迟"。这是我们唯一引入的时间常数 —— 用 nanoTime 减法比较,回绕安全。

旋钮 ③ scan interval(可选)

仅在启用 auto-promoter daemon 时出现(-i 10),给端到端时间再加一个扫描粒度。它在内核之外,随时可关。

Takeaway:没有任何"魔法数字"—— 所有恢复时间都是 lag.max + 5 s TTL 的算术结果;想知道你集群会有多快,把你的 lag.time.max 代进去即可。

Act 7 · 真实结果

诚实 · 13 份 evidence 文件地图:连不体面的结果也留在里面

全场军规一句话:no claim without a raw evidence file。每一页的每个数字都能点回下面某个 resources/evidence/*.md;包括那些证明我们错了、或够不着的边界。

机制 & 生命周期
  • scenario_matrix — S1–S8 故障矩阵(22 KB,最大)
  • observer_v3_lifecycle — 3.7.1 晋升/降级全程
  • kraft_controller_patch — v0.5 8 项能力矩阵
多版本 & ELR
  • multiversion_apply — 3.6–3.9 apply+compile
  • kafka40_port — 8/8 hunk 移植 4.0/4.1
  • elr_verification — KIP-966 双版本兼容
EOS & 一致性
  • eos_byte_level — CRC 逐 batch 恒等
  • txn_read_committed — 事务视图恒等
  • metrics_patch — 7 JMX gauge 编译
  • v07_operability — auto-promoter 端到端
  • auto_promoter_dryrun — dry-run 联锁
诚实边界也在文件里 —— 我们不藏
  • mm2_duplicate:对照组自曝其短 —— MM2 kill -9 后 20,000 条精确翻倍
  • kraft_probe:证明 ZK controller 侧 2 个 hook 在 KRaft 下完全不生效(v0.5 因此而生)
  • KRaft 在任 leader 热降级不生效(85 s+ 不动,必须 bounce)
  • 上游 KAFKA-19522(4.0+ELR 可能误选 fenced broker)—— 顺手实锤并写进结论

Takeaway:给人信心的不是"全绿的表",而是"连红的地方都指得出文件在哪"—— 13 份 evidence 里既有 8/8 全过的矩阵,也有 20,000 条重复、两个失效 hook、一个上游 bug。

Act 7 · 真实结果

Act 8 · 升级与运维:惰性 patch 的两步解耦

最后一个担忧:"改了内核,还能平滑升级、能回滚吗?" 答案藏在一个设计决定里 —— patch 完全惰性observer.ids 不存在或为空时,patched broker 逐字节等于原版 Kafka。

① 滚动升级 换 jar,零停机 ② 回滚 恢复原 jar 即可 ③ CI 哨兵 weekly 7 条腿 ④ 部署时间线 Day1→Week3+ ⑤ 六维对比 daemon vs 内核 核心信条:"换 jar""启用 observer" 彻底解耦,各自独立回退 —— 没有发明新机制,只是在传送带上装了一道闸门。

Takeaway:内核 patch 听起来吓人,但惰性设计把风险拆成两步 —— 先无害地换 jar(行为=原版),再择机填 observer.ids 启用;升级和启用可以分别回滚,互不牵连。

Act 8 · 升级运维

SOP · 一个在跑的老集群,怎么零停机换上 observer 能力?

Step 1 · 编译 patched jar 1–3 分钟,git apply + gradle,行为=原版 Step 2 · 逐台换 jar + 滚动重启 每台等 URP=0 再动下一台(ZK 换 1 jar, KRaft 换 core+metadata+storage 三 jar) URP=0 才滚下一台 Step 3 · 放一个空 observer.ids 此刻集群仍 = 原版行为(patch 惰性) Step 4 · 择机填 id → 立刻生效 ≤5 s 缓存刷新,observer 就位
# Step 3–4: 原子写,绝不原地编辑 (runbook 原文) # 启用 observer = broker 4 echo "4" | sudo tee /opt/kafka/observer.ids.tmp sudo mv /opt/kafka/observer.ids.tmp \ /opt/kafka/observer.ids # ≤5 s 后 canAddReplicaToIsr 开始拦 broker 4 # → 下次 isr-expiration 把它挤出 ISR # 回滚启用: 清空文件即恢复 vanilla 语义 : | sudo tee /opt/kafka/observer.ids
回滚 = 恢复原版 jar + 滚动重启

因为闸门打开(无 observer.ids)就是原生 Kafka,"换 jar"与"启用 observer"两步各自可独立回退——没有单向门,没有数据迁移。

为什么敢在生产滚

没有发明任何新机制 —— 所有改动都是在原有传送带上装闸门;闸门打开 = 原生行为。惰性即安全。

Takeaway:升级 SOP 只有 4 步,且每一步都可逆 —— 换 jar 阶段集群逐字节等于原版,填 observer.ids 才真正启用;不满意,清空文件或回滚 jar 即可,全程零停机零数据搬移。

Act 8 · 升级运维

工程 · 一套 patch 撑六个版本,维护成本怎么被压到可持续?

3 个 broker 侧锚点从 3.6.2 → 4.1.0 六版本 byte-identical;apply 脚本要求每个锚点恰好匹配 1 次,遇上游代码漂移就 fail loud;weekly CI 七条腿每周重验。

版本 模式 apply compile
3.6.2ZK✅ 3 文件2m08s
3.7.1 baselineZK+KRaft✅ runtime已部署
3.8.1ZK✅ 干净1m50s
3.9.1ZK✅ 干净2m08s
4.0.0KRaft✅ 8/8 hunk2m16s
4.1.0KRaft✅ 逐字节同 4.03m09s

CI 七条腿:3.6.2 / 3.7.1 / 3.8.1 / 3.9.1 (ZK) + 3.7.1 / 4.0.0 / 4.1.0 (KRaft),每周对全部支持 tag 重跑 apply + compile。

3 个 broker 侧锚点跨版本稳定

canAddReplicaToIsr / shouldWaitForReplicaToJoinIsr / getOutOfSyncReplicas 三处锚点代码逐字一致;ObserverIds.scala 自包含,唯一依赖 kafka.utils.Logging 六版本都在。

3.7.1 → 4.0 移植:8/8 hunk 零手工

10 hunk → 8 hunk(2 个 ZK-controller hunk 因 4.0 删 ZK 弃用),仅行号漂移 −13 ~ +166,锚点上下文精确匹配(shallow clone 下 --3way 回退直接 apply,测试更严)。4.0 → 4.1 patch 逐字节复用。

# tools/check-anchors.sh: 每锚点必须恰好命中 1 次 OK: buildPartitionRegistration initial-ISR filter OK: LeaderAcceptor.test observer gate OK: ineligibleReplicasForIsr observer defense DONE: all 3 RCM modifications applied # count != 1 → 大声失败,捕获上游漂移

Takeaway:多版本不是靠"每版一个 patch"硬扛,而是靠锚点稳定 + exact-match==1 fail-loud + weekly CI 哨兵 —— 上游一旦挪动锚点代码,CI 当周就红,维护成本被工程化压到可持续。

Act 8 · 升级运维

策略 · 自动化到底放内核里还是放外面?—— 一条时间线 + 六维对比

Day 1 手动模式 跑通 runbook Week 1 daemon dry-run 调参,不改集群 Week 2 真实模式跑 但不 enable(-e off) Week 3+ enable 或永久保持 "检测告警 + 人确认"
维度 外部 daemon(shell 脚本) 内核 controller hook
可审计✅ 每步 log + audit 行埋在 broker 逻辑里
kill switch✅ 随时 kill 进程需重启集群才能撤
dry-run-n 不改任何东西
bug 爆炸半径0 个分区所有分区
升级独立性✅ 与 broker 解耦绑定 broker 版本
策略迭代✅ 改脚本即可改内核 + 重编译

六维全部倒向外部 daemon。一句话总结这个决定:shell 脚本 bug 的爆炸半径 = 0 个分区,controller hook bug 的爆炸半径 = 所有分区 —— 所以内核只提供最小机制(一个文件驱动的闸门),策略永远留在可 kill、可 dry-run、可审计的外部。

Takeaway:自动化不进内核 —— 内核只管"允许 observer 身份存在"这一件机制,晋升/降级的策略放外部 daemon;这样人始终是权威、爆炸半径始终为零,Day 1 到 Week 3+ 逐步放权而非一步梭哈。

Act 8 · 升级运维
可用类: .kicker .bar .card .grid.cols-2 .cols-3 .codeblock(span.k .c) .note .svgbox ============================================================ -->

FAQ 一 · 我报的延迟到底是 avg 还是 P99?——顺便说说这些问答是怎么攒下来的

全场几乎每张性能图都写着 2.04–2.35 ms。第一个被反复追问的问题永远是:这是平均值,还是尾延迟?把口径先说死,后面所有数字才立得住。

Q PPT 里的延迟数,是 avg 还是 P99?该怎么读?
A · 都在,看场景——但结论一律用 avg 讲,需要时补 P99。
指标 代表什么 真机数字
avg 典型体验(主力指标) 2.04–2.35 ms
P99 尾延迟(辅助) 3AZ RF3 = 4 ms
P50 中位数 KRaft failover = 1 ms
测量法(可复现):Kafka 自带 EndToEndLatency —— 同进程发读算 RTT,免跨机时钟偏差;先灌 2000 条预热丢弃,再取 20000 条统计。工具输出天然就是 avg + percentiles。
Q 这 8 组问答本身,是怎么被沉淀、导出的?
A · 一个不体面的发现,逼出了一条知识工程 SOP。
# 想从转录里自动扒出侧聊问答(sidechain)—— # 实测本 CLI 版本: isSidechain 计数 = 0 // 没被标记, 也没落在已知位置 # 结论: 靠工具自动导出这条路当前走不通 # 兜底: 主 agent 有全部上下文 → 直接重建成文件
未来最佳实践(已固化为 SOP):每次侧聊得到满意答案,回主对话补一句「把结论追加到 btw-qa-log.md」——即时落盘,增量累积。
这页想说的元道理:知识也要有留存 SOP——不然再好的问答,散会就蒸发了。这本百科的 8 组 QA,就是这么一条条攒出来的。
Takeaway:延迟结论一律以 avg 讲(observer 场景 2.04–2.35 ms),P99 只在讨论尾延迟时补充;而这些问答能变成百科,靠的是「结论即时落盘」这条纪律,不是靠工具魔法。
Act 9 · FAQ

FAQ 二 · 选型四连问:ZK 还是 KRaft?能放几个 observer?minISR 怎么算?和上游 ELR 什么关系?

这四个问题几乎每个团队都会问,而且答案彼此咬合——一次说清,选型就没有悬念。

Q3 只能 KRaft 吗?老集群的 ZooKeeper 还行不行?
两种模式都已真机验证,策略是刻意不对称的:
  • ZK 版(覆盖 3.6–3.9)钉死一个锚点,服务最需要这能力的存量老集群
  • KRaft 版是主线(3.7 / 4.0 / 4.1),覆盖未来所有新集群(4.0 起上游已删 ZK)。
关键:broker 侧 3 个 hook 两模式逐字一致;只有 controller 侧各写一份(ZK 改 Scala,KRaft 改 Java)。语义 / 文件格式 / SOP / runbook 完全一致 → 从 ZK 迁到 KRaft 无断档
Q4 能放几个 observer?minISR 怎么设?
一句话记牢:observer 不计入 ISR,所以完全不参与 minISR 的账
  • 推荐 RF4 = 3 ISR + 1 observer,minISR=2:挂 1 AZ,ISR 3→2 ≥ minISR → 继续写,不用动 observer
  • 多 observer:observer.ids 写多个 id(如 4,5);晋升时挑 lag 最小的那个——运维显式指定,不是自动猜。
  • 每多一个 observer,只增加一份异步复制流量,不影响 acks=all 延迟(它不在 ISR)。
Q 那和上游 KIP-966(ELR / Eligible Leader Replicas)到底什么关系——会不会打架?
是 compose,不是 compete。 KIP-966 正是上游对「ISR 成员资格 ≠ 选举资格」的官方承认——和 observer 同一个概念方向。它管的是选举安全(防 unclean 丢数据),我们管的是延迟不受拖累。二者的候选集是 ELR ∪ ISR:observer 永不进 ISR ⇒ 结构性永不进 ELR / LastKnownElr——真机在 4.0(手动开)和 4.1(默认开)双版本 kill-ISR 序列里都从未见 observer 出现在 Elr:patch 一行 ELR 代码都不碰,纯结构性保证。(顺带实锤上游 KAFKA-19522:要 ELR 直接上 4.1,4.0 保持 ELR 关闭。)
Takeaway:ZK 与 KRaft 都验证过、迁移无断档;observer 天生不进 ISR 所以不动 minISR 账、多放几个只加异步流量;ELR 是上游同向的官方滩头,与 observer 结构性正交——选型没有隐藏地雷。
Act 9 · FAQ

FAQ 三 · 改了 Apache Kafka 源码再用,合规吗?——为什么我们分发 patch 而不是 binary

最后一个也是最容易被忽略、却最要命的问题:改内核这件事,法务上站得住吗?答案是 Apache-2.0 早就把边界画好了——照做就行。

Q Apache-2.0 允许修改再分发吗?要注意什么?
A · 允许。只有三条硬性要求,逐条对号入座:
① 标注 modified
NOTICE 里声明本发行版对原始文件做了修改。
② 保留 license
原始 Apache-2.0 许可证与版权声明原样保留。
③ 不得称 “Apache Kafka”
Apache Kafka 是商标——改过的产物不能挂这个名号对外发布。
工程立场:项目分发的是 patch,不是 binary —— 每个使用方在自己那侧对官方发行版打补丁、自行编译。这把 compliance 面压到了最小
Q 那和商业闭源方案(Confluent MRC Observers)到底怎么选?
A · 同语义,不同工程立场——核心能力(全量同步 / 不进 ISR / 不拖 HW / 无搬数晋升)完全一致,差的是产品形态。
商业 MRC 本 patch
分发 商业产品 Apache-2.0 源码 patch
支持 厂商支持合同 自维护 / 社区参考
今天能用 要授权 开源 Kafka 上即刻可用
官方表述照搬:要能力带支持合同 → 买 MRC要今天就在开源 Apache Kafka 上用(上游 KIP-929 至今是零字节空占位)→ 用这套最小可审计实现
Takeaway:Apache-2.0 明确允许改后再分发,只需标注 modified、保留 license、不冒用商标;分发 patch(非 binary)把合规面压到最小——法务不是拦路虎,而是一张早已写好的清单。
Act 9 · FAQ

生产拓扑 · 为什么 RF4 = 3 ISR 跨 3 AZ + 1 observer 让「丢一个 AZ」退化成非事件

拓扑摆对了,一半的运维焦虑就消失了。核心思路:让常态故障(丢 1 AZ)在 minISR 的账里自动扛过去,把 observer 这张牌只留给真正的灾难(丢 2+ AZ)。

REGION(三 AZ 一主 region)· acks=all 只等 ISR 三个 AZ-a b1 LEADER in ISR AZ-b b2 in ISR AZ-c b3 in ISR 远端 AZ / DC b4 OBSERVER not in ISR · byte-identical replica fetch · 计入 acks=all / HW 同一套 replica fetch · 全量同步 · 但 HW = min(ISR LEO) 不含 b4 挂 1 AZ → 非事件(零动作) ISR 3→2 ≥ minISR=2 → 写入继续,无需晋升 observer S1 实证(RF4 topic):kill leader,300/300 acked,failover 10.4 s observer 生死无关:kill 它 p50/p99 连噪声级变化都没有(S3) 挂 2+ AZ → 真正的灾难,才动 observer ISR 1 < minISR → fail-stop → 文件删 id 晋升,≤10 s 恢复写入 零重启零搬数(log 本来就 byte-identical)· 已 ack 写入 RPO=0 落后 observer 由原生 isCaughtUp 准入自然挡住,HW 永不回退
observer 在最慢 AZ = 快对基线
acks=all p50 2.04–2.35 ms,坐在物理下限(1.01 ms)与 3AZ 全 ISR(2.38 ms)之间
RF3 = 2+1 是最小验证拓扑
丢 1 AZ 即 fail-stop → 晋升;适合 POC,每次 AZ 损失都是运维事件
≥2 observer(RF5+)
三种标准布局(快对+第三AZ / 3AZ+远端DC / 多点选 lag 最小);机制已验证,真机布局未演练——诚实边界
Takeaway:RF4 = 3 ISR + 1 observer 让单 AZ 损失退化为「写入继续、零动作」的非事件——observer 平时对主链路完全透明,只在丢 2+ AZ 的真正灾难里秒级顶上。
Act 10 · 落地

决策框架与收束 · 三条路怎么选,以及那句可以带回公司的话

今天就要这个能力 → 三条路,选它当且仅当
路径 选它当且仅当
买商业 MRC 要支持合同 + 现成自动 promotion 策略 + 厂商背书
自维护私有 fork 有内核团队能长期养 fork(KIP-929 wiki 正文至今零字节,「等上游」不是选项)
用本 patch(第三条路) 要今天就跑在开源 Kafka 上:~60–115 行、Apache-2.0、分发 patch 非 binary、每条 claim 有 evidence
版本选型:要 ELR(KIP-966)直接上 4.1.0(默认开 + KAFKA-19522 已修);4.0 保持 ELR 关闭;3.6.2–4.1.0 六版本锚点 byte-identical,CI 每周守护漂移。
背下来的边界:唯一数据丢失通路 = lag + 晋升 + unclean election,靠 SOP(LEO 逐 broker 比对预检)拦、代码不拦,是显式 RPO>0 决定;未证区域 = ≥2 observer 真机布局 & auto-promoter 长 soak。
为什么你可以放心自己复现
① 机制可完全推导
全部 hook 围着一个 choke point:canAddReplicaToIsr。看懂这一个函数,就看懂了全部安全性质——「not in ISR」推出其余一切。
② 复现路径全部开源
S1–S8 故障注入、逐 batch CRC 字节对比、跨集群 mirror 对照组(同一个 kill -9 → 20,000 条精确翻倍)——两行 kafka-dump-log.sh,你 10 分钟能在自己集群自证。
③ 边界清单已交底
哪里代码拦(fail-safe)、哪里 SOP 拦(unclean 晋升预检)、哪里还没验证(多 observer / 长 soak)——军规:no claim without a raw evidence file,含所有不体面结果。
「我们没有发明新机制,只是在 Kafka 原有的传送带上装了一道闸门——
闸门打开,它就是原来的 Kafka。」
not in ISR ⇒ 不拖 HW、不计 acks、不掩盖 minISR、永不当选——其余一切都是推论。
开源参考实现 · Apache-2.0 patches / evidence / runbooks / CI 全公开可审计
Takeaway:要支持合同买 MRC,有内核团队可养 fork,要今天就在开源 Kafka 上用——这套 ~115 行可审计 patch 就是第三条路;机制可推导、复现可审计、边界已交底,回去就能决定要不要用。谢谢。
Act 10 · 落地