Server-Timing与mtr:把慢请求拆开看

网站变慢的时候,最常见的扯皮是「我们这边接口只要 50 毫秒」——可是用户那边确实转了五秒。中间那段去哪了?只靠看日志里的耗时字段是查不出来的,因为日志只记录了请求进来之后的时间,前面的排队、建连、DNS、CDN 回源全在视野之外。

这一篇讲的是把一段请求的耗时拆开、并且让这个拆分能长期留在页面上的做法。核心工具只有两个:响应头里的 Server-Timing,以及链路探测的 mtr。前者回答「慢在哪一步」,后者回答「慢在哪一跳」。

一、先建立一个耗时模型

一次页面请求从浏览器点击到首字节返回,中间的阶段是固定的,可以按顺序枚举:

DNS 解析 → TCP 握手 → TLS 握手 → 请求发出到服务端收到(网络单向延迟)→ 排队等待(accept 队列、FastCGI 队列、应用并发上限)→ 应用内部处理(SQL、缓存、RPC)→ 响应发出到浏览器收到(网络单向延迟 + 传输时间)。

用 Chrome 开发者工具看到的 Queueing / Stalled / DNS Lookup / Initial connection / SSL / TTFB / Content Download 就是这张表的浏览器版本。但生产环境里用户不会给你开 F12,所以需要服务端自己把这些数字写进响应头。Server-Timing 就是为此设计的:它是 HTTP 标准响应头,浏览器开发者工具的 Network → Timing 面板会直接把它渲染成一条条分段。

Server-Timing: db;dur=42.1;desc="MySQL", cache;dur=1.2, app;dur=68.5

语法很简单:每个指标一个 name=value,dur 是毫秒数,desc 是给人看的说明。浏览器只认 dur 和 desc,其它字段会被忽略但可以留着给日志用。

二、在应用里埋点:三个指标就够

不必一上来就埋十几个指标,先埋三个,覆盖 80% 的问题:

应用总耗时(从收到请求到生成响应)、数据库耗时(所有 SQL 之和 + 慢查询条数)、外部调用耗时(Redis、HTTP 上游之和)。有了这三个,问题基本能定位到「是数据库、是外部接口、还是 PHP 自己算得慢」。

// 极简实现(框架无关,核心是四个时间戳)
$t0 = microtime(true);              // 请求进入
$dbMs = 0; $rpcMs = 0;
// ... 业务代码,在 DB / RPC 封装里累加 $dbMs / $rpcMs ...
$appMs = (microtime(true) - $t0) * 1000;

header(sprintf(
  'Server-Timing: app;dur=%.1f, db;dur=%.1f, rpc;dur=%.1f',
  $appMs, $dbMs, $rpcMs
));

要点有两个。第一,只在测试环境或者带特定 Cookie 的请求上输出,公开暴露内部耗时结构对攻击者有价值,也容易被误读成「站很慢」。第二,数字必须来自同一个请求,不要用全局累计值,否则并发下完全对不上。

Nginx 侧也可以加一段,把上游时间和总时间写进去。这样两层的时间就能对上:

add_header Server-Timing "upstream;dur=$upstream_response_time, total;dur=$request_time" always;

注意 $upstream_response_time 在重试场景下是逗号分隔的多值,比如 0.120, 2.300。直接塞进 header 会变成两个非法字段,浏览器解析时会丢掉整行。稳妥做法是用 map 取最后一个值,或者干脆只在单上游的场景下用。

三、读到数字之后怎么判断

拿到 Server-Timing 之后,会看到几种典型形态,每种指向不同的责任方:

app 很大、db 和 rpc 都很小。说明时间花在应用自身:模板渲染、序列化大对象、循环里做了不该做的事。这时候要去 profile,而不是查数据库。

db 很大、app 与 db 差不多。数据库是瓶颈。用慢查询日志和 EXPLAIN 看具体是哪条语句,注意区分「单条慢」和「条数多」—— 前者查索引,后者查 N+1 查询。

app 很小,但 Nginx 的 total 远大于 upstream。差异在 Nginx 自己身上:可能是静态文件读盘慢、可能是 gzip 压缩 CPU 打满、可能是 limit_conn / limit_req 在排队。这一类最容易被忽略,因为应用日志里看着完全正常。

total 稳定地比浏览器测到的 TTFB 小一两百毫秒。差值来自网络往返和 CDN 回源,服务端再优化也没用,得从链路层看。

还有一种隐蔽的形态:upstream 比 app 大很多,差值几十到几百毫秒不等。这说明请求在 FastCGI 队列里排过队 —— 进程池满了,请求在 socket 上等着。这个数字的意义非常大,因为它把「应用慢」和「并发不够」区分开了。进程池容量怎么算,我在 PHP-FPM 进程池那篇里详细写过:pm.max_children 与 pm.max_requests 的乘积关系和内存占用。

四、mtr:慢在哪一跳

Server-Timing 能定位到「网络往返这一段有问题」,但不知道是哪一段网。这时用 mtr(My Traceroute),它把 traceroute 和 ping 结合起来,对每一跳持续发包并统计丢包率与延迟分布。

# 同时跑 ICMP 和 TCP,各 100 个包,出报告模式
mtr -T -P 443 -c 100 -r -w example.com

# 只看出问题的跳
mtr -T -P 443 -c 500 -r example.com | awk '$6 > 5 || $5 > 100'

几个关键点,踩过才知道:

用 TCP 模式,不要用默认的 ICMP。很多机房和 CDN 会丢弃或者限速 ICMP,你在第 5 跳看到 80% 丢包,可能只是那一跳不欢迎 ping,实际 TCP 流量毫无问题。加 -T -P 443 走真实协议,结论才可信。

