《Java 消息中间件·可靠消息与消费治理》14.3 恢复演练与 RTO/RPO

三节点 KRaft 上做一次带流量的完整演练:生产 15000 条、消费者常驻,中途停掉一台 broker。实测故障检测 4 秒、数据面恢复 29 秒、业务面 lag 归零 41 秒,生产者 0 失败。文章把这 41 秒拆成五段,说明哪一段是真 RTO、哪一段是人为停机窗口,并给出可重复的演练脚本要素与判据。

RTO 和 RPO 到底在量什么

RTO(Recovery Time Objective)是「业务从故障中恢复要多久」,RPO(Recovery Point Objective)是「最多能丢多少数据」。这两个词在文档里人人会写,落到消息系统上却经常被量错,最常见的有三种错法:

  1. 把「broker 进程重新起来」当成 RTO。 进程起来不等于分区有 leader,更不等于消费者能继续读。
  2. 把「ISR 恢复满」当成业务恢复。 ISR 满只说明副本同步完成,积压可能还堆在那里。
  3. 在空集群上演练。 没有流量的故障注入只能测出「进程能不能启动」,测不出「有流量时会不会丢、会不会卡」。

这一节做一次把三个坑都避开的演练:三节点、有生产者、有消费者、故障发生在持续写入的中途。然后把这几十秒拆开,看清楚每一段是谁决定的。

先说口径。消息系统的「恢复」至少有三个不同的时刻:

口径判据谁关心
进程可用broker 端口能连、能响应 API运维
数据面可用所有分区 ISR 回到副本数,acks=all 能成功平台
业务面可用消费者 lag 归零,积压消化完业务

三者之间可以差出好几倍。写 RTO 时不注明是哪一个,就等于没写。

演练设计

集群与故障方式沿用 14.1 的三节点 KRaft(apache/kafka:4.3.0),主题 rel14.drill2 三个分区、副本数 3、min.insync.replicas=2。

两个角色全程在场:

  • 生产者:kafka-producer-perf-test.sh,15000 条,限速 250 条/秒(约 60 秒发完),acks=all、enable.idempotence=true、delivery.timeout.ms=30000。
  • 消费者:自己写的 DrillConsumer,每消费满 500 条调用一次 无参 commitSync()(提交所有分区已消费的位置),空闲时也提交一次。

消费者这一处和 14.2 的实验不同,是有原因的。14.2 的消费者用 commitSync(singletonMap(tp, offset+1)) 只提交当前记录所在的分区,这个写法在崩溃重复的观测里够用,但会让 lag 永远不归零——因为没被提交的那个分区,它的 CURRENT-OFFSET 停在旧位置,而消费者已经读完了,没有新记录再去触发提交。第一版演练就卡在这里:消费者日志显示 30000 条全部处理完,组里却还有 518 条 lag。不是消费没完成,是提交没跟上。

这个坑值得单独记一笔:lag 衡量的是「已提交位置到日志末尾的距离」,不是「还没处理的条数」。 已处理未提交的数据同样计入 lag。用单分区提交写法时,lag 会稳定停在一个非零值上,看起来像消费卡住,其实只是提交策略问题。

时间线由脚本控制:先启动消费者,2 秒后启动生产者;生产进行到第 20 秒停掉 k2;再等 20 秒把它启动回来。全程每 1 秒采样一次消费组 lag。

实测时间线

一次运行的原始时间线(2026-10-29,本机 colima 4 vCPU):

[14:42:31] three brokers started
[14:42:37] ready after 2s
[14:42:38] topic created
[14:42:40] consumer started
[14:42:44] producer log present
[14:43:04] STOP k2 (T_STOP=1793256184)
[14:43:08] ISR shrank to 2 (T_ISR2=1793256188, detect=4s)
[14:43:28] START k2 (T_START=1793256208)
[14:43:33] ISR back to 3 (T_ISR3=1793256213, from-stop=29s, from-start=5s)
[14:43:44] producer finished (T_PRODEND=1793256224, from-stop=40s)
[14:43:45] consumer lag reached 0 (T_LAG0=1793256225, from-stop=41s)

整理成表,以停机时刻为零点:

事件时刻距停机说明
停掉 k214:43:040s故障注入
ISR 收缩到 214:43:08+4s三个分区同时检测到
启动 k214:43:28+24s人为设定的故障窗口
ISR 回到 314:43:33+29s从启动算 5 秒
生产结束14:43:44+40s15000 条全部成功
lag 归零14:43:45+41s积压消化完

