...
Back

不管你设计没设计,你的日志都是一套 schema

没人规划日志格式,可每个人最后都有一套 —— 在凌晨三点,靠 grep 一年前某人随手打的一个字符串,把它发现出来。

不管你设计没设计,你的日志都是一套 schema

你的日志是一套 schema 📋

日志感觉是代码库里最不"架构"的东西。你在困惑的地方加一行,它打印出来,你继续往下走。但那些行里的每一行,都会变成你日后用来理解生产环境的那个接口的一部分 —— 而这个接口在无人设计的情况下不断堆积。

你第一次在紧急情况下需要它时,才会发现它长什么样。而那是最糟糕的时机。


故障排查实际需要什么

看一看真实故障中日志是怎么被使用的,要求就很明显了:

一个请求的完整故事。 不是那一分钟的全部日志,而是某一个请求在它经过的每个服务里走过的那条具体路径。没有一个会传播的关联标识,你就只能靠时间戳来重建 —— 而它恰好在你最需要的时候失效,因为让故障变得有意思的那个量级,正是让时间戳关联变得无用的那个量级。

按维度过滤,而不是按文本。 "这个区域里这个端点的所有失败"是一次查询。如果严重级别、端点和区域都嵌在一个句子里,那它就是一个正则 —— 而且很脆。

计数。 "这件事发生了两次还是两千次"会彻底改变诊断方向,而你无法对没有结构的东西计数。

这三件事都无法事后补救。它们是"这行日志是怎么写的"的属性,不是"你怎么搜它"的属性。


收益最大的几条规则

把字段结构化,把消息留给人读。 一行 JSON,带一个可读的 message 加上带类型的字段,既能 grep 能查询。把值插进句子里,等于扔掉了所有你之后会想要的维度:

// 每一个你可能想过滤的值,现在都被困在散文里了。
log.info(`user ${id} upgraded to ${plan} after ${ms}ms`)
 
// 同一句话,外加一条可查询的记录。
log.info("subscription upgraded", { userId: id, plan, durationMs: ms })

从边缘开始传播一个请求 ID。 在第一个入口处生成它,放进每一行日志,传给每一个下游调用,并在错误响应里返回它。最后这一点被严重低估:一个能报出 ID 的用户,等于把那条精确的链路交到了你手上 —— 一份模糊的反馈就此变成一次查询就能查清的调查。

记录决策,不只是事件。 "缓存未命中"是事件。"缓存未命中:key 不存在,回落到源站"是一个带理由的决策。当行为是错的而不是崩了的时候,你需要知道代码为什么做了那个选择 —— 而那个理由恰恰是没人会记的东西。

按运维人员读它的方式使用级别。 ERROR 应该意味着"可能需要有人处理"。如果常规的、预期之内的情况也记成错误,这个级别就不再携带信息,而所有人都学会了忽略它 —— 到那时你付了日志的全部成本,却没有任何信号。


什么不能记

这部分不是可选项,也不只是合规问题。

绝不要记录凭据、令牌、会话标识或密钥 —— 包括藏在请求体、错误对象和异常转储里的,而那正是它们通常逃逸出去的地方。绝不要记录你不愿意给客服看的原始用户数据。

两个反复咬人的具体情况:

  • 错误对象可能携带引发它的那个请求,连请求头一起。把捕获到的异常原样打出来,是授权头最终出现在一个长保留期日志聚合器里的最常见方式之一。
  • 任何能用来认证的东西都是凭据,不管它叫不叫这个名字 —— 设备注册密钥、签名 URL、恢复令牌、包含上述任一项的配置块。如果把它粘进一个客户端就能获得访问权,它就不能进日志。

日志经常是你的数据中保护最弱的那份副本:可读范围广、保留时间长、还会被发给第三方。把一行日志当成一次公开发表来对待。


如果你什么都还没有,从哪开始

你不需要一次迁移。三个改动,按收益排序:

  1. 从边缘来的请求 ID,出现在每一行里。 光这一个改动对故障响应的帮助,超过其他所有改动之和。
  2. 在要紧的路径上做结构化字段 —— 认证、支付、任何带重试的东西。不用到处都做;先做那些你已经知道自己有疑问的热路径。
  3. 审计现有日志语句里的密钥。 grep 那些把整个对象、请求头或捕获到的错误传进去的日志调用。先修这些;它们是真正有下行风险的那批。

这些都不体面,但全都比另一个选项便宜 —— 那个选项是:在一个问题紧急的时候,你答不出关于生产环境的一个简单问题。