中间跳的丢包率不能直接当结论。路由器转发数据面和回应 ICMP 是两条不同路径,控制面被打限速非常常见。真正的判据是从某一跳开始,后面所有跳都持续丢同样的比例,那一段才是真丢包。只有一跳高、后面归零,忽略它。

延迟突变比丢包更重要。第 8 跳到第 9 跳延迟从 12ms 跳到 180ms,即使不丢包,也说明这一跳跨了长距离或者走了绕路。跨境、跨运营商的问题基本都是这个形态。

反向也要测。去程和回程可能走完全不同的路径。从服务端 mtr 到用户 IP,和从用户机器 mtr 到服务端,结果往往不对称。只有单向有问题时,要拿着结果找运营商,描述里必须写清楚方向。

五、把这两件事变成可复查的证据

最好的一次性调优,不如一个能长期留档的采样机制。我的做法是:

对核心页面每五分钟做一次带 Server-Timing 的探测,把 header 原样落进日志;同时每半小时对一个关键上游跑一次 mtr -c 60 -r,只保存每跳的 Loss% 和 Avg。

# 每半小时一次,落一行
*/30 * * * * mtr -T -P 443 -c 60 -r api.example.com | tail -n +2 \
  >> /var/log/mtr_api.log 2>&1

这样做的价值在于:用户投诉「最近很慢」时,你能直接拉出过去三天的 Server-Timing 分布和 mtr 曲线,一句话说清「是我们的 db 段从 40ms 涨到 300ms」还是「第 9 跳丢包从 0 涨到 4%,问题在运营商」。这比双方各说各话有效得多。

六、两个常见误区

误区一:只看平均值。平均 TTFB 200ms 看起来健康,但如果 5% 的请求是 3 秒,用户感受到的就是「这个站很卡」。耗时类指标一定要看 P95/P99,平均值几乎永远在骗人。 Server-Timing 本身只反映单次请求,所以要在日志里采样,然后做分位统计。

误区二:把 Server-Timing 当成性能优化手段。它不优化任何东西,只是让观测变便宜。但它有个副作用很值得利用:一旦埋点上去了,团队里关于「谁慢」的争论会立刻减少,因为每次争论都可以用一个具体的数字结束。

七、一个完整的排查例子

把前面的东西拼起来,看一个真实形态:早上九点用户开始报「打开首页要七八秒」,自建监控显示服务器负载正常、CPU 20%、数据库慢查询零条。

第一步不是怀疑数据库,而是拉出首页近一小时的 Server-Timing 采样,按小时聚合 P95:app 一直在 90ms 上下,db 稳定 12ms,total 却从 300ms 抬到了 4800ms。三个数字一摆,应用和数据库立刻被排除,问题在 Nginx 到浏览器之间。

第二步去 Nginx 那一层看。upstream 是 260ms,total 是 4800ms,差值 4.5 秒全在 Nginx 自己身上。这个形态只有几种可能:静态文件读盘卡住、gzip 压缩 CPU 排队、limit_req 或者 limit_conn 在限速、日志写入被磁盘 IO 拖住。

第三步用两个命令分辨:

# 看 Nginx worker 是不是卡在 IO 上
ss -lntp | grep nginx
cat /proc/$(pgrep -n nginx)/status | grep -E 'State|voluntary'

# 看是哪个 location 慢,按耗时排序
awk '{print $7, $NF}' /var/log/nginx/access.log   | sort | uniq -c | sort -rn | head -20

结果发现 worker 的状态是 D(不可中断睡眠),也就是在等磁盘。再一看 iostat -x 1,%util 打满 100%、await 三百多毫秒。到这里责任方就明确了:磁盘 IO 撑不住,不是代码问题。

第四步才轮到 mtr。这时候其实已经不需要它了,因为瓶颈已经定位在服务端磁盘。但为了排除「是不是同时还有网络问题」,从服务端到用户所在城市跑一次 mtr -T -P 443 -c 60 -r,确认每一跳延迟稳定、丢包为零,网络被排除,报告里可以明确写「网络正常,服务端磁盘 IO 为瓶颈」。

第五步看为什么磁盘突然忙。查 iotop -oPa 发现是 logrotate 在压缩一周前的日志,同时 auto 脚本在跑一个全量备份。两件事各占一半带宽,平时在凌晨跑没问题,这次被手动触发到了早上。

整个排查用了不到二十分钟,靠的不是经验多深,而是每个阶段都有数字可以互相排除。如果没有 Server-Timing,第一步就会变成「翻应用日志发现接口都很快」然后开始怀疑网络,接着重复抓包、重复问用户、重复重启服务,半天也定不下来。

这也说明了一件事:观测手段的价值不在于「能看懂」,而在于能把可能性一条条划掉。每一次测量都应该减少候选范围,如果测完还是「大概可能也许」,那就是指标选错了。

八、小结

定位慢请求,本质上是在回答两个正交的问题:慢在哪个阶段和慢在哪段网络。Server-Timing 回答前者,把 app / db / rpc / upstream / total 拆开,差值就是答案;mtr -T -P 回答后者,看「从哪一跳开始持续丢包或延迟突变」,而不是被单跳的数字吓到。两者都做成定期采样留档,故障复盘就从「互相猜」变成「对比曲线」。这套东西加起来不到一小时的配置量,但能省掉后面无数轮的扯皮。

Last modification:November 2nd, 2026 at 01:25 pm

Leave a Comment