阶段三 · Logging 日志

结构化日志

一句话总结

日志回答排障的最后一问--"为什么".但只有结构化日志(机器可解析的键值对,而非供人阅读的自然语言句子)才能被高效检索,聚合与关联;再为每条日志附上 Trace ID,即可将日志与指标,追踪拼接为完整的故障分析链路.

前置回顾

第 01 篇确立了排障主线:指标发现"出事了",追踪定位"在哪个环节",日志归因"为什么";同时也指出了日志的特点--细节最丰富,但成本最高,结构最杂乱.阶段二已将指标这一支柱讲透.现在进入日志阶段:本篇先解决"日志本身应如何编写才能有效用于排障",第 05 篇再解决"海量日志如何存储与查询".

非结构化日志的局限

先看一条典型的传统日志:

2026-06-29 03:14:22 ERROR User 88291 failed to place order 7731 after 3 retries, payment gateway timeout

人类阅读没有问题.但在排障过程中,实际需要回答的是以下问题:

  • "过去 1 小时内,所有支付超时的错误共有多少条?"
  • "用户 88291 今天所有的操作日志,按时间排序"
  • "哪个服务的订单失败率最高?"
  • "这条错误对应的那次请求,其完整调用链路是什么?"

面对上述纯文本日志,这些问题都难以高效回答--因为信息嵌入在自然语言句子中.机器要回答"所有支付超时",需要用正则表达式匹配 "payment gateway timeout" 这个字符串;要按用户聚合,需要从 "User 88291" 中解析数字.一旦日志格式不统一(不同开发者写法各异),正则匹配将难以维护.非结构化日志的根本缺陷在于:其设计目标是人类阅读,而非机器查询.

结构化日志:将句子拆解为字段

结构化日志(Structured Logging)的做法:不再拼接自然语言句子,而是输出机器可直接解析的键值对,通常采用 JSON 格式.同一条日志改写为:

{
"timestamp": "2026-06-29T03:14:22Z",
"level": "error",
"service": "order-service",
"msg": "failed to place order",
"user_id": "88291",
"order_id": "7731",
"retries": 3,
"error": "payment_gateway_timeout",
"trace_id": "a1b2c3d4e5f6"
}

上述问题现在全部转化为简单的字段过滤:

  • "所有支付超时" → error = "payment_gateway_timeout"
  • "用户 88291 的操作" → user_id = "88291"
  • "按服务看失败率" → group by service
  • "这次请求的完整链路" → 使用 trace_id 去追踪系统查询(详见下文)

非结构化日志如同一叠手写便签:每张写法不同,查找信息只能逐张翻阅,凭经验推断.
结构化日志如同填写规范的表格:字段对齐,列名统一,筛选,排序,统计均可通过查询语句直接完成.第 05 篇介绍的日志系统,本质上就是"能对这张表格执行查询的数据库".

核心原则:日志面向机器查询,而非面向人类阅读

这是从"传统日志"到"可观测性日志"最重要的观念转变.在分布式系统中,几乎不会逐行阅读日志--日志量巨大到根本无法人工通读.实际排障流程总是先查询和聚合(过滤出相关的几十条),再细看具体内容.

基于这一事实,日志的首要服务对象是查询引擎,而非人眼.结构化正是为高效查询而设计.

日志等级:控制信息密度的第一道过滤

日志等级(Log Level)决定一条日志的重要程度,是控制噪声的第一道机制.常用等级从轻到重:

等级 含义 典型用途
DEBUG 调试细节 开发期排查,生产环境一般关闭
INFO 正常的关键事件 服务启动,请求处理完成,关键状态变更
WARN 异常但可恢复 重试成功,降级,配置缺省回退
ERROR 出错,需要关注 请求失败,依赖不可用
FATAL 致命,进程即将终止 无法启动,不可恢复的崩溃

