三、可靠性、容错性和隔离性

Reliability, Fault-Tolerance, and Isolation

Cover Image

在继续下一个话题之前,我们需要明确的是:猫咪总是四脚朝地 故障(Failure)总是会发生的。

如何构建一个可靠的系统

大家通常认为,一个可靠的系统就是能够系统性地证明故障不可能发生。直觉上看,似乎很对。但它存在一个问题:在有限的条件下,这是不可能的。如果你能在可靠性这一点上花上上亿美刀,情况也许会有所改观;此外,也可以使用像 SPARK (基于合约(Contracts)的 Ada 扩展)之类的语言来形式化地证明每行代码的正确性。然而,经验表明这种方法也不是万无一失。

我们选择了接受这种现状。当然,我们必须尽可能地减少故障的发生,错误模型必须能够让故障透明化,并且易于处理。但是更重要的是,你需要在系统设计上下功夫,使得就算某个部分出现了问题,整个系统也还能够工作;同时还要让系统能够优雅地恢复出错的部分。这其实在分布式系统中是常识,有什么特别之处吗?

一切的前提是,我们要把一个操作系统看作是各个协作进程组成的分布式网络,就像由微服务组成的分布式集群、或是互联网本身一样。主要的差别包括延迟、信任级别、关于位置、身份的各种假设,等等。在一个高度异步的、分布式的、IO密集的系统中,故障是必然发生的。我的印象是,在很大程度上,由于宏内核的成功,我们还没有实现“操作系统作为一个分布式系统”的飞跃。然而,一旦你这样看待一个操作系统,很多设计准则都会发生改变。

在大多数分布式系统中,系统架构都会认为一个进程发生故障是不可避免的。从而我们需要花大力气来避免级联故障,定期记录日志,并且将程序和服务设计为允许重新启动的。

一旦你接受了这样的设定,很多事情都变得不一样了。

举个例子,隔离性就变得极端重要。Midori 的进程模型鼓励轻量级、细粒度的隔离。结果就是,程序、甚至是现代操作系统中的“线程”之间都是独立分隔的实体。相比于在同一个地址空间中共享可修改的状态,这样的设计可以更容易地在某个实体发生故障时提供保护。

高隔离性同样有利于实现简洁性。Butler Lampson 的经典论文 Hints on Computer System Design 探索了这一方面。我非常喜欢 Hoare 的这句话:

可靠性不可避免的代价就是简单化
C. Hoare

(译注:C. A. R. Hoare (或 Tony Hoare),发明了快速排序、霍尔逻辑形式验证等算法和系统)

通过把程序分成更小的部分,每一个部分都允许成功或失败,他们的状态管理可以变得更加简单。结果就是,从故障中恢复也变得更加容易。在我们的语言中,可能发生故障的地方都会显式说明,这进一步保证了内部状态机的正确性,同时能够点明哪些地方会与混乱的外部世界发生联系。在这个世界上,局部故障的代价并没有那么可怕。我并没有过分地重视这一点——架构方面提供的隔离性,是我后面描述的所有语言功能的基础。

Erlang 已经非常成功地把这种属性加入到了语言的基础部分。就像 Midori 一样,它使用了轻量级进程,通过消息传递互相连接,并且鼓励容错架构设计。一个通用的模式叫做“Supervisor”,有一些进程会负责监控和重启其他发生故障的进程。这篇文章非常好地阐述了这种理念——“Let it crash”,同时还推荐了一些用来构建可靠的 Erlang 程序的实用技术。

关键之处并非是如何防止故障,而是知道如何应对故障。一旦这样的架构建立了起来,你就会相信它的确能够正常工作。对于我们来说,我们进行了长达一周的压力测试,以确保我们的系统在整体上足够稳定,即便在这期间某些进程可能会因为故障而崩溃、重启。这让我想起了类似于 Netflix 的 Chaos Monkey 系统,它会随机杀掉集群中的某些机器来确保服务的运行状况良好。

随着分布式计算越来越流行,我很期待能够有更多人能够采用这样的思想。比如,在一个微服务的集群中,在单一容器上发生的故障通常能够被外部的集群管理软件无缝地处理(例如 Kubernetes、Amazon EC2 Container Service、Docker Swarm等等)。所以我这篇博客所描述的内容可能对写出可靠的 Java/Node.js/Javascript/Python/Ruby 服务有所帮助,但不幸的是你很可能需要跟你使用的语言作一番斗争。为了在出现问题时能够继续勉强工作,你可能需要写上一大堆的代码。

放弃

就算进程很轻量、隔离得很好、重启很简单,仍然会有人认为遇到 bug 就直接结束进程的做法太过激进。这也是能够理解的。我就来试着说服你吧。

如果你想要构建一个健壮的系统,当遇到 bug 时选择继续执行是很危险的。如果一个程序员没有考虑过某些状况的发生,谁都不知道那些代码会不会继续正常工作。重要的数据也许已经被破坏成不正确的状态了。举一个极端(也许有点傻)的例子:某段程序本来是要对你的银行存款向下取整,现在却开始向上取整了。

也许你认为放弃的粒度应该更小,这就有点复杂了。举例来说,假如你的进程遇到了 bug 并且出现了故障,这个 bug 可能是由于某些静态变量的值出错导致的。尽管其他的线程看起来没问题,你也没办法笃定它们一定不会被影响。除非你的系统支持某些特性——比如语言提供的隔离性、各个独立线程中可访问的顶层对象的隔离性、等等,否则最安全的假设就是只能扔掉整个地址空间,其他的操作都是有风险、不可靠的。由于 Midori 中的进程非常轻量,放弃一个 Modiri 进程就像在其他的系统中放弃一个线程一样。我们的隔离模型能够确保这样做的可靠性。

我必须得承认,“放弃”范围的界定可能产生滑坡谬误。可能在世界上所有的数据都已经坏掉了,你怎么知道放弃这个进程就够了呢?这里有一个很明显的区别:进程的状态是提前设计好的、非持久化的。在一个设计良好的系统中,进程可以随时被丢弃然后重新创建。的确,一个 bug 可以破坏外部持久化的状态,但如果这种更严重的问题发生了的话,你就需要使用不同的方法来处理了。

我们可以看一看容错系统的设计来了解更多的背景。放弃(快速失败)在容错系统中已经是一个非常常见的技术了,我们也可以把这一领域的大多数经验应用到普通的程序和进程中。也许最重要的一点就是定期记录日志、定期在检查点记录宝贵的持久化状态。1985年 Jim Gray 的发表了一篇论文 Why Do Computers Stop and What Can Be Done About It? 详细地描述了这种观点。随着程序不断地迁入云服务、并且激进地划分为更小的、互相独立的服务,这种明确区分非持久化和持久化的状态越发显得重要起来。这种潮流也影响了软件的开发方式,如今“放弃”在现代架构中也变得更加容易实现。同时,放弃也有助于防止数据的损坏,因为 bug 在下一个检查点到来之前就会被检测到,程序也不会在错误状态下继续执行。

对于 Midori 内核中的 Bug,我们的处理方式有所不同。例如,微内核的 bug 相比于用户态进程的 bug 来说,破坏性更大,最安全的方法是放弃整个“域(Domain)”(即地址空间)。幸运的是,你所了解的大多数典型的“内核”功能——调度器、内存管理、文件管理、网络栈、甚至设备驱动——都在用户态以独立进程的方式执行。这些模块中的错误可以用上文中提到的通常方法解决。