您还未登录! 登录 | 注册 | 帮助  

您的位置: 首页 > 软件测试技术 > 其他相关 > 正文

为故障而构建:简化生产调试的最佳实践

发表于:2023-08-31 作者:刘汪洋 来源:51cto

译者 | 刘汪洋

审校 | 重楼

很多年前,我在维护一个数据库驱动的系统时遇到了一个奇怪的生产环境的 bug。我读取的列有一个空值,但是代码中不允许这样,而且也没有地方可以让这个值为空。数据库严重损坏,我们没有任何线索。虽然有日志,但是由于隐私问题,关键信息并未被打印出来。即使我们能打印,我们怎么知道该找什么呢?

应用程序出错不可避免。我们努力减少出错,但总是还会出错。我们还有另一项工作,它并未得到足够的关注:故障分析。有一些最佳实践和常见方法,最著名的就是日志记录。我曾多次说过,日志其实是预知性的调试,但是我们该如何创建一个更容易调试的应用程序呢?

我们应如何构建系统,以便当它出现类似的错误时,我们能知道出了什么问题?

一个常见的理念是:“艰苦的准备让工作更轻松。” 在开发阶段,我们面临的挑战可能更大,因为我们无法预见在生产过程中会遇到哪些问题。但是,这个阶段的准备工作也很有价值,因为我们正在为生产阶段做准备。

这种准备超出了测试和质量保证的范围。它意味着我们需要为代码和基础设施在未来可能出现的问题做好准备。到了出现问题的那一刻,测试和质量保证就失效了。简单来说,这是一种对未知问题的预防措施。

失败的定义

首先,我们需要明确什么是失败。当我提到生产环境中的失败,人们往往会自动联想到系统崩溃、网站宕机或灾难级别的事件。然而,实际上,这些情况相对较少,大部分都由运维人员和系统工程师处理。

当我向开发人员询问他们最近遇到的生产问题时,他们通常会犹豫不决,甚至无法回忆起具体情况。然而在进行更深入的交谈和询问后,开发者们回忆起了一个他们最近处理过的错误。这个错误的确是在生产环境中出现的,并且是由客户报告的。他们必须在本地以某种方式复现它,或者审查信息来修复它。我们并不把这种错误看作是生产错误,但它们确实是。需要复现已经在现实世界中发生的故障,这使我们的工作变得更困难。

如果我们能够仅通过查看问题在生产环境中出现的方式就理解问题,那就太好了。

简洁性

简洁性非常显而易见,但是在实际应用中,人们对于简洁性的理解却各不相同。简洁性是主观的。以下哪段代码更简单?

return obj.method(val).compare(otherObj.method(otherVal));
 
 

还是这段代码更简单?

var resultA = obj.method(val);
var resultB = otherObj.method(otherVal);
return resultA.compare(resultB);
 

从代码行数来看,第一个示例似乎更简单,而且许多开发人员确实会更喜欢那样。但这可能是一个错误。注意,第一个示例在一行中包含了多个可能出现故障的地方,对象可能无效,或者三个方法中的任何一个都可能会失败。如果真的出现了故障,我们可能无法清晰地判断具体是哪一部分出了问题。

此外,我们无法适当地记录结果。我们无法轻易地对代码进行调试,因为这需要我们逐个进入每一个方法进行检查。如果故障发生在方法内,堆栈跟踪应该会引导我们到正确的位置,甚至在第一个示例中也是如此。

试想一下,如果我们调用的方法改变了某个状态,那么obj.method(val)是否在otherObj.method(otherVal)之前被调用呢?

第二种方式写代码,可以很容易地看出方法的调用顺序和结果。此外,我们还可以审查和记录中间状态,即 resultA 和 resultB 的值。

我们来看一个常见的例子:

var result = list.stream()
                 .map(MyClass::convert)
                 .collect(Collectors.toList());
 

这是一段非常常见的代码,与下面的代码有相似之处:

var result = new ArrayList<OtherType>();
for(MyClass c: list) {
    result.add(c.convert());
}
 

从可调试性的角度看,这两种方法都有其优点,我们的选择可能会对代码的长期质量产生重大影响。第一个示例中的一个微妙之处在于,返回的列表是不可修改的。这既是优点也是问题。

当我们试图更改不可修改的列表时,会在运行时出现故障,这是一种潜在的风险。然而,故障的原因通常是明确的,我们可以清楚地知道什么导致了这个问题。

如果我们对第二个示例中的列表结果进行更改,可能会引发一系列问题。然而,只要这些问题在生产环境中没有导致故障,我们就可以视为问题已经被解决。

