结构化日志落地:Nginx与PHP的JSON行

一、先分清:日志到底是给谁看的

「结构化日志」这个词这几年被说得很多,但落到个人站长的服务器上,多数人第一次接触时的反应是:我的日志本来就是一行一条,看着挺好的,为什么要改成那种带大括号的东西?

要回答这个问题,得先分清日志的两类读者。第一类是人:凌晨两点你 SSH 上去,想看刚才那个 500 错误前后发生了什么,这时候你要的是能一眼看懂的、有上下文的一行文字。第二类是机器:统计、告警、聚合、画图、按条件筛选,这时候你要的是能被稳定解析的字段。

Nginx 的访问日志和 PHP 的错误日志,天然是为第一类读者设计的。它们的格式是「按空格分隔的固定字段」,看着挺规整,实际上只要你改一次格式、加一个字段,所有历史解析脚本就全废了。而 PHP 的错误日志更随意,同一个错误可能写成完全不同的几种样子,取决于抛异常的位置和参数类型。

结构化日志解决的不是「好看」问题,是「让日志在机器面前有确定性」。一条 JSON 里有固定的键名和类型,无论内容是什么,jq 都能取到。判断你要不要做结构化,标准很简单:如果你从来没写过日志解析脚本,从来没因为日志格式变了而返工,那你可能暂时不需要;如果你开始想「统计一下最近哪些接口慢」或者「看看有多少请求是带某个 header 的」,那结构化能立刻省下大量时间。

但结构化也有真实成本:日志体积会变大(键名重复),人肉阅读变难,写入出错时更容易出现半截 JSON。所以本文的主张是:不是所有日志都该结构化,而是应该分清楚哪几类值得,以及怎么做到既机器友好又不牺牲人的可读性。

二、日志分四类,各自的正确形态

一台跑着网站和数据库的服务器上,日志大概可以分成四类,它们的最优形态并不相同。

第一类是访问日志(access log)。Nginx 的 access.log 就是这类。它天生是「同一结构的重复记录」,最适合结构化,但没必要写成 JSON——Nginx 的 log_format 用固定顺序的自定义格式已经足够好用,只要保证字段顺序不变、字段之间用不容易冲突的分隔符即可。不建议为了「结构化」把 access.log 改成 JSON,因为量最大的就是它,体积变大的成本最直接,而它本身已经足够规整。

第二类是应用日志(application log)。业务代码里主动打的那些「用户登录成功」「订单创建失败」之类的记录。这类最适合 JSON,因为一条记录里有不定数量的业务字段,固定格式会写得极其笨拙。

第三类是错误日志(error log)。PHP 的 error_log、PHP-FPM 的慢日志、MySQL 的 error log。这类日志的形态是「堆栈 + 上下文」,天然不适合结构化——堆栈本身就是多行的自由文本。对这类日志,正确的做法是保留原文,另加一两个前缀字段(时间、级别、请求 ID),而不是硬塞进 JSON。

第四类是系统日志(syslog)。内核、systemd、sshd 这些。不要动它。systemd-journald 已经有结构化的能力(journalctl -o json),改动原始 journal 格式得不偿失,而且很多工具依赖它。

先按这四类分清楚,你就不会犯「把 access.log 改成 JSON 导致磁盘一周写满」这种错,也不会出现「以为 PHP 错误日志能 jq 解析,结果一半是堆栈」的失望。

三、Nginx 访问日志:先把字段补齐,再谈结构化

Nginx 默认的 combined 格式只有十几个字段,缺了最关键的那几个:上游响应时间、请求总耗时、缓存命中状态、真实客户端 IP。对排查问题来说,这四个比什么都重要。

在 http 块里定义一个自己的格式:

log_format main_json escape=json
  '{"time":"$time_iso8601","remote":"$remote_addr",'
  '"host":"$host","req":"$request",'
  '"status":$status,"bytes":$body_bytes_sent,'
  '"rt":$request_time,"urt":"$upstream_response_time",'
  '"cache":"$upstream_cache_status",'
  '"ua":"$http_user_agent","ref":"$http_referer"}';

