《Java 消息中间件·可靠消息与消费治理》15.1 可靠性验收清单

三十条可靠性检查项,分生产端、消费端、broker 与主题、数据链路、运维演练五组。每一条都给可观测的判据、对应的章节、以及不达标时的修改动作——判据都是「做一件具体的事,看一次结果」,不是「已配置」。五组各自附一节最容易做错的地方,末附打分口径与按边际效率排序的修改顺序。

这份清单怎么用

验收最容易流于形式,因为「检查项」被写成了「配置项」:核对一下 acks=all 有没有配,打勾,结束。可配置存在不等于保护存在——acks=all 配上 min.insync.replicas=1,保护是零;enable.auto.commit=false 配上一行注释掉的 commitSync,位移根本不会前进。

所以这份清单的每一条都只认一件事:你能不能做一件具体的事,看到预期的结果。做不出来就是没通过,跟配置里写了什么无关。

三十条分五组,覆盖从生产端到演练的完整链路。每一条包含四个信息:

  • 检查项:要保证的到底是什么。
  • 判据:怎么用一次观测证明它在。
  • 对应章节:机制在哪一章展开。
  • 不达标怎么改:最小改动是什么。

判定原则

在逐条看之前,先接受三条原则,否则清单会被用成一张许愿表:

  1. 判据必须是观测,不是声明。 「已配置 acks=all」是声明;「杀掉一个 follower,写入仍成功且 ISR 显示 2」是观测。前者写在文档里,后者写在演练记录里。
  2. 通过要能复现。 一次通过可能是运气。涉及故障的检查项,至少跑两次,两次结果一致才算通过。
  3. 不通过的项要给出「什么时候修」。 三十条同时不达标是常态,全都立刻修是不现实的。最后一节给了按边际效率排序的修改顺序。

A 组:生产端

#检查项判据章节不达标怎么改
1强确认与最小同步副本同时生效停一个 follower,写入仍成功且 ISR 显示 2;再停一个,写入收到 NotEnoughReplicasException 而不是静默成功1.2、4.2把 min.insync.replicas 设为 2,别只改 acks
2幂等生产者开启客户端启动日志显示幂等生效;构造一个可重试失败场景,重复率为 01.2设 enable.idempotence=true,同时确认 max.in.flight≤5
3重试窗口自洽客户端启动不抛配置异常;delivery.timeout.ms ≥ linger.ms + request.timeout.ms附录 A按不等式重算三个值,通常是把总超时调大
4发送失败真的被处理send() 的回调里有日志与告警,不是空 lambda;故意断开 broker,能看到失败记录1.2、14.1补 whenComplete 或计数器,别吞异常
5分区键保证同一实体落同一分区同一 loanId 的连续事件打印出的分区号相同10.1、10.2给 ProducerRecord 显式传 key,别依赖默认轮询
6发送失败有业务兜底断网 30 秒后,业务侧能看到「未发出」的记录,而不是无声消失7.1、附录 B引入 outbox,或至少落一张待发表

第 1 条的判据值得强调:它同时验证了两件相反的事。ISR 为 2 时写入成功,说明冗余是真的;ISR 为 1 时写入失败,说明保护是真的。如果第二种情况也写入成功,acks=all 就只是个装饰。

幂等的真实边界

第 2 条容易给人一个过大的期待。生产端幂等确实能让「重试不产生重复」,但它的保护范围比多数人以为的小:

它覆盖它不覆盖
同一个生产者会话内的重试进程重启后的重发(PID 变了)
单个分区内的序列跨 topic 的写入
broker 侧的重复检测上游业务自己发的两次相同消息

第二列第一条是最关键的:PID 随会话变化。生产者崩溃重启后会拿到新的 PID,序列号从头开始,broker 不会把新会话的消息和旧会话的消息关联起来。所以「本地重试队列在进程重启后重新发送」这种情况,幂等救不了,只能靠业务键去重。

幂等的序列号窗口也不是无限的。broker 为每个 PID 保留最近若干个批次的序列号,超出窗口的老批次再到达时不会被识别为重复。正常重试不会触发这个边界,但如果生产端有一个积压很久的本地队列,重放时可能超出窗口。

结论:第 2 条是必需品,但它只管「生产端重试」这一种重复。第 9 条的消费端幂等不能因为第 2 条通过就跳过。

B 组:消费端