那么,我们应该选择哪个呢?

通过只读列表可以实现快速失败的原则,这对我们调试生成问题有帮助。当快速失败时,我们降低了连锁故障的可能性。这些可能是我们在生产环境中遇到的最糟糕的故障,因为对应用程序状态的深入理解在生产环境中具有很高的复杂度。

在构建大型应用程序时,我们经常提到 "robust"(鲁棒性)这个词。系统应该具有鲁棒性,这种鲁棒性应该由代码以外的部分提供。同时,你的代码本身应该遵循快速失败的原则。

一致性原则

在我之前关于日志记录最佳实践的分享中,我提到过,无论在哪家曾服务过的公司,都有一套自己的代码风格指南,或者至少会遵循某种公认的风格。然而,很少有公司会有一份关于日志记录的指南,告诉我们应该在何处记录日志,以及应该记录哪些内容。

我们追求的一致性,实际上比代码格式化更为重要。 在进行调试的时候,我们需要知道应该寻找什么。如果禁止使用某些特定的包,我期望这个规则能适用于整个代码库。 同样,如果大家普遍不推荐某种编码实践,我期望这是一个共识。

幸运的是,有了持续集成(CI),这些一致性规则可以轻易地得到执行,而不会增加我们的代码审查负担。像 SonarQube 这样的自动化工具是可插拔的,并且可以通过自定义的检测代码进行扩展。我们可以调整这些工具,以强制执行我们的一致性规则集,限制代码中某个特定子集的使用,或者要求适当的日志记录。

当然,每条规则都可能有例外。我们不应受过于严格的规则束缚。这就是我们需要能够调整这些工具,通过开发者审查来合并代码变更的原因,这非常重要。

双重验证

调试,其本质是在我们发现并修复 bug 的过程中对假设进行验证。通常,这一过程进行得相当迅速:我们找到问题所在,进行验证,然后修复。但偶尔,我们可能会在追踪某个 bug 上花费过多的时间,尤其是那些难以复现的 bug 或只在生产环境出现的 bug。

当我们遇到难以解决的 bug,重要的是能够后退一步反思,这通常表示我们可能需要重新审视我们的假设和验证方法。双重验证的关键在于利用不同的方法来确认假设的有效性,以保证我们的结论准确无误。

一般来说,我们需要验证 bug 存在的两个方面。例如,假设后端存在一个问题,而这个问题会在前端呈现出错误的数据。为了定位这个 bug,我最初做出了两个假设:

  • 前端准确地显示了后端的数据
  • 数据库查询返回了正确的数据

为了验证这两个假设,我可能会打开浏览器查看数据,使用网络开发者工具检查响应来确保显示的数据确实来自服务器查询。对于后端,我可以直接发出数据库查询,确认返回的数据是否正确。

但这仅仅是验证这些数据的一种方法。我们希望在理想情况下,有第二种验证方法。例如,如果缓存返回了错误的数据,又或者 SQL 查询的前提假设错误呢?

第二种验证方法应尽可能与第一种方法不同,以防止犯下与第一种方法相同的错误。对于前端代码,我们可能会尝试使用像 cURL 这样的工具进行验证。 使用像  cURL  这样的工具进行验证是一个好方法,我们应该尝试。但更好的方式可能是在服务器上查看记录的数据,或者调用支持前端的 WebService。

同样,对于后端,我们希望能够看到从应用程序内部返回的数据。这是可观察性的一个核心概念。一个具有可观察性的系统,就是我们可以对其提出问题并得到答案的系统。在开发过程中,我们应通过两种不同的方式来提高我们的系统可观察性,以便更好地回答问题。

为何避免使用三种以上验证方式?

我们通常不采用超过两种验证方式,这是因为过度验证会导致我们的成本增加,性能降低。我们需要将收集的信息量限制在合理的范围内,尤其在收集信息的过程中,必须重视个人信息保护的风险,这是我们必须重视的关键因素。

可观察性往往根据其使用的工具,柱状指标或者某些明显的特征来定义,但这其实是错误的。可观察性应当根据它提供的访问权限来定义。我们决定记录什么,监控什么,决定追踪的范围,决定信息的粒度,以及决定是否希望部署开发者的可观察性工具。

我们需要保证我们的生产系统能够得到适当的监控。为此,我们需要进行故障注入,可能还需要安排混沌工程的执行。在运行这样的场景时,我们需要考虑如何解决出现的问题。我们能对系统提出哪些问题?我们如何回答这些问题?