access_log /var/log/nginx/access.log main_json;

这里有两个关键点。第一,escape=json 是必须的,否则 User-Agent 里出现引号或反斜杠就会把整行 JSON 写坏,后续 jq 会解析失败。第二,rt 和 urt 都加上引号,因为当请求没有经过上游(比如静态文件直接由 Nginx 返回)时,upstream_response_time 是空的或者是一个短横线,写成数字会让 JSON 非法。

如果担心体积,有个折中方案:保持 combined 格式给日常用,另开一个 JSON 格式只记录慢请求或特定状态码。用 access_log ... if= 条件就不必全量双写:

map $request_time $is_slow {
    default 0;
    "~^[1-9][0-9]*\." 1;
}

access_log /var/log/nginx/slow.log main_json if=$is_slow;

这样只有耗时超过 1 秒的请求会进 slow.log,量可能只有总量的千分之一,但排查慢页面时这是最有价值的那份日志。个人站长非常推荐这个做法:日常日志保持精简,慢请求单独留全字段。

四、PHP 应用日志:用 JSON 行,别自己拼字符串

业务日志最容易踩的坑,是用字符串拼接去写「看起来像结构化」的内容:

// 反例:看着像 key=value,实际没法解析
error_log("user=" . $uid . " action=login ip=" . $ip . " ok=" . $ok);

这种格式的问题在于:任何值里出现空格,字段就错位了;任何值里出现等号,取值就有歧义。用户提交的昵称叫 a b=c,这条日志就废了。

正确的做法是构造数组,然后 json_encode 一行写出去,用换行符分隔(这是 JSON Lines 的约定,一行一个 JSON 对象,便于按行读取和 grep)。

