[译]像专家一样来写日志

像专家一样写日志

原文连接

日志就像犯罪现场的证据,而开发者就像 CSI 。日志在开发者调查 Bug 和服务器停摆的原因之间扮演了一个非常关键的角色,就像证据缺乏导致悬案一样,缺少有意义的日志会导致排错变得困难甚至不可能。我曾经见过一些人纠结于排错工具(像 tcpdump 和 strace ) 或者在服务器接近崩溃的时候向服务器部署新的代码以求获得更多的日志来分析问题。

就像一句老话"磨刀不误砍柴工"(译者注:原话是"A beard well lathered is half shaved" 直译是软化好的肥皂等于剃须的一半工作,取了其他人的翻译)所有的专业开发者必须知道如何有效的写日志,这篇文章不仅仅是针对如何提供一个好的应用日志记录实践,同时也解释了原因,比如什么时候记录日志,应该记录什么日志。

如何debug

在谈论 ”程序是什么” 以及 “如何记录日志以方便debug” 之前很难讨论如何记录日志。

像变换状态一样编程。

程序就是一系列的状态变换

状态就是程序在某个时间节点在内存中存储的的东西,程序的代码定义了程序是如何从一个状态转换到另外一个状态的。使用命令式编程语言的开发者(比如 java )更多关注的是存储过程而不是状态。但是,把程序认为是一些系列的状态是一种非常重要的思想,因为状态可以洞悉程序应该做什么而不是程序怎么样做到的这些。

举个例子,如果一个机器人要给我的汽车油箱加满油,如果用状态变换来解释不过是从 (tank=empty, money=$50) 的状态变换到 (tank=full, money=$15) 的状态,如果我们用详细的过程来描述:机器人需要找到加油站,把车开到加油站,付款。这个过程虽然非常重要,但是状态变换可以更加直观的衡量程序的正确与否。

Debug

Debug 是在思维上对状态变换的的重现过程,在脑海中重放程序接受了哪些输入,经过了一系列的状态变换然后产生输出结果,以此来判断究竟是哪里出现了错误,在开发中,开发者使用debug工具来支持他们脑海中的程序运行,但是在生产环境,很难使用debug工具,更多的是使用日志。

日志应该写什么

有了debug的定义, 日志应该写什么就变得很直白了。

日志应该包含充足的信息来保证我们重建状态变换

捕捉所有时间节点上的状态是不切实际且不重要的,警察只需要大概画像而不是全息影像来抓捕罪犯。对于日志来说同理,开发者需要在关键的状态变换的时候记录下日志,而且日志需要记录下关键的特征以及当前状态以及状态变换的原因。

关键状态变换

并不是所有的状态变换值得记录,识别关键状态的关键在于把执行的程序看成一连串的状态变化,将状态转换分为各种阶段,然后再关注阶段的变换。

举个例子,

假设一个app启动有三个阶段

  • 加载配置

  • 连接依赖

  • 启动服务

在程序启动和结束的时候记录日志是非常有必要的,当应用导入依赖挂了的时候,日志可以非常清晰的显示,程序加载了它的配置,进入了依赖连接状态,但是没有完成,有了这些,开发者可以很快发现错误。

关键特性

记录日志就像描绘你的程序,只有核心的业务逻辑应该被捕捉。当有像PII(个人信息)这样的敏感数据的时候,你需要记录日志,但是必须混淆他们,或者小心翼翼的记录这些信息。

举个例子,当 http 服务器从 “等待请求状态” 转换到 "请求接受" 的状态的时候,应该记录HTTP的请求方式以及URL,其他的元素,比如请求 header 以及请求 body 只有在影响到业务逻辑的时候才被记录下来。 举个例子,当服务器状态变换明显比如从 Content-Type:application/json 变换到 Content-Type:multipart/form-data 的时候,请求 header 才应该被记录下来。

状态变换的原因

记录下状态变换的原因对于 debug 非常有用。日志应该简单包含状态变换前后的原因并且解释为什么,他们可以帮助开发者连接各种事件点以及领导他们去重建程序的执行过程。

示例

一个简单的例子:假设服务器收到了异常的 SSN 号码,开发者想要记录这些。

一些反模式的日志缺乏关键状态以及原因:

  • [2020–04–20T03:36:57+00:00] server.go: Error processing request

  • [2020–04–20T03:36:57+00:00] server.go: SSN rejected

  • server.go: SSN rejected for user UUID “123e4567-e89b-12d3-a456–426655440000”

这包含了一些信息,但是并不能解决开发者在看到日志的一些问题,比如:什么样的请求导致了服务的问题? 为什么 SSN 号被拒绝了? 哪个用户被影响到了?一个好的 debug 应该是这样的:

[2020–04–20T03:36:57+00:00] server.go: Received a SSN request(track id: “e4a49a27–1063–4ab3–9075-cf5faec22a16”) from user uuid “123e4567-e89b-12d3-a456–426655440000”(之前的状态), rejecting it(接下来的状态) because the server is expecting SSN format AAA-GG-SSSS but got **-***(为什么)

谁应该写日志

很多情况下"谁"在写日志会导致你陷入一些陷阱,从一个错误的函数内写入日志会导致日志包含了很多重复或者无用的信息。

将程序视为抽象的一层

很多构造的很好的现代程序就像层层抽象的金字塔,高层的类/函数把复杂的任务分成很多小任务,底层的 类/函数就像黑盒一样抽象执行这些小任务以及向高层提供接口以供他们调用。这个方式让编程非常简单,因为每层只需要关注与它本身的逻辑而不需要去担心整体脉络。