例如,当特定问题出现时,我们通常关心有多少用户的操作正在实时影响系统数据。因此,我们可以为这个信息添加一个度量。

通过特性标志进行验证

虽然我们可以使用可观察性工具来验证假设,但我们也可以使用一些更具创新性的验证工具。其中,一个意想不到的工具就是特性标志系统。特性标志解决方案通常可以非常细粒度地操作,例如我们可以只为特定用户关闭或改变一个特性的设置等。

这种功能非常强大。如果特定的代码被封装在一个标志中,我们就可以通过切换一个特性来为我们提供对特定行为的验证。我并不建议在所有代码中都使用特性标志,但是能够拉动开关并在生产环境中改变系统是一种强大的调试工具,这种工具的威力常常被低估。

bug 分析会议

我在 90 年代开发过飞行模拟器,期间有幸与许多战斗机飞行员合作。我从他们那里了解到了"分析会议"这个概念。我之前只把这样的会议当作任务失败后的讨论,然而战斗机飞行员无论任务成功还是失败,都会在任务结束后立刻进行这样的会议。

我们可以从中学习以下重要的要点:

  • 即时性 - 我们需要在事件刚发生不久的时候讨论这些信息。如果等待过久,部分信息可能会丢失,而我们的记忆也会发生明显的改变。
  • 成功与失败 - 每次任务都包含成功与失败的元素。我们需要理解在哪些方面做对了,哪些方面做错了,尤其是在任务成功的情况下。

解决了一个 bug 之后,我们通常只想把这件事了结,不再讨论它。即使我们想要"炫耀",也通常只是对追踪过程的模糊记忆。通过开放的讨论,我们对做对的事情和做错的事情没有任何评判,我们可以了解到我们的现状。这些信息可以帮助我们在追踪问题时提升我们的效果。

这样的分析会议能帮我们发现在可观测数据中的缺失、不一致性以及问题流程。在许多团队中,流程是一个常见的问题。通常,当出现问题时,它的解决过程是这样的:

  • 客户发现问题
  • 向支持部门报告问题
  • 运维团队进行检查
  • 问题转交给研发部门

如果你在研发部门,你离客户隔了 4 层,而你接收到的问题可能不包含你需要的所有信息。尽管优化这些流程并不涉及到代码修改,但我们可以在代码中添加工具,以便更轻松地定位问题。一种常见的做法是为每个异常对象添加一个唯一的键。在出现故障的情况下,这将呈现到用户界面。

当客户报告问题时,他们可能会包含这个错误码,方便研发部门通过日志快速定位错误。这些都是通过这样的分析会议,我们可以发现并优化流程的方法。

审阅有效的日志与仪表板

仅仅等待失败的发生并非是解决问题的正确方式。我们需要定期查阅日志和仪表板,以便于追踪可能已经存在但未曾显露出来的 bug,并能建立正常运行状态的基准。健康的仪表板或者日志应该呈现出怎样的状态。

在日志中,我们可能会遇到一些错误信息。如果我们在追踪 bug 的过程中,花费时间去查看那些并无实质性影响的错误,那我们就是在浪费时间。理想情况下,我们需要尽可能地减少这些错误,因为它们会使得日志的阅读变得困难。但是在服务器开发中的现实状况是,我们不能总是做到这一点。不过,我们可以通过对源代码的深入理解和适当的注释,来降低这方面的时间开销。

结语

创建 Codename One 数年后,我们的 Google App Engine 账单突然飙升,甚至在几天内就会导致公司的破产。这是由于他们后端系统的一次更新,引发了一个突发性的回归问题。

这个问题的源头在于未被缓存的数据,但是由于当时 App Engine 的运行方式,我们无法定位到代码中哪一个具体的区域触发了这个问题。我们无法直接调试这个问题,只能尝试通过更新服务器并观察其运行情况来判断问题是否得到了解决。

幸运的是,我们当时解决了这个问题。我们对所有可能的部分尽可能进行了缓存处理。直到今天,我仍然不清楚是什么触发了这个问题,也不知道我们做了什么修复了问题。

但是我知道的是:

选择使用"App Engine"是我当时的一个决策失误。它未能提供足够的可观察性,并留下了关键的盲点。如果我在部署前花时间对可观察性能力进行审查,我就能够察觉这点。选择使用"App Engine"是我当时的一个决策失误。

译者介绍

刘汪洋,51CTO社区编辑,昵称:明明如月,一个拥有 5 年开发经验的某大厂高级 Java 工程师,拥有多个主流技术博客平台博客专家称号。

原文标题:Building for Failure: Best Practices for Easy Production Debugging,作者:Shai Almog