function applog($level, $event, array $ctx = []) {
    $rec = [
        'ts'     => date('c'),
        'level'  => $level,
        'event'  => $event,
        'rid'    => $_SERVER['HTTP_X_REQUEST_ID'] ?? '',
        'uid'    => $_SESSION['uid'] ?? null,
    ] + $ctx;
    file_put_contents('/var/log/app.jsonl',
        json_encode($rec, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . chr(10),
        FILE_APPEND | LOCK_EX);
}

几点细节值得说。第一,JSON_UNESCAPED_UNICODE 让中文不被转成 \uXXXX,日志人眼可读;第二,JSON_UNESCAPED_SLASHES 让路径保留斜杠,避免 URL 变成一堆转义;第三,LOCK_EX 在高并发下能防止两个进程写同一行导致交错;第四,chr(10) 就是换行符,别用双引号里包反斜杠 n 的写法,容易在跨文件复制时出岔子。

还有一条:键名要短且稳定。timestamp 和 ts 都能用,但一条日志多 8 个字节,一百万条就是 8MB。字段名一旦定下来就不要改,改了以后所有历史数据的聚合口径就断了。我的习惯是固定一套:ts / level / event / rid / uid,业务字段放在 ctx 里平铺。

五、请求 ID:让一次请求的所有日志串起来

结构化日志最有价值的能力,不是「能 jq 查」,而是能把一次请求跨越的所有环节串成一条线。这件事靠一个字段就能做到:请求 ID。

在 Nginx 生成一个 ID,往下游传,应用日志里带上它:

map $http_x_request_id $req_id {
    default $http_x_request_id;
    ""      $request_id;
}

log_format main_json escape=json
  '{"time":"$time_iso8601","rid":"$req_id","status":$status,'
  '"rt":$request_time,"urt":"$upstream_response_time"}';

add_header X-Request-Id $req_id always;

Nginx 的 $request_id 是内置变量(需要 nginx 1.11 以上),不需要额外模块。把 X-Request-Id 通过 fastcgi_param 传给 PHP:

fastcgi_param HTTP_X_REQUEST_ID $req_id;

# 在 PHP 里
$rid = $_SERVER['HTTP_X_REQUEST_ID'] ?? '';

有了这个,排查问题的流程就变成了:用户报「下单失败」,你从 access.log 里按 URL 和状态码找到那一条,拿到 rid,然后 grep 那个rid /var/log/app.jsonl,一次性看到这次请求的所有业务日志,包括它调了哪几个接口、每个耗时多少、最后在哪一步失败。

没有 rid 的时候,你只能按时间窗口 grep,结果混进几十条别人的日志,只能靠肉眼比对。这个差别在事故排查时非常明显——前者三十秒,后者半小时。

关于 rid 还有个小建议:Nginx 生成、应用沿用,不要应用重新生成。如果应用自己又造一个 ID,那就断链了。同理,如果链路后面还有别的服务,也要把这个 header 透传过去。

六、按天切分、按级别分离与体积控制

结构化日志比普通日志大 30%~60%,因为它每个字段都带键名。如果不控制体积,磁盘很快告急。三条措施必须一起做。

第一条:按天切分。这条日志写起来以后,如果是自己写文件,文件名带日期;如果是走 logrotate,配置一个按天的滚动规则。

/var/log/app.jsonl {
    daily
    rotate 14
    missingok
    notifempty
    compress
    delaycompress
    copytruncate
}

注意 copytruncate:它先复制再清空原文件,缺点是复制那一瞬间写入的行可能丢失。如果应用能响应 SIGHUP 重新打开文件,用 create 模式更严谨。个人站的日志丢几行可以接受,用 copytruncate 更省事;涉及审计类日志就别用它。

第二条:按级别分离。不要把 debug 和 error 写进同一个文件。用两个文件,或者在应用里按级别路由。debug 只在需要时打开,别常开——「打开 debug 排查完忘了关」是日志占满磁盘的常见原因之一。

第三条:设一个总量上限。logrotate 只管单个日志的历史份数,如果你的 debug 日志一天能写 2GB,14 天就是 28GB。加一个块大小限制:

/var/log/app.jsonl {
    size 200M
    rotate 10
    compress
    copytruncate
}

用 size 代替 daily 之后,日志是「写满 200M 就滚」,最多占 2GB,与写日志的频率无关。这是最抗意外的一种配置。同时给磁盘加一个 85% 的告警,双保险。

七、用 jq 把结构化日志真正用起来

结构化的价值只有在用起来之后才体现。下面这些是我最常用的查询,全部一行命令。

看最近 50 条错误:

jq -c 'select(.status >= 500)' /var/log/nginx/access.log | tail -50

统计每个状态码有多少:

jq -r '.status' /var/log/nginx/access.log | sort | uniq -c | sort -rn

找出最慢的 20 个请求,只输出关键字段:

jq -r 'select(.rt > 1) | [.rt, .status, .req] | @tsv' /var/log/nginx/access.log \
  | sort -rn | head -20

按小时统计平均响应时间,找出波动规律:

jq -r '[.time[0:13], .rt] | @tsv' /var/log/nginx/access.log \
  | awk '{s[$1]+=$2; n[$1]++} END {for (h in s) printf "%s %.0f ms %.0f req |", h, s[h]/n[h]*1000, n[h]}' \
  | sort

这四个查询对应四类日常问题:错误发生了什么、整体健康度、谁最慢、什么时候慢。它们不需要任何日志平台,一台 SSH 上去的机器就能跑,这正是结构化日志对个人站长最大的实惠——用 jq 换掉一整套日志系统的采购和维护成本。

顺带一提:如果日志量真的到了几百 MB 每天,jq 单线程处理会比较慢。这时可以用 grep -F 先粗筛再 jq,或者上 ripgrep 的 --json。但对日 PV 几万的站来说,jq 完全够用。

八、日志里的隐私与安全红线

结构化日志写得太顺手,很容易把不该记的东西记进去。三条红线必须守住。

第一条:绝不记录密码、Token、完整信用卡号。登录接口的日志里,请求体如果原样记下来,就等于把明文密码写进了磁盘。正确做法是记录「登录失败」这个事件和用户名,绝不记密码字段。如果确实需要记录请求体做排查,先做脱敏:手机号中间四位、邮箱本地部分、Token 只留前四位(替换成省略号,别用星号,免得和 Markdown 加粗混淆)。

$mask = function ($s) {
    if (strlen($s) <= 4) return '[masked]';
    return substr($s, 0, 2) . '...' . substr($s, -2);
};

第二条:日志文件权限要收。应用日志里往往有用户 ID、IP、路径,属于个人信息。文件权限设成 640,属主是运行 PHP 的用户,属组给运维,别让 www-data 之外的人随便读。logrotate 生成的归档文件也一样,注意 create 640。

第三条:别让日志目录能被 Web 访问。这条听起来很基础,但实际上每年都有站因为 /logs/ 目录可访问而泄露全站访问日志。确认 /var/log 不在站点根目录下,或者 Nginx 里明确 location ~ ^/logs/ { deny all; }。

还有一条容易忘的:日志里记录了完整的 URL,而 URL 里可能带查询参数,包括邮箱、重置密码的 token。这类敏感参数要么在 log_format 里做处理(用 map 把 $args 里特定键替掉),要么干脆不记录查询串。前者的写法:

map $args $safe_args {
    "~^(.*)(token|key|password)=[^&]*"  "$1$2=[masked]";
    default $args;
}

九、迁移节奏:从一条日志开始,别一次改全站

看到这里想立刻动手的人,最容易犯的错是一次性把 Nginx、PHP、cron 全部改成 JSON,然后第二天发现 logrotate 没配、磁盘写满、网站 502。

建议的迁移顺序是这样的。

第一步,先只加字段不改格式。把 Nginx 的 log_format 从 combined 换成带 rt/urt/cache 的自定义格式,但仍然是空格分隔。这一步零风险,收益立刻可见——你终于能看到哪些请求慢了。

第二步,只给最需要的那个模块加结构化日志。通常是「支付/下单/登录」这类关键链路,或者是你最想排查的那个接口。不要全站都加,先在一个点上验证工具链(jq 查询、logrotate、磁盘占用)都跑通了,再推广。

第三步,把 rid 打通。这一步的收益最大,但要改 Nginx 和应用的配置,所以要单独做,做完先在一个页面上验证一遍,确认 grep 能串起来。

第四步,才是考虑「要不要上采集系统」。对个人站来说,很多时候 jq + 每天的一份统计 cron 就已经够用,上 ELK 或 Loki 反而要维护一套额外的服务。真需要采集的时候,promtail 读 JSON 日志是几乎零成本的(它天生支持 JSON 解析和字段提取),可以作为第一个选项。

每一步之间隔几天,观察磁盘和 CPU 的变化。这样做的好处是:出问题时你清楚地知道是哪一步引入的。

十、小结

回到开头的问题:日志是给谁看的。答案不是「给人」或「给机器」,而是两类读者都要照顾,用不同的日志承担不同的职责。

Nginx 的访问日志保持精简的定长格式,另开一个慢请求 JSON 文件;应用日志用 JSON Lines 写在独立目录,按天或按大小滚动;错误日志保留原文,只加前缀字段;系统日志一概不动。再串上一个请求 ID,配合几条 jq 命令,你就有了一套不输给小团队的日志能力。

最后一句实话:结构化日志的成本不在写,而在维护那套键名的稳定性。字段改名、级别含义变化、时间格式换成另一种,比不结构化更糟。所以动手之前先把键名表写下来贴在文档里,比什么都重要。

Last modification:November 8th, 2026 at 08:27 pm

Leave a Comment