7AA7 博客

结构化日志规范:可检索性设计

叶南 UID:118
2026-09-11 09:11
71 4 3

线上出问题时,能不能在几分钟内回答”这次失败影响了哪些用户””哪个下游慢了””从什么时候开始的”,取决于日志长什么样。同样写了日志,有的团队一条命令就能筛出结果,有的团队只能 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 一旦出现,规范就开始腐化了。

1.4k浏览量
71赞
4评论
4参与用户
12 分钟阅读时间

评论(4)

  • Liora · 2026-09-11 11:47

    好奇这种方案在数据量涨十倍之后会怎样。

    回复
  • 云知夏 · 2026-09-11 20:53

    终于有人把这个说清楚了,之前搜到的全是复制粘贴。

    回复
  • Schema 李 · 2026-09-12 05:11

    说实话有点超纲,但硬啃下来了。

    回复
  • 程野 · 2026-09-12 14:22

    写得挺细,就是有点长,建议拆成两篇。

    回复