一、从一条日志说起
上周排查一个博客「发文章后首页不变」的问题,最后发现答案在一行日志里:
1.2.3.4 - - [23/Oct/2026:09:14:22 +0800] "GET / HTTP/1.1" 200 4127 "-" "Mozilla/5.0 ..."
1.2.3.4 - - [23/Oct/2026:09:14:23 +0800] "GET /index.php HTTP/1.1" 200 4127 "-" "Mozilla/5.0 ..."同一个访客、间隔一秒、两个不同的 URI、两次 200、返回字节数一模一样。看起来是「两个页面」,实际上是一次内部跳转被记成了两次请求。这种情况如果不理解 Nginx 的内部跳转,看日志一定会误判——你会以为有人重复刷新,或者以为是 CDN 回源两次。
日志分析的价值不在「统计有多少 PV」,而在用日志还原请求真实走过的路径。这篇文章把 access.log 里最容易误读的几个字段讲透:内部跳转、$request_time 与 $upstream_response_time 的关系、上游失败与重试、静态兜底成 200 的软 404、以及缓存命中状态怎么从日志里读出来。
二、先把默认格式拆开
Nginx 默认的 combined 格式是这样定义的:
log_format combined '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';七个部分:客户端 IP、认证用户、时间、请求行、状态码、响应体字节数、Referer 和 UA。这里面有三个坑:
$body_bytes_sent不包含响应头,也不包含 gzip 前的体积。想知道原始大小要用$bytes_sent(含头)。$request是原始请求行,包含完整 URL 和 query string。如果 URL 里带了中文或空格,会显示成\xE4\xB8\xAD这种十六进制转义。- UA 里有空格,所以用
awk '{print $12}'取 UA 一定错。正确做法是按引号切:awk -F'"' '{print $6}'(第 1 段是 IP 部分、第 2 段是请求行、第 4 段 Referer、第 6 段 UA)。
我自己用的格式在 combined 基础上加了五个字段,这一个改动让后面所有排查都变得可能:
log_format main '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time urt=$upstream_response_time '
'uct=$upstream_connect_time uh=$upstream_addr '
'cs=$upstream_cache_status host=$host '
'reqid=$request_id';关键提醒:log_format 是定义在 http 块里的,改完必须 nginx -t && nginx -s reload。reload 之后新旧日志行的字段数会不一致,所以改格式一定要配 logrotate,或者先 mv 掉旧文件再 reload,然后在分析脚本里按行判断是新旧格式。我吃过这个亏:一次改格式后分析脚本 awk 取第 9 列,结果一半行取到的是 rt=,一半取到的是别的字,报表直接错乱。
三、内部跳转:一次请求,两行日志
回到开头那个现象。Nginx 的 rewrite ... last、try_files 兜底、index 指令的补全、error_page 转向、以及 X-Accel-Redirect,都会产生内部跳转。内部跳转不经过网络,但在 Nginx 内部是一次新的 location 匹配,会再写一行 access_log。
3.1 怎么在日志里认出内部跳转
三个特征:
- 同一个时间戳(秒级相同或差一秒);
- 同一个
$remote_addr; reqid相同——所以我在 log_format 里加了$request_id。这是区分「内部跳转」和「用户真的点两次」的唯一可靠依据。没有request_id时只能靠时间戳+字节数猜,非常不可靠。
用 reqid 统计内部跳转的比例:
# 有多少个 request_id 产生了多于一行的日志
awk '{for(i=1;i<=NF;i++) if($i ~ /^reqid=/){split($i,a,"="); print a[2]}}' access.log \
| sort | uniq -c | awk '$1>1' | wc -l如果这个数字占总数比例很高(超过 20%),说明站点有大量内部跳转,可能来自 try_files 兜底或者伪静态规则链。内部跳转本身不是问题,但它会污染所有基于日志的统计:PV 被算成两倍、$request_time 被拆成两段、热门 URL 排行里混进 /index.php。
3.2 内部跳转对 $request_time 的影响
这一点最容易误判性能。假设用户请求 /post/123,Nginx 内部跳转到 /index.php:
- 第一行日志(
/post/123)的$request_time是完整的总耗时(包含内部跳转后的执行); - 第二行日志(
/index.php)的$request_time只是跳转之后那一段。
如果你用 index.php 那行统计「慢请求」,会系统性低估;如果用第一行统计,得到的是正确的总时间。所以在写慢请求分析的时候,一定要明确分析的是哪类 URI,或者直接统计「没有 reqid 重复的那些行」。
四、$request_time 与 $upstream_response_time
这两个字段的关系是理解整条链路的关键。定义上:
$request_time:从收到客户端第一个字节,到发送完最后一个字节的完整时间。包含 Nginx 读请求体、等 upstream、写响应、以及限速(limit_rate)造成的时间。$upstream_response_time:只算和 upstream 打交道的时间。从建立连接到收完响应头/体。
所以 $request_time − $upstream_response_time ≈ Nginx 自身耗时 + 客户端网络时间。这个差值很有用:
4.1 差大说明什么
# 找 request_time 远大于 upstream 时间的请求:问题在 Nginx 或客户端
awk '{
rt=urt=0
for(i=1;i<=NF;i++){
if($i ~ /^rt=/){split($i,a,"="); rt=a[2]}
if($i ~ /^urt=/){split($i,b,"="); urt=b[2]}
}
if(rt>1 && (rt-urt)>0.8) print $7, "rt="rt, "urt="urt
}' access.log | head -20常见原因按概率排:
- 客户端网络慢(尤其是移动端、跨境访问):Nginx 已经发完了数据但客户端收得慢,
send缓冲满,Nginx 等 socket 可写。此时$request_time大而urt小,属于正常。 limit_rate限速:这是人为拖长的,日志里会看到大文件下载的request_time极大。- gzip 动态压缩:CPU 吃紧的时候压缩大响应会占用 worker,此时
request_time变大但urt正常。解决方式是gzip_static on用预压缩文件。 - 静态文件读盘慢:磁盘 IO 饱和,
sendfile卡住。这种情况iostat会同时报警。
4.2 urt 有多个值说明发生了重试
$upstream_response_time 在发生 upstream 重试时,会输出用逗号和冒号分隔的多个值,例如 urt=0.052, 3.001(两个上游各一次)或 urt=0.001 : 30.002(第一个超时,第二个成功)。同样 $upstream_addr 也会列出多个后端地址。
这个信号极其重要:只要 urt 里出现逗号,就说明这个请求经历过重试。而默认配置下 Nginx 会对 GET 和幂等请求自动重试下一个 upstream——这意味着一次用户点击可能在后端被处理两次。对于有副作用的接口(下单、扣款、发信),这会是灾难。
# 统计发生重试的请求比例
grep -c 'urt=[0-9.]*, ' access.log规避方式:
upstream backend {
server 127.0.0.1:9000 max_fails=3 fail_timeout=10s;
}
# 在 location 里关掉非幂等请求的重试
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
# 关键:让 POST 不重试
map $request_method $next_upstream {
default "error timeout";
POST "";
}
proxy_next_upstream $next_upstream;map 里给 POST 返回空字符串,等价于关闭 proxy_next_upstream(只有配了 off 才是彻底关闭,空值不触发任何条件)。这个写法比在每个 location 里复制一遍更省事。
五、软 404:日志里看起来是 200,其实是「页面不存在」
这是个人站长最容易忽略、对 SEO 伤害最大的一类问题。try_files 的兜底写错了,会让所有不存在的 URL 都返回 200 + 首页内容:
# 错误写法:不存在的 URL 返回首页,状态码 200
location / {
try_files $uri $uri/ /index.html;
}
# 正确写法:静态站兜底必须是 404
location / {
try_files $uri $uri/ =404;
}前者在日志里表现为:GET /asdfghjkl.html 200 4127,而正常的文章页是 200 15800。所以光看状态码发现不了软 404,要看字节数:
# 找出返回 200 但字节数等于首页大小的请求
awk '$9==200 {print $7, $10}' access.log | awk '{print $2}' | sort -n | uniq -c | sort -rn | head -5如果某个字节数出现频率异常高(比如 4127 出现了 3000 次),而且对应的 URI 五花八门,基本可以确认是软 404。数字本身不重要,重要的是「同一个字节数 + 不同 URI」这个组合。
顺带说一句:软 404 不只影响 SEO,还会让你 看不到攻击扫描。扫描器扫 /wp-login.php、/.env 的时候全部返回 200,日志里一片绿,你就以为风平浪静。
六、从日志读缓存命中状态
如果用了 fastcgi_cache 或 proxy_cache,加上 $upstream_cache_status(我在格式里叫 cs)就能看出每个请求的缓存命运:
# 缓存在各类状态下的占比
grep -o 'cs=[A-Z_]*' access.log | sort | uniq -c | sort -rn七个值的含义要分清:
HIT:直接命中,最快;MISS:没命中,回源了;BYPASS:配置里proxy_cache_bypass生效(比如带登录 cookie),刻意跳过;EXPIRED:有缓存但已过期,回源拿新的;STALE:上游挂了,用旧内容顶着;UPDATING:正在后台更新,本次返回旧的;REVALIDATED:用proxy_cache_revalidate向源站确认后复用。
最重要的判断:如果 BYPASS 占比高得离谱(比如 60%),说明你的 bypass 规则把大多数请求都排除了——常见原因是 Set-Cookie 里有个 PHPSESSID,而你的规则是「有 cookie 就 bypass」。这时候缓存基本等于没开。修法是让 fastcgi_hide_header Set-Cookie,或者只在特定 cookie 上 bypass。
另外 cs=-(短横线)代表这次请求没有走缓存模块,通常是静态文件或者 location 没配 cache。看到大量 cs=- 而你以为开了缓存,说明 location 匹配没走到。
七、日志切分与「正在写入的那一半」
分析脚本最容易出的一个 bug:读 access.log 的时候 logrotate 正好在切,读到的是「一半老文件 + 一般新文件」,或者 wc -l 数到一行不完整的。三个防御手段:
- 分析已轮转的文件(
access.log.1、access.log-20261022.gz),它们已经关闭,不会变; - 如果必须读当前文件,先
cp到临时目录再分析,或者用tail -n +1配合行尾完整性检查; - 脚本里不要用
set -e——grep -c没匹配到返回 1,会导致整个脚本静默退出,你看到的结果是「0」,而真相是「脚本根本没跑完」。
每日统计的标准姿势:
#!/bin/bash
# /root/daily-log.sh — 凌晨跑,分析昨天的日志
Y=$(date -d yesterday +%Y%m%d)
SRC="/var/log/nginx/access.log.1" # logrotate 后的昨日文件
[ -f "$SRC" ] || { echo "缺 $SRC"; exit 1; }
TOTAL=$(wc -l < "$SRC")
PV=$(awk '$9==200' "$SRC" | wc -l)
P404=$(awk '$9==404' "$SRC" | wc -l)
SLOW=$(awk '{rt=0; for(i=1;i<=NF;i++) if($i ~ /^rt=/){split($i,a,"="); rt=a[2]} if(rt>1) c++} END{print c+0}' "$SRC")
echo "$Y total=$TOTAL pv200=$PV p404=$P404 slow1s=$SLOW"
# 404 top 路径(排除扫描器噪音)
awk '$9==404 {print $7}' "$SRC" | sed 's/?.*//' | sort | uniq -c | sort -rn | head -10八、三个我踩过的误判
8.1 「日志里全是 CDN 节点 IP」
接入 CDN 后 $remote_addr 全变成 CDN 的 IP 段,此时所有基于 IP 的统计(独立访客、限速、封禁)全部失效。必须配 real_ip 模块,并且 set_real_ip_from 只能填 CDN 官方的回源段:
set_real_ip_from 173.245.48.0/20; # 只填 CDN 段
set_real_ip_from 103.21.244.0/22;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;绝不能写 set_real_ip_from 0.0.0.0/0——那等于允许任何人伪造 X-Forwarded-For,你的 IP 限流、封禁、统计全部可被绕过。
8.2 「明明是 200,为什么用户说打不开」
可能的原因:状态码 200 但响应体为空(PHP 报致命错误被 display_errors=Off 吞掉,输出 0 字节)。看 $body_bytes_sent=0 的 200 请求:
awk '$9==200 && $10==0 {print $7}' access.log | sort | uniq -c | sort -rn | head这个查询救过我一次:某个 PHP 页面在特定参数下直接 fatal,返回 200 空体,日志里看着一切正常,只有用户投诉。
8.3 「请求量翻倍了」
除了内部跳转,还有两个原因会让日志条数虚高:被 error_page 内部转向(404 → /404.html 会写两行),以及HEAD 请求(探针、监控、部分爬虫先发 HEAD)。统计时排除一下:
awk '$6 !~ /^"HEAD/ && $7 !~ /^\/404.html/' access.log | wc -l九、最小可用的日志仪表盘
不需要 ELK,也不需要 Prometheus,四个数字 + 两个榜单就能覆盖日常 90% 的问题:
- 四个数字:总请求、5xx 数、P95
request_time、缓存命中率(HIT / 总数); - 两个榜单:404 top10(找软 404 和被扫描的路径)、慢请求 top10(带
urt拆分,判断是 PHP 慢还是 Nginx 慢)。
P95 的计算不用装工具,一行 awk 就够:
awk '{for(i=1;i<=NF;i++) if($i ~ /^rt=/){split($i,a,"="); print a[2]}}' access.log \
| sort -n | awk '{v[NR]=$1} END{print "p50="v[int(NR*0.5)], "p95="v[int(NR*0.95)], "p99="v[int(NR*0.99)]}'把这些存成每天的 CSV,一个月后你就能看出趋势:是内容变多导致变慢,还是某个版本上线后 P95 抬了一个台阶。运维里最有用的能力不是「出问题能查」,而是「不出问题的时候也能看出苗头」,日志就是最便宜的那双眼睛。