几乎所有站点都有 Nginx 访问日志,但绝大多数人从来没改过它的格式。默认的 combined 格式只记了 IP、时间、请求行、状态码、字节数、Referer 和 UA 七项,够用,但一旦要排查「这个请求到底慢在哪」「这个用户是不是走了 CDN」「这条 499 是被谁断开的」,这七项就全都不够。更要命的是,很多人事后才发现日志里缺字段,而日志是事后无法补录的——那一天的数据永远丢了。
这篇文章讲的是 Nginx 的 log_format 到底能记什么、哪些变量值得记、自定义格式会带来什么代价,以及怎么在不影响性能的前提下把日志变成真正能用的数据源。所有示例都可以直接抄进配置,跑完 nginx -t 就能生效。
一、log_format 的机制:它比你以为的更灵活
log_format 定义在 http 块里,可以定义多个,然后在 server 或 location 里用 access_log 指定用哪个。这一点很多人不知道:日志格式是按 location 可选的,你完全可以让普通页面用一个精简格式、让 API 路径用一个完整格式。
http {
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
log_format detail '$remote_addr $time_iso8601 "$request" $status '
'$body_bytes_sent $request_time $upstream_response_time '
'$upstream_addr "$http_referer" "$http_user_agent"';
}然后是选择使用:
server {
access_log /var/log/nginx/example.access.log main;
location ^~ /api/ {
access_log /var/log/nginx/example.api.log detail;
}
}这个能力很有价值。API 日志的重点是耗时和上游地址,不需要 UA;页面日志的重点是 Referer 和状态码分布。分开写之后,两边的日志都更短、更好 grep,也不会因为一个路径的日志量太大把主日志淹掉。
还有一点必须知道:log_format 只能在 http 块里定义,不能放在 server 里。如果 nginx -t 报 "log_format" directive is not allowed here,就是放错位置了。这条错误在改配置时非常常见,因为直觉上会觉得「格式是给这个 server 用的,应该写在 server 里」。
二、值得加的六个变量,按用途分组
Nginx 的日志变量有上百个,全记下来会让日志大到没法用。按用途筛,只有六类真正值得加。
第一类,时间。默认的 $time_local 格式是 09/Nov/2026:14:23:01 +0800,人看着舒服但脚本不好排序。$time_iso8601 输出 2026-11-09T14:23:01+08:00,字典序即时间序,sort 直接能用。如果你的日志要进 awk、要按时间切片、要被日志平台采集,一律用 ISO8601。这一条改动的收益极高、成本为零。
第二类,耗时。$request_time 是整个请求的耗时(秒,带三位小数),$upstream_response_time 是后端返回的耗时,$upstream_connect_time 是连接后端的耗时。这三个数的差值就是排查性能的钥匙:request_time 远大于 upstream_response_time,说明慢在 Nginx 侧或者客户端;两者接近,说明慢在后端;upstream_connect_time 特别大,说明连不上后端或者在等连接池。
这几个变量有一个坑:如果请求经过了多次上游(重试、内部跳转),$upstream_response_time 会是逗号分隔的多个值,比如 0.021, 0.034。直接当数字用会出错,awk 比较前要先取最后一个或者求和。这一点很多人第一次遇到时以为是日志格式坏了。
第三类,上游与真实 IP。$upstream_addr 记录请求被转发到哪个后端,多机负载时是判断「哪台机器慢」的唯一依据。$realip_remote_addr(需要 real_ip 模块)记录 CDN 传过来的真实客户端 IP,而 $remote_addr 此时是 CDN 节点的 IP。这两个都要记,因为在 CDN 后面的站点,只记 $remote_addr 等于所有用户都来自那几个节点 IP,统计完全失真。
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
log_format cdn '$realip_remote_addr $remote_addr $time_iso8601 "$request" '
'$status $request_time $upstream_addr';注意 set_real_ip_from 必须只写你信任的 CDN 网段。如果写成 0.0.0.0/0,任何人都能伪造 X-Forwarded-For 把日志里的 IP 改成任意值,这既是数据污染,也是安全风险(如果你的限流或封禁依赖日志里的 IP,就等于给了对方伪造身份的能力)。
第四类,缓存状态。$upstream_cache_status 输出 HIT / MISS / BYPASS / EXPIRED / STALE。如果站点开了 proxy_cache,这个字段不加,你就永远不知道自己缓存命中率是多少,只能靠猜。$upstream_cache_status 和 $request_time 一起看,能直接算出「命中时省了多少毫秒」。
第五类,请求体大小与协议。$request_length 是请求总长度(含头和体),$request_body_bytes 是请求体长度。这两个字段在排查「有人在上传超大文件」或者「某个接口被 POST 灌流量」时非常有用。$server_protocol 记录 HTTP/1.1 还是 HTTP/2,开了 HTTP/3 之后能看出来有多少流量走了 QUIC——这个数据只能从日志里拿,别处看不到。
第六类,链路标识。如果你能在入口生成一个请求 ID 并透传,那么 $http_x_request_id 会把前端、Nginx、后端、数据库的日志串成一条链。这是排查「偶发 500」最有效的手段,成本只是加一个 header。生成方式可以是 Nginx 侧用 $request_id(需要模块)或者用 $connection_requests 拼时间戳,后者不保证唯一但足够用。
三、完整的一份生产可用格式
把上面六类合起来,一份适合内容站的格式大致是这样。字段顺序按「先定位、后细节」排:前四个字段足以筛出任何一条记录。
log_format full '$remote_addr $time_iso8601 $request_method $uri $status '
'$body_bytes_sent $request_time $upstream_response_time '
'$upstream_addr $upstream_cache_status "$server_protocol" '
'$http_referer $http_user_agent $request_id';几个设计上的取舍值得说明。用 $uri 而不是 $request:$request 包含方法、URI 和协议三部分,还带查询串,日志里重复信息多、grep 起来要处理引号;$uri 只有路径,$request_method 单独一列,两者分开更适合 awk。把 $status 放前面:按状态码过滤是最常见的操作,位置靠前便于 awk '$5==404'。不带引号的字段不要混入可能含空格的变量:UA 和 Referer 必须加引号,否则空格会把列打乱,整个 awk 脚本就废了。
代价也要说清楚:这个格式比 combined 长了大约三倍,日志体积会相应增加。一个日均百万请求的站点,日志从每天 80MB 涨到 250MB 是完全可能的。所以如果你的磁盘紧张,要么只对关键 location 用完整格式、其余用精简格式,要么把日志保留期从 30 天缩到 7 天,要么开启压缩归档(下文会说)。
四、性能代价:写日志到底有多贵
有一个流传很广的说法是「自定义日志格式会拖慢 Nginx」,这个说法需要拆开看。Nginx 写日志是异步缓冲的(默认 buffer=64k),请求处理完把记录丢进缓冲区就返回,磁盘写入在后台进行。所以「多记几个字段」的代价主要是内存拷贝和格式化,实测在正常字段数量下影响在 1% 量级,几乎可以忽略。
真正会拖慢的是把日志写到网络文件系统或者每秒 flush。access_log path format buffer=64k flush=5s 是正确的写法,如果写成 flush=1s 甚至不加 buffer,每写一条都要走一次系统调用,那才会拖慢。另外,把日志写到 NFS 或者某些云盘的挂载点上,遇到网络抖动会让 worker 卡在 write 上,整个站点跟着卡——这是真实发生过的事故形态,日志目录必须放本地盘。
access_log /var/log/nginx/example.access.log full buffer=64k flush=5s;还有一条:gzip on 对日志没有作用(Nginx 不压缩日志),别去配。想省空间就在轮转时用 compress,或者用 gzip 单独跑一遍归档脚本。
五、从日志里挖出三件事:三个可以直接用的 awk
加了字段不用,等于白加。下面三条命令覆盖了日常最高频的三个问题,都是单行、可以直接贴进终端。
一是找最慢的页面。按耗时排序取前二十条,去掉静态资源,看是什么在拖后腿:
awk '$8 > 0.8 {print $8, $4, $5}' /var/log/nginx/example.access.log | sort -rn | head -20这里的列号对应上面 full 格式:$4 是 URI、$5 是状态码、$8 是 request_time。阈值 0.8 秒是经验值,可以按站点情况调整。
二是按状态码统计分布。一天跑一次,看有没有异常抬头:
awk '{print $5}' /var/log/nginx/example.access.log | sort | uniq -c | sort -rn | head -10三是按 URI 统计 404,找出死链。这是 SEO 侧最有价值的一条命令,比任何死链扫描工具都准,因为它统计的是真实用户和爬虫实际请求过的地址:
awk '$5 == 404 {print $4}' /var/log/nginx/example.access.log | sort | uniq -c | sort -rn | head -30输出里排最前的几条,如果是从外部来的(看 Referer),说明站内有链接写错了;如果是爬虫在反复请求,说明你的 sitemap 里还留着已经删掉的 URL。两种情况都该修,但修法完全不同。
再加一条排查慢请求归属的,把 upstream_addr 和耗时一起看,多机环境下能直接定位是哪台后端慢:
awk '$8 > 1 {print $9, $8}' /var/log/nginx/example.access.log | sort | uniq -c | sort -rn | head六、结构化日志:什么时候该上 JSON
当你的日志要被程序消费(进 ELK、进 ClickHouse、被自己的看板读),按空格分隔的文本格式就开始难受了:UA 里有空格、Referer 里有引号、字段一多 awk 的列号记不住。这时候该换成 JSON,Nginx 原生支持用 escape=json 输出:
log_format json escape=json '{"ts":"$time_iso8601","ip":"$remote_addr",'
'"method":"$request_method","uri":"$uri",'
'"status":$status,"rt":$request_time,'
'"up":"$upstream_addr","ua":"$http_user_agent"}';两个关键点。第一,escape=json 必须加,否则 UA 里的引号会把 JSON 结构打断,解析器直接报错。这是接日志平台时最常见的第一个坑。第二,数值字段不要加引号($status 和 $request_time 直接裸写),这样下游不用再做类型转换,聚合查询会快很多。
JSON 格式的代价是日志体积再涨一次(字段名重复了成千上万遍),而且人眼不好读。所以我的建议是分层:磁盘上保留文本格式给人看和应急 grep,同时用 syslog 或者 fluent-bit 把一份 JSON 送进日志系统。两边各取所需,不要指望一个格式同时满足两种用途。
七、切割与保留:别让日志把磁盘吃满
日志是唯一会无限增长的文件,不管它。常见的翻车形态是:某天发现站点挂了,登上去一看磁盘 100%,而凶手是三个月没轮转的日志。logrotate 的配置要覆盖四件事:
cat /etc/logrotate.d/nginx-app
/var/log/nginx/*.access.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
create 0640 www-data adm
sharedscripts
postrotate
[ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid)
endscript
}kill -USR1 是让 Nginx 重新打开日志文件的信号,必须发——否则轮转之后 Nginx 还在往那个已经被重命名的旧文件句柄里写,新文件永远是空的,而磁盘空间也不释放(进程还占着)。这个坑的表现是「轮转看起来正常,但日志文件一直是 0 字节,磁盘还在涨」,第一次遇到很难想明白。
还有一种更省心的做法是按日期直接命名,在 Nginx 里用变量拼日期不太方便,所以通常是 cron 每天 mv 再发信号。这种方式的好处是历史日志一眼就能找到对应的日期,排查「上周三几点出了什么事」时不需要去算 .3.gz 是哪天。代价是文件数量多,需要配合定期清理(保留 14 天就够了,再久的日志基本不会去看,价值不如省下的磁盘)。
八、改格式之前必须知道的两件事
第一,改格式不需要重启,但会有一段「混合格式」的窗口。nginx -t 通过后 nginx -s reload 就生效,已经在处理中的请求仍然用旧格式写。如果你的日志要被脚本解析,混格式的那几分钟会让脚本报错——处理方法是在脚本里跳过行数不符合预期的记录,或者干脆在凌晨低峰改,改完当天不做自动解析。
第二,历史日志不会回溯补齐。新加的字段只能从改完那一刻开始有值,之前的所有日志里这些列都是空的(或者按位置错位)。所以改格式时一定要同时归档旧日志,并在日志目录里放一个 README 记下「哪天改了什么格式」。三个月后你回头看旧日志,绝不会记得当时 $8 是哪个字段。这一条听起来琐碎,但它是「日志能不能被长期使用」的分水岭。
顺带说一句:如果你的站点前面有 CDN,Nginx 日志记录的是 CDN 回源的请求,不是用户的请求。这意味着 CDN 命中率越高,你的日志量越小,日志里的「访问量」会显著低于真实 PV。这不是配置错,是架构决定的。要拿到真实访问量,只能看 CDN 侧的统计,或者让 CDN 把它的日志回流给你。知道这个差别,就不会再纠结「为什么日志只有三千条而统计说有八千个访问」了。
小结
日志格式这件事,投入产出比极高:改一次配置,之后每一篇排查都能省下半小时。真正需要记住的是四点:时间用 ISO8601(可排序)、耗时要记三个(request_time / upstream_response_time / upstream_connect_time,差值即结论)、CDN 后面必须记真实 IP 且只信任自己的网段、轮转后必须发 USR1。至于 JSON 还是文本、完整还是精简、保留几天,都按流量规模自己定,没有唯一正确答案。唯一不能省的是「改格式时留一份说明」——因为日志的价值不在今天,在三个月后你需要它的时候。