关键实践:生产环境的日志等级应当支持动态调整.日常运行时使用 INFO 级别,排查特定问题时临时切换至 DEBUG 获取详细数据,查完后恢复.这样既避免了日常被 DEBUG 日志充斥(同时也节省存储成本,详见第 05 篇),又能在需要时获取完整的调试信息.

两种常见的等级误用

  1. 所有日志都使用 INFO:将本属于 DEBUG 级别的细节也以 INFO 输出,生产日志被大量低价值信息淹没,真正重要的事件反而难以发现.
  2. 用 ERROR 记录"预期内的失败":例如用户输入错误密码属于正常业务流程,却以 ERROR 级别记录,导致 ERROR 告警频繁误触发,最终运维人员不再关注 ERROR 级别的告警--这是"告警疲劳"在日志层面的体现(第 08 篇将详细讨论).

日志等级应反映真实的严重程度,而非随意指定.

字段规范:团队需要统一的字段命名标准

结构化日志的能力建立在字段名统一的基础上.如果服务 A 使用 user_id,服务 B 使用 userId,服务 C 使用 uid,那么"按用户聚合所有服务的日志"这一操作将无法实现.团队需要制定一份字段命名约定.以下为几乎必须具备的核心字段:

字段 作用
timestamp 时间(使用 ISO 8601/RFC3339 格式,带时区,避免本地格式)
level 日志等级
service 产生日志的服务名称(微服务架构中至关重要)
msg 人类可读的简短描述
trace_id / span_id 关联追踪信息(下一节详述)
业务字段 user_id / order_id 等,按约定命名

使用上下文注入避免重复编写

每条日志都需手动填写 service,trace_id 等字段吗?不必.成熟的日志库支持上下文注入(Context Injection):在请求入口处将 trace_id,user_id 等绑定到日志上下文(通常借助 Go 的 context,Java 的 MDC 等机制),此后该请求范围内的所有日志自动附带这些字段.

开发者只需编写业务相关信息,公共字段由框架统一注入--这也是保证字段一致性的工程手段.

Trace ID:连接三大支柱的关键纽带

这是本篇与整个系列衔接的核心章节.回到第 01 篇的排障场景:通过指标发现"下单延迟异常",通过追踪定位到"延迟发生在支付服务".现在需要查看"支付服务中那一次具体请求的日志细节"--即找到那次特定请求在支付服务中产生的日志.

如何查找?如果每条日志都带有 Trace ID(同一次请求在所有服务间共享的唯一标识,其生成与传播机制将在第 06 篇详述),问题就变得简单:

[指标告警<br/>下单 P99 超过 2s] → [追踪定位<br/>延迟在支付服务<br/>trace_id=a1b2c3] → [日志归因<br/>过滤 trace_id=a1b2c3<br/>→ 获取该次请求的全部日志详情]
# 在日志系统中,一条查询即可拉出该次请求的完整日志序列:
trace_id = "a1b2c3"

→ order-service: received order, calling payment
→ payment-service: calling third-party gateway
→ payment-service: gateway returned 429, retrying (1/3)
→ payment-service: gateway returned 429, retrying (2/3)
→ payment-service: success after 1823ms ← 根因:第三方限流导致重试,拖慢了整体延迟

Trace ID 是缝合三大支柱的连接线

第 01 篇指出三支柱的痛点是"数据割裂,需依赖人工进行关联".Trace ID 正是将其串联起来的那根线:指标图表上的一个异常点 → 关联到具体的 trace_id → 使用同一个 trace_id 既能在追踪系统查看调用瀑布图,又能在日志系统查看每个调用跳的详细日志.

没有这根线,就只能通过"时间戳大致匹配"来猜测关联关系,效率极低.因此,"每条日志携带 trace_id"并非可选优化,而是现代可观测性的基本要求--第 08 篇将进一步展开这一主题.

采样与成本:日志的取舍策略

