自建日志组件的最低要求与集成测试清单

· · Windows Development, Logging, Integration Testing, Test Design, Reliability

如果能够使用现成的logging框架,那当然更安全。不过,由于应用层面的限制或运维上的现实情况,有时依然无法避免自行实现logger。这时最先让人犯难的是,到底要实现到什么程度,才能做到”既不过于粗糙,又不过于沉重”的设计。

本文把对象范围限定在用于故障排查的应用程序日志上。不一次性背负审计追踪、分布式追踪、指标平台、云端聚合等需求,而是先定义现场真正用得上的最小组合,再整理为了让这套组合真正值得信赴、需要覆盖哪些集成测试要点。

先说结论

第一版应该守住的要点如下。

  • 格式采用UTF-8JSON Lines
  • 保持一行一条记录不被破坏
  • 必需字段为时间戳levelcategorymessage结构化fieldssessionIdprocessId
  • 基本原则是一个进程对应一个文件
  • 负载较低时采用同步写入,负载较高时采用single writer + bounded queue
  • Error / Critical以及会话的开始与结束要进行同步flush
  • 轮转与保留从v1阶段就要纳入
  • 存储位置不可用时,不要悄悄改写到其他位置

收敛到这个程度,在实现和运维两方面都不容易崩溃。

首先缩小对象范围

自建logger容易变得棘手,多半是因为一开始就想把所有需求都揽进来。如果想用同一套机制一次性覆盖诊断日志、审计日志、性能测量、分布式追踪、用户行为分析,需求会一下子膨胀起来。

这里讨论的对象,是用于排查应用故障的诊断日志。也就是优先保证事后能够追溯”何时”、”在哪个处理环节”、”发生了什么”、”当时处于怎样的上下文”。仅仅把范围缩小到这里,第一阶段的设计判断就会轻松很多。

最低限度需要满足的要求

1. 格式采用UTF-8 JSON Lines

即使用纯文本拼接的方式,也能保留日志,但之后很难以程序化的方式处理。反过来,如果一开始就采用沉重的自定义二进制格式,运维时的可观测性又会下降。

作为折中方案,UTF-8JSON Lines比较容易处理。只要一行对应一条记录,作为文本阅读也很直观,之后用脚本或工具解析也很方便。即使写入过程中被截断,也容易分辨出损坏的是哪一行,这一点非常适合实务场景。

2. 先固定必需字段

至少应该具备的字段是以下这7个。

  • timestamp
  • level
  • category
  • message
  • fields
  • sessionId
  • processId

如果只把日志做成message这一个字符串字段,之后搜索条件一旦增多就会很麻烦。反过来,字段加得太多,又会让调用方的负担骤增。最初先固定在这个范围,等真正需要时再考虑扩展会更稳妥。

3. 以”一个进程一个文件”为基本原则

让多个进程向同一个文件追加写入的设计,事故隐患远比看起来的多。因为互斥控制、部分写入、轮转时机、异常终止时的处理会一下子变得棘手。

请先以一个进程一个文件为基本原则。如果想把多个进程的日志汇总起来,应该在后段进行聚合,或者明确建立一个专门的聚合进程,这样更安全。

4. 写入策略按负载区分

日志量较少的阶段,同步写入更直观,也更便于排查故障。强行改为异步化,反而容易丢失结束前的日志,或让异常发生时的flush条件变得模糊。

另一方面,当日志量增大、同步I/O成为瓶颈时,就应该采用single writer + bounded queue。这时重要的是提前决定队列溢出时的策略。不要把丢弃旧日志、丢弃新日志、还是发出警告这个问题留在模糊地带。

5. 确定flush条件

ErrorCritical,以及会话开始与结束的日志,采用同步flush会在故障排查时很有帮助。但如果连日常的Info日志也全部flush,就会拖慢速度,因此不把所有级别一视同仁才是现实的做法。

6. 轮转与保留从v1阶段就要纳入

轮转常被认为”以后再加也来得及”,但一旦进入运维阶段,就会突然变得棘手。按大小、按天、按每次启动等方式都可以,但至少要确保”不会无限增长”以及”保留几份”这两点是明确的。

7. 存储失败时不要擅自改写到其他位置

当日志的存储位置不可用时,悄悄改写到其他地方的设计,会让日后的排查变得困难。运维人员在”应该存在的位置”找不到日志,故障应对的初期动作就会延迟。

如果无法保存,就应该通过应用内通知、事件日志、标准错误输出等明确可辨识的手段,把失败暴露出来。至少要避免出现”不知道日志跑去哪里了”这种状况。

v1最小组成的大致印象

第一版往往做到以下程度就足够了。

  • UTF-8 JSON Lines
  • 一个进程一个文件
  • 以会话为单位的文件名
  • 基于大小或每次启动的轮转
  • 保留份数的上限
  • Error / Critical的同步flush
  • 能够接收结构化fields的接口

超出这些范围的功能,等实际运维中真正遇到困扰之后再添加,反而更容易长期维护。

常见的反面例子

以下也列举一些应当避免的典型做法。

  • 把所有内容都塞进message字符串
  • 多个进程共享同一个文件
  • 不确定flush条件就全面改为异步化
  • 把轮转和保留往后拖延
  • 存储失败时悄悄改写到其他目录
  • 把网络传输或本地数据库存储一次性都塞进v1

