软件系统中的故障是不可避免的。如何处理这些故障会严重影响系统性能、可靠性和业务的底线。在这篇文章中,我想讨论失败的好处。为什么你应该寻求失败,为什么失败是好事,为什么避免失败会降低应用程序的可靠性。我们将从快速失败与故障安全的讨论开始;这将带我们进入关于一般故障的第二次讨论。
附注:如果你喜欢这篇文章和本系列的其他文章,请查看我的
快速故障系统旨在在遇到意外情况时立即停止运行。这种立即故障有助于尽早发现错误,使调试更加简单。
快速失败方法可确保立即捕获错误。例如,在编程语言领域,Java 通过在遇到null
值时立即生成NullPointerException
、停止系统并清除错误来体现这种方法。这种即时响应可帮助开发人员快速识别和解决问题,防止问题变得更加严重。
通过尽早发现并阻止错误,快速故障系统可以降低连锁故障的风险,即一个错误会导致其他错误。这样可以更轻松地在问题蔓延到整个系统之前控制和解决问题,从而保持整体稳定性。
为快速失败系统编写单元测试和集成测试很容易。当我们需要了解测试失败的原因时,这种优势就更加明显。快速失败系统通常会直接指出错误堆栈跟踪中的问题。
然而,快速失败系统也有其自身的风险,特别是在生产环境中:
故障安全系统采用不同的方法,旨在即使在意外情况下也能恢复并继续运行。这使得它们特别适合不确定或不稳定的环境。
微服务是故障安全系统的典型示例,通过其架构实现弹性。断路器(无论是物理断路器还是软件断路器)可断开故障功能以防止连锁故障,从而帮助系统继续运行。
故障安全系统可确保系统即使在恶劣的生产环境中也能生存,从而降低发生灾难性故障的风险。这使得它们特别适合关键任务应用,例如硬件设备或航空航天系统,在这些应用中,顺利从错误中恢复至关重要。
然而,故障安全系统也有缺点:
很难确定哪种方法更好,因为两种方法各有优点。快速故障系统可立即调试,降低连锁故障风险,并更快地检测和解决错误。这有助于尽早发现和解决问题,防止问题蔓延。
故障安全系统能够妥善处理错误,使其更适合关键任务系统和不稳定环境,因为在这些环境中,灾难性的故障可能会造成毁灭性的后果。
为了充分利用每种方法的优势,采取一种平衡的策略是有效的:
平衡的方法还需要在编码、审查、工具和测试过程中清晰一致地实施,确保无缝集成。快速失败可以与编排和可观察性很好地集成。实际上,这将故障安全方面转移到 OPS 的不同层,而不是开发人员层。
这就是事情变得有趣的地方。这不是在故障安全和快速故障之间做出选择的问题。而是为它们选择正确的层。例如,如果使用故障安全方法在深层处理错误,则不会注意到它。这可能没问题,但如果该错误产生不利影响(性能、垃圾数据、损坏、安全性等),那么我们以后就会遇到问题,而我们却一无所知。
正确的解决方案是将所有错误处理在一个层中,在现代系统中,最顶层是 OPS 层,这是最有意义的。它可以将错误报告给最有资格处理错误的工程师。但他们也可以提供即时缓解措施,例如重新启动服务、分配额外资源或恢复版本。
最近,我参加了一场讲座,演讲者列出了他们更新的云架构。他们选择使用允许他们在发生故障时重试的框架来走微服务的捷径。不幸的是,故障并不像我们想要的那样。你不能仅通过测试就完全消除它。重试并不是万无一失的。事实上,它可能意味着灾难。
他们测试了他们的系统,并且“它工作正常”,即使在生产中也是如此。但假设确实发生了灾难性情况,他们的重试机制可能会对他们自己的服务器发起拒绝服务攻击。像这样的临时架构可能失败的方式数量令人难以置信。
当我们重新定义失败时,这一点尤其重要。
软件系统中的故障不仅仅是崩溃。崩溃可以看作是一种简单而直接的故障,但还有更复杂的问题需要考虑。事实上,容器时代的崩溃可能是最好的故障。系统可以无缝重启,几乎没有中断。
数据损坏比崩溃更为严重和隐蔽。它会带来长期后果。损坏的数据可能导致难以修复的安全性和可靠性问题,需要大量返工,并且可能无法恢复数据。
云计算催生了防御性编程技术,如断路器和重试,强调全面测试和日志记录,以便从容地捕获和处理故障。在某种程度上,这种环境让我们的质量倒退了一步。
数据级别的快速故障系统可以阻止这种情况发生。解决错误不仅仅是简单的修复。它需要了解其根本原因并防止再次发生,延伸到全面的日志记录、测试和流程改进。这确保错误得到充分解决,从而降低其再次发生的可能性。
如果这是生产中的错误,那么如果您无法立即恢复生产,则可能应该恢复。这应该始终是可能的,如果不可能,那么您应该努力解决。
在进行修复之前,必须充分了解故障。在我自己的公司中,我经常因为压力而跳过这一步,在小型初创公司中,这是可以原谅的。在大公司中,我们需要了解根本原因。汇报错误和生产问题的文化至关重要。修复还应包括流程缓解,以防止类似问题进入生产。
快速失败系统更容易调试。它们本质上具有更简单的架构,并且更容易在特定领域中找出问题。即使是轻微的违规行为(例如验证),也必须抛出异常。这可以防止在松散系统中普遍存在的级联类型的错误。
单元测试应进一步加强这一点,以验证我们定义的限制并验证是否抛出了正确的异常。应避免在代码中使用重试,因为它们会使调试异常困难,而重试的正确位置应在 OPS 层。为了进一步促进这一点,默认情况下超时应该很短。
故障不是我们可以避免、预测或完全测试的。我们唯一能做的就是在故障发生时减轻打击。通常,这种“减轻”是通过使用长期运行的测试来实现的,这些测试旨在尽可能地复制极端条件,目的是找到应用程序的弱点。但这很少足够。强大的系统通常需要根据实际生产故障修改这些测试。
故障保护的一个很好的例子是 REST 响应的缓存,它让我们即使服务中断也能继续工作。不幸的是,这可能会导致复杂的小问题,例如缓存中毒或被禁用户由于缓存而仍可访问的情况。
故障安全最好只应用于生产/预发布和 OPS 层。这减少了生产和开发之间的变更量,我们希望它们尽可能相似,但这仍然是一个可能对生产产生负面影响的变更。然而,好处是巨大的,因为可观察性可以清楚地了解系统故障。
此处的讨论受我最近构建可观察云架构的经验影响。但是,同样的原则适用于任何类型的软件,无论是嵌入式软件还是云软件。在这种情况下,我们经常选择在代码中实现故障安全,在这种情况下,我建议在特定层中一致且有意识地实现它。
还有一种特殊情况,即库/框架经常提供不一致且记录不良的行为。我自己在某些工作中也犯过这种不一致的情况。这是一个很容易犯的错误。
这是我关于调试理论系列的最后一篇文章,该系列是我关于调试的书/课程的一部分。我们经常认为调试是当出现故障时采取的措施。但事实并非如此。调试从我们编写第一行代码的那一刻就开始了。我们在编写代码时做出的决定将影响调试过程,通常我们只是在遇到故障之前才意识到这些决定。
我希望这篇文章和系列文章能帮助您编写出为未知做好准备的代码。调试本质上是处理意外情况的。测试无济于事。但正如我在之前的文章中所述,我们可以采取许多简单的做法来让准备工作变得更容易。这不是一个一次性的过程,而是一个迭代过程,需要在遇到失败时重新评估所做的决定。