一条告警指向"某接口错误率上升",运维打开日志平台,发现这个服务的日志里没有 trace_id,只有 requestId;另一个服务里有 trace_id 但拼成了 traceId。两个服务都要查,于是要写两套查询语句,而且无法把两个服务的事件串起来。
日志字段看起来是"日志格式问题",实际是跨团队的查询契约。字段名不一致的代价不在写日志时,而在最需要它的时候——排障期间、时间紧、还要先花十分钟搞清字段叫什么。
1. 字段分三层,演进规则不同
| 层 | 例子 | 谁定义 | 演进约束 |
|---|---|---|---|
| 基础字段 | ts、level、service、msg |
平台 | 不可变 |
| 关联字段 | trace_id、span_id、user_id |
平台(强制) | 不可变 |
| 业务字段 | order_id、amount、channel |
业务团队 | 可增不可改 |
前两层必须统一,因为它们承担"把事件串起来"的职责。第三层应当允许自定义,否则业务会被迫把信息塞进 msg 字符串里——那才是真正的不可查询。
字段还能按"是否可枚举"再分一次,它决定该用索引还是该用全文检索:
| 字段形态 | 例子 | 该有的查询方式 |
|---|---|---|
| 低基数枚举 | level、env、status |
索引过滤 + 聚合 |
| 高基数标识 | order_id、trace_id |
精确匹配 |
| 自由文本 | msg、error.stack |
全文检索 |
| 数值 | duration_ms、amount |
范围过滤 |
把高基数标识放进全文检索、把自由文本当成索引字段,都会让查询成本失控。
关键区分:基础与关联字段不允许各团队自定,业务字段必须有明确归属。 混淆这两者,要么失去关联能力,要么失去扩展能力。
2. 关联字段为什么必须强制
| 字段 | 作用 | 缺失后果 |
|---|---|---|
trace_id |
跨服务串一条请求链 | 只能按时间猜 |
span_id |
定位链中某一跳 | 无法区分同链多次调用 |
user_id |
按人定位影响范围 | 无法回答"影响多少人" |
request_id |
单服务内部幂等与去重 | 重试无法区分 |
第三项在事故沟通中最有用:"影响多少用户"通常是第一个被问到的问题,而它只能靠 user_id 回答。
第四项易被与 trace_id 混为一谈:前者标识"一次调用尝试",后者标识"一次业务请求"。重试时 trace_id 相同而 request_id 不同,这个差别是排查重复处理的关键。
3. 字段名的约定怎么落地
| 手段 | 强制性 | 成本 |
|---|---|---|
| 结构化日志库 + 强制字段 | 高 | 中(统一依赖) |
| 采集侧补字段(如从 header 提取) | 高 | 低(平台改) |
| 规范文档 + 评审 | 低 | 低但不可靠 |
| 日志质量检查(管道中校验) | 中高 | 中 |
第二种最划算:如果 trace_id 来自请求头,采集侧可以统一补齐,业务代码不需要关心。这样既保证了字段存在,又不强迫每个团队改代码。
// 采集侧统一补齐:业务只写业务字段
processors:
add_fields:
fields:
service.name: "${SERVICE_NAME}"
env: "${ENV}"
extract_from_header:
source: http.request.header
rules:
trace_id: "x-trace-id"
user_id: "x-user-id"
// 缺失时打标而不是丢弃:让"没传"变成可统计的事实
on_missing: mark_missing
采集侧补齐还有个边界要注意:不能补出错误的值。如果请求头里没有 trace_id,绝不能生成一个随机的——那会让不同请求看起来像同一条链。正确做法就是打标为缺失。
on_missing: mark_missing 这一项很重要:如果缺失就直接丢弃,你永远不会知道有多少请求没传;打上 trace_id=missing 反而能统计出缺口比例。
4. 业务字段的演进规则
| 变更 | 允许 | 说明 |
|---|---|---|
| 新增字段 | 是 | 下游忽略未知字段即可 |
| 重命名字段 | 否(需过渡期) | 先双写两个名字,再删旧的 |
| 删除字段 | 否(需过渡期) | 先停写,观察一个周期 |
| 改变语义 | 否 | 等于新增一个字段 |
| 改变格式 | 视情况 | 时间格式、精度都要过渡 |
第四项最容易被忽略:把 duration_ms 的含义从"业务处理耗时"改成"含网络往返的耗时",字段名没变,所有历史看板的口径都变了,而且不会报错。
业务字段的另一个常见问题是枚举值不统一:同一个状态在三个服务里分别记成 PAID、paid、2。跨服务聚合时这三者互不相认,而每种都有自己的"正确性"。
// 语义变更的正确做法:加新字段,双写一段时间
旧:duration_ms = 业务处理耗时
新:duration_ms = 业务处理耗时(保持)
duration_total_ms = 含网络往返(新增)
// 看板逐步切到新字段,旧字段保留一个季度后下线
枚举统一不必一次到位,可以从"新增字段用统一值 + 旧字段保留"开始:
// 枚举统一的过渡写法
旧:status = "PAID" | "paid" | "2"
新:pay_status = "paid"(新字段,统一小写)
pay_status_code = 2(如确需数字,单独字段)
// 看板切到新字段;旧字段在兼容期后停止写入
5. 字段的可查询性
| 问题 | 后果 |
|---|---|
| 高基数文本字段(如 URL 全路径) | 索引膨胀,查询变慢 |
把 JSON 塞进 msg |
无法按字段过滤 |
| 字段类型不一致(有时数字有时字符串) | 查询与聚合失效 |
| 缺单位 | 数字无意义(ms?s?) |
第四项成本最低、收益最高:所有时长字段带单位后缀(_ms、_bytes),所有金额字段带币种或统一单位。没有单位的数字在跨服务对比时会引发争议。
// 一个可查询的结构化日志样例
{ "ts":"2026-11-10T05:12:33.412Z", "level":"warn", "service":"order",
"trace_id":"9f2c...", "request_id":"req-8812", "user_id":10042,
"event":"payment.retry", "order_id":"O-7781",
"duration_ms":842, "attempt":2, "reason":"timeout" }
// 关键:业务字段扁平、带单位、类型稳定;不把结构化信息塞进 msg
6. 排错:五种"日志查不到"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 无法跨服务串联 | trace_id 缺失或名字不同 |
采集侧统一补齐 |
| 按用户查不到 | user_id 未记或只在入口记 |
强制字段,贯穿链路 |
| 聚合结果为零 | 字段类型不一致 | 统一类型,采集侧转换 |
| 时间范围对不上 | 时区不一致或无时区 | 统一 UTC + ISO8601 |
| 关键字段被截断 | 字段长度上限 | 调整映射或改存摘要 |
排错时还有一个入口值得先试:用一条已知存在的记录反查字段名。从一条日志里复制出全部字段名,比读文档更快,也更接近实际。
// 从一条真实日志里导出字段名清单,作为约定的核对基准
curl -s "$LOG_API/search?q=trace_id:$TRACE&size=1" | jq -r '.hits[0]._source | keys[]' | sort
第五项很实际:字符串字段在多数日志平台有长度上限,超长会被截断,于是"最长的那些异常信息"恰好被切掉。重要的不是记完整堆栈,而是记可定位的摘要 + 引用。
7. 边界:什么情况不必统一字段
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单服务、无跨服务调用 | 无需串联 | 只需基础字段 |
| 调试日志(非结构化) | 只给人看 | 不强制结构 |
| 临时脚本输出 | 生命周期短 | 自由格式 |
| 已有独立的追踪系统 | 关联由追踪承担 | 日志不必重复 |
判断口径一句话:这个字段会不会被另一个团队用来查询。会,它就必须进约定;不会,它只是本团队的习惯。
8. 自测
- 日志字段三层中,哪一层允许自定义、哪一层不允许?混淆两者会分别失去什么?
trace_id与request_id的差别是什么?重试场景下为什么必须区分?- 为什么"采集侧补字段"比"规范文档 + 评审"更可靠?
- 缺失字段为什么应当打标而不是丢弃?打标后能得到什么数字?
- 改变字段语义为什么比改名更危险?正确的过渡做法是什么?
- 时长字段带单位后缀的成本与收益分别是什么?