《Java 消息中间件·可靠消息与消费治理》1.1 端到端可靠性的责任边界

一条消息从 send() 到下游落库要经过生产端、broker、消费端、下游存储四段,每段对「成功」的定义都不同。本篇给出四段责任表与各自的失败模式,并用 8 条消息把一次完整追踪和一次崩溃恢复跑出来,说明「消息不丢」到底停在哪一段,以及三段交接处必须写明的契约。

「消息不丢」缺一个主语

「这套消息系统保证不丢消息吗?」这个问题没法直接回答,因为它没说清楚「不丢」的范围。一条消息从业务代码调用 send() 开始,到下游数据库里出现对应记录为止,中间至少经过四个能独立失败的段落:

  1. 生产端把记录写进 broker;
  2. broker 把记录存下来并维持副本;
  3. 消费端读到记录、处理、提交位移;
  4. 处理结果写进下游存储。

每一段都有自己的「成功」定义,也都有自己的失败窗口。生产端的成功可能只是「leader 写进本地日志」,broker 的成功可能只是「当前 ISR 收到了」,消费端的成功可能只是「位移提交了」,下游的成功可能只是「SQL 返回了 affected rows = 1」。

这四段没有共同的原子边界。所谓端到端可靠,不是在某一段打开一个开关,而是把每一段能保证的事情写清楚,再在交接处补上缺失的保证。本篇先把四段的责任摆开,再用一条消息把四段串起来跑一遍。

四段责任表

先把结论压成一张表。表中的「能保证」都是在最有利的配置下,且不讨论硬件永久损坏。

段能保证什么不能保证什么失败时先看什么
生产端acks=all 且 ISR 足够时,记录已写进 leader 与当时所有 ISR 副本应用是否只发了一次;ISR 之外的副本是否有;下游是否收到RecordMetadata.offset()、发送失败回调、delivery.timeout.ms 超时
broker已写入并收到 ISR 确认的记录,在至少一个 ISR 副本存活时可读;副本可重新同步不干净选举后不丢;保留期到期后不删;单副本磁盘不坏Leader、Isr、Elr、UnderReplicated、段文件记录数
消费端已提交位移之前的记录不会再被同一个消费组投递处理是否真的成功;提交与处理是否原子;崩溃后是否重复CURRENT-OFFSET、LAG、CommitFailedException、再平衡日志
下游存储由下游自己的事务、唯一键或幂等写决定Kafka 完全不知道下游写没写、写了几次唯一键冲突数、对账差、重复行

这张表最重要的一列是「不能保证什么」。生产端不能保证业务只发一次,broker 不能保证不干净选举后数据还在,消费端不能保证处理与提交原子,下游不能保证 Kafka 的位移能覆盖它自己的写入。四段各自的保证拼起来,也不自动等于端到端保证。

生产端:成功是「当时」的成功

acks=all 的语义是等当时 ISR 里的所有副本确认。它不包含「以后也不会丢」。如果随后发生不干净选举,把没追上 HW 的副本提成 leader,已经确认的记录仍可能消失;如果 min.insync.replicas 配得比副本数还大,4.3.0 会把有效值钳到副本数,配置看起来更严,实际没有更严。这两点在第 1.2 节都有实测。

所以生产端的责任要写成一句可验证的话:在 acks=all、min.insync.replicas 不大于副本数、且发送时 ISR 不少于该值的前提下,broker 确认过的记录至少存在于一个 ISR 副本的日志里。这句话之外的「不丢」,生产端不负责。

broker:保证的是「副本里还有」

broker 端的可靠性由三件事共同决定:副本数、ISR 维持、以及选举策略。副本数决定最多能坏几台;ISR 决定写入时等谁;选举策略决定 leader 死后从谁那里恢复。

