耗时拆分:把 3 秒拆成 30 毫秒

耗时拆分:一次 3.2 秒请求拆成 8 段

「接口慢」是一个没有信息量的结论。真正有用的是把一次请求拆成若干可度量的阶段,直到每一段都小于你愿意单独讨论的阈值。本章给出统一的耗时日志格式、一个 3.2 秒的真实拆解案例,以及没有 APM 时的兜底方案。

一次请求有哪些阶段

接入层 → 认证 → 参数校验 → 业务计算 → 缓存读写 → DB 查询 → 下游 RPC → 序列化 → 写回

只统计总耗时会让你在「应用慢」和「数据库慢」之间反复猜。拆到阶段之后,一次采样就能指认大头,剩下的工作只是修它。

统一耗时日志格式

格式统一比埋得全更重要。推荐一行 key=value,既能 grep 也能直接进日志系统:

[slow] trace=8f3a1c2e path=/api/order/detail uid=10231 total=3218ms
       auth=3 db=1780 redis=12 rpc=940 serialize=8 calc=430 other=45
       db_count=7 rpc_count=3 slowest=orderMapper.selectByUser:1740ms
字段含义为什么需要
trace链路 ID,全链路唯一把应用、DB、下游的日志串成一条线
total请求总耗时与入口日志的 $request_time 对照
db / db_count数据库总耗时与调用次数次数多说明 N+1,单次大说明慢查询
redis缓存总耗时大 key、网络往返、连接池等待
rpc / rpc_count下游调用耗时与次数定位是哪一家下游拖慢
serialize序列化与反序列化大对象 JSON、重复编解码
calc业务计算剩余部分,占总耗时 30% 以上就值得看线程栈
other差值兜底项必须有,否则对不上账时会先怀疑埋点写错

other 不是可选项。没有兜底项,各阶段之和小于总耗时时,你会花时间证明「埋点没漏」而不是找真正的耗时。

一个真实案例:3.2 秒怎么来的

阶段耗时(ms)占比调用次数判读
接入层转发180.6%1正常
认证与鉴权60.2%1正常,命中本地缓存
参数校验40.1%1正常
DB 查询178055.6%7大头之一:单条 1.74s,缺索引
Redis 读写120.4%4正常
下游 RPC94029.4%3大头之二:串行调用且未设超时
序列化80.2%1正常
业务计算与其他45014.0%其中约 0.3s 是重复计算,可去掉
合计3218100%

两个大头合计 85%,处置动作非常明确:

DB :order_item 加复合索引 (user_id, created_at),1.74s → 12ms
RPC:三个下游改并行调用并设 200ms 超时,0.94s → 0.31s

改完之后总耗时从 3218ms 降到约 0.8s。这就是拆阶段的价值:不拆的话,最可能的动作是「加机器」,而加机器对缺索引和串行 RPC 毫无帮助。

埋点的实现方式

不同语言落地方式不同,约束一样:埋点代码不能太贵,也不能漏掉异常路径。

语言/框架落地方式注意
Java拦截器或 AOP 环绕通知 + 原子累加跨线程(线程池、CompletableFuture)要手动传递累加器
Gocontext 携带耗时结构 + middlewarecontext 传值,goroutine 结束必须回写,否则丢失
Python中间件 + threading.local异步框架要用 contextvars 而不是 thread local

统计耗时要用累加,而不是「开始时间减结束时间」这种两个时间点相减。请求会跨越线程、协程与异步回调,只有累加 DB/Redis/RPC 各自实际占用的时间,才能得到可信的阶段占比。

采样与开销控制

全量打印慢日志会自己制造故障:日志 IO 吃掉 CPU 与磁盘,还会把日志系统打爆。

策略适用场景参数建议
慢请求全采定位阶段与验证修复阈值 500ms,超过即打印完整阶段
固定比例采样常态观测1% 或千分之一
按 trace 采样单请求深挖指定 trace id 强制全量,其余不打印
只记指标不落盘有指标系统时只累加计数与直方图,不写文本日志

结论:常态只记指标(计数与直方图),符合阈值或采样命中时才写完整阶段日志。这样单进程每秒开销在微秒量级,可以长期开启。

没有 APM 时的兜底方案

1. 全局拦截器/中间件记录 total
2. 包装 DataSource、Redis Client、RPC Client,各自累加耗时
3. total 超过阈值时,把累加器序列化成一行 [slow] 日志
4. 日志按天切割,配脚本做统计:分布、Top 接口、Top 慢资源
grep '\[slow\]' app.log | awk -F'db=' '{print $2}' | awk '{print $1}' | sort -n | tail -20
grep '\[slow\]' app.log | grep -o 'slowest=[^ ]*' | sort | uniq -c | sort -rn | head
grep '\[slow\]' app.log | grep -o 'other=[0-9]*' | cut -d= -f2 | awk '{s+=$1} END {print s/NR}'
统计结果判断
某接口长期占据慢日志 top1先修它,收益最大
slowest 集中在同一条 SQL缺索引或语句设计问题
db_count 随请求增大而线性增长N+1 查询,改成批量
other 平均超过总耗时 10%有阶段没埋到,或存在未统计的等待
慢日志条数突然放大可能是引流或数据分布变化,先确认现象范围

这套东西大约一天工作量,能替代 APM 里 80% 的定位能力。即便已经有 APM,核心链路也应该做自定义埋点:APM 告诉你哪个方法慢,自定义埋点告诉你慢在哪个资源等待上。

巡检项

  • other 占比是否超过 10%,超过说明有阶段没覆盖。
  • db_countrpc_count 的单请求最大值是否异常增长。
  • 阶段耗时之和与入口 $request_time 的差值是否稳定,漂移说明埋点或链路有变化。
  • 慢日志阈值与采样率是否随流量规模调整过。

小结:把请求拆成可度量的阶段并统一日志格式,用累加而不是时间点相减;3.2 秒的案例里 85% 的耗时集中在一条缺索引的 SQL 和三个串行 RPC 上,先拆阶段再优化,才能避免用扩容解决代码问题。

笔记加载中…