一个 request_id 串起全链路:Nginx 请求追踪实战,日志再也不打架
线上排查最头疼的场景是什么?用户说「我刚才点了一下就报错了」,你打开 Nginx access log,几千行几百毫秒内刷过去,根本不知道哪一条对应他这次操作。后端 PHP 日志里有一堆报错,前端接口日志里也有一堆,但它们之间没有任何关联字段,你只能靠时间戳猜。
解决办法只有一个词:请求追踪(Request Tracing)。核心思想是给每一个进来的请求分配一个全局唯一的 ID,Nginx 生成后通过响应头和上游头传给后端,让前端、Nginx、PHP、数据库慢查询日志都带上这个 ID。出了问题,拿一个 ID 就能把所有层的日志串起来。
Nginx 从 1.11.0 起内置了 $request_id 变量,开箱即用,不需要任何模块。这篇文章把它讲透。
一、$request_id 是什么,怎么来的
$request_id 是 Nginx 内置变量,值是 16 字节的随机十六进制字符串,比如 8f3a1c9b4d2e6f70a1b2c3d4e5f60718。它在你第一次引用这个变量时惰性生成,整个请求生命周期内保持不变。
要拿到固定长度的 UUID 样格式(带连字符),可以自己在日志里格式化,但绝大多数场景直接用那 32 位十六进制就够了。
有个细节要注意:$request_id 只在当前 Nginx 实例内唯一,不跨实例。如果你前面挂了 CDN 或者负载均衡,上游可能已经带了 X-Request-ID。最佳实践是「上游有就复用,没有才生成」,后面会讲怎么写。
二、让上游拿到这个 ID:proxy_set_header
光有变量没用,得把它塞进传给后端的请求头。在 location 或 server 段里加:
location / {
proxy_set_header X-Request-ID $request_id;
proxy_pass http://127.0.0.1:8080;
}这样后端(Node、Java、Go、Python 应用)在 $_SERVER['HTTP_X_REQUEST_ID'] 或对应的请求头读取 API 里就能拿到它,然后写进自己的日志。如果是 PHP-FPM,除了 fastcgi_param 之外还可以直接用现成的:
fastcgi_param HTTP_X_REQUEST_ID $request_id;三、复用上游传来的 ID(CDN/负载均衡场景)
正确做法是优先用客户端或上游系统已经生成的 ID,避免双份追踪。用 map 判断:
map $http_x_request_id $req_id {
default $http_x_request_id;
"" $request_id;
}
server {
location / {
proxy_set_header X-Request-ID $req_id;
proxy_pass http://127.0.0.1:8080;
}
}逻辑:如果请求头 X-Request-ID 已存在(非空),就用它;否则用 Nginx 生成的。这样无论上游是 CDN、API 网关还是另一台 Nginx,链路 ID 都能贯穿到底。
四、把 ID 写进 access log
这是最重要的一步。修改 http 段的 log_format,把 $request_id 加进去:
log_format main '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rid=$request_id '
'rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/access.log main;重启后,每条日志末尾都会带一个 rid=... 字段。此后任何一次排查,先拿到 ID,然后:
grep "rid=8f3a1c9b4d2e6f70a1b2c3d4e5f60718" /var/log/nginx/access.log一秒定位到那一条请求的完整信息:状态码、耗时、上游耗时、UA。urt 这个字段还能帮你判断是后端慢还是网络慢。
五、返回给前端:暴露在响应头里
光记在服务器日志里不够 —— 用户反馈问题时你需要知道 ID。把 ID 加到响应头,前端 JS 出错时可以上报:
add_header X-Request-ID $req_id always;always 参数保证即使返回 4xx/5xx 也会带上这个头(默认只在 2xx/3xx 加)。验证一下:
curl -sI https://你的域名/ | grep -i x-request-id前端在发请求时可以读这个头,遇到错误弹窗里显示「错误编号 XXXX」,用户截图给你,你直接 grep 服务器日志,闭环了。
六、贯通后端:把 ID 传进应用日志
Nginx 侧做完,还要让应用日志也带上。以 PHP 为例,在入口文件(index.php)最前面加:
<?php
$rid = $_SERVER['HTTP_X_REQUEST_ID'] ?? '-';
define('REQUEST_ID', $rid);
// 让 error_log 自动带前缀
ini_set('log_errors', 1);
ini_set('error_log', '/var/log/php-fpm/www-error.log');
// 或者手动在每条日志前带上
function log_msg($msg) {
error_log('[' . REQUEST_ID . '] ' . $msg);
}
?>更有力的做法是接入 Monolog 之类的日志库,给每条日志加一个 processor:
$logger->pushProcessor(function ($record) {
$record['extra']['request_id'] = $_SERVER['HTTP_X_REQUEST_ID'] ?? '-';
return $record;
});这样应用日志里每条都带 request_id,和 Nginx 日志一一对应。数据库层同理,MySQL 8.0 可以通过 performance_schema 或应用层的慢查询注释带上 ID:
SELECT /* rid:8f3a1c9b... */ * FROM posts WHERE id = 1;慢查询日志里就会留下这个注释,慢 SQL 也能追溯到具体请求。
七、跨服务传递:让下游也带上
如果后端还要调用其他微服务,记得把 X-Request-ID 继续往下传,否则链路到第一个服务就断了。以 PHP 的 curl 为例:
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'X-Request-ID: ' . REQUEST_ID,
]);
// ...Node.js 用 axios 的话是 headers: {'X-Request-ID': reqId}。原则很简单:收到什么 ID 就继续传什么 ID,永远不重新生成,这样整条调用链共享同一个追踪号。
八、一个完整的最小配置
把上面所有点拼起来,一份可直接抄的配置:
map $http_x_request_id $req_id {
default $http_x_request_id;
"" $request_id;
}
log_format traced '$remote_addr [$time_local] "$request" $status '
'$body_bytes_sent rt=$request_time urt=$upstream_response_time '
'rid=$req_id';
server {
listen 443 ssl;
server_name 你的域名;
access_log /var/log/nginx/access.log traced;
add_header X-Request-ID $req_id always;
location / {
proxy_set_header X-Request-ID $req_id;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://127.0.0.1:8080;
}
}nginx -t 测一下语法,然后 systemctl reload nginx 平滑重载。
九、验证链路是否打通
- 发一个请求并抓响应头:
curl -sI https://你的域名/ | grep -i x-request-id,应返回 32 位十六进制; - 拿这个 ID 去 Nginx 日志里 grep,应能找到对应行;
- 查应用日志(带 processor 的话),应能找到同 ID 的记录;
- 制造一个后端错误(比如访问一个不存在的路由),确认 500 响应头里 ID 依然存在(验证
always生效)。
四步全过,说明从客户端到 Nginx 到应用日志的追踪链已经闭环。以后排查问题,先要一个 request_id,剩下的就是一条 grep 命令的事 —— 这才是「可观测」该有的样子,而不是在几千行日志里靠时间戳大海捞针。