随着阿里技术的发展,我们的技术系统越来越成为社会的基础设施,对于这些系统的可靠性要求也就越来越高。但是实际上很多的基础的产品和系统确仍然会出现一些稳定性问题,那么如何才能构建可靠的系统呢?是不是制定非常严格而细致的规则就可以做出可靠的系统呢?
航空业的教训
在回答这个问题之前,我们先来看看对于系统可靠性要求非常高的航空业是怎么做的?美国的FAA是在航空安全领域事实上的权威,为了保证航空器的安全,FAA制订了非常详细而复杂的航空器认证规则,而这些规则是否就保证了航空器的安全了呢?
让我们来了解一下最近的两起空难:
2019年3月10日,埃塞俄比亚航空ET302航班在起飞六分钟后坠毁,飞机上载有149名乘客和8名机组人员。
2018年10月29日,印尼狮航JT610航班在起飞后约十分钟坠毁,飞机上载有181名乘客,和8名机组人员。
几百条鲜活生命的消逝,这是多么严重的后果啊!究竟是什么原因导致了这样的灾难呢?
这两起空难的共同点是都是波音的737MAX机型,并且都是在起飞后不久发生的空难。那么这个背后的原因是什么呢?虽然官方的调查还没有结束,但是民间的分析指向了同一个原因,那就是这款机型的设计问题。
上图展示了Boeing 737 MAX的CFM LEAP引擎,值得注意的是和一般民航飞机不同的是,引擎的上沿和机翼平面几乎齐平。为什么会这么设计呢?这是因为波音737系列最早是上世纪60年代设计的,当时的引擎的直径小很多,外形更加细长,而机翼的高度是和引擎直径相匹配的。但是随着技术的发展,更新更省油的引擎直径变得越来越大,这时候原来的机翼高度无法满足更大直径引擎的安装空间,要想调整机翼的高度则需要改变起落架的设计,改变起落架的设计则需要改变起落架收起时相关机体位置的设计,而机体设计的变化会带来更多的变化从而会被FAA认为是一款全新型号的飞机,而全新型号的飞机则需要经历完整的FAA认证流程,会带来巨大的时间和经济成本。为了避免这样的成本波音选择了将引擎前移并且提升高度,但是这样带来了另外一个问题,由于空气动力学方面的原因,飞机会变得静不稳定,特别是在起飞阶段,引擎的推力会导致飞机迎角过高进入危险的失速状态。为了回避这个问题,波音引入了一个自动控制程序MCAS,通过读取迎角传感器的数据判断飞机是否迎角过高,如果过高的话自动控制飞机降低迎角,从而保证飞机的安全。
那么这么一套保证飞行安全的系统和空难有什么关系呢?事实上MCAS系统工作得非常好,根据波音自己的统计,Boeing 737 MAX系列已经完成了数十万次的安全起降。但是问题在于当传感器工作不正常时,MCAS有可能会根据错误的迎角数据做出错误的判断和动作,也就是在不应该降低迎角的时候降低迎角,导致飞机直冲地面。
一起后果扩大的故障
回到我们的工作中,前不久我们碰到了一起系统故障,其过程有一定典型的意义,为了描述方面,这里隐去一些具体细节,简单说一下故障的过程。
开始的时候,由于某些原因导致缓存命中率有所下降,而缓存命中率下降导致了数据库load升高,而数据库load升高以及可能的慢SQL导致了部分请求在获取DB connection的时候超时,从而引发了exception。当exception发生的时候,为了保证系统的可用性,系统逻辑进入了一段兜底逻辑,而这段兜底逻辑在特定的条件下产生了错误的返回,从而导致线上脏数据,而这些脏数据带来了业务资损和大量的人工订正数据的成本。
这个故障处理的过程并不是重点所以不再赘述,我们要问的是为什么一个简单的exception会导致这么严重的后果呢?
两个事例的共同点
如果我们仔细去观察上述两个事例,我们会发现其中有如下几个共同点:
- 为了好的目的而引入了非常简单的备用逻辑直接保证“效果”
- 这些备用逻辑在绝大部分情况下都能正常工作
- 但是在极端情况下这样的逻辑失效了,并且产生了严重的后果
换句话说,系统设计者在尝试用非常简单的逻辑去解决一个实际上复杂的问题,虽然实际上并没有完全解决问题,但是因为这样的逻辑能够通过大量的测试(或者合规检查),所以系统设计者“假定”问题得到了解决,从而放心地应用到了生产环境。
那么一个非常复杂的问题是否真的能够通过一个简单巧妙的办法解决吗?
没有银弹
在系统设计领域,我们通常会把问题的复杂性分为两类,分别是偶得复杂性,实质复杂性。
偶得复杂性 Accidental Complexity
所谓偶得复杂性是指由开发者自己在尝试解决问题时引入的复杂性挑战,一般而言是由解决问题的方法和手段带来的,对于特定的问题,不同的方法会带来不同的偶得复杂性。
实质复杂性 Essential Complexity
所谓实质复杂性是由事务本身所决定的,和解决方法无关。
对于偶得复杂性,通过变换解决办法是有可能用简单的办法来解决的,但是对于实质复杂性,我们是无法通过改变手段来解决的,而必须采用相应复杂的方法来解决问题。换句话说对于实质复杂的问题,不要指望有银弹。
上述事例中,实际的环境和问题是存在比较大的实质复杂性的,然而我们却试图通过一些非常简单的逻辑去解决问题,从而带来了严重的后果。
快速失败 Fail Fast
那么要想防止这类问题,设计高可靠的系统要怎么做呢?
这里我想介绍一条反直觉的软件设计原则,快速失败(Fail Fast):
In systems design, a fail-fast system is one which immediately reports at its interface any condition that is likely to indicate a failure.
这是一条反直觉的原则,大部分人听说这条原则的第一反应是这样不是让系统变得更加脆弱了吗?
实际上并不是,原因在于我们不能停留在某一次的失败(failure),而是需要观察完整的过程,如下图所示:
当问题发生时,系统立即停止工作,由人工介入找到并以合理的方式解决根本的问题,然后系统恢复运作。通过这样的选择,我们就能够更早更容易地暴露问题,每当系统发生问题之后,真正的根因会更快得以解决,所以最后我们就能得到一个更加可靠的系统。
需要说明的是,快速失败不是说系统设计不处理任何问题到处失败,而只是在面对essential complexity的时候,不要尝试用一个简单粗暴的方案去解决,要么就用一套合理的机制设计去解决它,要么就fail fast把控制权交给系统上层决策,通常来说最终可能会回归到人,由人来分析和处理问题。
根据实际的工作经验,我还发现一个有意思的现象,很多系统的设计者往往高估一些能轻易想到的问题的严重性,而低估那些想不到的问题的严重性。上述的软件系统故障的例子中,如果异常往外抛出,问题的后果可能仅仅是某些操作人员的部分操作失败,用户可能会重试,也可能会开工单把问题反馈上来。只要我们处理工单的同学及时响应并且解决根本问题,这个问题就不会演变成一个比较严重的问题。但是不抛出异常通过备用逻辑来兜底一旦失败,会导致比较严重的后果。如果系统的设计者能够认真衡量和计算这些后果的差别,就会做出更加合理的选择。
当然还有一点就是快速失败会把更多数量的问题暴露在用户面前,让用户在心理层面有不好的影响,但是需要注意的是不暴露问题不等于解决问题,暴露问题只是让大家看到了问题而已。为了更好更早地暴露问题,一方面我们需要引入更完备的测试防止问题进入生产环境,另外一方面也需要引导系统使用者以更加客观实际的心态来接受系统中的问题。
不要重复自己 Don't Repeat Yourself
和构建稳定系统相关的另外一条原则是软件工程中常说的DRY原则,也就是:
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
这一条和稳定系统的关系在于,通过合理的复用设计,能够大幅度提高系统的可测性,降低调试问题的难度,提高系统的可维护性,背后的逻辑还是比较简单的,这里不再赘述。
如何实践上述原则
那么要想实践上述原则构建可靠的系统需要注意哪些方面呢?
结合自己的工作经验,我认为主要是这么几个方面:
所有的原则都是有代价的
世界上没有免费的午餐,借用之前Choice课程老师的一句话,坚持价值观都是有代价的,我们也可以说
坚持原则都是要付出代价的。
这里的代价包括工作量,短期结果,解决问题的难度,带来的项目风险等等,系统的设计者需要做出合理的权衡,付出一定的代价才可能应用上述的原则。
刨根问底,5 whys找到根因
当问题发生时,最重要的事情在于找到问题的根因,只有我们解决了根本的问题,系统才会真正变得健壮起来,否则都只是假象。我们可以用 5 whys 的办法来找到问题的根本原因。
回归测试保障
个人认为自动化回归测试相当于汽车的安全带,我们需要构建覆盖度高的自动化回归测试保障体系,从而更早更好地发现问题,减少对于最终用户的冲击,把问题扼杀在萌芽状态。
让团队养成好的习惯
不管是一个开发者,还是一个开发团队,坚持原则并不是临时起意,而需要成为习惯。只有把原则变成习惯的个人或者团队,才能够真正贯彻这些原则。所以平常工作中某些看起来没有必要的坚持原则,实际上有助于习惯的养成,而当原则成为了团队的习惯,这些原则才能在需要的时候得以实践,获得回报。
不要把fail fast曲解为快速试错
可能有人会认为fail fast就是快速试错,也就是不断尝试,碰到正确的为止。需要强调的是快速失败需要很好的设计和机制保证。
后记
以上是我对于构建可靠系统的思考与实践总结,最近做了一次分享但是感觉没有讲好所以写下来,欢迎讨论和拍砖。