验收单要能证明两件事
卷一的验收回答的是「这个集群像不像它该有的样子」。卷二的问题不同:一条具体的业务链路,在重复、失败、积压、崩溃这些日常事件下,还能不能守住当初承诺的语义。
所以这一节的验收对象不是集群,是链路。判据也不能停在「集群健康」,必须落到「生产多少条、消费多少条、重复几条、丢几条」。
验收单要能证明两件事,缺一件就不成立:
- 每条依赖的性质,都有一个可执行的检查。 不能是「我们设计时考虑过」。
- 每个检查,都有一次真实的通过记录。 不能是「按文档配置了应该没问题」。
第二件比第一件难得多。下面十八个场景里,有八个是「配置项存在但行为没验证」,它们只能算半分。
验收对象
沿用卷一验收的集群参数,加上卷二特有的客户端配置:
| 项目 | 值 |
|---|---|
| broker | 3 节点 KRaft,Kafka 4.3.0 |
| topic | 副本 3,min.insync.replicas=2,3–12 分区 |
| 语义 | at-least-once;生产者幂等 + acks=all;消费者手动提交 |
| 幂等层 | 去重表或状态机(附录 B 的模板) |
| 客户端 | kafka-clients 4.3.0 |
一句话概括这个组合的能力边界:不会丢已确认的消息,但一定会重复;能扛住一台 broker 故障,扛不住两台;能保证分区内有序,保证不了全局有序。
验收就是逐条确认这些边界在真实流量下成立,以及边界之外的行为是不是可预期。
十八个场景
按 15.1 的五组组织:A 生产端、B 消费端、C broker 与主题、D 数据链路、E 运维演练。
| # | 场景 | 判据 | 证据 | 结论 |
|---|---|---|---|---|
| A1 | 强确认写入不丢 | acks=all + minISR=2,停一台后写入零失败 | 14.1、14.3 | 通过 |
| A2 | 副本不足时快速失败 | ISR 低于 minISR,1 秒内收到 NotEnoughReplicasException | 14.1(740 毫秒内 3 次) | 通过 |
| A3 | 生产重试不制造重复 | 注入 NOT_LEADER 重试,消费端条数等于生产条数 | 1.2、14.3 | 通过 |
| A4 | 幂等生产者的真实边界 | 换 PID 后序列号窗口失效,重复可被观察到 | 1.2、2.1 | 通过 |
| B1 | 手动提交可控 | 崩溃后从最后提交位置重读,重复条数可计算 | 1.3、14.2 | 通过 |
| B2 | 崩溃重复可计量 | 重复条数 = 已处理未提交条数 | 14.2(61 条) | 通过 |
| B3 | 业务幂等消解重复 | 同一事件投递两次,业务副作用只发生一次 | 2.2、附录 B | 有条件通过 |
| B4 | 消费组分区守恒 | 扩缩容前后,分配分区数之和等于主题分区数且无重叠 | 4.2 | 通过 |
| B5 | 积压可归因 | 三步法(总量、分布、速率)能唯一指向成因 | 5.1、5.2 | 通过 |
| B6 | 背压不拖垮下游 | pause/resume 后下游 p99 在承诺范围内 | 5.3 | 通过 |
| B7 | 毒消息不阻塞分区 | 坏消息进隔离主题,位移推进,LAG 回落 | 6.1、6.3 | 通过 |
| C1 | 主题配置符合语义 | --describe --all 中 minISR、cleanup.policy、retention 与设计一致 | 附录 A | 通过 |
| C2 | 事务原子性 | abort 后 read_committed 看到 0 条,read_uncommitted 看到全部 | 3.1 | 通过 |
| C3 | 事务超时与 fencing | 超时事务自动 abort;旧实例写入被拒 | 3.2 | 通过 |
| C4 | 出库与消息对齐 | kill -9 后重启补发,业务表条数等于事件条数 | 7.1 | 通过 |
| D1 | 契约演进兼容 | 加字段、删可选字段向后兼容;改类型必须升版本 | 8.2 | 有条件通过 |
| D2 | 一致性可度量 | 端到端延迟 P50/P95/P99 可采,超阈值样本占比可算 | 9.3 | 通过 |
| E1 | 故障演练可重复 | 六个时间点可采集,RTO/RPO 可计算 | 14.1–14.3 | 通过 |
十八条里十六条通过、两条有条件通过。不过这个比例没有意义——真正的信息在下面两节:哪些通过了但样本不够,哪些根本没通过。
五条判据要展开看
A1 与 A2:不丢的两个前提
「acks=all 就不丢」是卷二开头就拆掉的误判。它成立需要三个条件同时满足:
min.insync.replicas大于 1。 默认值是 1,此时acks=all只等 leader 一份写入,leader 一挂就丢。- ISR 里确实有 minISR 那么多个副本。 副本被踢出 ISR 后,ISR 可能只剩 leader 一个。
- 不启用不干净选举。 leader 只在 ISR 里选,才不会选到没追上的副本。
14.1 的实测把第 2 条的两面都测了:minISR=2 的主题在停一台后继续写入,50000 条里只有 2 条 NOT_LEADER_OR_FOLLOWER 重试,最终零失败;minISR=3 的主题在同一时刻开始拒写,740 毫秒内三次 NotEnoughReplicasException。
同一个故障、同一套集群,两个主题两种命运。这说明验收不能一句「我们用了 acks=all」了事,必须逐个主题确认 minISR,并且知道这个值意味着「一台故障就停写」还是「一台故障还能写」。
A2 的判据里有个容易被忽略的细节:失败必须是快速失败。如果生产者是等 delivery.timeout.ms 超时后才报错,业务侧感知到的时间就不是 740 毫秒而是 30 秒。验收时要看异常的到达时间,不能只看异常类型。
B2:重复的条数是算出来的,不是统计出来的
14.2 的结论可以直接当判据用:崩溃重复条数 = 已处理未提交条数。
那次实验里,消费者处理到 offset 460,最后提交停在 400,重启后从 400 继续,重复 61 条。61 = 460 − 400 + 1。
这条判据的价值在于它可预测。验收时可以反着用:先确定业务能容忍多少重复,再倒推出提交间隔的上限。如果业务幂等层每秒能消化 1000 条重复,而单分区提交间隔是 500 条,12 个分区最坏重复约 6000 条——这个量级需要提前算,不能等出事时才发现幂等层被打穿。
B3 标「有条件通过」的原因也在这里:模板验证过,规模没验证过。 附录 B 的去重表在功能上是对的(唯一键冲突、TTL、状态机迁移都跑过),但一张每天增长几十万行的去重表,它的索引膨胀、清理窗口、锁竞争,本机没有数据量去测。功能通过不等于容量通过。
C2 与 C3:事务的边界在哪
3.1 的判据是二元的,很容易验:同一事务发 3 条,commit 后 read_committed 看到 3 条,abort 后看到 0 条,而 read_uncommitted 全程能看到。
但这只证明了「Kafka 内部的一致性」,不证明「业务恰好一次」。事务提交成功后、下游消费之前,进程崩溃,下游仍然没收到这条消息——只是这一次它不会收到「一半」。所以事务解决的是原子性,不是投递。
3.2 补上的是另一半:事务不会永远开着。transaction.timeout.ms 到期后协调器主动 abort,客户端下一次提交收到致命错误。fencing 保证同一个 transactional.id 只有一个实例能写,旧实例被 epoch 推高后写入立刻被拒。
验收时要测的是这个:kill 掉持有事务的实例,让新实例接管,确认旧实例复活后写不进去。 这是「僵尸写入」的唯一防线,也是很多团队配了事务却没验过的地方。
C4:出库与消息的对齐
7.1 的 outbox 是本卷唯一一个「跨越数据库和 Kafka 两个系统」的实验。它的判据很硬:
业务表行数 == outbox 表已发布行数 == 下游消费去重后的行数
本机的做法是:写库和写 outbox 在同一个事务里,然后 kill -9 发布器,重启后补发。崩溃点前后的三组数字必须对齐。
这个场景值得单列,因为它是卷二里唯一一个不能用 Kafka 自身机制解决的问题。生产端幂等、事务、消费端重试,都只在 Kafka 内部有效;一旦业务库参与进来,一致性就只能靠 outbox 或者 CDC 这类跨系统方案。
E1:演练的数字怎么读
14.1 到 14.3 三个实验合起来给出一组可以复用的数字:
| 指标 | 本机实测 | 说明 |
|---|---|---|
| 故障检测到 ISR 收缩 | 4–5 秒 | 进程退出走连接断开路径 |
| 从启动到 ISR 满 | 4–5 秒 | 数据量小时几乎即时 |
| 从停机到 lag 归零 | 41 秒 | 其中 24 秒是人为停机窗口 |
| 生产者失败条数 | 0 | minISR=2 在停机期间仍满足 |
四个数字里只有前两个算「系统能力」,第三个被人为窗口污染,第四个依赖前提。验收报告里如果不写清楚这一点,半年后有人拿着「41 秒 RTO」去做容量规划,就会得出错误结论。
没有通过的十项
这一节比前面任何一节都重要。一份全是「通过」的验收报告不值得信。
| 项 | 为什么做不出来 | 风险 |
|---|---|---|
| 停两台 broker | 三节点 KRaft 失去 quorum,集群元数据不可用,无法在单机上恢复 | 不知道元数据不可用时的降级与恢复动作 |
| 网络分区 | 本机没有能做隔离的工具,docker stop 是干净退出不是断网 | 检测时间会从 4 秒变成 30 秒量级,未验证 |
| 磁盘挂起或写满 | 没有设备级故障注入手段 | 心跳正常但写入停滞,这是最难观测的故障形态 |
| 长时间慢故障下的告警时序 | 无法稳定运行数小时并制造渐变性劣化 | 告警阈值可能定得过松或过紧 |
| 跨机房延迟与主备切换 | 两个集群在同一台机器上,RTT 近似为零 | 13.3 测到的 70–113 毫秒是本机回环,不是真实链路 |
| 去重表的生产规模 | 没有几十万到上亿行的数据量 | TTL、索引膨胀、清理窗口都没有数据支撑 |
| Schema Registry 的兼容性检查 | 本卷不引入 registry 客户端,只做概念对照 | 集中式兼容策略的实际拦截效果未验证 |
| 日志挖掘型 CDC | 不引入 Debezium,只用手写轮询版 CDC 实测 | 日志挖掘的 op 语义、快照衔接未验证 |
| TLS 的稳态 CPU 成本 | 13.2 只跑了一次,测到约 24% 的差异 | 单次运行不足以支撑「TLS 慢多少」的结论 |
| 大规模消费组(100+ 成员)再平衡 | 4 vCPU 撑不起这个规模 | 大规模 rebalance 的停顿时间与分区守恒未验证 |
十项里有四项的共同原因是同一个:单机、三节点、无网络工具。这不是「以后有空再补」的清单,而是卷二在方法论上留给卷三和真实环境的部分。
一份可以改造成脚本的检查骨架
下面这段不是本机完整跑过的脚本,而是把十六条「通过」判据的组织方式固定下来。每个函数只做一件事,失败时打印判据原文而不是「出错了」。
#!/bin/sh
set -u
BOOT="${BOOT:-127.0.0.1:9092}"
TOPIC="${TOPIC:-libraryd.acceptance}"
GROUP="${GROUP:-libraryd.check}"
KAFKA_BIN="${KAFKA_BIN:-/opt/kafka/bin}"
fail=0
ok() { printf 'PASS %s\n' "$1"; }
bad() { printf 'FAIL %s -> %s\n' "$1" "$2"; fail=$((fail + 1)); }
# A1:主题的 minISR 必须与可靠性档位一致
check_min_isr() {
v=$("$KAFKA_BIN"/kafka-configs.sh --bootstrap-server "$BOOT" \
--describe --entity-type topics --entity-name "$TOPIC" --all 2>/dev/null \
| tr ',' '\n' | sed -n 's/.*min.insync.replicas=\([0-9]*\).*/\1/p' | head -1)
[ "${v:-1}" -ge 2 ] && ok "min-isr=$v" || bad "min-isr" "当前 ${v:-1},acks=all 不构成不丢保证"
}
# A2:ISR 不足时写入必须快速失败,而不是挂到超时
check_fast_fail() {
start=$(date +%s)
if printf 'probe\n' | "$KAFKA_BIN"/kafka-console-producer.sh \
--bootstrap-server "$BOOT" --topic "$TOPIC" \
--producer-property acks=all >/dev/null 2>&1; then
ok "write-path"
else
elapsed=$(( $(date +%s) - start ))
[ "$elapsed" -le 1 ] && ok "fast-fail(${elapsed}s)" \
|| bad "fast-fail" "失败用了 ${elapsed}s,业务无法快速降级"
fi
}
# B4:分配守恒,分区总数等于各消费者分到的数量之和
check_assignment() {
n=$("$KAFKA_BIN"/kafka-topics.sh --bootstrap-server "$BOOT" \
--describe --topic "$TOPIC" 2>/dev/null | grep -c 'Partition:')
m=$("$KAFKA_BIN"/kafka-consumer-groups.sh --bootstrap-server "$BOOT" \
--describe --group "$GROUP" 2>/dev/null | awk '$2 ~ /^[0-9]+$/ {c++} END {print c+0}')
[ "$n" = "$m" ] && ok "assignment($n)" || bad "assignment" "主题 $n 个分区,组内只有 $m 个有归属"
}
# C1:消费组的 lag 必须可读且为 0(否则积压未消化)
check_lag() {
total=$("$KAFKA_BIN"/kafka-consumer-groups.sh --bootstrap-server "$BOOT" \
--describe --group "$GROUP" 2>/dev/null \
| awk 'NR>1 && $6 ~ /^[0-9-]+$/ {s+=$6} END {print s+0}')
[ "$total" = "0" ] && ok "lag=0" || bad "lag" "仍有 $total 条未提交"
}
check_min_isr
check_fast_fail
check_assignment
check_lag
[ "$fail" -eq 0 ] && echo "ALL PASS" || echo "$fail 项未通过"
exit "$fail"
几个刻意的选择:
- 只检查「通过」的项。停 broker、杀消费者这类需要注入故障的检查不放进自动任务,避免 CI 在共享集群上误伤。
min.insync.replicas从--all里取,不从自己的配置文件里取。 主题级覆盖会盖住 broker 默认值,改动之后没人记得。- lag 判据用求和而不是逐分区。 多分区时只要有一个分区没提交完,总和就不会是 0。
- 失败时打印原始数值。
当前 1,acks=all 不构成不丢保证比FAILED有用得多。
怎么打分,怎么汇报
15.1 的三十条清单是 0/1/2 打分:0 未做、1 做了没验证、2 验证过。把这一节的十八条套进去,本卷的自评是:
| 分数 | 条目数 | 典型例子 |
|---|---|---|
| 2(验证过) | 16 | 强确认写入、崩溃重复、事务原子性、outbox 对齐 |
| 1(做了没验证) | 2 | 业务幂等的生产规模、契约演进的 Jackson 严格模式 |
| 0(未做) | 10 | 停两台、网络分区、磁盘挂起、大规模组 |
换算成比例是 32 / 36 条在「2」这一档——但分母是 28 条能做的检查,不是 30 条清单。这个换算关系要在汇报时说明,否则「89% 通过」会被误读成「几乎没有风险」。
汇报时最有价值的一页不是通过率,而是那张「没有通过的十项」:每一行写清楚「为什么做不出来」和「风险落在哪里」。团队评审时应该先看这一页,因为它决定了上线后第一次真出事故时,你手上有没有可用的操作手册。
遗留风险与卷三分工
按影响排序,卷二留下的缺口是:
- 跨集群语义(最高)。 13.3 只证明了「820 条 1:1 复制、突发约 1.5 秒追平」,没有证明主备切换时的位移处理。而卷三的事件驱动架构里,多集群几乎是默认形态。
- 业务幂等的容量模型。 去重表能不能扛住生产量,取决于索引、清理和锁竞争,这三样都只能在真实数据量下测。
- 契约治理的组织方式。 8.2 已经把「哪种变更兼容」讲清楚了,8.3 只做了概念对照。真实团队的问题不是「能不能加字段」,而是「谁批准这次变更、怎么通知下游」。
- 长尾故障的观测。 磁盘挂起、慢网络、GC 长停顿,这些不会让进程退出,只会让延迟变长。14.x 的演练都是干净故障,测不出这一类。
卷三《事件驱动架构》从这里接手:它不再讨论「一条消息会不会丢」,而是讨论「几十条链路、几百个消费者、跨多个集群时,事件流本身怎么治理」。卷二的判据会变成卷三的输入条件——比如「每个主题的 minISR 已经确认」,在卷三里会升级成「跨集群的两端都要确认,且切换时不能回退」。
小结
- 卷二验收的对象是链路不是集群,判据必须落到「生产多少、消费多少、重复几条、丢几条」。
- 十八个场景里十六条通过、两条有条件通过;有条件通过的都是「功能对、规模没验」。
- 真正的不丢需要三个条件同时成立:
minISR>1、ISR 里确实有那么多副本、不启用不干净选举。 - 重复条数可预测:等于已处理未提交的条数,可以反推提交间隔上限与幂等层容量。
- 事务保证原子性,不保证投递;fencing 是僵尸写入的唯一防线。
- 十项未通过的检查里,四项的共同原因是单机、三节点、无网络工具,这些必须在真实环境里补。
阅读导航:上一节 15.2 容量与成本核算 | 下一节 目录与学习路径
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。