日志记录的 9 项最佳实践

译文 2025-10-30 16:22:15

我们一直在构建日益复杂的分布式系统,但在排查这些系统问题时,所依赖的工具却往往不过是功能增强版的printf语句。尽管日志记录从计算机发展初期就已存在,但仍有太多团队将其视为事后才需考虑的事项。

由此引发的后果大家都不陌生:因未删除调试日志而产生的高额云服务账单、花费整个下午却仍无法理清那些看似包罗万象却毫无实质内容的日志、本应通过工具自动完成的跨服务事件关联,却要手动去完成这一费力不讨好的工作。

本指南旨在解决这些问题。日志虽不能完全代表可观测性,但我们可以将其从散落在代码库中的非结构化字符串,转变为能带来切实洞察的有效信号。以下这份最佳实践清单将助你实现这一目标。

现在,让我们开始吧!

如果您有多个告警系统,想要聚合统一到一个平台,做降噪、升级等,可以试试 Flashduty On-call,好用的很,免费注册试用:https://console.flashcat.cloud 转眼创业 4 年了,得到了很多用户的认可,感谢大家一路同行。

1. 从结构化日志记录入手

在现代系统中,非结构化、字符串格式的日志是一种不良模式。如果你仍在编写那种仅为方便运维人员在服务器上使用grep命令查询的日志,那么不仅是落后于时代,更是在主动构建一个“不可观测”的系统。

日志本质上是数据,从创建之初就应被当作数据对待。这意味着每一条日志条目都必须是结构化的、可被机器解析的对象(JSON是目前通用的格式)。日志中的每一项信息都应成为独立的键值对,以便后续进行索引、查询和聚合操作。

摒弃以下这种文本块形式的日志输出:

[2025-07-24 07:45:10] INFO: Payment processed for user 12345 in 54ms. Request ID: abc-xyz-789

取而代之的,应输出可查询的数据记录:

{
  "level": "info",
  "timestamp": "2025-07-24T06:45:10.123Z",
  "message": "payment processed",
  "service": "billing-api",
  "duration_ms": 54,
  "user_id": "12345",
  "trace_id": "abc-xyz-789"
}

这种方式从根本上改变了你与日志交互的模式,让你摆脱繁琐的基于正则表达式的文本搜索,转而采用更强大的基于查询的分析方式。

借助结构化日志,你可以轻松解答各类问题,例如:“过去一小时内有多少笔支付操作失败?”“显示所有与请求ID abc-xyz-789相关的日志”,甚至“展示今天与用户12345相关的所有日志”,只需执行简单且快速的查询即可。

如今,大多数现代日志记录框架都支持或默认输出结构化日志,而且编程语言也开始将结构化日志作为核心功能,而非可选附加功能(例如Go语言的log/slog包)。

2. 通过OpenTelemetry确立可观测性约定

采用结构化日志记录只是第一步。如果没有统一的标准,不同服务间日志的字段名称和语义仍会混乱不一。

某个服务可能将用户ID记录为"user_id": "12345",另一个服务记录为"userId": "12345",还有一个服务则记录为"customer": { "id": "12345" }。当这种不一致性扩散到数十个服务中时,可观测性几乎会变得无从谈起——因为每个服务都在使用“不同的语言”。

要解决这个问题,你需要确立一套可观测性约定:为所有服务的遥测数据制定统一且强制执行的模式。而OpenTelemetry(简称OTel)正是实现这一目标的基础,它提供了通用的结构(日志数据模型)和通用的术语表(语义约定)。

好消息是,你无需替换现有的埋点工具。OpenTelemetry提供了两条清晰的路径(或称为“桥梁”),帮助你的日志符合约定要求:

对于你能掌控的应用程序,可以通过附加器(appender)或导出器(exporter),将OpenTelemetry直接与现有的日志库集成。该组件会拦截来自日志库的结构化日志,在内存中将其转换为OpenTelemetry模型,然后直接发送到收集器(collector)或后端系统。

对于无法修改的遗留系统或第三方系统,可允许它们继续向标准输出(stdout)或本地文件写入日志,再由OpenTelemetry收集器接收这些日志并将其转换为OpenTelemetry模型,之后转发到后端系统。

最后需要注意的是,没有强制执行机制的约定毫无用处。可以使用OpenTelemetry Weaver等工具,记录并强制执行核心属性模式,这些核心属性是每个服务都必须包含的。

