容错
容错绝对是分布式系统最难搞定的事儿,至少我这样认为,因为意外总是会发生。
处理故障在许多方面意味着要放弃全局一致性。Akka是基于不粗要调用方负责处理故障的想法而建立的。它主张由发生故障的actor负责处理问题,在actor不能处理的情况下,会向其“监督者”寻求帮助。由于Actor模型基于消息机制设计,就意味着系统的很多部分都是异步的,异步又进一步导致出现故障时解决问题的难度。而Akka有一套完整的机制,将故障尽量限制在对应的actor上,缩小出现故障的影响范围和降低解决故障的成本和时间。但·Akka只是提供了故障恢复的机制,具体怎么恢复还是需要我们自己设计。
故障类型
在之前我也说过,分布式系统有很多不确定性,会出现各种意外,对意外分门别类总是有好处的。
异常
不管是单机系统,还是分布式系统,系统故障中最常见的类型之一。其实自从学了Scala,觉得传统的异常机制不太完美。因为它以一种奇怪的方式改变了程序的流程。而且绝大部分你总是会发现没有处理的异常,也就是说,异常变成了程序异常退出的一个出口,而异常一旦有很多个,就意味着程序“千疮百孔”,各种漏洞。这样的结果很可能导致系统释放资源的代码没有资源,造成资源泄露或其他意想不到的问题。所以我更喜欢用一个Try来封装所有的异常。
JVM致命错误
这就比较难办了,很多时候都是任期发生,然后找出问题再解决。需要注意的是,Try无法处理这种会使系统崩溃的严重错误,比如内存溢出。看看Try的源码就知道为啥了。
外部服务故障
系统往往需要与外部系统进行交互,外部API出现问题也是比较头疼的。因为问题不可控,我们只能选择接受,而无法解决。但意识到这种问题的存在非常重要。
不符合服务等级协议(SLA)
其实这就比较主观了,毕竟SLA还是可以定的比较宽松的,^_^。如果一个系统总是能返回一个正确的结果,但却不一定能保证在合理的时间内返回,这就比较尴尬了。最好的系统仍然可能成为高负载的情况下的受害者,因为它无法达到预定义的服务等级协议(SLA)。这其实还挺尴尬的,有时候SLA还会变化,就更麻烦了。
操作系统和硬件级故障
这个吧,只能买好一点的机器。。。不过还是可以指定适当的预防措施的。
故障隔离
我觉得这是处理故障的最正确的一个设计理念。那就是不要试图去消除全部的故障,而是承认其存在,将其限定在合理的范围内,隔离其影响范围。幸运的是,Akka提供了相关的工具。
舱壁模式
这种模式对于我来说还是比较新颖的,因为我一直以为船舶一旦触礁,就会有大量的水流到船体内,然后就挂了。Akka提供了以actor层次结构形式实现的舱壁模式工具。也就是actor内部的错误不会蔓延,只影响其自身的功能。如果需要,还可以进一步分解,让特定actor只处理特定时间段内的请求,这样可以将请求中的故障隔离在请求发生的时间段内。
这种设计理念我还是很喜欢的,毕竟很多时候,导致出现故障的原因,也许只是遇到了某个异常的请求,忽略这个请求,简单的重启继续后续消息的处理也许是非常合理的。
优雅降级
这在互联网的架构博客中经常会被提及。其实刚开始我对它的理解比较粗浅,什么优雅降级,不就是let it go吗?所谓的优雅降级,就是牺牲一部分系统功能,使当前最重要的业务功能可用。优雅降级可以避免使这鞥个站点发生故障,只让其中一部分不可用,其他功能不受故障影响。这种降级,有时候是系统自动发生的,有时候是人工启动的。比如在业务高峰期,系统资源不足,可以选择性的将部分功能下线,以确保最核心的业务功能可用,就是所谓的丢车保帅。
使用Akka集群隔离故障
Akka内置了舱壁模式,使其更加透明。集群分片某种意义上来说,就是对actor进行故障隔离。另外一个好处就是它可以自动将失败的节点上的actor重新分配给其他节点。
使用熔断器控制故障
熔断器通常用来解决高负载情况下系统的故障隔离的。简单点来说,就是当系统处于高负载时,为了避免后续的消息使情况进一步恶化,成为压死骆驼的最后一根稻草,自动的使所有调用失败,甚至不会尝试处理调用请求。也就是尽快的通知调用方,调用失败了。故障恢复或者负载变低时,调用恢复正常。熔断器提供 一个让发生故障的系统在一段时间内恢复原有功能的方法。
在预先设置好的时间段过去之后,熔断器进入“半开”状态。此时,进入熔断器的第一个调用将被处理,如果调用成功,则熔断器进入关闭状态,系统恢复正常运行。如果第一个调用还是失败,则熔断器返回到“打开”状态,继续使所有的调用快速失败。如果回到“打开”状态,则在接下来的一段时间内都保持打开状态。
幸运的是Akka内置了熔断器CircuitBreader,使用还是比较方便的。
故障处理
前面已经对故障的类型做了分类,也对故障隔离和故障控制做了介绍,但没有说如何解决故障。Akka是设计理念是“let it crash”。从崩溃中恢复才是弹性系统应该具备的能力。首先认识的故障的发生,才能正面的解决和规避它。
异常处理
异常一般分为非致命异常和致命异常。当然了是否致命是对整个系统来说的,不是对某个代码、函数或者actor来说的。
在单个actor中,可以简单的处理可预测的故障,比如用try catch快封装;当异常不能被它的actor处理时,就会把异常发送给上层的监督者,由监督者决定如何处理。幸运的是Akka内置了几种常见的恢复策略。OneForOneStrategy提示监督者将恢复逻辑应用于发生故障的actor,其他actor不受影响;AllForOneStrategy告诉监督者将恢复逻辑应用于所有的子actor。恢复逻辑一般就是重启,actor重启的代价比较小,只需要做好相关的业务初始化代码即可。Decider是一个partial函数,它根据异常类型决定采取的应对方式。Decider将异常类型映射到以下执行。
Resume。告诉发生故障的actor继续处理消息,即忽略发生故障的消息。
Restart。告诉发生故障的actor应该重启。
Stop。告诉发生故障的actor应该被停止。比如临时、一次性的actor,失败后不再需要,就可以停止。
Escalate。将故障升级。其实就是把异常向上汇报,由监督者的监督者处理。
其实选择合适的异常处理策略还是比较难的,因为异常就是意外,很难预测。我们所能做的就是对已知的故障进行应对,无法预见的就只能交给后期版本迭代了。
JVM致命错误怎么办呢?Akka自身没有现成的工具可以做到这一点。但可以引入第三方工具,比如ConductR或者supervisor。当检测到对应JVM退出时,简单的重启。但还可以优化一下,比如在应用程序中设置一个回退点。在Akka中,我们可以选择将消息持久化,从某个时间点开始,回放消息。当然了这需要系统具备至少一次的交付机制。
外部服务的故障处理
其实外部服务的故障我们往往没法解决,只能将其隔离,限制影响范围。可以将外部服务系统是为一个有界的上下文,为它创建一个包装器包装它。简单情况下,用一个actor封装对应的操作就好了。不过还可以用AtLeastOnceDelivery机制来确保外部服务恢复时,消息被重新发送处理。
作者提到过渡使用AtLeastOnceDelivery机制表明我们正在尝试实现全局一致性,这一点对我的启发挺大的。其实我们在使用某个技术细节的时候,往往只考虑它带来的好处,而看不到隐藏的弊端。再加上程序员都是分流派的,互相怼来怼去,就更加夸大某个技术的好处,而选择性忽略其问题。技术要合理的使用。AtLeastOnceDelivery机制最适合调用流水线的两端,不要在管道的中间阶段使用它。
结论
基于actor的分布式系统发生系统故障的可能性非常低,同事比其他类型的系统更容易从故障中恢复。这一点我深信不疑,看看我开源的代码就知道原因了。不过在分布式系统中,对于故障处理是一件非常麻烦的事情,因为它有可能占用系统设计、开发总时间的50%,甚至更多。