这些做法乍看都很方便,但都容易让排查和运维变得更沉重。

集成测试应基于真实文件、真实线程、真实进程来考虑

logger是仅靠单元测试难以让人安心的组件。因为只验证字符串格式化和JSON化,无法捕捉到实际运维中才会暴露的I/O、并发、轮转、结束时flush、权限错误等问题。

因此,集成测试需要使用真实文件、真实线程,必要时还要用真实进程来确认。至少要避免出现”平时都能通过,但故障时不可信”这种状态。

应该覆盖的集成测试项目

单次写入的健全性

  • 一行是否对应一条JSON记录
  • 是否能以UTF-8重新读取
  • 必需字段是否每次都齐全
  • 是否因换行混入而破坏成多行

同一进程内的并发执行

  • 多个线程同时写入时记录是否会损坏
  • 记录数量是否存在多余或缺失
  • 使用队列时,顺序与丢弃策略是否符合规格

flush与结束时的行为

  • Error / Critical是否立即生效
  • 正常结束时队列内是否已清空
  • 在接近异常终止的路径下,必要的结束日志是否仍能保留

轮转与保留

  • 达到轮转条件时能否切换到新文件
  • 超过保留上限的旧文件是否按规格被删除
  • 轮转前后JSON行是否不会损坏

异常场景

  • 存储目录不存在时的处理
  • 没有写入权限时的处理
  • 磁盘已满等场景下失败时的通知或返回值
  • 队列溢出时的行为

多进程场景的处理

如果规格是一个进程一个文件,那么”其他进程不会试图写入同一个文件”这件事本身,就可以作为验证对象。反之,如果采用聚合进程的方式,则还需要连同转交失败的场景一起验证。

v1阶段至少要通过的测试数量

一开始就想全部覆盖,会让测试变得过于沉重。v1阶段至少应该通过的,大致是以下这6项。

  1. 单线程下的正常写入
  2. 多线程同时写入
  3. Error / Critical的flush
  4. 轮转与保留
  5. 存储位置异常时的失败通知
  6. 正常结束时的drain与最终flush

只要这6项测试通过,就能明显摆脱”字符串确实输出了,但运维时不可信”的logger状态。

总结

自建logger的第一个目标,不是功能丰富,而是”故障时可以被信赖”。为此,把格式固定为UTF-8 JSON Lines、收窄必需字段、以一个进程一个文件为基本原则,并提早确定flush、轮转、保留、失败时的行为,都是有效的做法。

而这套设计是否真正能够发挥作用,必须通过使用真实文件、真实线程、真实进程的集成测试来验证。与其一开始就把实现做大,不如先把最小组成和最低限度的测试集固定下来,之后再逐步扩展会更顺畅。

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

面向 BtoB 企业的 Google 广告小预算运营 ── 用每月数万日元做出成果的设计与每周操作流程

本文面向希望以每月数万日元预算启动 Google 广告的 BtoB 企业,是一份实务指南。内容整理了平均每日预算与结算上限的运作原理、转化跟踪的搭建、聚焦高意向关键词、否定关键词与搜索字词报告的每周运营方法,以及小预算下的出价策略选择,均以 Google 官方资料为依据。

常见问题

汇总了咨询这一主题时常见的问题。

自建logger的日志格式应该选什么?
推荐使用UTF-8的JSON Lines,保持一行一条记录不被破坏的格式。纯文本拼接后续难以用程序化方式处理,而自定义的二进制格式又会降低运维时的可观测性。JSON Lines既能以文本方式阅读,也便于脚本和工具解析,即使写入过程中被截断,也容易分辨出哪一行损坏,因此更适合实务使用。必需字段固定为timestamp、level、category、message、结构化fields、sessionId、processId这7项。
日志写入应该用同步还是异步?
应按负载区分。日志量较少的阶段,同步写入更直观,也更便于排查故障。强行改为异步化,反而容易丢失结束前的日志,或让异常发生时的flush条件变得模糊。当日志量增大、同步I/O成为瓶颈时,可以采用single writer + bounded queue的方案,并且要提前决定队列溢出时的策略:是丢弃旧日志、丢弃新日志,还是发出警告。Error/Critical以及会话开始与结束的日志建议采用同步flush,这样在故障排查时会很有帮助。
可以让多个进程写入同一个日志文件吗?
应该避免。让多个进程向同一个文件追加写入的设计,会让互斥控制、部分写入、轮转时机、异常终止时的处理一下子变得棘手,事故隐患远比看起来的多。基本原则是一个进程对应一个文件,如果想把多个进程的日志汇总起来,应该在后段进行聚合,或者明确建立一个专门的聚合进程,这样更安全。
自建logger的集成测试应该确认哪些内容?
需要用真实文件、真实线程、真实进程来验证。仅靠字符串格式化和JSON化的单元测试,无法捕捉到实际运维中才会暴露的I/O、并发、轮转、结束时flush、权限错误等问题。v1阶段至少要通过6项测试:单线程下的正常写入、多线程同时写入、Error/Critical的flush、轮转与保留、存储位置异常时的失败通知,以及正常结束时的drain与最终flush。只要这6项通过,就能明显摆脱「运维时不可信」的logger状态。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表