| 系统 | 角色名 | 同步全量日志 | 参与投票 / 算 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 投票 | 扩读吞吐、跨机房,不拖慢写多数派 |
| 谁 | 形态 | 可得性 |
|---|---|---|
| Confluent MRC Multi-Region Clusters Observers |
商业产品 CP ≥ 5.4,核心卖点 |
闭源 · 付费 |
| 大厂内部 Learner replicas |
私有 fork 各自实现 |
不公开 |
| Apache Kafka 上游 KIP-929 |
"Observer Replicas" wiki 提案页 |
正文 0 字节 占位符,非计划 |
kafka.observer.ObserverIds)不碰 import 块——这是多版本 patch 冲突面最小化的工程手法(Act 3 展开)。Partition.scala ZK / KRaft 两模式共享——这是 KRaft 移植时 broker 侧三个 hook 原封不动复用的根据。Isr: 2,3 / Replicas: 2,3,1,acks=all 保持 2.35 ms avg。Isr: 2,3 不进;同时 acks=all 稳在 2.35 ms——假说在运行期完全成立。
| 场景 | 发生了什么 | 旁听生怎么动 | 结果 |
|---|---|---|---|
| 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,服务读写 |
从这一幕起, 我们把整套 patch 摊在桌上——不是"大概改了哪里", 而是真代码、真行号、真调用链。前半场只讲两个文件: 一个决定"谁是旁听生"(ObserverIds), 一个决定"旁听生怎么被挡在 ISR 门外"(Partition)。
# 整行注释、逗号或空白都能分隔、非数字 token 静默忽略而非报错。3 / 3,4 / 每行一个, 三种写法等价。kafka.observer.ObserverIds.isObserver(...), 不碰任何 import 块——多版本 patch 移植时 import 区最易冲突, 不动它就把冲突面压到最小。这是"3.6→4.1 六版本零手工"的工程前提之一。/opt/kafka/observer.ids), env 只是兼容老部署的回退。格式刻意宽容、package 刻意独立——这两个"小气"的决定, 换来的是运维安全与多版本可移植。System.nanoTime() 可能溢出回绕成负数, 但 now - last 的差值在回绕时依然正确 (二补数运算)。若改用 wall-clock, 一次 NTP 校时或闰秒就可能让缓存"永不过期"或"疯狂刷新"。热路径上的判定必须对时钟异常免疫。NonFatal 捕获 → 保留上次缓存值 + 打 WARN, 异常绝不外抛。配置文件永远不能打死 broker——chmod 000、写入垃圾、误删, broker 都照常跑, 只是"身份名单冻结在最后一次成功读取"。晋升方向天然 fail-safe: 读不到就当它还是 observer, 挡在 ISR 外。canAddReplicaToIsr @1044——整个项目的原点, v0.1 只有它return false"。return false 让 AlterPartition 请求根本不会发出——拦截点在 broker 进程内, 天然跨 ZK / KRaft 两模式 (同一份 Partition.scala)。design-story 原话: "the only gate"。调用链: follower 每次 fetch 追平 leader LEO 时, leader 就会走到这里决定"要不要发起 ISR 扩容"。放行才有后续, 拦住则一切归零。
getOutOfSyncReplicas @1312——净增 1 行, 借 isr-expiration 的便车observer.ids 成为双向开关: 删 id = 晋升, 加 id = 降级。被挤出去后想回 ISR 会被 Hook #1 拦住——状态不会来回抖。真机: 降级 Isr: 2,3,1 → 2,3 约 9–12 s (搭周期任务便车比晋升多一个相位差)。lag.time.max.ms/2 就跑一次的便车, 零重启把它挤出 ISR。与 Hook #1 合成一个稳定的双向开关。maybeIncrementLeaderHW @1176——封掉那个 30 秒的理论窗口isCaughtUp + isReplicaIsrEligible——和 Hook #1 的 canAddReplicaToIsr 不是同一处。挡住"能不能进 ISR"不等于挡住"HW 要不要等它"。所以要单独补这一个 && 前缀条件。为什么存在这个窗口? 一个尚未进 ISR、但仍处在 replica.lag.time.max.ms (默认 30 s) 追赶窗口内的 observer, 理论上会被 HW 推进逻辑"等一下"——即便它永远进不了 ISR。
Broker 侧的 ObserverIds.scala 已经能读文件、判身份了——那 KRaft 为什么还要一个 157 行的
ObserverReplicas.java? 答案不在功能, 在模块依赖方向: KRaft 的 quorum controller 活在
metadata 模块 (纯 Java), 而 metadata 不允许反向依赖 core——所以 Scala 版够不着, 只能做一个逐条对齐的纯 Java 镜像。
observer.ids 不只放 broker, controller 节点也得有 (拓扑 B V5 实证)。
filterInitialIsr 就是为了堵下一页要讲的那个绕过闸门的洞。return false 能锁死 7 个选举入口
ZK 版要在两个独立函数里分别拦"初始 ISR"和"unclean 选举"; KRaft 把所有选举路径收敛成一个公共谓词
LeaderAcceptor.test(int)——所以只需在它开头加一行, clean/unclean/preferred/controlled-shutdown 全被一次挡死。三处 hook 从"入口 → 兜底 → 纵深"层层收口。
Leader: none 而非选它 (可用性换一致性); ineligibleReplicasForIsr 让 broker/controller 双闸门互为兜底, 任一侧漏部署都朝安全侧失败。
v0.1 的假说是: ISR 准入只有 canAddReplicaToIsr 一个 choke point, 挡住它一切自动跟随。运行期确实成立——但我们把方法论定成
"attack your own patch", 于是主动找不走这个门的路, 结果一天内找到两条。
initializeLeaderAndIsrForPartitions, controller 把 all live replicas 直接塞进 initial ISR——根本不经过 maybeExpandIsr。只 gate Hook #1 时, observer 一出生就在 ISR 里。unclean.leader.election=true 且 ISR 全灭时, 原生逻辑 assignment.find(liveReplicas.contains) 会挑中 observer——可它永不能回 ISR, 分区就此死锁。修法: 宁可 Leader: none 也不丢一致性。maybeExpandIsr。这两课不是失败, 是方法论的产物: 先攻破自己, 再补门。
v0.2 用环境变量 KAFKA_OBSERVER_BROKER_IDS 定身份——改身份要重启 broker。运维审查一句话点破:
"灾难中途还需要重启的 DR 机制, 本身就是 design smell"。于是 v0.3 换成文件驱动 + 5s 缓存 + fail-safe——晋升/降级从此零重启。
| 文件 | 行 | 归属 |
|---|---|---|
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 删除 |
observer.ids 丢失/损坏 → broker 继续跑, 保留上次缓存值 + WARN——配置文件永远不能打死 broker。每代 patch 脚本存 patches/archive/, 失败可审计。
Kafka 的秘密是:broker 侧的 Partition.scala 是 ZK 与 KRaft 两模式共用的一份代码,
而 controller 侧是两套完全独立的世界。于是我们的 3 个 broker hook「免费」跨模式生效,controller 侧才要各写一份。
这一幕从一个决定性探针讲起——用真机替代推理。
在写任何一行 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 包 | ✅ 生效 |
Partition.scala 都没有任何 ZK 依赖分支[源码事实]。
canAddReplicaToIsr 返回 false 意味着 AlterPartition 请求根本发不出去——拦截点在 broker,与 controller 实现无关。
acks=all 会等 observer、minISR 计数含它;
ZK 模式走 PartitionStateMachine.scala,KRaft 模式走 ReplicationControlManager.java,两条路互不干扰。
有趣的是:KRaft 版一行谓词覆盖 7 个选举入口,ZK 版做到同等覆盖要两个独立 hook。
语义等同——observer 定义、observer.ids 文件格式、晋升/降级 SOP、runbook 完全一致,迁移期能力无断档。
但有一张「行为差异清单」必须背下来。
observer.ids 必须到 controller 节点;晋升时先改 controller,否则 AlterPartition 被 INELIGIBLE_REPLICA 拒绝(fail-safe,只加延迟)。
Leader: none 而非 leader=3——可用性换一致性。
| 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.0 | n/a (上游删 ZK) | ✅ v0.6 · 6/6 矩阵 |
| 4.1.0 | n/a | ✅ v0.6 · 逐字节复用 |
| kill-ISR 四步 (minISR=2, obs=3) | describe |
|---|---|
| 初始 | Isr:1,2 Elr:— |
| kill b2 | Isr:1 Elr:2 (3 不进) |
| 再 kill b1 | Leader:none Elr:1,2 (3 不当选) |
| 重启 b2 | Leader:2 干净选主·零丢数 |
整套生命周期收敛成一句话:一切状态转换 = 编辑一个文件,其余全是 Kafka 原生 ISR 机制自动完成。
Takeaway: 这一幕把生命周期讲到不留死角 —— 逐秒晋升/降级、三种运维模式、S1–S8 八场故障实弹、以及"节点掉线到底谁做什么"的速查表, 而所有动作的入口只有一个文本文件。
observer 一直在做全量 follower 复制, 数据本来就 byte-identical(真机 baseOffset=5000、5001 条全达)—— 晋升只改"身份", 不动数据。这是与 reassignment 的本质区别: 不需要等新副本追数据。
放行只是打开闸门, 原生准入仍要求 LEO 追平。S5 真机: 追 30k 条(15MB)耗时 5.4s 才进 ISR, HW 永不回退。晋升侧无对称硬边界, 由 isCaughtUp 兜底。
Takeaway: 晋升时序完全可推导 = 5s 缓存 + 一次 fetch 往返; 之所以"秒级且安全", 是因为数据早就在那里, 闸门打开的只是选举资格, 而追平检查仍由原生 ISR 准入把守。
原生 shrink 路径中 leader 永不把自己移出 ISR —— 写回文件什么都不会发生(静默无效, 不报错)。必须先挪 leadership: kafka-leader-election.sh --election-type preferred 或 bounce。
否则降级动作本身就把 ISR 打到 minISR 以下, 触发 NOT_ENOUGH_REPLICAS fail-stop。demote 脚本对这两条都做硬阻断(晋升脚本只做告知式检查)。
Takeaway: 降级慢在"搭周期任务的便车"这一个相位差上(9–12s), 而真正要背下来的是那两条硬边界 —— 降级 leader 会静默失效、降到 minISR 以下会 fail-stop, 脚本替你硬拦。
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, 两头兼得 |
它是纯外部脚本 —— 可以被 kill、可以 dry-run、bug 的爆炸半径是零个分区。一句话: "shell 脚本 bug 的爆炸半径是 0 个分区,controller hook bug 的爆炸半径是所有分区。"为处理故障而生的功能,本身不能引入更大的故障域。
Takeaway: 别再纠结"自动 vs 手动"—— 进出 ISR 永远是 Kafka 原生自动完成; 你真正选的是"谁按下那个文件编辑按钮", 而这三种模式随时可以互换、可以并存。
对标 Confluent observerPromotionPolicy=under-min-isr 等价语义; 但它是外部 watchdog, 与人工走完全相同的原语(底层调 promote.sh/demote.sh -y)。
-e 打印状态 exit 0-n 全量 trace 零变更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 / 可审计的外挂脚本里 —— 九条安全设计确保它最坏也只等于一次人为误操作。
全部真机注入(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。
kill 两个 primary 后, Leader: none Isr: 1(仍持已死成员)—— observer 活着但永不静默接管。这正是排除机制的意义: 宁可无 leader, 不错误准入。
kill observer → 灌 30k 条(15MB)→ 重启并立即晋升(最坏 race)。replica 3 追完 ~30k 条后 5383ms 才进 ISR, caught up BEFORE admission, HW never went backwards。
若对落后的已晋升副本做 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, 你就永远踩不上它。
只清 broker 侧文件, controller 仍保留 id。broker 侧闸门放行 → 提 AlterPartition → controller 反复拒绝。
committed ISR 全程不动, 对齐 controller 文件后 5.8s 自愈。
chmod 000 / 灌垃圾 / 删文件三连:
整个故障期间写入 100/100 acked, id 集合不 flap。
kill active controller, 新 controller 3.7s 接管, 执法无空窗:
初始 ISR 过滤 + 选举排除, 换 controller 也一字不差。
任何不一致 / 损坏 / failover, 系统只有一个方向的错法 —— 把副本挡在 ISR 外(fail-safe), 永远不会错误准入。broker 缺闸门 controller 兜底、文件读不了就冻结旧值、controller 挂了新 controller 立刻接手。没有一条路径能让 observer "偷偷"进 ISR 或被选主。
Takeaway: 观察 S6/S7/S8 三种"环境烂掉"的姿势, 系统的错误永远偏保守一侧 —— 宁可拒绝准入、宁可冻结旧值、宁可 Leader:none, 这就是为什么灾难中误操作也不会变成数据事故。
半夜告警响了, 我盯着一屏日志, 第一条命令该敲什么?
答案是一张"症状 → 场景 → 第一条命令 → 然后"速查表 —— 先认症状, 再决定动不动 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 → 改文件"这一套。
先把话说死: observer 不"实现" exactly-once, 它"保留" exactly-once —— 因为复制链路完全位于 EOS 设防面之外。这一幕用三组真机字节级证据把这句架构论断钉成事实。
validateAndAssignOffsets=false 为什么让三件事同时成立
Follower(observer 走同一条路)落盘走 UnifiedLog.appendAsFollower() —— Kafka 原生代码, 非 patch。看懂这个函数, 就看懂了 EOS 为什么免费。
baseOffset / lastOffset 原样落盘 —— 全集群单一 offset 空间。producerId / producerEpoch / baseSequence + 事务 COMMIT/ABORT marker, 都作为被复制字节的一部分落盘, 不解包不重写。
东京 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, 对比输出。
| 字段(前 5 batch 采样) | Leader | Observer | 一致 |
|---|---|---|---|
| baseOffset:0 lastOffset:77 | ✓ | ✓ | ✅ |
| producerId:5009 epoch:0 | ✓ | ✓ | ✅ |
| baseSequence:0 lastSequence:77 | ✓ | ✓ | ✅ |
| crc:3058053539 | ✓ | ✓ | ✅ 字节级 |
真机(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。
| Batch | 动作 | 内容 |
|---|---|---|
| 1 | commit | commit1-0 ~ commit1-4 |
| 2 | commit | commit2-0 ~ commit2-4 |
| 3 | abort | ABORT-0 ~ ABORT-2 |
| 4 | commit | commit3-0 ~ commit3-4 |
把"同一类故障"打到 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。
producerId=-1 ⇒ 无从去重。
| 维度 | 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 文本文件 |
在讲任何一个数字之前,先把秤摆出来 —— 所有数据来自 ap-northeast-1 三 AZ 真机,非模拟、非推算,每一条都能回溯到一份 raw evidence 文件。
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。
同一台 loadgen 跑五种布局,p50 acks=all produce 摆成一把标尺 —— observer 方案 2.04–2.35 ms 正好坐在"快对基线"上,因为 HW = min(ISR 成员 LEO),而 observer 从不在 ISR 里。
对照的关键:配置法让跨 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) 这条定义的直接推论。
patch 不改任何 timing:failover ≈ replica.lag.time.max.ms,晋升/降级 = 5 s 文件缓存 + native ISR 流程。全场只有三个旋钮:lag.time.max / 5 s TTL(硬编码) / scan interval。
| 事件 | 实测 | 构成 / 主导项 |
|---|---|---|
| observer 晋升 | 4–10 s | KRaft 4 s / ZK ≤10 s = 5 s 缓存 + 一次 fetch 往返 |
| observer 降级 (follower) | 9–12 s | 3.7.1≈9 s / 4.0≈12.2 s = 缓存 + isr-expiration 相位 |
| leader failover | 10.4 s | ≈ replica.lag.time.max.ms(broker 活性超时),与 vanilla 一致 |
| controller failover | 3.7 s | KRaft quorum 重选,broker 无感,执法无空窗(S8) |
| 落后 observer 追平进 ISR | 5.4 s | 追 30k 条/≈15 MB,isCaughtUp 准入,HW 从不回退(S5) |
| 文件分裂自愈 | 5.8 s | 对齐 observer.ids 后 controller 停止 Rejecting,零重启(S6) |
| auto 端到端晋升 | ≤14 s | scan→PROMOTE-OK 12 s(daemon 再加一个 scan interval) |
| auto 端到端降级 | 31 s | scan→DEMOTE-OK:含 5 s 双确认 + preferred 检查 + shrink |
主导所有 failover / ISR 收缩时间(此处 10 s)。它是 vanilla Kafka 的原生参数,patch 一个字都没改。
硬编码在 ObserverIds,主导晋升/降级的"生效延迟"。这是我们唯一引入的时间常数 —— 用 nanoTime 减法比较,回绕安全。
仅在启用 auto-promoter daemon 时出现(-i 10),给端到端时间再加一个扫描粒度。它在内核之外,随时可关。
Takeaway:没有任何"魔法数字"—— 所有恢复时间都是 lag.max + 5 s TTL 的算术结果;想知道你集群会有多快,把你的 lag.time.max 代进去即可。
全场军规一句话: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 项能力矩阵multiversion_apply — 3.6–3.9 apply+compilekafka40_port — 8/8 hunk 移植 4.0/4.1elr_verification — KIP-966 双版本兼容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 因此而生)Takeaway:给人信心的不是"全绿的表",而是"连红的地方都指得出文件在哪"—— 13 份 evidence 里既有 8/8 全过的矩阵,也有 20,000 条重复、两个失效 hook、一个上游 bug。
最后一个担忧:"改了内核,还能平滑升级、能回滚吗?" 答案藏在一个设计决定里 —— patch 完全惰性:observer.ids 不存在或为空时,patched broker 逐字节等于原版 Kafka。
Takeaway:内核 patch 听起来吓人,但惰性设计把风险拆成两步 —— 先无害地换 jar(行为=原版),再择机填 observer.ids 启用;升级和启用可以分别回滚,互不牵连。
因为闸门打开(无 observer.ids)就是原生 Kafka,"换 jar"与"启用 observer"两步各自可独立回退——没有单向门,没有数据迁移。
没有发明任何新机制 —— 所有改动都是在原有传送带上装闸门;闸门打开 = 原生行为。惰性即安全。
Takeaway:升级 SOP 只有 4 步,且每一步都可逆 —— 换 jar 阶段集群逐字节等于原版,填 observer.ids 才真正启用;不满意,清空文件或回滚 jar 即可,全程零停机零数据搬移。
3 个 broker 侧锚点从 3.6.2 → 4.1.0 六版本 byte-identical;apply 脚本要求每个锚点恰好匹配 1 次,遇上游代码漂移就 fail loud;weekly CI 七条腿每周重验。
| 版本 | 模式 | apply | compile |
|---|---|---|---|
| 3.6.2 | ZK | ✅ 3 文件 | 2m08s |
| 3.7.1 baseline | ZK+KRaft | ✅ runtime | 已部署 |
| 3.8.1 | ZK | ✅ 干净 | 1m50s |
| 3.9.1 | ZK | ✅ 干净 | 2m08s |
| 4.0.0 | KRaft | ✅ 8/8 hunk | 2m16s |
| 4.1.0 | KRaft | ✅ 逐字节同 4.0 | 3m09s |
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。
canAddReplicaToIsr / shouldWaitForReplicaToJoinIsr / getOutOfSyncReplicas 三处锚点代码逐字一致;ObserverIds.scala 自包含,唯一依赖 kafka.utils.Logging 六版本都在。
10 hunk → 8 hunk(2 个 ZK-controller hunk 因 4.0 删 ZK 弃用),仅行号漂移 −13 ~ +166,锚点上下文精确匹配(shallow clone 下 --3way 回退直接 apply,测试更严)。4.0 → 4.1 patch 逐字节复用。
Takeaway:多版本不是靠"每版一个 patch"硬扛,而是靠锚点稳定 + exact-match==1 fail-loud + weekly CI 哨兵 —— 上游一旦挪动锚点代码,CI 当周就红,维护成本被工程化压到可持续。
| 维度 | 外部 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+ 逐步放权而非一步梭哈。
全场几乎每张性能图都写着 2.04–2.35 ms。第一个被反复追问的问题永远是:这是平均值,还是尾延迟?把口径先说死,后面所有数字才立得住。
| 指标 | 代表什么 | 真机数字 |
|---|---|---|
| avg | 典型体验(主力指标) | 2.04–2.35 ms |
| P99 | 尾延迟(辅助) | 3AZ RF3 = 4 ms |
| P50 | 中位数 | KRaft failover = 1 ms |
EndToEndLatency —— 同进程发读算 RTT,免跨机时钟偏差;先灌 2000 条预热丢弃,再取 20000 条统计。工具输出天然就是 avg + percentiles。btw-qa-log.md」——即时落盘,增量累积。
这四个问题几乎每个团队都会问,而且答案彼此咬合——一次说清,选型就没有悬念。
observer.ids 写多个 id(如 4,5);晋升时挑 lag 最小的那个——运维显式指定,不是自动猜。ELR ∪ ISR:observer 永不进 ISR ⇒ 结构性永不进 ELR / LastKnownElr——真机在 4.0(手动开)和 4.1(默认开)双版本 kill-ISR 序列里都从未见 observer 出现在 Elr:。patch 一行 ELR 代码都不碰,纯结构性保证。(顺带实锤上游 KAFKA-19522:要 ELR 直接上 4.1,4.0 保持 ELR 关闭。)
最后一个也是最容易被忽略、却最要命的问题:改内核这件事,法务上站得住吗?答案是 Apache-2.0 早就把边界画好了——照做就行。
NOTICE 里声明本发行版对原始文件做了修改。| 商业 MRC | 本 patch | |
|---|---|---|
| 分发 | 商业产品 | Apache-2.0 源码 patch |
| 支持 | 厂商支持合同 | 自维护 / 社区参考 |
| 今天能用 | 要授权 | 开源 Kafka 上即刻可用 |
拓扑摆对了,一半的运维焦虑就消失了。核心思路:让常态故障(丢 1 AZ)在 minISR 的账里自动扛过去,把 observer 这张牌只留给真正的灾难(丢 2+ AZ)。
| 路径 | 选它当且仅当 |
|---|---|
| 买商业 MRC | 要支持合同 + 现成自动 promotion 策略 + 厂商背书 |
| 自维护私有 fork | 有内核团队能长期养 fork(KIP-929 wiki 正文至今零字节,「等上游」不是选项) |
| 用本 patch(第三条路) | 要今天就跑在开源 Kafka 上:~60–115 行、Apache-2.0、分发 patch 非 binary、每条 claim 有 evidence |
canAddReplicaToIsr。看懂这一个函数,就看懂了全部安全性质——「not in ISR」推出其余一切。kafka-dump-log.sh,你 10 分钟能在自己集群自证。