弹性工程

弹性工程

Scroll Down

当被问到怎样才能提高功能可靠性,大部分人的第一反应可能是更严谨的功能设计和更高质量的测试,并多多少少能谈些在自己工作中的实践。的确,严谨的功能设计和高质量的测试可以大大降低出BUG的概率,从而提升功能可靠性。但是如果换个问题:你能保证你做的功能万无一失吗?相信绝大部分的人心里立马就打起了退堂鼓。人非圣贤孰能无过,即使设计再严谨、测试再细致,人也可能因为一时疏忽或者认知盲区而导致出错。但万无一失是否对于工程开发来说是个非常苛刻的要求呢?万无一失从字面上解释,就是尝试10000次也不会出错1次,换算成SLA也就是可用性大于99.99%,4个9的可用性要求不低,但也绝对称不上苛刻,尤其在众多高流量互联网场景下,核心接口的TPS轻松过万,业务高峰期更可能达到十万、百万级,在这些场景下,仅仅做到万无一失可能还不够哩。在这些可用性要求4个9甚至更高的业务场景下,显然仅靠预防出错是无法达标的,那究竟这些场景是凭借什么能够达到这么高的可用性的呢?答案是弹性工程。

要理解弹性工程,我们首先要明确两个重要的概念:错误(Fault)失败(Failure)。错误(Fault)是指系统中因为各种原因产生的非预期的内部状态,如线程阻塞、内存泄漏、依赖无法响应、部分节点宕机、进入系统的非预期数据、代码抛出异常。而失败是指系统无法执行其预期的工作,它往往意味着系统正常运行时间和可用性的损失。如果错误不被封装和处理,任由它在系统中传播,那么最终便会导致失败,当错误转变为失败时,就意味着发生了故障。当着两个概念明确了之后,我们可以发现,保证系统可用性最重要的是不出故障,而非不犯错误,只要我们能够及时介入处理错误,阻止其最终转变成失败,从而造成故障,系统仍然能处于高可用的状态。那些仅仅着眼于预防犯错误的人,很明显混淆了错误和失败的区别。如果妄图靠根绝一切错误来提高系统的可用性,甚至一遇到与错误相关的东西,就欲立刻处置而后快,很可能不但没解决错误还反倒掩盖了错误,最终引起更严重更难处理的问题,这在Java项目中最经典的体现莫过于:

try {
...... //业务逻辑
} catch (Exception e) {
}
...... //后续业务逻辑

在这种思路下,若系统真出了个BUG,往往功能一触即溃,恢复困难。

弹性工程并不期待能够根绝错误本身,甚至默认错误终会发生。它聚焦在错误与失败之间的转化,通过对错误进行及时而合理的处理赋予了系统本身一定的容错性,从而避免错误最终转化为失败,以达到系统的高可用。甚至有些时候,我们还需要主动“犯”些错误,比如之后要讲的降级,本质就是通过主动创造并使用业务可接受的小错误去替换业务无法接受的大错误,从而达到业务有损可用的目的。

那弹性工程具体该如何实践呢?我觉得概括的来说具体可以分为以下两步走:

  1. 故障隔离
  2. 容错

故障隔离

既然失败产生的原因是错误不受控的传播,那只要我们有能力将错误的传播范围控制在一个确定而可接受的小区域内,就能有效控制错误的影响范围,从而增加错误影响的确定性。只要错误可能产生的影响的确定了,那后续便可以有针对性的使用容错手段来处理错误。

故障隔离从架构层次角度可以分为两类:横向隔离和纵向隔离

横向隔离

在没有横向隔离的情况下,一条调用链中的任何一个位置出现一个未捕获的异常,都会导致整个调用链的失败。横向隔离的作用就是将错误隔离在调用链中限定的区域,使其不至于导致整条调用链的失败。横向隔离按照架构元素粒度从细到粗可以分为代码级别隔离、分层级别隔离和服务级别隔离三种,代码级别隔离最常见的方式就是语言中的异常捕获机制,如Java中的try-catch,将异常隔离在try代码块中后执行catch代码块中的降级逻辑。分层级别隔离是一个服务层与层之间的隔离,它是代码级别隔离的衍生产物,代码级别的隔离太过依赖编码人员的个人经验与意识,因此在一些架构分层干净的服务中,我们可以对一些次要的架构层做统一的故障隔离。服务级别隔离指的是上游对下游依赖的隔离,防止下游的故障影响上游服务的正常运作,这里的隔离手段往往会采用熔断器的方式。

