这份清单怎么用
验收最容易流于形式,因为「检查项」被写成了「配置项」:核对一下 acks=all 有没有配,打勾,结束。可配置存在不等于保护存在——acks=all 配上 min.insync.replicas=1,保护是零;enable.auto.commit=false 配上一行注释掉的 commitSync,位移根本不会前进。
所以这份清单的每一条都只认一件事:你能不能做一件具体的事,看到预期的结果。做不出来就是没通过,跟配置里写了什么无关。
三十条分五组,覆盖从生产端到演练的完整链路。每一条包含四个信息:
- 检查项:要保证的到底是什么。
- 判据:怎么用一次观测证明它在。
- 对应章节:机制在哪一章展开。
- 不达标怎么改:最小改动是什么。
判定原则
在逐条看之前,先接受三条原则,否则清单会被用成一张许愿表:
- 判据必须是观测,不是声明。 「已配置
acks=all」是声明;「杀掉一个 follower,写入仍成功且 ISR 显示 2」是观测。前者写在文档里,后者写在演练记录里。 - 通过要能复现。 一次通过可能是运气。涉及故障的检查项,至少跑两次,两次结果一致才算通过。
- 不通过的项要给出「什么时候修」。 三十条同时不达标是常态,全都立刻修是不现实的。最后一节给了按边际效率排序的修改顺序。
A 组:生产端
| # | 检查项 | 判据 | 章节 | 不达标怎么改 |
|---|---|---|---|---|
| 1 | 强确认与最小同步副本同时生效 | 停一个 follower,写入仍成功且 ISR 显示 2;再停一个,写入收到 NotEnoughReplicasException 而不是静默成功 | 1.2、4.2 | 把 min.insync.replicas 设为 2,别只改 acks |
| 2 | 幂等生产者开启 | 客户端启动日志显示幂等生效;构造一个可重试失败场景,重复率为 0 | 1.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 间隔之内 | 压测期间日志里没有因超时触发的 rebalance | 4.3、12.3 | 调大 max.poll.interval.ms,或减小 max.poll.records |
| 11 | 再平衡导致的提交失败被捕获 | 频繁扩缩容时,CommitFailedException 被捕获并记日志,线程不崩 | 4.3 | 在提交处捕获该异常,放弃本批位移即可 |
| 12 | lag 有告警且真的会响 | 人为制造积压,告警在约定时间内触发 | 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 列实际数量 ≥ 3 | 4.2、附录 A | 单机集群先加 broker,光写配置创建不出 3 副本 |
| 15 | 位移主题副本数匹配集群规模 | __consumer_offsets 的 Replicas 数合理,消费组能正常提交 | 13.1、附录 A | 多机设 3,单机设 1,否则建不出来 |
| 16 | 禁止非同步副本当 leader | unclean.leader.election.enable=false | 4.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 生成提前到构造事件处,别放在重试回调里 |
| 22 | schema 变更向后兼容 | 用旧版消费者读新版消息,不抛反序列化异常 | 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 条如果不做持续检查,只在发版前人工看一眼,迟早会漏。三个可落地的位置:
- 构建期:把消息 schema 与消费端 schema 做一次结构比对,不兼容就让构建失败。
- 消费端启动期:读到未知字段时打警告日志,而不是静默忽略。
- 演练期:用旧版本消费者订阅新主题,跑一批真实消息,这是唯一能覆盖「值被曲解」这种半兼容情况的方法。
第 8.3 节讨论过 Schema Registry 的取舍:集中管理能自动化第 1 项,但要为一个注册中心付出运维成本。不引入它的话,第 1 项可以退化成一段脚本,代价是每次改 schema 要人工跑。
E 组:运维与演练
| # | 检查项 | 判据 | 章节 | 不达标怎么改 |
|---|---|---|---|---|
| 27 | 单 broker 宕机演练做过 | 有带时间线的记录,包含生产失败数、切换耗时、恢复追赶时长 | 14.1 | 排一次演练,用 docker stop |
| 28 | 消费端崩溃演练做过 | 有 kill -9 后重复条数的实测值 | 14.2 | 排一次演练,记录重复范围 |
| 29 | RTO/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、2、14 条:强确认 + 幂等 + 副本数。几乎零成本(幂等只是配置),防住的是最常见的数据丢失。
- 第 7、8、9 条:位移控制 + 提交时机 + 消费幂等。防住重复与「处理了但丢了」。
- 第 12、19 条:告警。它们本身不防故障,但决定了故障被发现的速度——没有告警,后面所有保护的效果都要打折。
- 第 18、23 条:保留期与 outbox。防的是「丢得无法恢复」。
- 第 27 到 30 条:演练。放在最后排期,但不要放到最后做——演练会暴露前面所有条目的真实状态。
最后一句话是这份清单里最重要的:清单的价值不在于打多少分,而在于每一条都有一个「做一次就能看出真假」的判据。分数会骗人,观测不会。
清单对应的配置项见附录 A,可复制的实现见附录 B。15.3 节用这三十条对 libraryd-mq 做一次完整验收。
阅读导航:上一节 14.3 恢复演练与 RTO/RPO | 下一节 15.2 容量与成本核算
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。