默认 unclean.leader.election.enable=false 时,ISR 全灭的分区会保持离线,不会拿落后副本顶上。这保住了数据,牺牲了可用性。把它打开,或者手工执行不干净选举,可用性回来了,但落后副本上的缺失就变成永久缺失。第 1.2 节会用 kafka-leader-election.sh --election-type UNCLEAN 把这个取舍演示出来。

另外两个经常被忽略的边界是保留期和压缩。retention.ms 到期删除是正常行为,不是「丢消息」;cleanup.policy=compact 只保留每个 key 的最新值,旧值被清掉也是设计行为。责任表里的「不丢」默认不含这两种情况。

消费端:保证的是「位移之前不再投递」

消费端唯一能向 broker 承诺的东西是位移。commitSync 成功,表示这个消费组下次从该位移之后开始读。它不表示:

  • 位移之前的记录都处理成功了(提交可能跑在处理前面);
  • 位移之后的记录都处理失败了(可能已经处理但没提交);
  • 处理是幂等的(重复投递仍会产生重复副作用)。

要让「提交」有意义,必须先定义「处理到哪一步才算可以提交」。是先写下游再提交,还是先提交再写下游,这两个顺序对应 at-least-once 和 at-most-once,第 1.3 节会把两种顺序的丢失去向都跑出来。

下游存储:Kafka 管不到的最后一段

下游写数据库、写缓存、调接口,Kafka 都不知道结果。消费者提交了位移、进程随后在下游写入时失败,Kafka 认为这条消息已经处理完,下游却没有对应记录。反过来,下游写成功、提交位移失败,重启后会再写一次。

这一段只能由下游自己保证:唯一键、幂等写、事务消息表、outbox,或者对账任务。它不属于 Kafka 的保证范围,也不应该被算进「消息中间件可靠」的账上。

一条消息的四段实测

下面把一条消息真的走完四段。实验环境是 Temurin 25.0.5+8、kafka-clients 4.3.0、apache/kafka:4.3.0,1 个独立 controller 加 3 个 broker,topic r11.trace 为 1 分区 3 副本,生产端 acks=all。

先发 5 条,每条打印 broker 返回的位移:

producer ack: p=0 off=0 key=book-0 value=loan-0
producer ack: p=0 off=1 key=book-1 value=loan-1
producer ack: p=0 off=2 key=book-2 value=loan-2
producer ack: p=0 off=3 key=book-3 value=loan-3
producer ack: p=0 off=4 key=book-4 value=loan-4

再启动一个关闭自动提交的消费者,读到 5 条、逐条写入下游文件、最后提交位移 5:

consumer polled=5 fromOffset=0
downstream write: off=0 value=loan-0
downstream write: off=1 value=loan-1
downstream write: off=2 value=loan-2
downstream write: off=3 value=loan-3
downstream write: off=4 value=loan-4
committed=5

查询消费组与日志末端:

group=r11-trace topic=r11.trace
committed=5 logEnd=5 lag=0

再直接数 leader 段文件里的记录:

file=/var/lib/kafka/data/r11.trace-0/00000000000000000000.log
5

复现用的命令如下。段文件统计必须用 grep -c "^| offset:",kafka-dump-log.sh 每条记录输出两行,用 offset: 会数成两倍:

./run.sh TraceLab produce 5
./run.sh TraceLab consume r11-trace "$PWD/downstream-trace.txt" 5
./run.sh GroupOffsets r11-trace r11.trace
docker exec mqrel-k1 sh -c 'unset JMX_PORT; f=$(ls /var/lib/kafka/data/r11.trace-0/*.log | head -1);
  /opt/kafka/bin/kafka-dump-log.sh --files "$f" --print-data-log 2>/dev/null | grep -c "^| offset:"'

本机是 4 vCPU / 7.7 GiB,实验期间宿主机 load average 为 57.97 32.58 26.89,还有别的代理在并行跑任务。本节所有耗时与条数都是单次运行,条数是确定性的,耗时随调度波动,不作基准。

四段的观察点连起来是这样:

段观察手段实测值
生产端RecordMetadata.partition()/offset()p=0,off=0..4
brokerlogEndOffset 与段文件记录数5,5
消费端消费组已提交位移5
下游文件行数5

这条链路一切正常时,四段的值能对上。真正的问题是:任意一段断开时,谁还能对上谁。

第三段断开:处理了、没提交

继续往同一个 topic 发 3 条,落在 off=5、6、7。消费者读到这 3 条,处理前 2 条后直接 halt(9),不提交:

producer ack: p=0 off=5 key=book-5 value=loan-5
producer ack: p=0 off=6 key=book-6 value=loan-6
producer ack: p=0 off=7 key=book-7 value=loan-7

consumer polled=3 fromOffset=5
downstream write: off=5 value=loan-5
downstream write: off=6 value=loan-6
processed=2 uncommitted=1 -> halt(9) before commit

此时查消费组:

group=r11-trace topic=r11.trace
committed=5 logEnd=8 lag=3

下游文件已经有 off=5、6 的内容,但 Kafka 只知道位移停在 5。用同一个组重启消费者,会从 5 重读:

consumer polled=3 fromOffset=5
downstream write: off=5 value=loan-5
downstream write: off=6 value=loan-6
downstream write: off=7 value=loan-7
committed=8

最后的下游文件是 10 行,而实际只有 8 条消息:

   1 loan-0
   1 loan-1
   1 loan-2
   1 loan-3
   1 loan-4
   2 loan-5
   2 loan-6
   1 loan-7

这段实验说明两件事。第一,broker 段没有丢:off=5、6、7 一直在日志里,重启后完整读到。第二,消费端「不丢」的代价是重复:处理成功但未提交的记录会被再处理一次。重复的范围就是「已处理、未提交」的那一段,最坏是一整个 poll 批次。

这里也能看出「消息不丢」的主语差异:broker 段说不丢,是它真的还在;消费端说不丢,是它一定会再投一次;下游说不丢,是它得自己扛住重复。三段用的是同一个词,含义完全不同。

每段的失败长什么样

把四段常见的失败模式列出来,方便在事故里定位「现在丢的是哪一段」:

段失败方式生产者/消费者当时的感受数据后果定位信号
生产端acks=1 后 leader 崩溃且落后副本被提为 leader100 条全部返回成功幸存副本 0 条,消费者读不到发送成功数远大于可消费数
生产端批次在 delivery.timeout.ms 内没等到确认回调收到超时可能已写入也可能没写入超时异常与重复并存
brokerISR 全灭后执行不干净选举无感知未复制的尾部被截断Leader: none 后手工选举成功
broker保留期到期或 compact 清掉旧值无感知旧记录被正常删除retention.ms、段文件消失
消费端自动提交在 close() 时提交了拉取位置无异常,进程正常退出未处理的记录被永久跳过已提交位移大于下游已处理最大位移
消费端处理完未提交就崩溃无异常重启后重复处理位移落后于下游已有记录
下游非幂等写遇到重放无异常重复行或重复扣款唯一键冲突、对账差

这张表里最危险的是「无感知」的那几行。生产端 acks=1 的丢失不会抛异常;自动提交在 close() 时的跳过也不会抛异常;不干净选举更是运维主动做的操作。能抛异常的失败都不算最坏,最坏的是监控面板上一切正常。

第 1.2 节会把第一行做成完整实验:100 条 acks=1 全部返回成功,杀掉 leader、把落后副本提为 leader 后,消费者读到 0 条,而被杀 leader 的段文件里有 100 条。第 1.3 节会把第五行做成实验:自动提交在 close() 时把 20 条里未处理的 15 条永久跳过。

三段交接处要写清楚的契约

四段各自内部做好还不够,交接处必须有明确的契约。缺一个,端到端就会出现一个没人负责的缝。

契约一:生产端到 broker