随后,在CI/CD(持续集成/持续部署)流水线中通过自动化手段提供支持:如果构建过程中出现未经批准的属性,或遗漏了必需的属性,就自动让构建失败。这样一来,遵守约定就会成为工程标准中与生俱来的一部分。

3. 为日志补充充足的上下文信息

在确立约定之后,你必须确保每条日志都补充了足够的上下文信息,使其能独立发挥作用。没有上下文的日志消息只是无用的“噪音”。每条日志都应能解答关于其来源、范围和目的的关键问题。

你可以将上下文分为两个层面:

平台上下文

这是环境和请求级别的上下文,应自动附加到每一条日志中,无需开发人员付出额外努力。它能告诉你日志的来源以及相关联的对象。

这正是平台工程发挥作用的地方,而OpenTelemetry提供的工具能让这种自动化变得无缝衔接:

  • OpenTelemetry SDK会自动注入trace_idspan_id,将日志与生成它的特定请求关联起来。
  • OpenTelemetry收集器可以通过resourcedetectionprocessork8sattributesprocessor等处理器,自动检测并附加来自主机环境的元数据,例如cloud.provider(云服务提供商)、cloud.region(云区域)、k8s.pod.name(K8sPod名称)和service.version(服务版本)。

平台上下文是日志的基础。它确保即使是最基础的日志,也能追溯到特定的请求、服务实例,以及运行该实例的特定区域和具体版本。

事件上下文

这是只有应用程序代码才能知晓的业务级上下文。它是调试中最有价值的信息,也是最难处理得当的部分。例如,实体标识符和其他特定于领域的属性,这些信息能解释尝试执行的操作是什么、为何执行该操作。

你不应仅依赖人工操作来补充事件上下文。相反,应建立一种模式:将支持上下文的日志记录器(大多数日志框架都提供该功能)注入到请求生命周期中。

通过这种模式,在请求处理过程中确定相关标识符后,这些标识符会自动传递到所有下游日志行中,无需你付出额外努力。

通过将自动化的平台上下文与系统性注入的事件级上下文相结合,你生成的日志将“天生具备关联性”——它们不再是孤立的消息,而是能反映系统行为的丰富、连贯的叙事。

OWASP(开放式Web应用程序安全项目)日志记录指南提供了关于有用事件属性的可靠参考。但请务必在命名这些属性时遵循OpenTelemetry的语义约定,以确保日志保持一致性和互操作性。

4. 将日志级别用作可执行的信号

在日志记录领域,很少有话题能像日志严重级别的合理使用一样引发大量争议。

有些人主张简化处理:只使用INFO(信息)和ERROR(错误)两个级别,分别表示系统“正常运行”或“出现故障”。

从理论上看,这种方式似乎简洁明了,但在实际应用中,它会丢失过多细节。并非所有异常都是故障,也并非所有故障都需要通知相关人员处理。通过减少日志级别的数量,你会模糊重要的差异,无法区分可执行的信号和辅助性的细节。

采用更精细的日志级别(如DEBUG(调试)、WARN(警告)或FATAL(致命错误))会有效得多。这些级别能传递更明确的含义:

  • WARN通常表示出现了不寻常但需要处理的情况,但情况并不紧急(例如弃用警告);
  • ERROR标识实际发生的故障;
  • FATAL表示出现了无法恢复的情况,将导致进程终止;
  • DEBUG会捕获用于调查的详细信息,但默认不会在生产环境中输出,以免造成干扰。

要合理使用这些日志级别,需遵循以下原则:

  • 为避免“狼来了”的情况(即频繁发出警报导致真正重要的警报被忽视),日志级别应能反映问题的严重程度和可执行性。如果无需人员采取行动,就不应将其记录在会触发警报的级别中。
  • 日志记录中最难的部分是将详细程度调整到合适的水平。日志过多会增加成本、拖慢系统速度,并让工程师淹没在无用信息中;日志过少则会导致调试时缺乏有效信息。详细日志虽有其用途,但应限定范围、短期使用,绝不能作为默认设置。
  • 确保无需重新部署,就能实时调整日志的详细程度——无论是针对某个服务、某个模块,甚至是某个特定用户。这有助于你在调查实时事件时,生成恰好所需的详细信息。

日志级别并非日志的核心价值,但如果能统一、合理地使用,它们会成为强大的信号。它们能帮助你区分常规信息和异常情况,仅在最需要时才显示底层细节。将日志级别简化为两个,会毫无必要地丢失这种重要信号。

