1.5 延迟预算:把 200 ms 拆到每一跳
上一节:1.4 SLO 的四要素写法 | 下一节:1.6 基线与容量曲线 配套代码:01-metrics-and-slo/05-latency-budget
一句话结论
延迟预算把「优化」从「猜哪里慢」变成了「看哪一跳超预算」。 它还能顺带解决一个高频故障源:超时设置必须逐层递减,否则会出现「上游已经超时返回,下游还在傻等」的级联堆积。
一、用「出门旅行的预算」理解延迟预算
假设你今天上午有 4 小时(240 分钟)要完成三件事:去银行、去超市、回家做饭。
你会本能地做预算:
| 环节 | 预算 | 实际 |
|---|---|---|
| 路上通勤 | 40 min | 35 min ✅ |
| 银行排队 + 办理 | 60 min | 95 min ⚠️ |
| 超市采购 | 50 min | 45 min ✅ |
| 做饭 | 60 min | 55 min ✅ |
| 余量 | 30 min | 10 min |
结果一目了然:超预算的是「银行」这一环,其他都正常。你不需要分析每件事,直接就知道问题在哪。
如果没有预算,你只知道「今天超时了 25 分钟」,然后要挨个回忆哪一步慢了。
后端的延迟预算完全同理——把总延迟拆给每一跳,谁超了就是谁的问题。
二、一个完整的延迟预算表
目标:订单查询接口,客户端视角 P99 ≤ 200 ms。
| 环节 | 预算 (P99) | 说明 |
|---|---|---|
| 客户端 → 网关(网络 + 客户端排队) | 25 ms | 同城网络、连接复用 |
| 网关处理(TLS / 鉴权 / 限流) | 15 ms | 含一次鉴权缓存查询 |
| 应用排队(调度 / 线程 / 连接) | 20 ms | 饱和度升高时最先牺牲的一项 |
| 应用逻辑(业务 + 序列化) | 50 ms | 你写的代码 |
| ├ PostgreSQL | 60 ms | 复杂查询上限 |
| ├ Redis | 5 ms | 单次命令 |
| └ 下游 HTTP 依赖 | 40 ms | 含重试预算 |
| 响应传输 | 10 ms | — |
| 余量 | — 已超 | 见下方说明 |
先别急着看数字——这张表其实是故意算错的,用来讲第二件事:预算必须自洽。
预算自洽性检查
25 + 15 + 20 + 50 + 60 + 5 + 40 + 10 = 225 ms
225 > 200,已经超了!
这说明两件事:
- 预算表要先做加法。很多人拆完预算就忘了加起来验证。
- 超了就得砍。要么提高 SLO(200 → 250 ms),要么压缩某一跳。
调整后的版本(把 PostgreSQL 从 60 压到 40,下游从 40 压到 25,留 30 ms 余量):
25 + 15 + 20 + 50 + 40 + 5 + 25 + 10 = 190 ms,余量 10 ms ✅
余量必须是非零的正数,用来吸收抖动。第 0 章讲过:延迟对利用率是非线性的,没有余量的预算在流量波动时必然崩。
三、延迟预算的两个用途
用途 1:定位(哪一跳红了)
接口 P99 从 200 ms 涨到 400 ms 时,你不需要猜,直接对照预算表:
| 环节 | 预算 | 实际 | 状态 |
|---|---|---|---|
| 网络 + 客户端排队 | 25 | 27 | ✅ |
| 网关 | 15 | 16 | ✅ |
| 应用排队 | 20 | 95 | 🔴 超 75 ms |
| 应用逻辑 | 50 | 52 | ✅ |
| PostgreSQL | 40 | 42 | ✅ |
结论:涨的 200 ms 里有 75 ms 来自「应用排队」,其余来自它的连锁反应。下一步该查的是饱和度指标(线程池队列、连接池 pending、CPU 节流),而不是数据库或业务代码。
注意这个例子和第 1.1 节 的排查例子是同一类问题:「排队」这一项没有对应的代码,只能通过饱和度指标发现。
用途 2:设置超时(更救命)
核心规则:本层的超时必须小于上游给你的超时。
上游(网关)给应用 150 ms
应用给 PostgreSQL 的超时必须是 < 150 ms,比如 60 ms
应用给 Redis 的超时必须是 < 60 ms,比如 10 ms
如果不遵守会怎样?看这个真实的故障链:
① PostgreSQL 变慢,每个查询要 3 秒
② 应用给 PostgreSQL 设的超时是 30 秒(默认值,没人改)→ 应用线程被占住 3 秒
③ 网关给应用的超时是 1 秒 → 1 秒后网关放弃,返回 504 给用户
④ 但应用线程还在等数据库(还有 2 秒)→ 线程没有释放
⑤ 新请求进来,继续占用新线程 → 线程池被耗尽
⑥ 所有接口都开始超时 → 整个服务雪崩
如果应用给 PostgreSQL 的超时是 60 ms:
① PostgreSQL 变慢
② 60 ms 后应用主动放弃,快速失败,返回降级结果或错误
③ 线程立即释放
④ 其他请求不受影响,服务整体仍然可用
这就是「快速失败」的价值:宁可让少数请求失败,也不要让整个服务被拖垮。超时不是「容忍时间」,而是「放弃的时机」。
超时预算的写法
| 层 | 上游给的时间 | 本层自己的超时 | 剩余给下游 |
|---|---|---|---|
| 网关 | 300 ms(客户端) | 250 ms | 250 ms |
| 应用 | 250 ms | 200 ms | 200 ms |
| PostgreSQL | — | 60 ms | — |
| Redis | — | 10 ms | — |
| 下游服务 | — | 80 ms | — |
每一层的超时都要比上游小,且要为「重试」预留时间(如果要重试,单次超时必须更短)。
四、两个必须注意的细节
❶ 预算要按分位数拆,不能按平均值拆
「平均 20 ms + 平均 30 ms = 平均 50 ms」是可以的(平均值可加)。但你要控制的是 P99,而分位数不可加(见 1.2 节)。
实践做法:
- 预算表按 P99 数值填写(作为目标上限),不要指望「各跳 P99 相加 = 总 P99」。
- 用实测数据验证:如果「应用内 P99 = 40 ms」但「依赖 P99 之和 = 100 ms」,说明依赖之间可能有并行、或者测量口径不一致。
- 最可靠的方法是用 tracing 的 span 树看真实的关键路径。
❷ 并行调用的预算不是相加,而是取最大值
串行:A(20ms) → B(30ms) → C(10ms) 总耗时 = 60 ms
并行:A(20ms) ∥ B(30ms) ∥ C(10ms) 总耗时 = 30 ms(最慢的那个决定)
所以预算表要区分串行段和并行段,否则会得出错误的结论。
五、本节小结
- 延迟预算 = 把 SLO 的总延迟拆给每一跳,并留出非零余量。
- 它的第一大用途是定位:接口变慢时,哪一跳超预算就是哪里的问题。
- 它的第二大用途是指导超时设置:本层超时必须小于上游超时,且逐层递减。
- 超时不递减是级联雪崩的头号原因:上游超时返回了,本层还在等,连接和线程被占住。
- 预算要先做加法验证自洽性;并行段取最大值而不是相加;分位数不能简单相加。
六、自测
- 网关给应用 200 ms,应用给 PostgreSQL 设了 5 秒超时(默认值)。请描述在数据库变慢时会依次发生什么。
- 一个接口的预算表里,「应用排队 20 ms」这一项,你用什么指标去验证它有没有超标?(提示:这一项没有对应的代码)
- 串行链路 3 跳的 P99 分别是 50 ms、80 ms、70 ms,总 P99 是 200 ms。这个关系说明什么?能不能说「总 P99 = 各跳 P99 之和」?
- ① 数据库变慢(比如每个查询 3 秒);② 应用线程因为等数据库被占住 3 秒(因为超时是 5 秒,不会提前放弃);③ 网关在 200 ms 时超时返回 504 给用户,但应用线程仍在等待;④ 新请求继续到来,占用新的线程,线程池逐渐被耗尽;⑤ 所有接口开始排队、超时,服务整体雪崩;⑥ 数据库恢复后,服务还需要一段时间才能恢复(因为积压的请求还在处理)。正确做法:应用给数据库的超时设为 60 ms 左右,快速失败、立即释放线程。
- 饱和度指标——具体是:线程池/协程调度器的队列深度、连接池的
pending数量、以及容器的 CPU 节流计数。因为「排队时间」不体现在任何一段代码里,只能用「有多少东西在排队」这个客观事实来间接测量。可以配合 Little’s Law 反推:排队时间 = 在途请求数 / 到达率 − 服务时间。 - 说明分位数不能相加。三跳 P99 之和是 200 ms,而实测总 P99 也是 200 ms——这只在这个特定样本上「恰好相等」,不构成规律。真实系统中,不同跳的 P99 往往不在同一批请求上发生(A 慢的那 1% 和 B 慢的那 1% 可能不是同一批请求),所以总 P99 通常小于各跳 P99 之和;但如果慢是相关的(比如同一个大促流量同时影响三跳),总 P99 可能接近甚至超过相加值。结论:预算表用 P99 作为「目标上限」是可以的,但不能用「各跳 P99 相加」来推算总 P99,必须实测。