生产端与消费端的最终结果:

15000 records sent, 249.966671 records/sec (0.12 MB/sec), 7.51 ms avg latency,
438.00 ms max latency, 7 ms 50th, 10 ms 95th, 26 ms 99th, 267 ms 99.9th.
PRODUCER_EXIT=0
GROUP           TOPIC           PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
rel14-drill2-g  rel14.drill2    0          4868            4868            0
rel14-drill2-g  rel14.drill2    1          5472            5472            0
rel14-drill2-g  rel14.drill2    2          4660            4660            0

4868 + 5472 + 4660 = 15000,一条不少。消费者日志里 grep -icE 'exception|error' 的结果是 0。

拆开看这 41 秒

41 秒听起来像 RTO,但它里面有一大段不是系统恢复能力,而是我自己设定的停机时长。而且停机窗口不是脚本里写的 20 秒——ISR 检测用了 4 秒,sleep 20 之后才启动,所以实际窗口是 24 秒。

把 41 秒按阶段拆开:

阶段耗时由什么决定能否缩短
故障注入到检测4s连接断开 + 控制器感知基本不能,4 秒已是快速路径
人为停机窗口20s脚本的 sleep这是演练参数,不是 RTO
启动到 ISR 满5s副本追赶 + 控制器把副本加回 ISR能,取决于积压量与磁盘
生产收尾11s生产者剩余条数与故障无关
积压消化1s消费者吞吐 vs 生产者速率能,取决于消费能力

真正和「恢复能力」有关的只有第一段和第二段:检测 4 秒、副本追平 5 秒。其余都是人为窗口或业务本身的节奏。

如果按「从 broker 启动算」的口径,业务面恢复是 17 秒(T_LAG0 - T_START);按「从故障发生算」是 41 秒。两个数都对,取决于你问的是「故障持续期间系统还能不能服务」还是「故障发生后多久能恢复正常」。

那 4 秒的检测为什么这么快?14.2 讲过一条规律,这里同样成立:进程被停掉时操作系统会关闭它持有的 TCP 连接,控制器收到断开信号立刻把副本移出 ISR,不等 replica.lag.time.max.ms 超时。 如果故障是「进程活着但卡住」(比如 JVM 长 GC、被 SIGSTOP),才会走超时那条路,那就要等满 30 秒默认阈值。

生产者视角:这期间丢没丢

PRODUCER_EXIT=0,15000 条全成功。日志里只有一条重试记录:

WARN Got error produce response with correlation id 2680 on topic-partition
rel14.drill2-1, retrying (2147483646 attempts left). Error: NOT_LEADER_OR_FOLLOWER
WARN Received invalid metadata error in produce request on partition rel14.drill2-1
due to org.apache.kafka.common.errors.NotLeaderOrFollowerException

一条重试,耗时在毫秒级,其余请求没有感知到故障。这能成立有两个前提:

  1. acks=all 加上 ISR 至少还剩 2 个副本。 k2 停机后 ISR 从 3 变成 2,min.insync.replicas=2 依然满足,所以写入可以继续。
  2. 幂等生产者。 重试的请求带序列号,broker 去重,不会因为重试产生重复。

把 min.insync.replicas 改成 3,结果会完全不同。14.1 在同一套集群上测过:ISR 一降到 2,acks=all 的写入立刻抛 NotEnoughReplicasException,生产者只能等或失败,740 毫秒内连续 3 次。

org.apache.kafka.common.errors.NotEnoughReplicasException:
  Messages are rejected since there are fewer in-sync replicas than required.

这就是那个反直觉的取舍:min.insync.replicas 调高提升的是「不丢」的保证,代价是「一台机器故障就停止写入」。 三副本、minISR=2 的组合能容忍一台故障继续写;minISR=3 的组合一台故障就拒写,但绝不会出现「两个副本都写成功、第三个没写上」的窗口。

选哪个不是技术问题,是业务问题:写入中断 30 秒和极端情况下丢一条消息,哪个更不能接受。

消费端视角:重复从哪来

这次消费者没有崩,也没有被踢出组,所以没有重复——15000 条就是 15000 条。但演练不能只看这种「顺利」路径,否则会得出「消费端不需要幂等」的错误结论。

故障期间消费端其实有三种可能的遭遇:

情况触发条件后果
消费者连的就是停掉的那台连接断开,组协调器不可达重新发现协调器、重新加入组,期间暂停消费
消费者连的是正常 broker分区 leader 切换短暂的 NOT_LEADER_OR_FOLLOWER,内部处理,上层无感
消费进程本身出问题崩溃、卡住14.2 讨论的两条路径:立即重分配 vs 等 session 超时

本次属于第二种:消费者连的是 k1,停的是 k2,它只是在分区 2 的 leader 切换时短暂重连了一下,日志里没有异常。

演练设计里必须显式考虑第一种情况。 方法有两种:让消费者连接所有 broker(这本来就是 bootstrap.servers 的默认做法,但客户端只会挑其中一台建连),或者跑多轮,让停掉的 broker 有概率正好是被连的那台。更彻底的办法是在停 broker 之前先查一下消费者实际连的是谁——这一点没有公开 API,只能从 broker 端的连接数或者消费者日志的 Node X connected 推断。

如果不做这一步,演练测出来的 RTO 会偏乐观。真实故障不会挑「对你影响最小的那台机器」坏。

数据面 RTO 与业务面 RTO

把这次的数据按三种口径摊开:

口径本次实测典型目标谁决定
故障检测4s≤ 10sreplica.lag.time.max.ms、连接断开路径
数据面恢复(ISR 满)从启动 5s / 从停机 29s≤ 60s副本追赶速度、积压量
业务面恢复(lag=0)从停机 41s视业务而定停机窗口 + 消费者吞吐

写 SLA 时最容易被忽略的是第二列之外的东西:RTO 的分母是「故障持续时间」,而故障持续时间不由系统决定。 一个 5 分钟没被发现的故障,恢复能力再好,业务中断也是 5 分钟。所以真正的 RTO 指标要拆成两半:检测时间(MTTD)和恢复时间(MTTR)。这次演练里 MTTD 是 4 秒——前提是有人或监控系统在看着 ISR 指标。没人看的话,MTTD 等于「下一次有人发现为止」。

RPO 这次是 0:acks=all、minISR=2、没有 unclean 选举,15000 条一条不少。RPO 不为 0 的典型配置组合:

配置可能的 RPO
acks=1最后一批未确认的写入,通常是几十毫秒的量
acks=all + minISR=1只有 leader 收到就确认,leader 挂了会丢 leader 上独有的部分
允许 unclean.leader.election可能回退到不同步的副本,丢已确认消息

min.insync.replicas 的默认值是 1,这是最危险的地方:它让 acks=all 看起来很强,实际只保证 leader 一份写了。附录 A 的配置矩阵里把它列为第一条要改的项,原因就在这里。

把演练变成可重复的门禁

一次性的演练只能证明「当时能恢复」,不能证明「下次也能」。把它变成上线前的门禁,最少要固定这几件事:

输入条件

  • 集群规格、副本数、min.insync.replicas 与生产一致;
  • 有持续流量,生产者速率接近生产环境的峰值而不是空跑;
  • 至少一个消费者在消费,提交策略与生产一致;
  • 故障注入方式固定(停进程 / 停网络 / 停磁盘,三者测的不是同一件事)。

记录项

  • 五个时间点:故障注入、检测到、恢复动作、数据面恢复、业务面恢复;
  • 生产者失败条数与重试次数;
  • 消费者重复条数(对比消费前后的 offset 与业务侧计数);
  • 恢复后的 ISR、lag、分区 leader 分布。

判据

数据面:所有分区 ISR == replication.factor,且在目标时间内
生产端:失败条数 == 0,或失败条数 <= 业务允许的 RPO
消费端:lag == 0,重复条数 <= 幂等层能消化的量
一致性:生产条数 == 消费条数(去重后)

频率

  • 每次改动副本数、minISR、acks、磁盘或网络配置后跑一次;
  • 每季度跑一次完整演练,包含「停 controller 所在节点」的用例——本次停的 k2 恰好不是 controller leader,故障会更温和;
  • 混沌演练里加入「同时停两台」,验证的是不同的问题:三节点 KRaft 停两台会失去 controller quorum,这时集群不是变慢,而是写不进去。

最后一条容易漏。14.1 只停了 1 台,集群还能选主;停 2 台时 Raft 多数派(2/3)被打破,控制器无法提交任何元数据变更,新建主题、改配置全部失败。这是「容错 1 台」和「容错 2 台」的本质差别,不是线性的。

