验收不是「跑通了」,是「逐条对上判据」
一个集群能启动、能发能收,离「可以承载业务」还有很远。真正的验收要回答的是:每一条我们依赖的性质,有没有一个可执行的检查,和一次真实的通过记录。
这一节把卷一十四个章节的实验重新组织成十二个验收场景。每个场景给出三样东西:
- 判据:一条可以写成脚本、结果只有通过或不通过的命令或指标。
- 证据:本卷哪一节做过,当时的关键数字。
- 结论:通过、有条件通过,或者未验证。
最后单独列一节「没通过的项」。一份只有「全部通过」的验收报告不值得信,因为总有些事在本机做不出来。
验收对象
验收的是一个最小的 libraryd-mq 集群,参数固定:
| 项目 | 值 |
|---|---|
| broker 数 | 3(KRaft,broker 与 controller 同进程) |
| topic 副本数 | 3 |
min.insync.replicas | 2 |
| 消息语义 | at-least-once(生产者幂等开启,消费者手动提交) |
| 客户端 | kafka-clients 4.3.0 |
| 服务器 | Kafka 4.3.0,JDK 21.0.11(镜像内)与 25.0.5+8(宿主机客户端) |
这个配置的容错边界是 1 个节点,不是 2 个。这一点在第 4.3 节已经用实验证明:三节点 KRaft 停掉两个就失去 controller quorum 的多数派,整个集群连元数据都查不了。验收清单里的所有「停一台」场景,都建立在这个前提下。
十二个场景
| # | 场景 | 判据 | 证据 | 结论 |
|---|---|---|---|---|
| 1 | 集群可启动 | 三个节点 kafka-broker-api-versions.sh 都返回版本 | 2.1 节:镜像启动汇总块 error 0 | 通过 |
| 2 | 副本分布正确 | --describe 显示 Replicas: 1,2,3 且 Isr: 1,2,3 | 2.1 节、4.1 节 | 通过 |
| 3 | 写入后立即可读 | 生产 5 条,消费者从 offset 0 读到 4 | 1.1 节:两个消费组读同一段日志得到不同位置 | 通过 |
| 4 | 分区内有序 | 同一 key 的消息 offset 严格递增 | 1.2 节:无 key 时落点 1,2,2,0,1,跨分区无序 | 通过 |
| 5 | 副本不足时拒写 | ISR 降到 min.insync.replicas 以下,acks=all 报 NotEnoughReplicasException | 4.2 节、13.2 节:26 毫秒内收到异常 | 通过 |
| 6 | leader 故障可切换 | 停 leader,1.8 秒内新 leader 就位、数据零丢失 | 4.3 节实测 | 通过 |
| 7 | 消费者可重平衡 | 两个消费者,分区从 3-0 变成 2-1 | 6.2 节实测时间线 | 通过 |
| 8 | 积压可定位 | LAG 从 0 涨到 26821,三步判定法可判断成因 | 14.2 节 | 通过 |
| 9 | 滚动重启不中断写入 | follower→leader 顺序,200 次写入零失败 | 13.2 节:最长 43ms | 通过 |
| 10 | 落后副本能追上 | 停机后重启,100 MB 追赶约 5 秒 | 13.3 节实测 | 通过 |
| 11 | 副本数据一致 | kafka-replica-verification.sh 报 max lag is 0 | 14.3 节 | 通过 |
| 12 | 指标可采、版本可升 | 十二个指标能取出;客户端与服务端双向兼容 | 15.1、15.2 节 | 有条件通过 |
下面把其中有判据价值的部分展开。
场景 3 与 4:位移语义和分区顺序
这两条最基础,也最容易被想当然。
「写入后立即可读」的前提是所有 ISR 都追上了。第 4.1 节做过一个反例:用 docker pause 冻结一个 follower,生产 5 条返回了 offset 5–9,消费者却一条都读不到。同一秒内的两组数字是 leader 磁盘上 19 条、消费者可见 16 条。
所以判据不能写「写完能读到」,要写成:
写入 N 条 → 等待 HW 追上 LEO → 消费者能读到 N 条
中间那个等待条件,在正常运行下是毫秒级,在副本异常时会变成无限期。验收脚本如果不写这一步,会在副本异常时误判为「数据丢失」。
分区顺序的判据更直接:同一分区内 offset 严格递增,跨分区不保证。第 1.2 节实测 5 条无 key 消息落在 1,2,2,0,1,这个序列就是判据本身。任何依赖全局顺序的设计,在 3 个分区上就已经不成立。
场景 5:什么时候写入会被拒
这一条是验收里唯一一个「必须制造失败」的场景。
配置是 min.insync.replicas=2、副本数 3。停掉一个 broker 后 ISR 变成 2,仍然满足 2,写入继续。再停一个,ISR 变成 1,acks=all 的写入立刻被拒。
第 13.2 节的实测记录了这条路径的时延:
生产者 26 毫秒内收到 NotEnoughReplicasException
26 毫秒说明这不是超时后的失败,是 broker 立刻拒绝。这个区别很重要:超时意味着客户端要等 delivery.timeout.ms(本卷配置下 30 秒),立刻拒绝意味着业务能马上感知并降级。
验收时必须确认生产者的失败是「快速失败」而不是「长时间挂起」。判据可以写成:
移除第二个副本 → 生产者在下一次 send 的 1 秒内收到异常 → 恢复副本 → 写入恢复
场景 6:切换时间与数据丢失
第 4.3 节的实测数字是 1.8 秒:从停掉 leader 到新 leader 就位。同期数据零丢失,因为新 leader 是从 ISR 里选的,ISR 里的副本都已经追上了 HW。
但这一条有两个容易误读的地方。
第一,1.8 秒是本机三节点同机容器的数字,网络延迟接近于零。真实机房里的切换时间还要加上副本间的网络往返和 controller 的处理时间。验收时要按自己的部署重新测,不能抄这个数字。
第二,停掉 leader 之后集群仍然可用,前提是 controller quorum 还有多数派。三节点停一个,剩两个,多数派还在。如果两个 broker 恰好都在 controller 侧失效,集群会进入另一种状态:分区可能还在,但元数据不可查——第 4.3 节用 TimeoutException: listPartitionReassignments 记录了这个状态。
场景 7 与 8:重平衡的代价与积压的归因
这两条放在一起讲,因为它们失败时的表现很像:都是「消费者没收到消息」,但原因完全不同。
重平衡。第 6.2 节记录了一次完整的分配过程:所有成员先 JoinGroup,组协调者选出 leader,leader 拿全组订阅算出分配,再 SyncGroup 下发,之后成员靠 Heartbeat 维持存活。判据不是「重平衡成功」,而是分区守恒:
消费者数 3 → 2 → 3 的每一次变化后,分配到的分区总数都等于 topic 的分区数,没有重叠
重叠是最隐蔽的错误:两个消费者同时认为自己拥有分区 P,P 的位移会被两边交替提交,结果是重复消费与消息跳过交替出现,而 --describe 的 CONSUMER-ID 一栏看起来一切正常。
积压。第 14.2 节把 LAG 从 0 推到 26821,用的是一段刻意放慢的消费逻辑。归因分三步:
- 看总量:
kafka-consumer-groups.sh --describe的LAG列求和。 - 看分布:是所有分区一起涨,还是集中在某几个。集中说明数据倾斜,一起涨说明消费能力不足。
- 看速率:连续两次采样,LAG 在涨还是在跌。跌得比生产慢就是消费端瓶颈。
这里有一个第 15.1 节提过的事实值得再强调一次:LAG 不在 JMX 的 broker 指标里。kafka.server:type=BrokerTopicMetrics 下面没有它,因为 LAG 是「组位移」与「分区 LEO」的差值,属于消费者视角。要从 kafka-consumer-groups.sh、__consumer_offsets 里的位移,或者客户端 records-lag-max 指标取。
验收判据写成:
人为积压 2 万条 → 30 秒内三步归因能唯一指向一个原因 → 恢复后 LAG 回落到 0
三步里任何一步得不出唯一结论,说明监控缺项,不是集群有问题。
场景 9:滚动重启的顺序不是风格问题
第 13.2 节对比了两种顺序,用的是同一个探针(acks=all、幂等开启、每秒一条):
| 顺序 | 结果 |
|---|---|
| follower → follower → leader | 200 次写入零失败,最长 43ms |
| leader → 新 leader → … | 多一次选举,一次写入卡 2.6 秒 |
差别不在「能不能完成重启」,两种顺序都能完成。差别在过程中有没有发生额外的 leader 选举。先停 leader,集群必须先选一个新 leader,那个瞬间所有针对该分区的写入都要重新发现元数据,于是出现 2.6 秒的停顿;先停 follower,leader 不动,写入路径不受影响。
2.6 秒对很多业务是可以接受的,但它是一个可以被设计消除的停顿。验收判据因此写成:
滚动重启期间,生产者 p99 延迟不超过基线的 2 倍,失败数为 0
而不是「重启后集群能起来」。
场景 10:追赶和重建是两回事
第 13.3 节实测了两种恢复路径:
| 情形 | 行为 | 本机实测 |
|---|---|---|
| follower 只落后,log start 未越过 | 从自己的 LEO 继续 fetch | 100 MB 追赶约 5 秒 |
| leader 的 log start 已越过 follower | 丢弃本地段,从 leader 的 log start 重建 | 丢掉一个约 297 MiB 的旧段,从 offset 349995 重建 |
第二种是「重建」,代价大得多,而且会在一段时间内让该分区的副本数不足。判据是:
停机时长 < retention.ms 内该分区的最短段存活时间 → 走追赶
否则 → 预期重建
验收时要知道自己属于哪种,不能只看「恢复后 ISR 满了」就认为没问题。
场景 11:一致性要三层取证
第 14.3 节给出了一个反直觉的结论:offsetLag=0 不代表字节一致。
那次实验里,手动截断了一个 follower 的段文件,重启后它从 leader 重新拉取,offset 语义完全对上(offsetLag=0、replica-verification 报 max lag is 0),但它的段文件比 leader 大 15712 字节,因为文件里还留着被截断前的残批。
第 13.3 节还有另一个方向的陷阱:kafka-log-dirs.sh 的 offsetLag 对 ISR 之外的副本会直接报 0,一台实际落后约 100 MiB 的 follower 看起来完全同步。两个陷阱合起来说明一件事——0 这个数字本身不携带信息,必须和 ISR 状态、段文件大小一起看。
所以一致性检查要三层,不能只看一层:
| 层 | 手段 | 能发现什么 | 不能发现什么 |
|---|---|---|---|
| 位点层 | --describe 的 ISR、offsetLag | 副本是否落后 | 字节级损坏 |
| 逻辑层 | kafka-replica-verification.sh | 同 offset 的消息内容是否一致 | 文件尾部的残批 |
| 字节层 | kafka-dump-log.sh 的 crc、段文件大小 | 磁盘上的真实字节 | 需要停写或低峰执行 |
生产环境的验收按前两层做,第三层只在怀疑硬件问题时做。
场景 12:为什么是「有条件通过」
监控和升级这两条,功能上都验证过,但都有一个前提没有在本机满足。
监控部分(15.1 节)验证了十二个指标能取出来、数值会随流量变化、broker 级与 topic 级计数会差出内部 topic 的量。但没有验证的是告警在真实故障下的时序——本机做不出持续几小时的慢故障,也做不出告警平台的去抖行为。
升级部分(15.2 节)验证了四组客户端与服务端的交叉组合,以及 KIP-848 协议在旧 broker 上的硬失败。但没有验证的是多节点滚动升级的完整时序,原因同样是三节点停两个会失去 quorum。
这两条的状态是「机制已验证,工程流程未验证」,所以标为有条件通过。
没有通过的项
这一节比前面任何一节都重要。
| 项 | 为什么做不出来 | 会带来什么风险 |
|---|---|---|
离线分区(Leader: -1) | 需要 ISR 全空,三节点停两个会丢 quorum | 不知道分区彻底不可用时的恢复动作 |
| 脑裂 | 没有网络隔离工具,KRaft 又要求多数派 | 分区网络抖动时的行为未验证 |
| ISR 反复抖动 | 无法稳定复现阈值附近的震荡 | 告警阈值可能定得过松或过紧 |
| 段中间字节翻转(bit rot) | 只做了尾部截断 | 硬件级损坏的检测路径未验证 |
| follower 领先 leader 的分歧 | 需要 unclean 选举或 5 节点 | 真实数据分歧的修复流程未验证 |
五项里有三项(离线分区、脑裂、follower 领先)的共同原因是同一个:本机只有一个三节点集群。要验证它们,需要至少 5 个节点或者 dedicated controller 部署,以及一个能做网络隔离的工具。
这不是「以后有空再补」的清单,而是下一卷必须在别的环境里补上的部分。
遗留风险与下一卷的分工
把上面的缺口按影响排序:
- 不干净选举与数据分歧(最高)。它决定「副本真的对不上时怎么办」。卷二的可靠性设计全部建立在「副本可能分歧」这个前提上,不能在卷一留下空白。
- 长时间慢故障下的告警行为。它决定告警规则能不能用。
- 多节点滚动升级的完整时序。它决定升级当天的操作手册。
- 硬件级损坏的检测。它决定要不要相信
replica-verification的结果。
卷二《可靠消息与消费治理》会从第 3 项和第 1 项开始:先讲发送端的重试与去重,再讲副本分歧的修复,最后把「至少一次」的边界做成一份可以逐条勾选的清单。
一份可以直接改造成脚本的检查骨架
下面这段不是本机跑过的完整脚本,而是把上面十二条判据的组织方式固定下来:每个场景一个函数,函数只做一件事,失败时打印判据原文而不是「出错了」。改造成自己的版本时,主要改 BOOT、TOPIC 和三个阈值。
#!/bin/sh
set -u
BOOT="${BOOT:-127.0.0.1:9092}"
TOPIC="${TOPIC:-libraryd.acceptance}"
GROUP="${GROUP:-libraryd.check}"
fail=0
ok() { printf 'PASS %s\n' "$1"; }
bad() { printf 'FAIL %s -> %s\n' "$1" "$2"; fail=$((fail + 1)); }
# 场景 2:三个副本都在 ISR 里,没有任何分区副本不足
check_replication() {
out=$(kafka-topics.sh --bootstrap-server "$BOOT" --describe --topic "$TOPIC")
echo "$out" | grep -q 'Isr: [0-9]*,[0-9]*,[0-9]*' \
&& ok "replication" || bad "replication" "Isr 不足 3 个"
}
# 场景 5:写入必须成功,且失败时是快速失败而非超时
check_write_path() {
if kafka-console-producer.sh --bootstrap-server "$BOOT" --topic "$TOPIC" \
--producer-property acks=all --producer-property enable.idempotence=true \
<<'EOF' >/dev/null 2>&1
acceptance-probe
EOF
then ok "write-path"; else bad "write-path" "acks=all 写入被拒"; fi
}
# 场景 7:分配守恒,分区数等于消费者分到的总数
check_assignment() {
n=$(kafka-topics.sh --bootstrap-server "$BOOT" --describe --topic "$TOPIC" | grep -c 'Partition:')
m=$(kafka-consumer-groups.sh --bootstrap-server "$BOOT" --describe --group "$GROUP" \
| awk '$2 ~ /^[0-9]+$/ {c++} END {print c+0}')
[ "$n" = "$m" ] && ok "assignment" || bad "assignment" "$n 个分区只有 $m 个有归属"
}
# 场景 11:副本位点层一致性
check_replica_lag() {
out=$(kafka-replica-verification.sh --broker-list "$BOOT" \
--topic-white-list "$TOPIC" --max-wait-ms 5000 2>/dev/null | tail -1)
echo "$out" | grep -q 'max lag is 0' \
&& ok "replica-lag" || bad "replica-lag" "$out"
}
check_replication
check_write_path
check_assignment
check_replica_lag
[ "$fail" -eq 0 ] && echo "ALL PASS" || echo "$fail 项未通过"
exit "$fail"
几个刻意的选择:
grep -c 'Partition:'而不是wc -l。--describe的输出里还有一行Topic:汇总,行数会多一。- 写入探针用 heredoc 而不是
--num-records。前者在任何版本都能用,后者在 4.3.0 已经有替代写法。 replica-verification用--max-wait-ms而不是无限等待。它会真的扫数据,没有上限时在大 topic 上会跑很久。- 失败时打印原始输出。判据是给人看的,
$out比一行FAILED有用得多。
改造成 CI 任务时,把 check_replication、check_write_path、check_replica_lag 三项设成每次必跑,另外几项设成手动触发的演练。原因是场景 5、9、10 需要停节点,不适合放在自动任务里。
这份清单怎么用
验收不是一次性动作。这十二个场景里有九个可以在每次发版前重跑,另外三个(故障切换、滚动重启、副本追赶)建议每季度做一次演练。
把它抄成脚本时,注意两条本卷反复踩过的坑:
- 容器内执行 CLI 用内部 listener(
127.0.0.1:9092或容器名),宿主机才用映射端口。混用会让命令看起来「连上了但读不到元数据」。 - 判断
--under-replicated-partitions的结果要看输出行数,不要看退出码。没有输出就是健康,命令本身永远返回成功。
卷一到此结束。下一卷从这里开始:既然副本可能落后、可能分歧、生产者可能重试,那么「消息不丢」这件事到底要付多少代价。
阅读导航:上一节 15.2 版本升级与兼容性 | 下一节 目录与学习路径
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。