2172 字
约 7 分钟
18
S16-A30 · 日志的可搜索性:字段设计决定能不能查
2024-12-31
2026-11-10

一次线上问题花了两小时,其中一小时在"确认日志里到底有没有这条记录"。日志确实有,但那行写成 用户 12345 下单失败,原因:库存不足——所有内容都在 message 里,于是只能全文模糊搜索,搜"12345"会命中十几个不相关的行。

日志的可搜索性不是采集系统决定的,而是写日志那一刻的字段设计决定的。
还有一个更基础的问题:日志写完就再也改不了。字段设计一旦上线,历史数据就固定了;想着"以后再优化"的团队,往往要等到两年后数据都无法回溯时才动手。

而它通常在一次事故之后才被重视,因为那时才发现"能查到"与"能搜到"是两件事。


1. 日志的三种消费方式

方式 依赖
人看(排障) message 可读
机器查(过滤) 字段可精确匹配
机器算(聚合) 字段可分组计数

第一种要求"读得懂",第二、三种要求"结构稳定"。只满足第一种的日志,在事故里几乎不可用:因为排障时你要找的是"某个用户的所有记录",而不是"读过全部日志找感觉"。

// 同一件事的两种写法
A(不可搜):用户 12345 下单失败,原因:库存不足
B(可搜)  :{"event":"order.create.failed","user_id":12345,"reason":"stock_short","sku":"A-12"}
// A 只能模糊查;B 可以按 user_id 精确过滤,按 reason 分组计数

2. 字段设计的五条要求

要求 说明
关键标识独立成字段 user_id、order_id、trace_id
值不带单位与描述 latency_ms: 83 而非 "耗时 83ms"
原因用枚举而非自由文本 reason: "stock_short"
字段名全局统一 不能一处 user_id 一处 uid
时间只出现一次 由日志框架统一加

第四项是最容易破坏的:同一个概念在不同模块里被起了不同名字,于是查询时必须写 (user_id:1 OR uid:1 OR userId:1),而这个 OR 总有一天会漏掉一个。

还有一条"值不重复"的原则:同一个值不该出现在两个字段里。比如 msg 里写了 order O-7781,同时又给了 order_id 字段——将来改格式时两者会不一致,而查询条件只能选一个。

第五项也常出错:message 里再写一遍时间,导致"时间字段有两个,且不一致"。

{
  "ts": "2026-11-10T14:30:12.481Z",
  "level": "error",
  "event": "order.create.failed",
  "trace_id": "9f2c1a7e",
  "user_id": 12345,
  "order_id": "O-7781",
  "reason": "stock_short",
  "sku": "A-12",
  "latency_ms": 83,
  "msg": "create order failed"
}

3. 事件命名:比字段更容易被忽略

命名方式 可查性
msg: "下单失败" 差(中文、易变)
event: "order_create_failed" 中
event: "order.create.failed" 好(层级可通配)

第三项的好处是可以用通配查询:event: order.create.* 一次拿到所有下单相关事件,而不用维护一份事件清单。

事件名一旦发布就是查询依赖:改名的成本等于"改掉所有看板与告警里的查询"。所以宁可新增事件名,也不要改旧名——这与接口版本化是同一类约束。

命名还要遵守"名词.动词.结果"的顺序:order.create.failed 而不是 failed_create_order,因为前者按业务域聚合更自然。

// 事件命名规范(三段式)
<域>.<动作>.<结果>
order.create.ok        order.pay.failed
cache.get.miss         auth.token.expired
// 通配示例
event: order.*.failed      → 所有下单/支付失败
event: *.create.ok         → 所有创建成功

4. 高基数字段:能存但不能随便查

字段 基数 建议
trace_id 极高 存,仅在已知值时可查
user_id 高 存,需注意索引成本
event 低(百级) 建索引
reason 低 建索引
endpoint 中(模板化后) 建索引

第二项在日志平台上会造成索引膨胀:每个用户一个值,索引比数据还大。实践中常见的折中是"user_id 可存但默认不建索引,需要时按时间窗 + 精确值查"。

第二项在实践中还有一个折中:把 user_id 存为字符串并只对"前缀"建索引,或者只索引哈希值。这样既能按已知用户查,又不会让索引体积失控。

第一项更是如此:trace_id 的检索方式永远是"拿着一个已知值去查",而不是"列出所有 trace_id",所以它不需要索引。

5. 从日志到可回答的问题

想回答的问题 需要的字段
这个用户经历了什么 user_id + trace_id
哪一类失败最多 event + reason
失败是否集中在某实例 instance / zone
从失败到成功隔了多久 时间 + trace_id
哪个版本开始出现 version

字段齐了还要能被查到,所以还需要一层"字段字典":同一份字段定义同时供采集配置、查询界面与文档使用。三处各写一份的结果是"文档里有、索引里没有",而查不到时没人知道该信哪个。

// 字段字典:采集、查询、文档的唯一来源
fields:
  - name: event      type: keyword  indexed: true   doc: "三段式事件名"
  - name: reason     type: keyword  indexed: true   doc: "失败原因枚举"
  - name: user_id    type: long     indexed: false  doc: "用户ID,按需精确查"
  - name: version    type: keyword  indexed: true   doc: "发布版本(git sha)"

第五项(version)最常被漏掉:没有版本字段,就无法回答"是不是这次发布引入的",而这是事故里最常问的问题。

// 强制字段:由框架统一注入,业务代码不必关心
log_context:
  ts: auto
  level: auto
  service: "${APP_NAME}"
  version: "${GIT_SHA}"        # 关键:能回答"哪个版本开始"
  instance: "${HOSTNAME}"
  trace_id: from_mdc           # 由网关生成并透传
  msg: caller

6. 排错:五种"日志查不到"

现象 成因 处理方向
精确查不到,模糊能查到 内容在 message 里 拆成字段
同样的查询在不同服务失效 字段名不统一 制定字段字典
查不到某版本的数据 缺 version 字段 注入版本
查询很慢或超时 高基数字段建了索引 移除索引
无法按原因聚合 原因是自由文本 改枚举

更麻烦的是多语言混用:同一个原因在 Java 侧是英文枚举、在 Python 侧是中文文案,两边都进了日志,于是按原因分组时会得到两条并列的记录。

第五项在排障后期影响很大:"库存不足"在一个模块写作"库存不足"、另一个写作"库存不够"、第三个写作"stock short",于是按原因统计永远对不上。

7. 边界:什么情况不必强求结构

场景 为什么 做法
本地开发日志 只给人看 自由文本
一次性脚本 不长期 随意
启动期日志(十几行) 一次性 自由文本
已有专用审计系统 另有通道 跟随审计规范

最后一条与成本有关的边界:结构化不是免费的。字段越多,写入体积越大,索引也越多——所以字段应当由"要回答的问题"倒推,而不是"把能想到的都记上"。

判断口径一句话:这段日志将来会不会被"按某个值筛出来"。会,就必须是字段;不会(只是给人读一遍),自由文本更合适。


8. 自测

  1. 三种消费方式各依赖什么?为什么"只满足人看"的日志在事故里几乎不可用?
  2. 字段设计五条要求里,哪一条最容易破坏?后果是什么?
  3. 为什么推荐 order.create.failed 这种三段式命名?它能支持什么查询?
  4. trace_id 与 user_id 都是高基数,处理方式为什么不同?
  5. 事故里最常问"是不是这次发布引入的",它需要哪个字段?为什么最常被漏掉?
  6. 判断"是否需要结构化"的那个问题是什么?
S16-A30 · 日志的可搜索性:字段设计决定能不能查
https://www.clxhxhhr.top/posts/6467/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。