4.1 什么是容错

首先让我们看看我们所说的容错是什么意思,还有我们为什么要写代码去包容这些错误。 在理想世界里,系统总是可用的,并且保证每个操作都是成功的。 这里只有俩个方法可用达到这种理想,一是永远不失败,二是失败后可以恢复,当然也要保证恢复不失败。 In most architectures,what we have instead is a catch all mechanism that will terminate as soon as an uncaught failure arises. 即使程序试图提供恢复策略,测试也是很困难的,而且保证恢复不失败,有增加了一层复杂度。 在程序中,每做一件事情都要检查它的返回值,针对不同的返回值做一些事情。 异常处理的出现简化了错误处理,不用在正常代码中检查错误了,只要在异常处理中来做就好。

做一个无故障系统,在理论上听起来还不错。 但是建立一个高可用的分布式系统却不是一件简单的事情。 主要的原因是系统的很多部分不是我们能控制的,而且这些部分很可能出问题。 再有一个普遍的问题是:多个合作者之间通过使用共同的组件进行交互,出了问题,谁来负责,也不是很清楚。 一个资源不可用的很好的例子就是网络:它可能会随时掉线或者部分可用,如果我们想继续,我们就需通过其他的路径进行通信或者过一会再重试。 我们可能依赖第三方服务,它随时都有可能不可用。 运行我们软件的服务器可能会失败或者不可用,或者直接硬件故障。 你显然不能让烧了的服务器重启,或者让坏了的磁盘修复。 这就是为什么运营商的机架上发生宕机,要有一个计划让它恢复。

既然我们不能阻止所有的故障的发生,我们就要采取一种策略,铭记以下:

  1. Things break.系统要容错,可恢复的故障不能导致灾难性的失败。
  2. 在一些情况下,尽量长的保持系统的主功能可用是可以接受的。同时失败的部分被停止并清理出系统,确保不会重启系统或产生不可预知的结果。
  3. 其他一些情况下,有些组件很重要,就需要有备份(active backups),当主组件出问题时,可以快速的替代主组件。
  4. 在系统的某些部分发生故障时,不应该使整个系统崩溃,所以我们需要一种方法来隔离特定的故障,让我们可以稍后处理。

当然,AKKA的工具箱不包含容错的银弹, 我们仍然要处理具体的失败。下面这些AKKA的特性是我们容错时需要的:

====缺个表格=====

你可能会说,等等,为什么我们不用老方法或者exception来恢复故障?通常exception用于回退一系列行为,防止状态不一致,而不是恢复故障。 接下来让我们看看用exception进行故障恢复是多么费劲。

普通的久对象和exception

让我们来看个从多个线程接收日志的实例程序。 从文件中读取信息,并解析成行存入数据库。 某个程序监视文件的增加,并通知其他线程处理这些新文件。 下面这个图给出了程序的概述,并强调了虚线圈着的部分。

日志处理程序

如果数据库连接断了,我们希望建立一个到另外一个数据库的新连接,而不是退出。 如果数据库连接出现了故障,我们希望断开连接,阻止程序的其他地方继续使用它。 某些情况下,我们希望重连,以恢复当前连接的错误状态。 下面的伪代码就可以说明潜在的问题。 我们先看看用标准的exception来处理重连同一个数据库。

首先所有被多线程使用的对象都启动。 启动后它们就可以处理监控程序发现的新文件。 我们启动writer(写数据库的对象,后面统一叫writer)。 下图显示了创建writer的过程。

创建writer

所有writer依赖的参数都传给了构造函数,创建writer的线程吧url传给了database factory,用来创建一个连接。 下面我们来设置日志处理进程,每个都有一个writer的引用,如下图所示

创建日志处理

下面的图显示了实例程序中各对象的调用关系。

调用栈

当监视程序发现多个日志文件时,会同时启动多个线程调用上面的流程。 下图是抛出DbBrokenConnectionException异常时的调用栈,当收到异常时,我们应该建立到另一个数据库的连接。 我们省略了细节,只给出了对象间的调用关系。

调用栈

我们更喜欢用一正确的数据库连接从DbBrokenConnectionException异常中恢复,而不是把异常从栈中抛出。 我们首先面对的问题是,如果不破坏现在的设计,我们就无法编码创建新的数据库连接。 同时我们也没有足够的信息创建新的连接,因为我们不知道哪些连接是好的,哪些连接发生了异常。

重新建立新的连接和提供连接的信息,会打乱我们原有的简单设计和一些基本的最佳实践,比如封装、控制反转、单一责任等。 (Good luck at the next code peer review with your clean coding colleagues!) 我们只是想失败的地方,加入异常处理和记录日志的逻辑。 即使我们找到一个地方可以重新建立连接,我们也必须非常小心,确保没有其他的线程正在使用这个失败的连接,否则就会有数据丢失。 在java中有三个连接池, only one even has a working implementation of dead connection removal on another thread. 这显然与我们现在的工具不能很好的相容。

此外,线程间通信异常不是一个标准的功能,你必须自己建立。 让我来看看容错的要求,即使我们有机会来容错,我们也忍受不了。

  • 故障隔离:多个线程可能会同时抛出异常,这使得隔离很困难。我们必须增加一些锁机制。 从对象链中移除失败的连接也很困难,应用不得不重写。这里没有标准的支持让我们移除连接,所有我们必须在对象内部建立一定程度的间接方法。
  • 结构:对象直接的结构非常简单、直接,默认不提供取出对象的支持。
  • 冗余:像上面例子看到的那样,当一个异常从调用栈抛出后,你可能会丢失故障恢复的上下文或者数据输入的上下文。
  • 更换:没有更换调用栈上对象的默认策略,你必须自己想办法。依赖注入框架可以为此提供一些支持,但是如果是对对象的直接引用而不是间接引用,你就麻烦了。 你最好要确保修改对象的地方是多线程安全的。
  • 重启:跟更换类似,恢复对象的初始状态,也没有自动提供,你必须通过一定程度的间接方式来提供。 所有对象之间的关系必须要重新定义。 如果对象的依赖关系也要重启(也就是说日志处理程序会抛可),而且要有序,事情就更复杂了。
  • 对象的生命周期:在构造之后垃圾回收之前,对象是存在的,其他任何机制,你必须自己实现。
  • 挂起:当捕获到异常并从栈上抛出时,输入数据或者上下文会丢失或无效,你必须自己实现一种缓冲器来保存这些信息,直到故障恢复。 如果代码是由多线程来调用的,你必须加锁,防止同时有多个异常发生。 你必须实现一个方法在故障恢复后,用保存的数据进行重试。
  • 关系分离:异常处理跟正常流程交织在一起,无法定义独立的处理流程。

这看起很失望,要想让一切正常运行是多么复杂和痛苦。 这里好像缺少了些根本性的功能,可以使容错变得简单。

  • 在对象不可用时,重建对象和它的引用,作为框架的首要功能提供。
  • 对象之间直接通信,所有很难隔离它们。
  • 故障恢复的代码跟功能代码混在一起。

还好,我们有个简单解决方案。我们已经看了一些actor的功能,这能够帮我们简化问题。 actor可被props对象建立和重建,这是actor系统的一部分,而且actor之间的通信是通过actor的引用而不是直接通信。 下一节我们就看看actor系统是如何解耦功能代码和故障恢复代码,还有在故障恢复的过程中actor系统是如何挂起和重启actor的。