5. 避免敏感数据进入日志

日志记录中最大的风险之一,就是意外包含个人身份信息(PII)或其他敏感数据。在所有可能出现的日志错误中,这种错误最有可能让公司因负面事件登上新闻头条。

Twitter和GitHub曾发生过将明文密码意外写入内部日志的高调事故,这些案例深刻提醒我们:此类问题不仅后果严重,而且极易发生。

这类数据泄露很少是出于恶意目的。通常是因为开发人员专注于当前任务,没有意识到自己的日志记录选择会带来下游的安全隐患。

有效的防范措施必须是系统性、多层次的。你必须假设会出现人为失误,并设计能在多个环节拦截敏感数据的防护措施。

在应用程序层面,应避免记录可能包含敏感字段的整个对象。相反,应实现支持日志记录的安全表示形式,默认排除或屏蔽敏感数据。这意味着,对象的新属性可能需要先经过允许列表验证,才能被记录到日志中。

// User is a domain object that may contain sensitive fields.
type User struct {
	ID        string
	Email     string
	Password  string
	CreatedAt time.Time
}

func (u User) LogValue() slog.Value {
	// Only allowlist the fields considered safe to log.
	return slog.GroupValue(
		slog.String("id", u.ID),
		slog.Time("created_at", u.CreatedAt),
	)
}

对于临时构建的结构,大多数日志框架允许你配置“中间件”:在日志写入前,自动屏蔽或清除键名匹配黑名单的已知敏感字段。

最后,在OpenTelemetry收集器(或类似工具)中应用敏感数据清除操作,确保日志离开系统前不会有任何敏感数据遗漏。

# One of the most effective techniques is using an attribute allowlist
processors:
  redaction/allowlist:
    allow_all_keys: false
    allowed_keys:
      - http.method
      - http.url
      - http.status_code

service:
  pipelines:
    logs:
      receivers: [...]
      processors: [attributes/allowlist, ...]
      exporters: [...]

日志中包含敏感数据的错误很容易发生,但通过多层次防护,你可以大幅降低其影响。最有效的解决方案不是追究个人责任,而是培养一种文化:让团队共同对可观测性数据的质量和安全性负责。

6. 将日志记录性能视为核心关注点

人们有时会忽略一个事实:日志记录并非“无成本”操作。每一条日志都会消耗CPU、内存和I/O资源。在小规模系统中,这种消耗几乎无法察觉,但在大规模系统中,它可能会成为真正的性能瓶颈。

要防止日志记录拖慢服务速度,可采用以下做法:

选择高性能的日志库

不同日志库的性能差异很大。首先应选择现代、高效的日志库,这类库在设计时就以最小化开销为目标。例如,在Go语言生态中,内置的log/slog包或Zerolog、Zap等第三方库,其性能比Logrus等旧版库高出多个数量级。

采用异步日志记录方式

不要让应用程序的主线程等待日志写入磁盘或网络。相反,应采用异步日志记录:将日志消息写入快速的内存缓冲区,由单独的后台进程处理速度较慢的I/O操作。这样一来,日志记录对请求延迟的影响几乎可以忽略不计。

关注高频代码路径

避免在高频循环或关键代码路径中添加详细的日志语句。如果需要在这些区域记录诊断信息,可采用智能采样或速率限制的方式——在收集必要洞察的同时,避免日志量淹没系统。

延迟执行高开销操作

日志记录中一个更隐蔽的性能问题,在于日志参数的计算方式。例如,在Python中,logger.debug(f"Processing {x}")这类日志语句,即使DEBUG级别已被禁用,仍会先计算格式化字符串。

更好的写法是logger.debug("Processing %s", x):这种方式会延迟字符串格式化操作,直到确认需要输出该日志消息时才执行,从而在关键代码中节省宝贵的计算资源。

总而言之,应像对待业务逻辑一样对待日志记录代码。如果你不会在关键路径中阻塞请求或分配不必要的内存,那么也不应让日志库这样做。

7. 智能管理日志量和成本

放任日志量无限制增长,是快速消耗成本、淹没系统并让工程师无法获取有效信息的常见方式。在大规模系统中,日志的采集和存储成本每年可能高达数百万,而大量无用的日志流会掩盖事件排查中真正需要的信号。

解决办法不是停止记录日志,而是“更聪明地记录日志”。最佳起点是从源头削减“高数量、低价值”的日志。一个典型例子是负载均衡器的成功健康检查日志:这些信息作为指标(metric)远比作为无休止的日志行更有价值,后者几乎无法提供额外信息。

