前同事发了个朋友圈,"经历了三个月,终于完成了全系统的流量切换,一个系统好不好就看它能承受多少的异常流量",让我非常有感触,兴之所至想要总结一下自己工作近十年来对"系统健壮性"的理解。
系统健壮性
从定义上来说,系统健壮性,也称为鲁棒性或稳健性,指的是一个系统在面对各种内部或外部错误、异常情况或不理想环境时,仍能保持稳定运行并正确执行其功能的能力 ,其核心在于实现稳定运行 和正确执行。
每个系统的规模不同、复杂度不同、技术栈不同、开发团队的水平也会有差异,但健壮性是可以通过系统的可用性(Availibility)和可靠性(Reliability)来一视同仁地进行评估的。一般来说,系统越复杂、规模越大,其健壮性也会越差,即使代码非常简单且具有良好编程的系统,其基础设施也有可能出问题,比如系统重启、网络丢包甚至数据中心断电,假如系统的每一个组件或节点出现问题的概率为 P,那整个系统不出问题的概率则为 (1-P)n,那么系统的健壮性就越难实现。
如果把系统的健壮性分解一下,可以解读为系统在异常处理 和故障恢复两方面的能力:
异常处理
用户行为异常
对于面向用户的系统,程序设计者很难预测用户的行为,即便我们可以一定程度上约束和规范用户的行为,比如从 UX 层面限制用户的操作、用代码校验用户的输入内容,但随着系统的不断迭代、功能不断演进,已有的一些校验和限制将会失效,系统对用户的约束将会减弱。约束用户行为是非常合理且有效的保护系统健壮性的行为,但任何方法和手段都会有边际成本,如果开发团队过度关注约束用户的行为将会影响开发进度和工作效率,因此用户的行为异常是无法避免的。
在系统测试中,常常会采用边缘测试和异常测试来模拟用户行为,测试系统在边界或极限情况下的行为表现。
系统调用异常
无论系统大小、复杂与否都不可避免地会产生依赖关系。这些依赖关系可能来自系统外部,比如调用第三方的 API 或者引用外部数据;也有可能来自系统内部,比如微服务架构下的不同服务互相调用、分布式服务的负载均衡和锁、单体服务的数据读写操作等。任何服务都不是100%可靠的,所以依赖方应当恰当处理被依赖方失败的场景,比如重试、熔断或者服务降级;假如涉及到关键数据的处理,应当对数据有临时备份以保证数据的完整性。
在代码的单元测试中,为了测试的独立性通常会采用模拟的方式来屏蔽对外部资源的依赖,但这种方式往往也屏蔽了对依赖调用异常的处理能力,因此在代码中也应当添加异常流的测试用例。另外,优秀的系统范围的集成测试和端到端测试也能够帮助检测依赖调用产生的异常,要跟它们做朋友。
系统运行异常
我们通常认为机器是可靠的,但其实不是。即使是在云计算、容器化技术如此发达的场景下,仍然会有难以预测的事故出现,比如 2021 年的得克萨斯州大雪造成的停电影响了很多云计算数据中心的服务。所有程序的正常运行都必须依赖底层基础设施的良好运转,得益于硬件虚拟化和容器技术的进步,现在的基础设施运行异常可以从软件层面得到处理和恢复,但真正的硬件异常仍然是系统设计和实现中仍然需要考虑的重要内容。举个例子,总不能一台机器故障就导致整个系统瘫痪吧?
另外,程序的 bug 也是系统运行异常的很重要的组成部分,这些 bug 可能会导致进程崩溃、系统过载甚至造成数据污染。因此,正确处理程序的异常(Exception)、持续监控系统的健康以及合理限制不同组件的影响范围也是至关重要的。这些也正体现了程序员的编程和系统设计能力,所以在软件开发中这部分内容一般都能够得到实现和保证。
因此,正确设计并实施软件测试能够帮助发现和规避绝大部分的程序 bug,良好的系统边界设计能够帮助限制异常的影响范围。
故障恢复
程序故障恢复
程序在运行的过程中难免会遇到错误,当出现错误或异常之后,系统应当能很快地恢复。举一个很经典的反例,大家都知道 Node.js 默认以单进程、单线程的方式运行,这意味着所有代码都在一个主线程上执行,并且只有一个JavaScript 引擎实例在运行,因此当程序在运行中出现了没有正确捕获并处理的异常时,可能会导致进程退出从而使得整个程序崩溃(这个问题并不是 Node.js 独有的,在此仅以其举个例子 )。因此,为了提高系统的健壮性,我们需要部署多实例,并且采用带探针的负载均衡提高系统可用性,在 Kubernetes 等强大平台的帮助下我们还能实现服务的故障自动恢复(依赖于服务探针,详见Configure Liveness, Readiness and Startup Probes)。
基础设施故障恢复
我们常常会以基础设施能够稳定运行为前提编写系统代码,这种做法高效且合理;但是对于系统设计者来说,基础设施则是系统健壮性中至关重要的一环。假想程序运行的机器重启或者无响应了,可能是磁盘损坏、机房断网、甚至有可能只是某个插座松了或者其他不可预料的事情,比如别人清明烧纸,北邮清明烧服务器或者微软Azure云服务故障超过24小时,原因竟是被雷劈了。
随着基础设施成本的降低,尤其是云计算时代来临后,很多系统开始对关键组件甚至全组件做基础设施冗余方案,比如搭建一套备用集群可以随时切换。在 Kubernetes 等服务编排平台的加持下,基础设施的管理和故障恢复变得更加透明,各大云服务商也纷纷推出了托管的云计算平台,比如 Azure AKS、Google GKE、Amazon EKS 等。用户只需要部署系统服务和相关的组件,基础设施则完全由云平台进行管理,并且保证 99.99% 甚至更高的可用性。
但是这些服务并不能让系统设计者高枕无忧,基于云平台的基础设施仍然需要有对应的故障恢复方案来保障关键服务的可用性和重要数据的完整性。我们仍然需要之前的备用机,只是它们被部署在了云平台上。
系统性灾难恢复
传统的服务器容灾和备份方案常采用两地三中心方案来提高系统的可用性,这种方法现在仍然适用只是成本相对高昂,毕竟搭建和维护多个机房的前期投入还是很大的。得益于云平台的发展,即使是初创公司也可以廉价且轻松地实现"两地三中心"的灾备方案,只需要在不同的区域部署服务器并设置好数据备份方案即可。
如果真的发生了系统性的灾难,比如整个数据中心遭遇了自然灾害导致服务全不可用,这种情况下系统都没有了,还有健壮性可言吗?答案是肯定的,所有的备份和备用服务器也是系统的一部分,即使正在运行的服务遭遇了毁灭性打击,能够很快地恢复系统也正是"健壮"的体现。
绝大部分的云平台都提供了不同的服务区域(region)并在同一区域内提供不同的可用区(avaibility zone):服务区域代表了该数据中心所在的地理区域(可以理解为不同的城市)而可用区指在同一个区域内电力和网络相互独立的物理区域(可以理解为不同的机房)。
得益于云服务商强大的基建建设和运维能力,系统可以通过部署在不同的区域和可用区来提高灾难恢复的能力,比如主要的服务部署在阿里云华北2(北京)区,并在华南1(深圳)区部署相同规模的集群(非必要时可以停止实例运行以节省费用)并且将同一个区域内的数据库服务部署在不同的可用区内。与此同时,应当正确配置数据的本地和远程备份,一方面保护数据安全另一方面提高故障恢复的能力。
这种情况下,即使某一个机房遭遇故障也不会导致服务完全不可用,因为其他可用区(机房)仍然在正常提供服务;即使某一个城市遭遇自然灾害也能够很快地在另一个城市很快地恢复服务。
总结一下
系统健壮性不是独立存在的,它需要优秀的系统设计、缜密的编码思路、稳定的基础设施和完善的灾备方案。
&¥%%......&R*&()(&
编不下去了,说点人话
- 通过 UX 约束用户行为,避免不合逻辑的操作
- 通过代码校验用户输入,避免异常内容
- 调用其他组件时,无论是 API 还是数据库,记得检查调用和执行结果
- 经常使用 try catch 捕获并处理异常情况
- 数据操作要引入事务(transaction)
- 和测试做朋友,单元测试、集成测试、系统测试、端到端测试等各种测试要经常跑
- 混沌测试是个好东西,不一定非得真的搞出点什么,一群人坐在一起纸上谈兵也很好
- 分布式系统要合理设计并利用数据锁(lock),并考虑死锁的情况
- 系统设计一定要划分好边界,系统边界、模块边界、服务边界(微服务),控制单个异常影响范围
- 对各个组件都要冗余设计,避免单点故障
- 合理设计熔断和服务降级机制,减少系统异常的影响范围
- 对关键服务进行访问控制管理,避免非关键服务发疯导致关键服务过载
- 对异常情况下的重试机制设置次数/时间上限,避免对异常服务持续调用
- 对各个组件和集群都尽量采用分布式高可用的部署方案
- 尽量多使用负载均衡,哪怕只有一个实例
- 负载均衡必须要有健康探针
- 正确配置系统监控和告警规则
- 使用云服务管理基础设施
- 使用云服务的备份和冗余服务
- 永远都有 plan B