纵向隔离

在一条调用链中,往往越深的节点复用性就越强,复用性强虽然是一件好事,但它同时也意味着这段代码的出错可能会对更多的调用链路产生影响,纵向隔离的作用正是对这类节点进行拆分,避免出现一损俱损的情况。比如多实例部署就是最常见的一种纵向隔离的体现,在多实例部署的情况下,单个实例内部的故障不会扩散到其它实例上,服务整体依旧可以对外提供正常的服务。

容错

在故障隔离之后,我们就要开始考虑对隔离边界外的部分如何对边界内的故障进行容错,我把常见的容错手段概括成这几类:重试、流控、熔断、降级。

重试

重试是一种对外部故障较为乐观的容错手段,它假设外部故障本身拥有即时的自我修复能力,使用起来也较为简单。在实际业务场景中,也确实有大量问题是靠重试就能解决的,比如下游系统繁忙造成的超时、限流等场景。使用重试前一定要清楚的判断出当前场景是否适合重试,盲目的重试会给下游带来倍数增长的处理符合,在读写扩散的场景下甚至可能演变成对下游的DDOS攻击。重试的策略由下游业务的可接受性与功能特性决定,在设计重试前一定要先确认被重试的功能是否支持幂等,失败的尝试是否会造成预期外的副作用,只有在确认不会对下游带来预期外的副作用时,才能放心的实施重试。

流控

流控是一种保护系统资源的容错手段,任何系统都只能在一定负荷下,正常的对外提供服务,当流量异常陡增情况下,系统就会过载,如果任由系统过载可能会导致系统响应变慢或资源耗尽引起系统崩溃。为防止这些情况出现就需要有选择的对非核心的接口进行流控以防止它们占用过多的系统资源影响核心功能的可用。

熔断

熔断是一种回退为结果的容错手段,当下游的失败率达到一定阈值的之后让接下来的请求执行回退操作,从而防止应用程序不断地尝试执行可能会失败的操作,浪费系统资源或加重下游错误的影响,常见的回退操作有自定义降级,故障沉默和快速失败。

降级

降级是一种以牺牲部分非核心体验为代价,以保证功能核心体验可用的容错手段。它的本质是系统资源与功能体验的取舍。当系统的资源不足以支撑完整的功能体验且无法立即增加资源时,系统将会面临资源耗尽而不可用的风险,为最大程度的保证系统可用,我们只能视业务放弃部分非核心体验,让被释放的资源用于支撑核心功能体验,常见的可被牺牲的非核心体验有:数据一致性、非核心路径次要功能以及核心路径功能的非必要部分。

一些发散

聊完了弹性工程实践的基本思路,又想做一些发散思考。我相信每位工程师都不希望自己负责的功能出BUG,但也必须要直视只要是人负责的功能就一定有概率出现BUG的事实,对写BUG这件事我们既不要盲目自信也不要避之不及,客观的直视错误出现的可能性才是一个工程人应有的素养。弹性工程带给了我们一套容错的方法论,让我们有能力应对非预期产生的错误,其核心思路从更为抽象的角度来说,无非是先想办法掌控局面(通过故障隔离),后制定方案解决问题(通过容错),看似朴素,但其实普适性非常强。就如很多Junior的工程师改BUG问题越改越多,其本质就是还没控制住局面就尝试直接解决问题,没有精准的根因定位和影响面判断,任何解决方案都不过是一个丑陋而粗糙的补丁。先努力掌控重要的事实基础,再基于此以严密逻辑论证得出结论,不让任何工程决策成为无源之水、无本之木,这才是一名专业工程师与普通码农的本质区别。