按故障时长分级:三种演练测三件事

同一次演练只能回答一类问题。故障时长不同,被压到的薄弱环节完全不同:

故障时长主要测什么观察点常见结论
几秒(短于客户端超时)客户端重试是否透明生产者失败数、延迟毛刺用户无感,只有延迟长尾变高
30 秒到 1 分钟ISR 收缩与恢复、minISR 边界ISR 变化、是否 NotEnoughReplicas短窗口能扛,长窗口开始拒写
5 分钟以上积压消化、消费者会话稳定性lag 曲线斜率、是否触发 rebalance故障期间不痛,恢复后追赶才是瓶颈

这三档不能互相替代。短故障测出来「0 失败」,说明重试机制有效;它不能证明长故障下积压能消化。反过来,长故障演练里生产者失败了很多次,也不代表短故障处理得不好。规划演练时按这三档各设一次,比反复调一个用例的时长更有信息量。

还有一个容易被忽略的变量是故障发生时的负载水位。这次的 250 条/秒是刻意选的中间值:太低测不出积压,太高在本机环境下会先被 CPU 卡住而不是被故障卡住。真实环境里应该按业务峰值来设,而不是按「能跑通」来设。

一份可复制的演练记录至少包含下面这几行:

集群:__ 节点 / 副本数 __ / minISR __ / 版本 __
注入方式:stop | kill -9 | SIGSTOP | 网络分区 | 磁盘挂起
流量:生产 __ 条/秒,消费 __ 条/秒,持续时间 __ 秒
T_STOP / T_ISR2 / T_START / T_ISR3 / T_PRODEND / T_LAG0:六个时间戳
结果:生产失败 __ 条,消费重复 __ 条,最终 lag __
结论:数据面 RTO __ 秒,业务面 RTO __ 秒,RPO __ 条

把这些字段固定下来,两次演练之间才有可比性;否则每次记录的东西不一样,「这次比上次快」这种结论就没法验证。

这个实验没有覆盖什么

必须写清楚,否则上面的数字会被当成通用结论:

  1. 单机容器环境。 4 vCPU、三个 broker 加消费者挤在同一台机器上,磁盘和网络都是本地的。真实环境里副本追赶的速度由网络带宽和磁盘 IO 决定,数字会差一个量级。
  2. 单次运行,不作基准。 宿主机负载在实验期间从 14 到 18 波动,同一脚本再跑一次,检测时间可能变成 5 秒或 6 秒。这里的价值是量级和拆分方法,不是具体秒数。
  3. 没有测 controller 故障。 停的是普通 broker。停 controller leader 会触发重新选举,恢复路径不同。
  4. 没有测网络分区。 docker stop 是干净的进程退出,连接会正常关闭。网络分区(比如 iptables DROP)会走超时路径,检测时间从 4 秒变成 30 秒量级。
  5. 没有测磁盘故障。 磁盘写满或 IO 挂起时,broker 进程还在、心跳还在,但写入停滞,这是最难观测的一类故障。

第 4 条和第 5 条值得单独设计实验,它们和「进程退出」是三条完全不同的故障路径,恢复时间不在一个数量级上。把 docker stop 的 4 秒当成所有故障的检测时间,是这类演练最常见的过度概括。

小结

  • 消息系统的「恢复」有三个口径:进程可用、数据面可用(ISR 满)、业务面可用(lag 归零),写 RTO 必须注明是哪一个。
  • 本次带流量演练:检测 4 秒、从启动到 ISR 满 5 秒、从停机到 lag 归零 41 秒,其中 24 秒是人为设定的停机窗口。
  • 扣掉人为窗口,业务面恢复是 17 秒;这 17 秒里 11 秒是生产者在发剩余数据,真正的追赶只用了 1 秒。
  • 生产者 15000 条 0 失败,只有一次 NOT_LEADER_OR_FOLLOWER 重试——前提是 minISR=2 在停机后仍被满足。
  • 演练必须在有流量、有消费者、故障发生在中途的条件下做,并且要显式覆盖「停掉消费者正连接的那台」。
  • docker stop 走的是连接断开路径,测不出网络分区和磁盘挂起;后两者要用不同的注入方式单独测。

阅读导航:上一节 14.2 故障注入:消费端崩溃 | 下一节 15.1 可靠性验收清单

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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