文档目录

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 节会详细讲「池化类瓶颈」——它们的主要表现形式就是「排队」。


六、本节小结

  1. 延迟必须分解:总账无法指导优化,明细才能。
  2. 五段分解法:网络+客户端排队 / 网关+服务排队 / 应用自身 / 各依赖 / 传输。
  3. 分解靠减法:三个测量点(客户端、网关、应用内)两两相减。
  4. 并行调用不能简单相减(会算出负数)——要用 tracing 的 span 树看关键路径。
  5. 「服务排队」常常是最大的那块,但它不在任何代码里——只能靠减法 + 饱和度指标发现。
  6. 分位数相减是近似:用于判断「哪段是大头」够用,不能用于精确归因。
  7. 埋点位置决定分解的准确性——依赖 Timer 要包含「等连接」的时间。

七、自测

  1. 客户端 P99 = 500 ms,网关 P99 = 480 ms,应用内 P99 = 120 ms。请说出最大的一段延迟在哪里,以及你会先查什么。
  2. 你的应用内 P99 = 90 ms,三个依赖的 P99 分别是 65 / 4 / 8 ms(之和 77 ms),应用自身开销 13 ms。请说明「优化应用代码」的收益上限是多少,以及你更该优化什么。
  3. 为什么「P99(总) − P99(依赖)」不一定等于「应用自身开销的 P99」?这个偏差在实践中会带来什么问题?
  1. 最大的一段是网关到应用之间的排队:480 − 120 = 360 ms,占总延迟的 72%。先查什么:① 应用侧的饱和度指标——线程池/协程调度器队列深度、数据库连接池 pending、容器 CPU 节流比例(/sys/fs/cgroup/cpu.stat);② 线程 dump(连续多份)——看应用的工作线程是否都处于忙碌或阻塞状态,如果线程数很少而请求堆积,说明线程池太小;③ 协程 dump——如果用了协程,看有多少协程挂在调度器上排队;④ 还要检查应用是否有阻塞调用占满了调度器(第 2 章 2.7 节,这是最常见的"CPU 不高但请求排队"的原因)。注意:这个差值也可能包含"网关自身处理"的一部分,所以要用网关的细分指标确认网关处理时间是否正常。
  2. 优化应用代码的收益上限是 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 指标区分)。
  3. 因为分位数不可加也不可减(第 1 章 1.2 节)。P99(总) 是「所有请求中第 99% 位置上的耗时」,P99(依赖) 是「依赖调用的第 99% 位置」——这两个位置上的请求很可能不是同一批。举例:总延迟最高的那 1% 请求,可能是"依赖很快但应用自身很慢"的请求(比如序列化大对象),此时 P99(总) 很高但 P99(依赖) 不高,相减就会高估应用自身开销。反过来也会低估。实践影响:① 用分位数相减做"哪段是大头"的判断是可以的(当差值远大于噪声时);② 想做精确归因必须用同一批请求的数据——这就是 tracing(span 树)的价值:它记录的是每一个具体请求的各段耗时,可以精确相减;③ 实践中,如果分位数相减算出负数或很小的值,说明链路可能并行,或测量口径不一致,此时不要下结论。