RTO 和 RPO 到底在量什么
RTO(Recovery Time Objective)是「业务从故障中恢复要多久」,RPO(Recovery Point Objective)是「最多能丢多少数据」。这两个词在文档里人人会写,落到消息系统上却经常被量错,最常见的有三种错法:
- 把「broker 进程重新起来」当成 RTO。 进程起来不等于分区有 leader,更不等于消费者能继续读。
- 把「ISR 恢复满」当成业务恢复。 ISR 满只说明副本同步完成,积压可能还堆在那里。
- 在空集群上演练。 没有流量的故障注入只能测出「进程能不能启动」,测不出「有流量时会不会丢、会不会卡」。
这一节做一次把三个坑都避开的演练:三节点、有生产者、有消费者、故障发生在持续写入的中途。然后把这几十秒拆开,看清楚每一段是谁决定的。
先说口径。消息系统的「恢复」至少有三个不同的时刻:
| 口径 | 判据 | 谁关心 |
|---|---|---|
| 进程可用 | 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)
整理成表,以停机时刻为零点:
| 事件 | 时刻 | 距停机 | 说明 |
|---|---|---|---|
| 停掉 k2 | 14:43:04 | 0s | 故障注入 |
| ISR 收缩到 2 | 14:43:08 | +4s | 三个分区同时检测到 |
| 启动 k2 | 14:43:28 | +24s | 人为设定的故障窗口 |
| ISR 回到 3 | 14:43:33 | +29s | 从启动算 5 秒 |
| 生产结束 | 14:43:44 | +40s | 15000 条全部成功 |
| 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
一条重试,耗时在毫秒级,其余请求没有感知到故障。这能成立有两个前提:
acks=all加上 ISR 至少还剩 2 个副本。 k2 停机后 ISR 从 3 变成 2,min.insync.replicas=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 | ≤ 10s | replica.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 __ 条
把这些字段固定下来,两次演练之间才有可比性;否则每次记录的东西不一样,「这次比上次快」这种结论就没法验证。
这个实验没有覆盖什么
必须写清楚,否则上面的数字会被当成通用结论:
- 单机容器环境。 4 vCPU、三个 broker 加消费者挤在同一台机器上,磁盘和网络都是本地的。真实环境里副本追赶的速度由网络带宽和磁盘 IO 决定,数字会差一个量级。
- 单次运行,不作基准。 宿主机负载在实验期间从 14 到 18 波动,同一脚本再跑一次,检测时间可能变成 5 秒或 6 秒。这里的价值是量级和拆分方法,不是具体秒数。
- 没有测 controller 故障。 停的是普通 broker。停 controller leader 会触发重新选举,恢复路径不同。
- 没有测网络分区。
docker stop是干净的进程退出,连接会正常关闭。网络分区(比如iptables DROP)会走超时路径,检测时间从 4 秒变成 30 秒量级。 - 没有测磁盘故障。 磁盘写满或 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 可靠性验收清单
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。