若要进一步控制日志量,某些日志框架允许将日志持续记录到内存环形缓冲区中。在正常情况下,缓冲区会不断覆盖旧数据,因此不会有日志数据离开内存;但当系统出现错误时,缓冲区中的数据会与错误日志一起导出——这样既能在系统正常运行时保持低日志量,又能在出现问题时捕获故障发生前的上下文信息。

另一种有效的方法是日志采样。很少有场景需要保留每一条日志,尤其是对于常规事件。例如,你可以保留所有失败请求的日志,但对于成功请求,每100条仅保留1条——这样既能获得具有代表性的视图,又不会增加过多负担。

日志采样示例

日志采样在级联故障(即一个故障引发多个后续故障)场景中也很有帮助:当某个服务开始重复输出相同错误时,你无需保留一百万条相同的日志条目。几条有代表性的样本就能清晰反映问题,同时不会淹没系统或大幅增加成本。

需要注意的一点是,OpenTelemetry目前尚未原生支持日志采样——其采样策略仅适用于追踪(trace)数据。因此,你需要通过日志框架或可观测性流水线来实现日志采样功能。

8. 让OpenTelemetry收集器成为日志流水线的核心

对于小型系统,直接从应用程序向后端传输遥测数据可能可行,但这种方式无法扩展。随着系统规模增长,它会导致与供应商的强耦合、性能瓶颈、高数据丢失风险,以及其他运维难题。

更好的架构是将OpenTelemetry收集器置于可观测性流水线的核心位置。收集器是一个高性能、与供应商无关的服务,它能接收所有遥测数据、对数据进行处理,然后将其路由到多个后端系统。

通过收集器,你可以在统一位置执行以下操作:强制执行标准、屏蔽敏感字段、标准化数据格式、过滤无用日志流,或自动附加环境元数据。

此外,由于收集器能同时处理日志、指标和追踪数据,它可以完全替代边缘节点上仅用于日志的代理(如Fluent Bit、Logstash或Filebeat),为你提供统一的流水线,而非由多个单一用途工具拼凑而成的复杂体系。

这种架构的优势在于灵活性:你是否希望将安全事件发送到一个后端,将应用程序日志发送到另一个后端?是否希望在生产环境中丢弃DEBUG级日志,但在测试环境中保留它们?是否希望在无需开发人员参与的情况下,为日志添加新的Kubernetes元数据?只要以收集器为核心,所有这些需求都只需通过配置更改即可实现,无需启动专门的工程项目。

9. 选择原生支持OpenTelemetry的后端系统

你已经完成了艰巨的工作:实现了结构化日志记录、通过OpenTelemetry确立了日志模式、为日志补充了丰富的上下文信息,还构建了以收集器为核心的稳健流水线。最后一步,是确保你的可观测性平台能充分利用这些投入。

此时,选择原生支持OpenTelemetry的平台就变得至关重要。原生支持OpenTelemetry的平台不仅是能接收OTLP(OpenTelemetry协议)数据的后端,其整个数据模型都围绕OpenTelemetry标准构建。

这意味着它天生就能理解不同信号(日志、指标、追踪)之间的内在关联,并将语义约定视为核心要素,而非普通的标签集合。

例如,它知道db.query.text不只是一个字符串,而是数据库查询语句——它能解析该语句以识别操作类型、标记缓慢查询,并提供数据库性能的预制仪表板。Dash0正是基于这些原则构建的平台。

通过将高质量的日志埋点与使用相同“原生语言”的平台相结合,你不仅能获得更优质的数据,还能实现更快速的调试、更清晰的洞察,以及更可靠的系统——这正是你所有努力应得的回报。

结语

如果日志记录工作做得好,它将从“昂贵的负担”转变为“提升效率的推动力”——它能缩短事件响应时间、减少运维开销,并提高系统的可靠性。

下次生产环境出现故障时(故障总会发生),日志的质量将决定你是在黑暗中盲目猜测,还是能胸有成竹地排查问题。

作者:Ayooluwa Isaiah
原文:https://www.dash0.com/guides/logging-best-practices

标签: Logging
快猫星云 联系方式 快猫星云 联系方式
快猫星云 联系方式
快猫星云 联系方式
快猫星云 联系方式
快猫星云
OpenSource
开源版
Flashcat
Flashcat