线上出问题时,能不能在几分钟内回答”这次失败影响了哪些用户””哪个下游慢了””从什么时候开始的”,取决于日志长什么样。同样写了日志,有的团队一条命令就能筛出结果,有的团队只能 grep 半天然后靠猜。
纯文本日志难以聚合
2026-10-21 10:23:11 INFO order created, id=88213 user=u_1029 amount=199.00
2026-10-21 10:23:12 INFO creating order for user u_77, amount 59
这两条记录同一件事,格式却不一样:一条用 key=value,一条用自然语言。想统计”创建订单的平均金额”,只能写两条不同的解析规则;加了一个字段,规则就得跟着改。更麻烦的是字段顺序不固定、键名不统一(user / uid / user_id 混用)、值里有空格和换行(多行堆栈会直接破坏行式解析)。
grep 能解决”找一条”,解决不了”算一批”。跨服务关联更是无从下手——没有统一字段,就没有办法把一次请求在所有服务里的记录串起来。
结构化日志的做法是:每条日志是一个对象,字段固定、类型稳定,输出成 JSON 一行一条。检索系统直接按字段过滤、聚合、排序,不需要解析文本。
字段命名约定
约定要少而硬,重点是这些基础字段在所有服务里名字完全一致:
| 字段 | 类型 | 说明 |
|---|---|---|
ts |
RFC3339 字符串,带毫秒与时区 | 事件时间,统一 UTC 存储 |
level |
枚举 | debug/info/warn/error |
service |
字符串 | 服务名,与注册中心一致 |
env |
字符串 | prod/staging/dev |
trace_id |
字符串 | 贯穿一次请求的全局 ID |
span_id |
字符串 | 当前调用片段 |
user_id |
字符串 | 操作主体,未登录时省略而非填空 |
duration_ms |
数字 | 耗时,数字类型才能做百分位聚合 |
result |
枚举 | ok/fail,便于按结果聚合 |
event |
字符串 | 事件名,名词化、稳定,作为检索主键 |
error |
对象 | {kind, msg, stack},仅失败时有 |
几条容易引发分歧但值得统一的规则:
- 时间统一 UTC 输出,展示层负责转本地时区。混合时区会让排序错乱。
- 耗时用数字而不是字符串(
"128ms"无法聚合)。 event用点分命名(order.create、payment.callback),保持稳定——它相当于日志的 API,改了会破坏别人的看板和告警。- 布尔结果用独立的
result字段,不要塞在消息里。 - 字段类型要稳定:同一个字段不能一次是字符串一次是数字,检索系统会据此推断列类型,混用会让该字段整体不可聚合。空值用字段缺失表示,不要写空字符串或
null混着来。
级别的语义
级别最常见的滥用是”出问题就打 error”。级别应该表达的是”需要谁做什么”,不是”严不严重”。
- error:需要人介入。系统处于不正确状态、有数据损失风险、核心功能不可用。这类日志应该能触发告警,并且每一条都应该有人看。
- warn:异常但已自动处理,或值得关注的趋势。重试成功、降级生效、配置回退到默认值、接近阈值。不需要立刻处理,但应该出现在日报里。
- info:正常的关键节点。服务启动停止、请求完成、状态变更。用于回答”发生了什么”。
- debug:排查用的细节。仅在高负载排查或预发环境开启,生产默认关闭。
判断一条日志该用 error 还是 warn,可以问一句:”这条日志出现时,值班的人需要在半夜起来处理吗?”答案是否,就不该是 error。把所有异常都打成 error,结果是告警疲劳,真正的故障被淹没。
参数校验失败、用户输入错误、限流拒绝这类”系统正确拒绝了错误请求”的情况,属于 info 或 warn——系统在做它该做的事。
上下文传播
trace_id、user_id、request_id 这类字段不该由每个调用点手动传。手动传一定会漏,而且会让函数签名被日志参数污染。
标准做法是请求级上下文:请求进入时创建一个 context,把字段放进去,日志组件从 context 里自动取出并注入每条日志。
func Handle(ctx context.Context, req *Request) {
ctx = log.With(ctx,
"trace_id", trace.ID(ctx),
"user_id", req.UserID,
"endpoint", "POST /orders")
log.Info(ctx, "order.create.start", "items", len(req.Items))
start := time.Now()
order, err := svc.Create(ctx, req)
dur := time.Since(start).Milliseconds()
if err != nil {
log.Error(ctx, "order.create.fail", "duration_ms", dur, "result", "fail",
"error", log.Err(err))
return
}
log.Info(ctx, "order.create", "duration_ms", dur, "result", "ok",
"order_id", order.ID)
}
调用方只传变化的字段,公共字段自动带上。这套机制在跨协程、跨线程池场景容易断掉——异步任务提交时要显式把 context 序列化进去,否则后台任务的日志会丢失 trace_id,链路上出现断点。
必记与不记
值得记的:请求入口与出口(含耗时与结果)、状态变更(谁在什么时候把什么从 A 改成了 B)、外部调用(目标、耗时、结果)、重试与降级、鉴权失败、配置加载与热更新。
不该记的:
- 密码、密钥、token、会话 Cookie、签名值,哪怕做了部分掩码也不要记完整值。
- 身份证号、银行卡号、手机号等个人敏感信息的完整值。需要关联时用哈希或后四位。
- 完整请求体。它可能包含上面所有东西。只记字段名单或经过脱敏的关键字段。
- 大段 payload、文件内容、Base64 数据。它们会撑爆日志量,检索时也没用。
脱敏应该在日志组件的入口做,而不是靠每个调用点自觉。实用做法是维护一个敏感字段名黑名单(password、token、secret、authorization、id_card),序列化时自动替换为 ***;同时限制单条日志的最大长度,超出截断。
保留期也要分层约定:error 保留更久(例如 30 天),debug 保留几天即可。这既控制成本,也符合敏感数据的合规要求——含个人信息的日志留存越久,风险敞口越大。
采样与分级
日志成本随流量线性增长,高频路径上的 info 日志很快会超过存储预算。处理方式是分级采样:
/debug 路径健康探测 → debug,生产不输出
GET /products 每次请求 → 1% 采样
POST /orders → 100% 记录
任何 error → 100% 记录(不受采样影响)
同一 trace 内 → 一旦采样,全程记录
末尾这条很重要:采样必须按 trace 决策,而不是按单条日志决策。同一请求的一部分日志被采、另一部分被丢,会让链路排查变成拼图。
采样比例应该可动态调整,排查期间临时调高,结束后恢复。降级策略也要写清楚:磁盘或日志管道故障时,优先丢弃 debug 与高频 info,保留 error。
一份可复用的格式
{
"ts": "2026-10-21T10:23:11.482Z",
"level": "error",
"service": "order-service",
"env": "prod",
"version": "1.14.2",
"host": "order-7d9f-x2k",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"user_id": "u_1029",
"event": "order.create.fail",
"result": "fail",
"duration_ms": 1284,
"error": {
"kind": "DownstreamTimeout",
"msg": "inventory service timeout after 1000ms",
"stack": "order/service.go:142 order.(*Service).Create"
},
"context": {
"order_id": "88213",
"sku_count": 3,
"payment_method": "card"
}
}
几个设计点:服务版本和主机名要在字段里,出问题时能快速判断”是不是某次发布引入的”;error 是结构化对象而不是一坨字符串,才能按错误类型聚合;业务字段放在 context 下而不是顶层,避免与保留字段冲突,也让”公共字段”和”这条日志特有的字段”界限清晰。
新增一条日志时值得先问一句:它打算回答什么问题。答不上来的日志通常是噪音——上线后没人查,但一直占着存储和检索资源。反过来,排查时反复需要的字段(比如”这次请求走的是哪个灰度分组”)应该补进公共上下文,因为它早晚还会被问到。
规范落地的方式通常不是文档,而是日志库:把字段名、级别语义、脱敏规则、采样策略都封进一个公共库里,业务代码只调 log.Info(ctx, event, kv...)。库之外的手写 JSON 一旦出现,规范就开始腐化了。
Comments (4)
好奇这种方案在数据量涨十倍之后会怎样。
终于有人把这个说清楚了,之前搜到的全是复制粘贴。
说实话有点超纲,但硬啃下来了。
写得挺细,就是有点长,建议拆成两篇。
Please Sign in to join the discussion.