6.2 延迟分解:把总账拆成明细
上一节:6.1 分析流程五步法 | 下一节:6.3 火焰图读法 配套代码:06-analysis-and-profiling/02-latency-decomposition
一句话结论
「这个接口要 400 ms」是一个总账。 分析的第一步是把它拆成网络 / 排队 / 应用 / 各依赖几段——因为不同的段对应完全不同的修法,而总账无法告诉你该改哪里。
一、用「工厂流水线的工时分析」理解延迟分解
工厂发现「一件产品从下单到发货要 8 天」,要改进就得知道这 8 天花在哪:
| 工序 | 耗时 | 占 8 天的比例 |
|---|---|---|
| 订单排队等排产 | 3 天 | 37% |
| 原料采购 | 2 天 | 25% |
| 生产加工 | 1 天 | 12% |
| 质检 | 0.5 天 | 6% |
| 包装入库 | 0.5 天 | 6% |
| 发货运输 | 1 天 | 12% |
看到这张表,改进方向立刻明确:优化「生产加工」(只占 12%)最多省 1 天;而"订单排队"占了 3 天——那才是大头。
后端的延迟分解完全一样,而且结论常常反直觉:
一个 P99 = 200 ms 的接口:
网络 + 客户端排队 70 ms (35%)
网关 + 服务排队 90 ms (45%) ← 真正的瓶颈
应用自身代码 7 ms (3.5%) ← 你想优化的地方
数据库 25 ms (12.5%)
缓存 + 下游 8 ms (4%)
你写的代码只占 3.5%。 如果只优化它,最多省 7 ms。
二、五段分解法
分解公式
① 网络 + 客户端排队 = 客户端延迟 − 网关延迟
② 网关处理 + 服务排队 = 网关延迟 − 应用内延迟
③ 应用自身开销 = 应用内延迟 − Σ各依赖延迟
④ 各依赖 = 直接读依赖级指标(dep.postgres / dep.redis / dep.downstream)
⑤ 响应传输 = 已包含在 ① 中(或单独埋点)
每一段怎么测
| 段 | 测量方式 | 常见问题 |
|---|---|---|
| ① 网络 + 客户端排队 | 客户端(k6/浏览器)延迟 − 网关延迟 | 压测客户端同机 → 数据失真 |
| ② 网关 + 服务排队 | 网关延迟 − 应用内延迟 | 这一段常常是最大的一块,但最容易被忽略 |
| ③ 应用自身 | 应用内延迟 − 依赖之和 | ⚠️ 并行调用时不能简单相减(见下方) |
| ④ 各依赖 | 依赖级 Timer(第 1 章 1.3) | 依赖计时含"等连接"时间 |
| ⑤ 传输 | 通常含在 ① 里 | 大响应体才有显著影响 |
⚠️ 关键陷阱:并行调用不能简单相减
串行:A(20ms) → B(30ms) → C(10ms) 总耗时 = 60 ms
→ 应用自身 = 60 − (20+30+10) = 0 ✅ 正确
并行:A(20ms) ∥ B(30ms) ∥ C(10ms) 总耗时 = 30 ms(取最大值)
→ 应用自身 = 30 − (20+30+10) = −20 ❌ 负数!错误
并行链路的正确做法:用 tracing 的 span 树看真实的关键路径,或者用「wall 火焰图」看线程实际花在哪。
判断你的链路是串行还是并行:
// 串行:可以直接相减
val a = deps.onPostgres { repo.findById(id) }
val b = deps.onRedis { cache.get(key) }
// 并行:不能简单相减,要用 span 树
val (a, b) = coroutineScope {
val da = async { deps.onPostgres { repo.findById(id) } }
val db = async { deps.onRedis { cache.get(key) } }
da.await() to db.await()
}
三、一个完整的分解实例
现象:订单查询接口 P99 = 400 ms(SLO 是 200 ms)。
第一步:三个测量点的数据
| 测量点 | P50 | P95 | P99 |
|---|---|---|---|
| ① 客户端(k6) | 45 | 180 | 400 |
② 服务端(http_server_requests) |
42 | 170 | 380 |
③ 应用内(app_handler_duration) |
25 | 60 | 90 |
第二步:做减法
| 段 | 计算 | P99 |
|---|---|---|
| 网络 + 客户端排队 | 400 − 380 | 20 ms |
| 服务排队(进 handler 之前) | 380 − 90 | 290 ms ⭐ |
| 应用内总耗时 | 90 ms | 90 ms |
看到这里方向已经很明确了:290 ms 花在「请求进来但还没进 handler」这一段——这是排队。
第三步:继续拆应用内的 90 ms
| 依赖 | P99 |
|---|---|
dep.postgres |
65 ms |
dep.redis |
4 ms |
dep.downstream |
8 ms |
| 依赖之和 | 77 ms |
| 应用自身开销 | 90 − 77 = 13 ms |
第四步:完整的分解表
| 段 | P99 | 占比 | 修法方向 |
|---|---|---|---|
| 网络 + 客户端排队 | 20 ms | 5% | 网络优化、压测机分离 |
| 服务排队 ⭐ | 290 ms | 72% | 查线程池/连接池/CPU 节流 |
| 应用自身(序列化、业务逻辑) | 13 ms | 3% | 优化代码(收益有限!) |
| PostgreSQL | 65 ms | 16% | 查慢查询、索引 |
| Redis | 4 ms | 1% | — |
| 下游 HTTP | 8 ms | 2% | — |
第五步:结论与下一步
结论:72% 的延迟(290ms)花在「服务排队」上,不是数据库也不是代码。
下一步(针对服务排队):
① 查线程池/协程调度器队列深度
② 查容器 CPU 节流(第 4 章 4.8)
③ 查是否有人在 Default 调度器上做阻塞调用(第 2 章 2.7)
④ 查连接池 pending(虽然应用内依赖只占 65ms,但如果池里有排队,
这段会被算进"排队"而不是"依赖")
注意最后一条的微妙之处:如果请求在等待分配连接,那么这段时间算在「排队」里还是「依赖」里,取决于你的埋点位置。
四、埋点位置决定分解的准确性
// ❌ 埋点从「拿到连接后」开始 → 等连接的时间被算进"排队",不容易发现
sample.stop(handlerTimer) // 那 Timer.start 在哪里?
// ✅ 埋点从「进入 handler」开始,依赖 Timer 包含"获取连接"
get("/orders/{id}") {
val sample = Timer.start(registry) // ← 进入 handler 就开始计时
try {
val order = deps.onPostgres { // ← 这个 Timer 包含"等连接 + 查询"
repo.findById(id)
}
call.respond(order) // ← 包含序列化
} finally {
sample.stop(handlerTimer)
}
}
关键原则:
| 埋点位置 | 会算进哪一段 |
|---|---|
| Timer 从进入 handler 开始 | 应用内(含依赖) |
| 依赖 Timer 包住「获取连接 + 执行」 | 依赖(含等连接) |
| 响应写出在 Timer 内 | 应用内(含序列化) |
如果你发现「应用内延迟」和「依赖之和」很接近(差值很小),说明你的代码本身很快——这时优化代码是无效的,要去优化依赖或排队。
五、三个必须注意的点
❶ 分位数不能相减(严格来说)
P99(总) − P99(依赖) ≠ P99(应用自身开销)
原因:P99 是「第 99% 位置上的值」,两组的第 99% 位置不一定是同一批请求。
实践建议:
- 用同一批请求的数据做减法(例如从 tracing 里取每个请求的 span 时长)。
- 或者接受近似,但要意识到这是近似——分位数相减用于判断"哪一段是大头"是够用的,但不能用来精确归因。
❷ 测量误差会累积
每做一次减法,两端的测量误差就叠加一次。所以:
- 差值很小时(比如 3 ms),不要下结论;
- 差值很大时(占总延迟 30% 以上),结论是可靠的。
❸ 「排队」这一段最容易被忽略
因为它不在任何代码里。你在代码里怎么找都找不到——只能通过:
- 两个测量点的减法;
- 饱和度指标(队列深度、池 pending);
- 或者
wall火焰图看线程停在哪个等待点。
第 5 节会详细讲「池化类瓶颈」——它们的主要表现形式就是「排队」。
六、本节小结
- 延迟必须分解:总账无法指导优化,明细才能。
- 五段分解法:网络+客户端排队 / 网关+服务排队 / 应用自身 / 各依赖 / 传输。
- 分解靠减法:三个测量点(客户端、网关、应用内)两两相减。
- 并行调用不能简单相减(会算出负数)——要用 tracing 的 span 树看关键路径。
- 「服务排队」常常是最大的那块,但它不在任何代码里——只能靠减法 + 饱和度指标发现。
- 分位数相减是近似:用于判断「哪段是大头」够用,不能用于精确归因。
- 埋点位置决定分解的准确性——依赖 Timer 要包含「等连接」的时间。
七、自测
- 客户端 P99 = 500 ms,网关 P99 = 480 ms,应用内 P99 = 120 ms。请说出最大的一段延迟在哪里,以及你会先查什么。
- 你的应用内 P99 = 90 ms,三个依赖的 P99 分别是 65 / 4 / 8 ms(之和 77 ms),应用自身开销 13 ms。请说明「优化应用代码」的收益上限是多少,以及你更该优化什么。
- 为什么「P99(总) − P99(依赖)」不一定等于「应用自身开销的 P99」?这个偏差在实践中会带来什么问题?
- 最大的一段是网关到应用之间的排队:480 − 120 = 360 ms,占总延迟的 72%。先查什么:① 应用侧的饱和度指标——线程池/协程调度器队列深度、数据库连接池
pending、容器 CPU 节流比例(/sys/fs/cgroup/cpu.stat);② 线程 dump(连续多份)——看应用的工作线程是否都处于忙碌或阻塞状态,如果线程数很少而请求堆积,说明线程池太小;③ 协程 dump——如果用了协程,看有多少协程挂在调度器上排队;④ 还要检查应用是否有阻塞调用占满了调度器(第 2 章 2.7 节,这是最常见的"CPU 不高但请求排队"的原因)。注意:这个差值也可能包含"网关自身处理"的一部分,所以要用网关的细分指标确认网关处理时间是否正常。 - 优化应用代码的收益上限是 13 ms(占总 P99 400 ms 的 3.25%)——即使把应用自身开销降到 0,也只能省 13 ms。更该优化的是:① 服务排队(如果存在的话,从三个测量点的减法里看)——这通常是最大的一块;② PostgreSQL 的 65 ms(占 16%)——查
pg_stat_statements找慢查询、检查索引与执行计划;③ 如果排队存在,优先解决排队(因为 290 ms 级别的排队比 65 ms 的查询更值得优化)。判断顺序:先看哪一段占比最大,从最大的开始。同时要注意:如果 PostgreSQL 的 65 ms 里有很大一部分是"等待连接池分配连接",那么根因其实是池或并发,而不是 SQL 本身(要用pending指标区分)。 - 因为分位数不可加也不可减(第 1 章 1.2 节)。P99(总) 是「所有请求中第 99% 位置上的耗时」,P99(依赖) 是「依赖调用的第 99% 位置」——这两个位置上的请求很可能不是同一批。举例:总延迟最高的那 1% 请求,可能是"依赖很快但应用自身很慢"的请求(比如序列化大对象),此时 P99(总) 很高但 P99(依赖) 不高,相减就会高估应用自身开销。反过来也会低估。实践影响:① 用分位数相减做"哪段是大头"的判断是可以的(当差值远大于噪声时);② 想做精确归因必须用同一批请求的数据——这就是 tracing(span 树)的价值:它记录的是每一个具体请求的各段耗时,可以精确相减;③ 实践中,如果分位数相减算出负数或很小的值,说明链路可能并行,或测量口径不一致,此时不要下结论。