《Java 消息中间件·可靠消息与消费治理》15.3 libraryd-mq 卷二验收

把卷二四十五章的实验汇总成十八个验收场景,每条给出可执行的判据、证据章节和结论;另外单独列出十项本机做不出来因而不能算通过的检查,说明各自的风险。文末附一份可以直接改造成脚本的检查骨架、0/1/2 打分口径,以及卷二遗留问题与卷三的分工安排,方便团队评审时直接对照使用。

验收单要能证明两件事

卷一的验收回答的是「这个集群像不像它该有的样子」。卷二的问题不同:一条具体的业务链路,在重复、失败、积压、崩溃这些日常事件下,还能不能守住当初承诺的语义。

所以这一节的验收对象不是集群,是链路。判据也不能停在「集群健康」,必须落到「生产多少条、消费多少条、重复几条、丢几条」。

验收单要能证明两件事,缺一件就不成立:

  1. 每条依赖的性质,都有一个可执行的检查。 不能是「我们设计时考虑过」。
  2. 每个检查,都有一次真实的通过记录。 不能是「按文档配置了应该没问题」。

第二件比第一件难得多。下面十八个场景里,有八个是「配置项存在但行为没验证」,它们只能算半分。

验收对象

沿用卷一验收的集群参数,加上卷二特有的客户端配置:

项目值
broker3 节点 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 秒内收到 NotEnoughReplicasException14.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 就不丢」是卷二开头就拆掉的误判。它成立需要三个条件同时满足:

  1. min.insync.replicas 大于 1。 默认值是 1,此时 acks=all 只等 leader 一份写入,leader 一挂就丢。
  2. ISR 里确实有 minISR 那么多个副本。 副本被踢出 ISR 后,ISR 可能只剩 leader 一个。
  3. 不启用不干净选举。 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 秒是人为停机窗口
生产者失败条数0minISR=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% 通过」会被误读成「几乎没有风险」。

汇报时最有价值的一页不是通过率,而是那张「没有通过的十项」:每一行写清楚「为什么做不出来」和「风险落在哪里」。团队评审时应该先看这一页,因为它决定了上线后第一次真出事故时,你手上有没有可用的操作手册。

遗留风险与卷三分工

按影响排序,卷二留下的缺口是:

  1. 跨集群语义(最高)。 13.3 只证明了「820 条 1:1 复制、突发约 1.5 秒追平」,没有证明主备切换时的位移处理。而卷三的事件驱动架构里,多集群几乎是默认形态。
  2. 业务幂等的容量模型。 去重表能不能扛住生产量,取决于索引、清理和锁竞争,这三样都只能在真实数据量下测。
  3. 契约治理的组织方式。 8.2 已经把「哪种变更兼容」讲清楚了,8.3 只做了概念对照。真实团队的问题不是「能不能加字段」,而是「谁批准这次变更、怎么通知下游」。
  4. 长尾故障的观测。 磁盘挂起、慢网络、GC 长停顿,这些不会让进程退出,只会让延迟变长。14.x 的演练都是干净故障,测不出这一类。

卷三《事件驱动架构》从这里接手:它不再讨论「一条消息会不会丢」,而是讨论「几十条链路、几百个消费者、跨多个集群时,事件流本身怎么治理」。卷二的判据会变成卷三的输入条件——比如「每个主题的 minISR 已经确认」,在卷三里会升级成「跨集群的两端都要确认,且切换时不能回退」。

小结

  • 卷二验收的对象是链路不是集群,判据必须落到「生产多少、消费多少、重复几条、丢几条」。
  • 十八个场景里十六条通过、两条有条件通过;有条件通过的都是「功能对、规模没验」。
  • 真正的不丢需要三个条件同时成立:minISR>1、ISR 里确实有那么多副本、不启用不干净选举。
  • 重复条数可预测:等于已处理未提交的条数,可以反推提交间隔上限与幂等层容量。
  • 事务保证原子性,不保证投递;fencing 是僵尸写入的唯一防线。
  • 十项未通过的检查里,四项的共同原因是单机、三节点、无网络工具,这些必须在真实环境里补。

阅读导航:上一节 15.2 容量与成本核算 | 下一节 目录与学习路径

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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