#检查项判据章节不达标怎么改
7位移由业务控制enable.auto.commit=false,且代码里能找到显式提交1.3关掉自动提交,在处理完成后 commitSync
8提交发生在处理完成之后日志时间戳:处理结束 < 提交时刻,每一批都成立1.3、12.2把提交移到 process() 返回之后,别放在回调里
9重复消费被业务吸收同一条消息人工投递两次,业务表只变化一次2.2、附录 B加去重表或状态机,见 2.2 的三种实现
10处理耗时在 poll 间隔之内压测期间日志里没有因超时触发的 rebalance4.3、12.3调大 max.poll.interval.ms,或减小 max.poll.records
11再平衡导致的提交失败被捕获频繁扩缩容时,CommitFailedException 被捕获并记日志,线程不崩4.3在提交处捕获该异常,放弃本批位移即可
12lag 有告警且真的会响人为制造积压,告警在约定时间内触发5.1、5.2接入 records-lag 而非 records-lag-max,后者空转变 NaN
13一个组一个职责组订阅的主题列表里没有不相关的主题4.1拆组;多组订阅的拖累见 4.1

第 12 条的坑在 5.1 节实测过:records-lag-avg 与 records-lag-max 在窗口没有新样本时会变成 NaN,很多监控 SDK 把 NaN 丢弃或填 0,面板上就出现「lag 归零」的假象。用 records-lag(最后值)或直接用 CLI 的 LAG 列做告警源。

提交时机的三种错误写法

第 8 条的判据是「处理结束 < 提交时刻」。下面三种写法都会让这个不等式在某个路径上不成立:

// 错误一:在 send 的回调里提交
producer.send(rec, (meta, err) -> {
    consumer.commitSync();   // 回调线程里提交,与本批处理是否完成无关
});

// 错误二:异步提交不看结果
consumer.commitAsync();      // 失败被静默吞掉,位移可能没推进

// 错误三:批处理中途提交
for (int i = 0; i < records.count(); i++) {
    process(records.get(i));
    if (i % 100 == 0) consumer.commitSync();  // 提交了尚未处理的部分
}

第三种最隐蔽,因为它「看起来在处理完的地方提交」。但 commitSync() 提交的是「本批最后一次 poll 返回的位置」,不是「当前处理到第 i 条的位置」。想按条提交,需要把处理拆成多个 poll 批次,而不是在一个批次内部提交。

正确形态只有一种:一批全部处理完,然后提交这一批的位移;提交失败就不推进,下一轮重新处理这一批。

一条额外建议:把提交延迟做成指标

第 8 条如果只靠人工看日志,很快会失效。更稳的做法是记录「每条消息从 poll 到 commit 的耗时」,做成指标。这个指标的 p99 一旦接近 max.poll.interval.ms,第 10 条就快不达标了——它是第 8 和第 10 两条的早期预警。

C 组:broker 与主题

#检查项判据章节不达标怎么改
14关键主题副本数达标kafka-topics.sh --describe 的 Replicas 列实际数量 ≥ 34.2、附录 A单机集群先加 broker,光写配置创建不出 3 副本
15位移主题副本数匹配集群规模__consumer_offsets 的 Replicas 数合理,消费组能正常提交13.1、附录 A多机设 3,单机设 1,否则建不出来
16禁止非同步副本当 leaderunclean.leader.election.enable=false4.1保持默认,别为了「快点选出 leader」而开启
17主题不会被静默创建故意发一个拼错的主题名,事后查不到该主题附录 A关掉 auto.create.topics.enable
18保留期覆盖故障发现周期算出来并写进文档:保留期 ≥ 发现 + 定位 + 修复15.2按对账周期重设,别统一 7 天
19磁盘水位有告警使用率超过阈值触发告警15.2加监控;注意段尾空占会高估实际数据量
20日志段大小与保留粒度匹配段大小不会导致删除滞后超过预期附录 A调整 log.segment.bytes 与 log.roll.ms

第 18 条是清单里唯一一条「靠算」而不是「靠观测」的。它的判据是一个不等式:保留期要长过「异常被发现的时间」。这个时间由业务对账周期决定,不由运维决定。把它写成文档,是因为它是复盘时最常被问的那个数字。

ISR 抖动:最容易被误读的信号

第 14 条的判据是「副本数 ≥ 3」,但日常巡检时看到的是 Isr 列在变化。这里有一个判断口径的问题:ISR 短暂收缩是正常现象,不是故障。

判断标准不是「有没有收缩过」,而是:

观察含义动作
偶发收缩,几秒内恢复正常的 GC 停顿或网络抖动不用处理,记入趋势
频繁收缩,每次都恢复磁盘 IO 或网络带宽持续吃紧查磁盘延迟与网卡利用率
收缩后长时间不恢复follower 卡住,或 replica.lag.time.max.ms 内追不上查该 broker 日志,可能是磁盘故障
ISR 长期只剩 1另外两个副本已经掉队最高优先级,此时 acks=all 已退化

第三、四种情况才是问题。第一种如果每次都在同一时刻发生,可能和定时任务或备份有关,值得查一下——它不会立刻造成故障,但会持续侵蚀「重建期间再坏一台」的安全边际。

replica.lag.time.max.ms 决定了判定的窗口(默认 30 秒)。把它调小会让 ISR 更敏感,收缩更频繁;调大则故障副本在 ISR 里待得更久。默认值在多数场景是合适的,不要因为「看到 ISR 在抖」就去调它。

D 组:数据链路

#检查项判据章节不达标怎么改
21事件 ID 由生产端生成且重试不变模拟一次发送重试,两次的 eventId 相同2.2、8.1把 ID 生成提前到构造事件处,别放在重试回调里
22schema 变更向后兼容用旧版消费者读新版消息,不抛反序列化异常8.2只加可选字段,不改类型、不删必填
23本地写库与发事件不丢意图在 commit 之后、send 之前 kill,恢复后事件最终仍发出7.1、附录 B上 outbox,见附录 B 模板三
24死信与重试主题有容量上限与告警毒消息进 DLQ 时触发告警,DLQ 不无限增长6.1、6.2加告警与保留策略,定期人工处理
25补偿操作可重入同一补偿执行两次,结果一致9.2用 token 或条件更新,见附录 B
26有一致性延迟的度量有「事件产生到下游可见」的 p99 指标9.3在事件里带产生时间戳,下游算差值

第 23 条的判据是最难做的一次观测,因为它要求在精确的时刻中断进程:事务提交后、消息发出前。做法是在 send() 之前插一个可受控的阻塞点(读一个文件或环境变量),然后 kill。测不出来,就说明这个窗口没有被覆盖。7.1 节用这个手法实测过:naive 版本 kill 之后 business_loan=1 outbox=0,事件永久丢失。

四种 schema 变更各自会发生什么

第 22 条的判据是「旧版消费者能读新版消息」。把常见的四种变更分开看,就能判断自己的改法属于哪一类:

变更旧消费者读新消息说明
加一个可选字段正常,忽略新字段安全,首选
加一个必填字段读到了但值为空旧生产者不填,新消费端会看到 null
删一个字段正常,直到有人读它旧消费者的代码里还有这个字段,读到 null
改字段类型反序列化失败或值被曲解最危险,int 改 long 有时能过,String 改 int 必炸

第二行的坑在于「序列化能过、语义错了」。JSON 里少一个字段不会报错,反序列化出来是 null,然后在新消费端的某处 NPE,或者更糟——被当成默认值 0 处理了。

安全的做法是只用「加可选字段」这一种变更,其余三种都当成「加新字段 + 弃用旧字段」。需要删字段时,先让所有消费端不再读它,等一个版本周期再真正删。

兼容性检查放在哪

第 22 条如果不做持续检查,只在发版前人工看一眼,迟早会漏。三个可落地的位置:

  1. 构建期:把消息 schema 与消费端 schema 做一次结构比对,不兼容就让构建失败。
  2. 消费端启动期:读到未知字段时打警告日志,而不是静默忽略。
  3. 演练期:用旧版本消费者订阅新主题,跑一批真实消息,这是唯一能覆盖「值被曲解」这种半兼容情况的方法。

第 8.3 节讨论过 Schema Registry 的取舍:集中管理能自动化第 1 项,但要为一个注册中心付出运维成本。不引入它的话,第 1 项可以退化成一段脚本,代价是每次改 schema 要人工跑。

E 组:运维与演练

#检查项判据章节不达标怎么改
27单 broker 宕机演练做过有带时间线的记录,包含生产失败数、切换耗时、恢复追赶时长14.1排一次演练,用 docker stop
28消费端崩溃演练做过有 kill -9 后重复条数的实测值14.2排一次演练,记录重复范围
29RTO/RPO 有目标值且与实测对照文档里有目标,旁边有实测值,两者差距被解释14.3先定目标,再演练,别反过来
30升级与扩缩容有回滚方案演练过回退,知道回退需要多久4.3、14.3在预发环境演练一次滚动升级与回滚

