一次线上问题花了两小时,其中一小时在"确认日志里到底有没有这条记录"。日志确实有,但那行写成 用户 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. 自测
- 三种消费方式各依赖什么?为什么"只满足人看"的日志在事故里几乎不可用?
- 字段设计五条要求里,哪一条最容易破坏?后果是什么?
- 为什么推荐
order.create.failed这种三段式命名?它能支持什么查询? trace_id与user_id都是高基数,处理方式为什么不同?- 事故里最常问"是不是这次发布引入的",它需要哪个字段?为什么最常被漏掉?
- 判断"是否需要结构化"的那个问题是什么?