举个例子,一个网站应该由:业务逻辑层,HTTP层,TCP/IP层层构成。当响应一个URL请求的时候,业务逻辑层关注于展示哪个网页以及把网页内容传递给HTTP层,HTTP层把内容打包成一个HTTP响应,接下来TCP/IP层把HTTP响应拆分成TCP包并发送出去。

不要在错误的层级写入日志

作为抽象层级的结果,不同的层级对于正在进行的任务有不同的信息,在之前的例子中, HTTP 层对 TCP 层的包裹如何发送并不知情,更不知道用户在请求这个 URL 时候的意图,当选择记录日志的时候,开发者需要选择对的层级,以及对于整体的状态变换和原因有正确的认知。

在我们的 SSN 验证规则例子里,验证 SSN 的逻辑跳转到了一个 SSN 验证器类里:

public class Validator {
    // other validation functions ...
 
  public static void validateSSN(String ssn) throws ValidationException {
    // do the validation
    String regex = "^(?!000|666)[0-8][0-9]{2}-(?!00)[0-9]{2}-(?!0000)[0-9]{4}$";
    Pattern pattern = Pattern.compile(regex);
    Matcher matcher = pattern.matcher(ssn);
 
    if (!matcher.matches()) {
      // --> Log Location A <--
      logger.info("Bad SSN blah, blah, blah...");
      throw new ValidationException(String.format("expecting SSN format AAA-GG-SSSS but got %s", ssn.replaceAll("\\d", "*")));
    }
  }
}复制代码

有另外一个函数验证并且更新用户信息,它调用了 SSN 验证器:

public class Validator {
    // other validation functions ...
 
  public static void validateUserUpdateRequest(UserUpdateRequest req) {
    // validate other attribute of req ...
 
    try {
      validateSSN(req.ssn);
    } catch (ValidationException e) {
      // --> Log Location B <--
      logger.info(String.format("Received a user update request(track id %s) from user uuid %s, rejecting it because %s", req.trackID, req.uid, e.getMessage()));
      // other error handling logic ...
    }
 
    // other logic ...
  }
}复制代码

有两个位置,位置A 和 位置B 来写日志,但是位置B有充足信息来写日志,在位置A,程序并不知道正在响应什么请求,也不知道哪个用户正在请求,日志并没有多少帮助而且增加了冗余,validateUserUpdateRequest来抛出错误给调用者(validateRequest) 更为合理,因为调用者包含更多的上下文来写日志。

尽管如此,这并不意味着在底层写入日志是不重要的,特别是当底层无法将错误暴露给更高层级的时候。举个例子,网络层包含了重试机制,这意味着更高层级并不会意识到网络问题。总体上看,底层的程序可以更多的写入 DEBUG 级别的日志而不是 INFO 级别来避免冗余,如果需要,开发者可以调整更高的日志级别来获取更多信息。

应该写入多少日志?

对于有用的信息和日志容量有一个明显的交换(trade off),越多的有意义的日志写入,越容易重现状态变换,有两个方法来帮助你控制日志量:

预估日志和你工作负载之间的关系

为了控制日志容量,先进行预估非常重要,大部分程序有两种工作负载:

  • 接受一项工作内容(请求)并响应

  • 把工作内容放到某个地方(池化)然后处理。

大多数的日志都由工作负载触发,程序拥有的工作负载越多,它写入的日志就越多,还有一些与工作负载无关的日志,但是当程序开始负载的时候,这些日志的数量变得无关紧要了。开发者需要对日志的数量和工作数量进行计算

日志数量=X * 工作数目 + 常量级的日志

X可以通过检查代码计算,开发者需要对于他们程序的 X 值有一个好的主意并且能够预估到日志容量和预算,一些关于X的常识:

  • 0<X<1:这意味着日志只是抽样甚者起不到作用,比如只记录下错误日志,或者使用别的日志抽样算法,这种方式可以减少日志的容量,但同时也限制了排障。

  • X~1:这意味着每个工作只记录下了一个粗略的日志,但是这也是记录下1个包含充足信息的日志的理由(见 如何写日志 部分的内容)

  • X>>1:让X的值大于1必须得有充足的理由.因为当工作负载达到峰值的时候,举个例子:当服务器接收到像风暴一样的HTTP请求的时候,X的放大导致了日志实例巨大的工作负载,这往往会导致更多的问题。

让日志等级物尽其用

当X的值仍然非常大,甚至当优化之后还是非常大怎么办?这时候日志等级就可以帮上忙了,如果X的值仍然远远大于1的时候,应该将一些日志降低到INFO级别,当排障的时候程序可以运行在DEBUG等级来暂时性的提供更多的信息。

总结

为了像专家一样的来写日志,第一需要把程序设想为抽象层级的一连串的状态变换,当你脑海里记住以下关键问题的时候,记录日志的关键问题就会被解决:

  • 什么时候记录日志:当关键的状态变换发生的时候。

  • 记录什么日志:当前状态的关键信息以及状态变更的原因。

  • 谁来记录日志:在正确的层级记录日志以保证包含当前抽象层级拥有关键的上下文。

  • 记录多少日志:估算X值 日志数量=X \* 工作数目 + 常量级的日志 把X值控制在合适的范围内。


猜你喜欢

转载自juejin.im/post/5eef7ff0e51d4573c837fc07
今日推荐