E 组的四条有一个共同点:它们不是「配置对不对」,而是「人会不会」。前 26 条做得再好,如果没人真的演练过,第一次真正的故障仍然会超出预期——因为真实故障里还有「谁来决定」「谁有权限」「现在是几点」这些配置管不了的因素。

演练的最小可行形态

「排一次完整演练」听起来是大工程,常常因此被无限推迟。其实第 27、28、30 条各有一个 30 分钟内能做完的最小版本,在本地容器里就能跑:

# 演练一:单 broker 宕机(第 27 条)
docker stop mq-broker-1          # 观察生产端回调、ISR 变化、恢复耗时
docker start mq-broker-1         # 记录追赶完成的时间点

# 演练二:消费端崩溃(第 28 条)
kill -9 <consumer-pid>           # 记录重启后第一批消息的 offset 范围
                                 # 与崩溃前已提交的 offset 相减,就是重复条数

# 演练三:生产端断网(第 6、30 条)
docker network disconnect bridge <app-container>   # 30 秒后恢复

每次演练只记录三样东西,记录模板比演练本身更重要:

记录项例子
时间线14:02:11 停 broker,14:02:12 生产端首次失败,14:02:13 切换完成
失败量生产端报错 3 条,消费端停顿 2 秒
恢复耗时从停止到 ISR 恢复 3 个,用了 47 秒

第一行的时间线精度要求到秒,因为 RTO 是按秒谈的。第二行的失败量决定「客户端重试配置够不够」。第三行是第 29 条 RTO 目标值的输入。

本地容器演练不能替代生产演练,但能暴露大部分配置与代码问题——13 章到 15 章的所有实验都是这么做的。先做本地版,把「配置层面不达标」清干净,再上生产演练,能省掉一次尴尬的全员演练。

打分口径

每条给 0 到 2 分,满分 60:

分数含义
0没做
1做了,但没有用观测验证过
2做了,且按判据验证过,能复现

「做了但没验证」只给 1 分,这条规则是故意的。可靠性工程里最危险的区间就是「以为做了」,因为它既不触发告警,也不产生记录,出事时连从哪查起都不知道。

一个实际的分布预期:刚开始做这份清单的团队,通常 C 组得分最高(配置类,容易核对)、E 组得分最低(要排演练、要占人)。而 E 组的低分恰恰是 RTO 超标最直接的原因。

三种典型分布,对应三种不同的下一步:

分布说明下一步
C 组满、E 组空典型的「配置做完了,没人练过」排本地演练,成本最低收益最高
A、C 组高、B、D 组低基础设施到位,应用层没治理从第 7、9 条入手,改消费代码
全组中等、无满分都做了但都没验证过逐条补「观测」这一步,不要撒胡椒面

第二种分布最容易被忽略,因为 broker 侧的巡检面板全是绿的。但重复消费、位移丢失这类问题不会体现在 broker 指标上——它们只体现在业务数据里,被发现时通常已经过去几天。

这张清单本身也需要版本化。系统扩了集群、换了 schema 管理方式、加了跨集群之后,第 14、22、29 条的判据都要跟着改。把清单放进版本库,和代码一起 review,比放在某个人的笔记里更可靠。

不达标时的修改顺序

按边际效率排,不要按清单顺序:

  1. 第 1、2、14 条:强确认 + 幂等 + 副本数。几乎零成本(幂等只是配置),防住的是最常见的数据丢失。
  2. 第 7、8、9 条:位移控制 + 提交时机 + 消费幂等。防住重复与「处理了但丢了」。
  3. 第 12、19 条:告警。它们本身不防故障,但决定了故障被发现的速度——没有告警,后面所有保护的效果都要打折。
  4. 第 18、23 条:保留期与 outbox。防的是「丢得无法恢复」。
  5. 第 27 到 30 条:演练。放在最后排期,但不要放到最后做——演练会暴露前面所有条目的真实状态。

最后一句话是这份清单里最重要的:清单的价值不在于打多少分,而在于每一条都有一个「做一次就能看出真假」的判据。分数会骗人,观测不会。


清单对应的配置项见附录 A,可复制的实现见附录 B。15.3 节用这三十条对 libraryd-mq 做一次完整验收。


阅读导航:上一节 14.3 恢复演练与 RTO/RPO | 下一节 15.2 容量与成本核算

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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