《Java 消息中间件·Kafka 内核与客户端》15.3 libraryd-mq 卷一验收

把前十四章的实验汇总成一份可以照着执行的验收清单:十二个场景,每个给出可量化的判据、本卷对应的实测证据和结论,并配一份可以直接改造成脚本的检查骨架。另外单独列出五项本机做不出来、因而不算通过的检查,说明它们会带来什么风险、下一卷在什么条件下能补上。

验收不是「跑通了」,是「逐条对上判据」

一个集群能启动、能发能收,离「可以承载业务」还有很远。真正的验收要回答的是:每一条我们依赖的性质,有没有一个可执行的检查,和一次真实的通过记录。

这一节把卷一十四个章节的实验重新组织成十二个验收场景。每个场景给出三样东西:

  • 判据:一条可以写成脚本、结果只有通过或不通过的命令或指标。
  • 证据:本卷哪一节做过,当时的关键数字。
  • 结论:通过、有条件通过,或者未验证。

最后单独列一节「没通过的项」。一份只有「全部通过」的验收报告不值得信,因为总有些事在本机做不出来。

验收对象

验收的是一个最小的 libraryd-mq 集群,参数固定:

项目值
broker 数3(KRaft,broker 与 controller 同进程)
topic 副本数3
min.insync.replicas2
消息语义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,32.1 节、4.1 节通过
3写入后立即可读生产 5 条,消费者从 offset 0 读到 41.1 节:两个消费组读同一段日志得到不同位置通过
4分区内有序同一 key 的消息 offset 严格递增1.2 节:无 key 时落点 1,2,2,0,1,跨分区无序通过
5副本不足时拒写ISR 降到 min.insync.replicas 以下,acks=all 报 NotEnoughReplicasException4.2 节、13.2 节:26 毫秒内收到异常通过
6leader 故障可切换停 leader,1.8 秒内新 leader 就位、数据零丢失4.3 节实测通过
7消费者可重平衡两个消费者,分区从 3-0 变成 2-16.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 014.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,用的是一段刻意放慢的消费逻辑。归因分三步:

  1. 看总量:kafka-consumer-groups.sh --describe 的 LAG 列求和。
  2. 看分布:是所有分区一起涨,还是集中在某几个。集中说明数据倾斜,一起涨说明消费能力不足。
  3. 看速率:连续两次采样,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 → leader200 次写入零失败,最长 43ms
leader → 新 leader → …多一次选举,一次写入卡 2.6 秒

差别不在「能不能完成重启」,两种顺序都能完成。差别在过程中有没有发生额外的 leader 选举。先停 leader,集群必须先选一个新 leader,那个瞬间所有针对该分区的写入都要重新发现元数据,于是出现 2.6 秒的停顿;先停 follower,leader 不动,写入路径不受影响。

2.6 秒对很多业务是可以接受的,但它是一个可以被设计消除的停顿。验收判据因此写成:

滚动重启期间,生产者 p99 延迟不超过基线的 2 倍,失败数为 0

而不是「重启后集群能起来」。

场景 10:追赶和重建是两回事

第 13.3 节实测了两种恢复路径:

情形行为本机实测
follower 只落后,log start 未越过从自己的 LEO 继续 fetch100 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 部署,以及一个能做网络隔离的工具。

这不是「以后有空再补」的清单,而是下一卷必须在别的环境里补上的部分。

遗留风险与下一卷的分工

把上面的缺口按影响排序:

  1. 不干净选举与数据分歧(最高)。它决定「副本真的对不上时怎么办」。卷二的可靠性设计全部建立在「副本可能分歧」这个前提上,不能在卷一留下空白。
  2. 长时间慢故障下的告警行为。它决定告警规则能不能用。
  3. 多节点滚动升级的完整时序。它决定升级当天的操作手册。
  4. 硬件级损坏的检测。它决定要不要相信 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 版本升级与兼容性 | 下一节 目录与学习路径

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收