第 01 篇指出日志"成本最高".具体来说,一个中等规模的系统每天产生TB 级别的日志是常见情况.全量存储并建立全量索引的成本非常可观(第 05 篇将看到这是 EFK 方案的主要痛点).因此需要有明确的取舍策略:

  • 提高日志等级:生产环境关闭 DEBUG 级别,是最直接的减量手段
  • 采样(Sampling):对高频,重复性日志(如健康检查,正常的 INFO 日志),仅保留一部分.但错误日志(ERROR)绝不能采样--它们数量稀少且信息密度极高,丢失一条即可能丢失关键线索
  • 分级存储:近期日志存放在可快速查询的热存储中,历史日志归档至低成本的冷存储(对象存储)

采样的基本原则:保留异常,稀释正常

采样并非"无差别地丢弃一半数据".正确的策略是按信息价值区别对待:正常路径的日志(成功请求,健康检查)信息密度低且量大,可以大胆采样;错误,慢请求,异常路径的日志信息密度极高,应全量保留.

这一"保留异常,稀释正常"的思路,在第 07 篇追踪的"尾部采样(Tail-Based Sampling)"中将再次出现--它是可观测性应对海量数据的通用策略.

快速回顾

  • 非结构化日志的缺陷:面向人类阅读,信息嵌入在句子中,机器难以过滤/聚合/关联
  • 结构化日志:输出机器可解析的键值对(JSON),将"阅读句子"转变为"查询字段";核心原则--日志面向机器查询,而非面向人类阅读
  • 日志等级反映真实严重程度,生产环境应支持动态调整;避免所有日志使用 INFO 级别,避免用 ERROR 记录预期内的业务失败
  • 字段规范:团队统一"列名"(timestamp/level/service/msg/trace_id + 业务字段),通过上下文注入保证一致性并减少重复编写
  • Trace ID 是连接三支柱的纽带:每条日志携带 trace_id,才能从指标异常→追踪定位→日志细节完整贯通,这是基本要求
  • 成本与采样:日志成本最高;通过调整等级,采样和分级存储进行控制;采样基本原则是保留异常,稀释正常,ERROR 级别日志绝不采样

结构化日志采用 JSON 格式,人类直接阅读不如纯文本方便,本地开发时如何处理?

这是一个真实的使用体验权衡,解决方案是按环境区分输出格式:本地开发使用人类友好的彩色文本格式(多数日志库支持通过配置切换为 console 输出),方便边编写边查看;生产/测试环境输出 JSON,提供给日志系统处理.同一套日志代码,通过配置切换输出格式即可,无需在两种需求之间做出妥协.

日志,指标,追踪在信息上似有重叠,能否仅使用日志并从日志中计算出指标?

技术上可行(从日志聚合出 QPS,错误率),部分系统也确实采用了这一方案.但需要权衡:基于日志计算指标成本高,延迟大(需处理海量文本),远不如专用指标系统(Prometheus)那样低成本,实时性强.反之,指标也无法替代日志提供的细节信息.务实的做法仍是各取所长:指标用于高频实时的健康度监控,日志用于低频深入的根因归因,两者通过 trace_id 关联.第 01 篇提及的"以结构化事件统一数据底座"是更前沿的方向,但就工程成熟度和成本而言,当前大多数团队仍维持三支柱的分工格局.

动手练习

  1. 改写传统日志为 JSON:从项目中选取一段传统文本日志,手动将其改写为结构化 JSON,标注哪些信息被提取为独立字段.
  2. 使用结构化日志库:使用所用语言的结构化日志库(Go 的 slog/zap,Python 的 structlog 等)输出若干条 JSON 格式日志,确认字段对齐.
  3. 实现上下文注入:在请求入口绑定一个 request_id,使该请求范围内的所有日志自动附带该字段,验证无需每行手动编写.
  4. 验证 Trace ID 串联:为日志添加 trace_id 字段(先用任意值模拟),然后使用 grepjq 按 trace_id 过滤出"同一次请求"的所有日志,体会 Trace ID 串联的效果.
  5. 审视日志等级使用:审视服务中日志等级的使用情况:是否存在"所有日志使用 INFO"或"用 ERROR 记录正常业务失败"的情况?列出应当调整等级的条目.