要写清楚的是「什么条件下 send() 返回成功才算数」。至少包含:

  • acks=all;
  • topic 的副本数与 min.insync.replicas 的组合确实能在坏一台时继续写;
  • delivery.timeout.ms 大于业务能接受的最大发送延迟;
  • 发送失败时业务是重试、落库补偿,还是直接报警。

验证方式不是看配置,而是做一次 leader 故障:记录生产者认为成功的条数,再从新 leader 读一遍,两个数字必须相等。

契约二:broker 到消费端

要写清楚的是「提交位移代表处理到哪一步」。至少包含:

  • 关闭自动提交;
  • 处理成功之后再提交;
  • 提交失败时的重试边界(哪些异常可以原地重试,哪些必须重新加入消费组);
  • 重复投递的业务容忍度或幂等方案。

验证方式是杀掉消费者进程再重启,比较「已处理集合」与「重启后重读集合」,差集就是重复范围。

契约三:消费端到下游

要写清楚的是「同一条消息被处理两次时下游怎么办」。至少包含:

  • 业务唯一键是什么;
  • 下游写入是否幂等,或者是否和位移提交在同一个事务里;
  • 重复写被拒绝时算成功还是失败;
  • 对账任务的频率与判据。

验证方式是把同一个位移的消息故意处理两次,下游的行数或余额必须不变。

责任表怎么用

这张表不是文档装饰,它有三个实际用途。

第一,写 SLO 时明确分母。说「消息不丢」之前,先说是「生产端确认后不丢」「broker 段不丢」还是「下游最终一致」。三者的验收方法完全不同。

第二,出事时快速定位段。生产者说发送成功、消费者说没收到,先看 broker 的 ISR 与 leader 历史;消费者说处理了、下游说没有,先看提交位移与下游写入日志。段与段之间不要互相猜。

第三,评审配置时逐段对照。acks=all 解决第一段,副本与 min.insync.replicas 解决第二段,手动提交解决第三段的一半,幂等或 outbox 解决第四段。任何一段空着,端到端就有一个已知的失败窗口。

每段的最小验收动作

不需要等一次真实事故,四段各自都可以主动制造一次小故障来验收:

段最小验收动作通过判据
生产端杀掉 leader,比较发送成功数与新 leader 可读条数两个数字相等;不等则说明确认语义不够强
broker停一个副本,确认 ISR 收缩后仍能写;再停到低于 min.insync.replicas前者成功,后者显式报 NOT_ENOUGH_REPLICAS
消费端处理到一半 halt(9),用同一个组重启已处理未提交的记录被重读,提交位移不越过未处理记录
下游把同一条消息处理两次下游行数、余额或状态不变

这四组动作对应本卷后面的第 14 章故障注入与第 15 章验收。本节先给出动作和判据,具体脚本在第 14 章补齐。

本节判据

回答四个问题:

  1. acks=all 返回成功,是否等于消息一定不会丢?——不等于。它只保证当时 ISR 收到了;不干净选举、保留期、磁盘损坏都在保证之外。
  2. 消费者提交了位移 5,是否等于 offset 0..4 都处理成功?——不等于。提交只表示下次从 5 开始读,提交可以跑在处理前面。
  3. 处理完 5 条、提交前崩溃,重启后会怎样?——从上次提交位置重读,已处理未提交的部分会重复。本机实测重复 2 条、下游 10 行对应 8 条消息。
  4. 下游写数据库失败,Kafka 会知道吗?——不会。Kafka 只知道位移,下游的写入结果必须由下游自己保证。

下一节把第一段拆开:acks 的三个取值到底等谁、retries 和 delivery.timeout.ms 谁先到算谁、幂等生产者去重的是哪一种重复,以及 4.3.0 里 min.insync.replicas 被副本数钳制的实测。

阅读导航:上一节 目录与学习路径 | 下一节 1.2 生产端:acks、重试与幂等

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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