日志里该写什么:能定位问题,也守住隐私
卷中目录
一次超时,三行日志对不上
支付回调在傍晚开始变慢,接口成片返回 504。客服只能提供一串订单号。日志平台里能搜到三条记录:网关写着「request failed」,订单服务写着「update error」,支付服务写着「upstream timeout」。三行没有共同标识,机器时钟又差了大约两秒,先后顺序靠猜。故障本身持续了十几分钟,把同一次调用从三条文本里拣出来,却花了近一个小时。

这个场景说明日志的职责不是「把发生过的事都堆下来」。它要让后来的人用很少的字段回答四个问题:这次用户动作穿过了哪些进程,卡在哪个依赖,失败能不能重试,影响的是哪个租户。与此同时,回调正文里常常有卡号片段、手机号和签名材料。为了省事整包落盘,定位能力会换成长期的隐私风险。能检索的结构,和不能落盘的内容,必须在写日志的那一刻一起定下来。
结构化是为了让字段可被查询
自由文本适合人眼扫一眼,不适合作为唯一形态。同一件事变着说法写,「支付超时」「pay timeout」「调用渠道失败」,检索时就要准备三套关键词。结构化日志把稳定事实拆开:时间、级别、服务名、环境、路由、状态码、耗时、错误码、追踪标识。消息正文只留一句人能读懂的摘要,动态值进字段,不进句子。
字段名要稳定。用 latency_ms 就不要有的服务写 cost、有的写 elapsed。枚举用团队约定的一种写法,避免 Timeout 和 timeout 被当成两类错误。高基数的值,例如带查询串的完整 URL、用户输入的搜索词,可以出现在单条日志里,但要截断,并且先去掉敏感参数;不要拿它们当聚合维度,否则索引和费用都会膨胀。
一条请求日志可以长这样,其中的值都是虚构的:
{
"ts": "2026-04-12T09:41:08.221Z",
"level": "error",
"service": "order-api",
"env": "prod",
"trace_id": "4f0c9a2e7b11",
"span_id": "a91c",
"request_id": "req_8c31",
"route": "POST /v1/orders/{id}/pay",
"status": 504,
"latency_ms": 8021,
"error_code": "PAY_UPSTREAM_TIMEOUT",
"upstream": "pay-gateway",
"attempt": 2,
"user_ref": "u_1942"
}这里没有请求体,没有 Authorization,没有手机号。user_ref 是内部用户编号,不是登录名。订单号如果长度有限、会展示给用户、并且查询权限受控,可以记;如果订单号能反推出证件或住址,就改记内部主键。
请求、事件、作业是三条时间线
网页请求、消息消费、定时作业的生命周期不一样,却经常被混成一种叫 log 的东西。分开记,后面才知道一条记录代表什么。
请求日志覆盖一次进出。关键是路由模板,而不是填好参数的路径,避免 /users/1902 和 /users/1903 变成无穷多个系列。状态码、耗时、响应字节数、上游名称放在固定位置。一次重试不要覆盖前一次的记录,用 attempt 区分。
领域事件描述业务状态变化,例如订单从「待支付」到「已支付」。要有实体类型、实体 id、迁移前后的状态名、触发来源(用户提交、渠道回调、对账作业)。不要把状态机的全部上下文打成一段中文。状态名用稳定枚举,报表和告警才不会因为文案改了一个字就断掉。
作业日志描述批处理:作业名、分片、游标、本批处理条数、失败条数、是否还会重试。批处理最容易在循环里对每一行打 info,几小时后把磁盘打满。默认只在批次开始、批次结束和失败时打 info 或 error。行级细节放到 debug,生产默认关闭,或按追踪标识临时打开,并设到期时间。
哪些字段真能帮你定位
定位依赖的是可连接和可区分,不是文采。下面这些字段在多数后端里值得保留。
追踪标识。一次用户动作穿过网关、应用和消息队列时使用同一个 trace_id。进程内的一段工作用 span_id,父段用 parent_span_id。队列消息要把追踪标识放进消息头,消费者继续沿用,而不是自己编一个新的。没有透传,异步链路在日志里就是断的。
请求标识。有的网关会生成 request_id,方便和接入层访问日志对照。它和追踪标识可以相同,也可以不同,但要有文档说明谁生成、谁原样透传、谁有权改写。调用方若可以随便塞值,就要校验长度和字符集,拒绝换行和控制字符。
稳定错误码。给调用方和值班人看的短码,例如 PAY_UPSTREAM_TIMEOUT。自然语言消息可以变,错误码不要随文案改动。错误码表达类别,不表达秘密。不要把上游返回的原始报文塞进错误码字符串。
重试次数与是否可重试。同一次逻辑失败被重试三次,如果不记 attempt,曲线上会像三倍的用户请求。可重试和不可重试要分开计数,否则告警阈值会被重试放大。
上游与依赖。超时要能看出是数据库、缓存还是外部 HTTP。只写「timeout」无法分派。依赖使用逻辑名,例如 pay-gateway,不要写一台会变的内网 IP 当作唯一线索。IP 可以另作字段,但排障入口应是服务名。
资源标识的内部形式。用户、租户、订单用内部 id。租户尤其重要,否则多租户故障会被混成一团。内部自增 id 会暴露业务量,连续 id 还可能被拿去枚举。日志读者如果多于值班工程,考虑记不透明的公共 id。
代码位置的逻辑名。用例名,例如 place_order,比只留文件行号更抗重构。行号可以留,但不要只靠行号。发布一次,行号就全变了,旧告警链接会指到无关代码。
关键分支。走了降级、用了缓存、幂等键命中、读了从库,这些是「为什么这次和上次不一样」的原因。分支用布尔或短枚举,不要用一段故事。
耗时不要只记总数。若数据库占了七秒、外部调用占了半秒,拆成 db_ms 和 upstream_ms 能少做一轮剖面。拆太细会让每条日志膨胀,一般保留总耗时和最慢的两三个依赖即可。时间用整数毫秒,不要有的日志写「8s」、有的写浮点秒。
哪些内容不该出现
隐私和秘密经常是顺手带上的。请求体日志中间件、ORM 的 SQL 调试、异常对象的默认打印,都会把不该留的东西卷进来。
这些内容不写进日志:
- 口令、会话令牌、刷新令牌、API 密钥、私钥、签名原文、一次性验证码。
Cookie、Authorization、Set-Cookie的内容。若要确认有没有带认证,记布尔值has_auth,不记内容。- 银行卡号、安全码、证件号、完整手机号、完整邮箱、详细住址、精确经纬度。
- 能重置账号的链接,以及邀请链接里的 token 参数。
- 健康数据、通讯录、聊天正文、用户上传文件的内容。
- 数据库连接串。异常信息里若拼接了 URL,用户名和口令会跟着出现。
- 尚未公开的业务数据,例如未发布的价格策略全文。
自由文本同样危险。用户昵称、备注、搜索词会被写进 message。约定是:message 由开发者写死模板,动态值全部走字段,字段经过允许清单。禁止把 request.body 或异常参数直接格式化进消息。
标识符也要分寸。对手机号做无盐哈希,常见号段仍能被字典还原。若确需不可逆关联,使用带服务端密钥的 HMAC,并且把结果放进访问更严的审计流,而不是通用调试索引。调试日志多数时候只要内部 id,不需要变换后的手机号。
脱敏要分目的
把手机号中间四位换成星号,适合展示给客服的界面,不适合当作安全边界。日志若仍保留前三后四,在小用户群里常常能对上具体的人。按用途选择做法,而不是统一打三个星。
调试关联记内部 id,不记手机号。客服需要对照时,在权限更窄的后台查询,不在日志里保留可识别值。防重复和安全审计若必须使用邮箱或电话,做带密钥的 HMAC,单独存储、单独授权。排错时若只需要确认「这像一个邮箱」,记域名或记 email_present,不记本地部分。支付排查若必须看卡,只在支付专有日志流里保留品牌和后四位,不进通用检索。
不可逆变换要防字典。可逆加密意味着密钥放在日志系统旁边。一旦日志和密钥一起备份到过宽的存储,加密就失去意义。多数调试日志用不上可逆,直接不收集。
还要注意二次泄露。返回给客户端的错误已经脱敏,服务端却把原始异常记下来,脱敏就白做了。两边使用同一套清洗函数,放在记录边界,而不是散落在每个捕获分支里靠人记得调用。
异常对象里常常藏着配置
许多语言的异常会带上原因链。数据库驱动把 SQL 和参数放进消息,HTTP 客户端把请求 URL 放进消息,查询串里的 token 就跟着落盘。模板引擎还可能把用户数据嵌进栈的局部信息。
处理方式可以固定下来:记录异常类型名,记录你映射之后的错误码,记录经过清洗的短消息。栈可以留,但要先过清洗,去掉 URL 里的用户信息、连接串和参数数组。不要为了「万一有用」把异常对象整棵序列化写进去。对象图里往往有配置、请求和用户会话。框架若做不到裁剪,就只记类型和栈帧上的函数名,不记局部变量。
日志注入是另一类问题。用户输入若包含换行,后接伪造的字段,在按行采集时会变成额外一条日志。把动态值放进结构化字段,并在采集端拒绝或转义裸换行,比事后在检索里过滤更可靠。JSON 日志还要保证一条记录是一个对象,不能让用户内容把对象提前截断。
级别、采样和体积
信息够用之后,剩下的问题是成本和噪声。每条日志都写完整 SQL 和完整响应,会把真正的错误淹没,也会把账单抬高。
级别可以这样约定。error 表示请求结果失败或作业失败,保留追踪标识、错误码、依赖名和耗时,不含秘密。warn 表示被降级、将要重试、数据被跳过,要能统计次数。info 表示请求完成、状态迁移、批次摘要,成功请求只留一条。debug 表示分支细节和外部报文的结构摘要,生产默认关闭,按服务和追踪标识打开,并自动过期。
高流量的成功路径可以采样,例如保留百分之一,但失败路径不要按同样比例丢弃。采样决策应当放在请求入口,并写进追踪标识的行李里,这样下游服务对同一次请求做出一致选择。看错误率时用指标,不只用日志条数,否则采样会让告警失真。
单条也要截断。消息超过大约一两千字就失去可读性,超长字段往往是误把文件或 HTML 打了进来。字符串字段设上限,超出部分标记 truncated。数组只留长度和前几个元素的类型,不留元素正文。二进制内容永远不进日志,只记长度和内容类型。
留存、权限和再加工
日志会被检索、导出、复制到测试环境,甚至拿去喂内部工具。隐私风险在这些再加工环节放大。
按用途拆流。调试流保留期短,例如两周到一个月,访问范围是值班工程。安全审计流字段更少、保留更久、审批更严,用来回答谁在何时做了管理操作,而不是用来复盘普通异常栈。不要把两套需求塞进同一个全量索引,否则短保留做不到,窄权限也做不到。
权限跟着数据走。能看生产日志的人不应该自动能看全部租户的正文级字段。租户标识可以用于过滤,平台侧的查询也应留审计。把日志下载到个人电脑做分析,等于绕开留存策略。默认禁止批量导出,或导出前再跑一次清洗,并记录导出人。
指标与日志分工。延迟直方图、错误码计数、饱和度用指标。日志用来解释某一次为什么慢。采集延迟会让「按日志条数告警」时准时不准。告警走指标,日志留给出事之后的那一次点击。
在代码边界收口
个人自觉不可靠,因为新接口会不断出现。把策略做成中间件和基础库。
HTTP 入口生成或接收追踪标识,校验格式。记录访问日志时使用路由模板。出站客户端记录方法、主机、路径模板、状态码和耗时;查询串用允许清单,默认丢弃。业务代码通过日志门面提交事件名和字段字典,不拼接字符串。
字段字典经过允许清单和拒绝清单。拒绝清单至少覆盖口令、令牌、证件、卡号这些键名的常见变体。比较前把大小写、中划线和下划线规范化,避免 accessToken 和 access-token 漏网。未知字段不要默认放行。新字段要显式登记:类型、是否可能含个人数据、保留在哪条流里。
测试里断言一件具体的事:给处理器喂一份带密钥和手机号的请求,得到的日志 JSON 不含这些值,也不含原始请求体。这比代码评审时说「记得不要打日志」更耐久。回归测试还要覆盖换行注入,确认用户输入不能劈出第二条记录。
评审清单可以短。有没有新的「把 body 打出来」;错误码是否稳定;追踪标识是否跨过队列;新增字段是否可能含个人数据;生产级别是否会在循环里爆炸。满足这几条,日志才能在故障当天帮上忙,而不是在合规检查时成为负债。
字段字典怎样演进才不断裂
日志字段一旦被仪表盘和保存的检索引用,改名就等于切断历史。新增字段用新名字,不要把 user_ref 改成有时是内部 id、有时是邮箱的混合类型。类型保持稳定:耗时永远是整数毫秒,布尔值用 true 和 false,不用 0、1 和 yes 混用。
若必须废弃字段,写入端先双写新旧字段,度过一个观察期,更新共享检索,再停写旧字段。这和数据库先扩展再切换是同一纪律。常用查询放进团队共享的检索库,不要只存在个人浏览器里,否则字段含义只会留在口头,新人会把旧字段重新加回来。
采样率、截断长度、拒绝清单的版本也要能对上某条日志。可以在每条记录里放一个短的 log_schema 版本号。排障时若发现某段时间突然多了正文,就能知道是规则版本变了,而不是流量本身变了。
必须看载荷时,用会过期的开关
有些故障确实需要看报文形状,例如第三方改了字段名。为此准备受控的调试开关,而不是平时全开。开关要求限定服务、限定路由、限定追踪标识或租户,到期自动关闭。输出仍走清洗,密钥位置永远替换。打开和关闭都写审计。调试副本存到更短寿命的位置,不进保留一个月的通用索引。
若问题只在特定用户侧复现,让用户提交反馈编号,服务端把该编号和 trace_id 关联。支持人员用编号换追踪标识,再查调试流。用户不需要把页面截图里的令牌发到群聊,工程也不需要在共享文档里粘贴原始报文。
日志写得少而准,比写得多更考验设计。先定你要回答的问题,再定哪些原始材料永远不进入回答。两者都落成字段和拒绝规则之后,新同事才不会在慌乱时把整个请求体打进生产日志。
见字如晤