云计算基础设施的快速发展使得分布式微服务架构成为可能,系统架构复杂度随之激增,运维难度也越来越大,稳定性面临前所未有的挑战。割裂式的组件保障再也没法满足业务快速增长的需求,保障团队逐渐从后台走向前台,同时结合实践经验开始反哺稳定性设计模式、工具和管理体系建设,并且开始转变为“面向自恢复”的思路,系统稳定性才得以大幅度提升。本文结合理论与实践对稳定性建设做了一些总结,供各位阅读思考,本文为上篇。
认识故障
Richard Cook 1998年在其著名论文《How Complex Systems Fail》中提到关于故障的18个观点,他从事于医疗IT研究领域,理解这些观点有助于我们理解复杂系统故障的本质和应对策略:Complex systems are intrinsically hazardous systems. 复杂系统本质上都是高风险系统。
Complex systems are heavily and successfully defended against failure. 复杂系统都对故障严加防范并且行之有效。
Catastrophe requires multiple failures–single point failures are not enough. 灾难性事故由多起故障共同造成-单点故障不足以兴风作浪。
Complex systems contain changing mixtures of failures latent within them. 复杂系统中潜伏着变化多端的故障组合。
Complex systems run in degraded mode. 复杂系统运转时总是处于降级模式。过去我们常认为系统追求的目标是“应用零异常“,实际上复杂系统很难做到零异常,常常因为外部环境的变化产生新的异常并且触发熔断降级等安全保护策略。针对异常管理而言,零异常的目标应该转变为对外高可用率,对内精细化管理异常,使得已知异常和新增异常得到正确识别并且及时修复可修复的异常。
Catastrophe is always just around the corner. 灾难总是近在咫尺。
实际上这也是分布式软件系统的一大特点,光纤可能被挖断、机器可能故障、电力可能中断、人员可能误操作,几乎无法穷举的故障类型,让系统性故障成为分布式系统的一种潜在特质。
Post-accident attribution accident toa ‘root cause’ is fundamentally wrong. 在事发之后将事故归咎于某一“罪魁祸首”的做法完全不可取。
因此我也不建议在事故评审时将某个行为或做法视作罪魁祸首,应当举一反三地看待问题,也只有这样才能促成组织内各职能的全面提升与产品的全面提升,而非单方面改进。
Hindsight biases post-accident assessments of human performance. 事后成见会扭曲事故评定人员的认知。已知事故结论的情况下,人们(特别是专家)常常假设当事人如果注意到XX现象或者不要这么做就能够避免这次事故或者减小事故影响,这种马后炮行为常常没有客观还原事故真实过程,对事故评定结论产生重大影响。
Human operators have dual roles: asproducers & as defenders against failure. 操作人员分饰二角:他们既是故障的始作俑者,也是故障的防范者。
All practitioner actions are gambles. 当事人的举动完全是在冒险。事故发生之后,人们往往会认为事发之前导致事故的重大故障就已经在所难免,之所以最终会酿成事故,是因为当事人在故障迫近时处理失当或玩忽职守。但实际上,当事人在采取行动时完全是在冒险,他们无法预知自己的行动会导致什么后果。灾后分析通常都不会将这些行为判作明智之举。反过来看:即便处理得当,也不过是瞎猫碰上死老鼠,无法得到广泛认同。
Actions at the sharp end resolve all ambiguity. 风口浪尖上的行为令一切模糊性消失殆尽。各种组织都存在一定的模糊性,而且这种模糊性往往是蓄意造成的,它体现在生产目标、资源使用效率、运作成本,以及对不同程度的潜在事故的容忍度等多个方面。然而在评判那些被抛至风口浪尖的从业人员的行为时,这些模糊性却消失殆尽。发生事故之后,当事人的行为往往会被视为“失误”或“违规”,但这类评判带有严重的事后成见,往往无视业绩压力等其他诱因。
Human practitioners are the adaptable element of complex systems. 从业人员会对复杂系统进行调整。
Human expertise in complex systems is constantly changing. 复杂系统中的专业人才不断更替。
Change introduces new forms of failure. 变化会引入新的故障。
Views of ‘cause’ limit the effectiveness of defenses against future events. 抵御未来事件的效果受限于人们看待“肇因”的方式。
Safety is a characteristic of systems and not of their components. 安全性是系统整体的特性,而不是系统中各部件的特性。
People continuously create safety. 人们持续不断地营造安全的环境。
Failure free operations require experience with failure. 无故障运营需要故障处理相关的经验。
事实上,关于工业/医疗/安全生产的稳定性方法体系早于软件系统的稳定性体系,也给软件生产运维人员提供了一些启示。在单体架构时代,我们所说的稳定性停留在根因,比如通过5-Why等分析方法去分析根因,并且极力阻止故障的发生。而对于组件数成百上千的复杂系统,思路发生了一些变化,比如:事故没有根因、为什么(5-Why)发生事故要转变到如何(How)发生事故、我们想尽办法让系统正常工作而不是阻止故障发生。在做稳定性工作之前,首先需要认识到上述错综复杂的环境。伴随着云基础设施的成熟应用,软件系统拆分粒度得以细化,也给极致的DevOps带来了可能性(run what you build),随之而来的就是复杂系统的稳定性运维挑战,很多公司也引入了软件可靠性工程师这个角色(Site Reliability Engineering)来去适应这样一个“系统革命“,这些工程师致力于通过洞悉系统、建设相关工具体系并进行事故管理得出稳定性最佳实践,不断提升系统的整体稳定性,并将“让业务丝般顺滑”作为团队主要目标之一。
构建稳定性-探索已知的未知
一个健壮的系统即使在遭受短暂波动、持续压力,亦或是某个系统组件故障下仍保持正常工作,这就是大部分人所说的稳定性。测试使得问题可见,才得以修复,这也遵循了墨菲定律所说,任何未经测试的潜在问题必然发生。同时我们也都知道光纤是有可能被挖断的、设备终有一天一定会故障宕机等这些事实存在的风险,抱着怀疑的态度去设计,系统将会更具弹性。当然,怀疑对象基于人对现实世界的认知,如果对现实世界的组件和行为认知不全或者认知不正确就可能导致这个点成为一个隐患点,因此对于这些点的梳理也至关重要。不同公司不同系统应用的组件和行为各式各样,这里暂时不去穷举这些组件和行为,而是换一种思路,去了解一个系统可能发生故障的模式化总结。故障模式
线上遇到的故障多种多样,而总结出故障模式有助于我们抵御故障,并将最佳实践落地到系统中,以此来达到相同的事故仅出现一次的目标。当然随着新组件的引入、软件架构的变化、新运维流程的加入,必定会引入新的故障模式,因此下列模式也仅仅是前人总结的历史典型。- 针对组件依赖、二方依赖和三方依赖,可以使用熔断或使用消息队列解耦
- 强弱依赖测试非常有用,健壮的系统应当容忍弱依赖故障
- 网络抓包是一个很有效的问题分析办法,例如对HTTP、Redis协议进行抓包诊断
- 若系统防御性措施做得不好,依赖故障会很快导致系统级联故障最终影响用户
所有依赖最终都有可能发生各种各样的故障(网络故障、消息语义不符、响应变慢、请求悬挂),我们应当提前为这些故障做好应对的准备。防御性编程的手段如熔断、超时控制、消息中间件解耦、健康检查,通过防御性编程,通常能避免依赖故障导致的系统整体事故。连锁反应通常发生在一个有资源泄漏或者过载的集群中,唯一根治的办法就是找到并修复问题。成本允许的话,通过集群隔离部署或者弹性扩缩容也能在一定程度上降低整体故障的概率。水平扩展的软件架构虽然已经在资源层面冗余,能容忍单点资源故障,但在高负载情况下容易出现因竞态条件导致的并发性能问题。极端情况下,例如集群只有两个节点,那么挂了一个节点就会导致另一个节点负载翻倍,最终可能导致资源耗尽故障。另外例如第一个节点由于内存泄漏或间歇过载而故障,其他节点也会逐渐因此发生故障导致集群不可用。级联故障是最频繁的导致问题恶化的故障模式,避免级联故障也是弹性工程的核心,可以使用熔断和设置合理的超时来规避。应对这类故障需要时刻留意资源池(如业务线程池)情况,资源池耗尽通常是级联故障的诱因。级联故障始于某个组件缺陷而影响到调用方,最典型的就是数据库故障,一旦数据库故障,若应用没有设计异构的兜底逻辑,应用随之故障。每一个依赖故障都可能导致级联故障,而此时若有重试,故障会更加严重。用户是我们的衣食父母,但却又是系统里比较难应对的部分,请求数、数据集、资源消耗都会随着用户数呈倍数增长。- 用户流量最终引发系统容量问题,弹性扩缩容是一个解决办法(注意运用Little's Law来评估系统容量)
- 与用户相关的无界对象缓存,如内存用户Session最终可能导致堆内存不够用而引发out-of-memory或疯狂gc,最极端情况甚至连log4j或javaagent都会停止工作,监控因此失效,设计时务必只在内存中保留少量有界缓存
- 由于本地端口数受限,open/closed sockets资源都会受限,注意主动发起/关闭的短连接占用的本地端口
线程阻塞是大部分故障的原因,同样也导致连锁反应和级联故障,需要注意资源池的持续监控,并且设定合理的超时。再强调一次,Little's Law对系统容量的评估至关重要。
典型场景是大促权益类产品的免费发放导致的系统故障(预估发放有限个免费权益,而实际上面临的是无限的洪峰)。shared-nothing架构如静态着陆页,或提供系统降级模式是个有效的应对办法。这类业务需要留意共享资源,如数据库。- 线下与线上由于集群规模不同,评估放大效应的结果不同
- 点对点通信,如Redis Cluster的Gossip协议,两两连接有典型的放大效应,整个集群的连接数是O(n^2),那么就需要评估单机系统的连接资源情况
- 共享资源,如共享中心缓存、数据库,因此我们也说最具可扩展性的架构是shared-nothing架构
前端的某次活动导致的调用量剧增,后端线程数不够用就是一个典型的容量不匹配的例子。系统容量评估的是短板容量,时刻注意短板的服务器数量和线程数情况。容量不匹配是放大效应的一个特殊例子,需要做好容量压测观察系统表现的准备。瞬时负载有多种情形,例如Java服务批量重启时配置初始化和服务节点注册都会导致注册中心压力剧增,定时任务凌晨dump数据,都会导致瞬间的负载比正常运行时更高,单一节点如此,多个节点更会放大,如果忽视很容易因此引发故障。因此建议在服务设计时考虑引入随机数进行错峰以及有退避策略的重试,另外就是做容量评估时尽可能包含这种瞬时负载情况。比如应用网关的Tomcat线程数开的是上限2000个,平时正常运转可能只需要500个,而应用内使用了大量的ThreadLocal导致占用大量内存,500个线程时可能没有问题,但一旦2000个线程全部运行起来,系统可能就会疯狂gc最终导致故障。人工变更与自动化变更的预期和行为需要保证一致,若人工需要执行与自动化不一致的干预(如手工升级),需要关闭自动化行为。比如你的目标是关闭一个正常的实例,好进行数据迁移,而自动化认为实例故障了,则重新拉起实例,那么迁移的数据就不一致了,在复杂的变更中极易引发系统性故障。- 单个节点探测到大部分节点都挂了明显可能是个错误的结论,可能是自身网络或者JVMgc问题;对于观察到的状态与预期状态偏差很大的情况,可以加入人工干预进行确认
- 如果根据队列长度确定需要创建多少消费者实例,需要有一个机制来避免无限创建实例,例如设定一个成本能承受的上限值,防止其他特殊故障时导致的无限实例创建
- 自动化应对策略需要考虑策略执行和生效的时间(自动化操作耗费时间大于监控间隔时间会造成这样的问题),保证不会重复执行策略,例如扩容一个消费者需要5分钟,如果每秒检测到消费不足都去执行一次扩容操作,最终可能会扩容300个实例,实际上1个实例足矣
- 不要过于迷信管控系统,管控系统期望的状态与探测到的当前状态在某些特殊情况下可能是错的
- 不耐心的用户在页面等待过程中频繁点击重试可能导致产生更多流量
- 考虑快速失败,服务在其平均延时超过容忍时间时可以快速失败
- 留意内存泄漏和资源竞争,竞争不充足的资源可能会产生慢请求,并且慢请求反过来又恶化这种情况,最终导致恶性循环,内存泄漏导致的垃圾回收可能会造成慢请求
稳定性设计
如今分布式系统除了数据库、缓存等组件,还引入了大量的分布式调用,所有的分布式调用经由网络,一条链路涉及的层层调用将历经数十上百次网络调用,网络以及二方依赖的可用性并不是100%,设想如果没有超时,系统一旦遇到一次资源抖动问题,将立即扩散并且几乎无法自恢复。10年以前,亚马逊发现每增加100ms的延时将损失1%的营收,谷歌发现搜索结果生成每增加0.5s,流量就要下滑20%。用户侧来说,认为一个Web服务足够快通常说的是10-100ms,比这更高的响应延时就可能开始损失用户。超时通常用来描述一个服务的出流量(快速失败通常说的是入流量),例如RPC调用依赖服务、调用Redis、Memcache。合理的超时配置阻止依赖故障升级为线程阻塞故障,因此超时可以避免级联故障。另需注意超时需要设定合理的退避策略,例如指数型递增的退避周期,不做退避的立即重试通常无法发挥效用,而且反复相同周期的重试会立即放大故障。熔断工作原理类似于保险丝,检测响应时间或错误率(故障密度)若达到定义周期内的故障阈值则打开熔断实现快速失败逻辑(如直接给用户响应一个异常、从兜底静态化存储中返回降级数据、从个性化数据降级到非个性化数据或者调用其他备份服务进行响应),同时一旦问题恢复,则重置为正常状态,熔断在系统可用性里发挥着举足轻重的作用。发生熔断通常是故障的预兆,因此对熔断进行记录、监控、报警对运维来说至关重要,同时熔断频率也是容量评估中的重要一环。我们可以把熔断定义为预故障,而把超时定义为故障。隔离实际上是故障分区,物理冗余是最通用的隔离方法,集群中一台机器故障并不影响其他机器,同样一台机器中的一个进程故障并不影响另一个进程,云主机交付时也常用反亲和来定义宿主机故障的隔离。单元化、多Region、多AZ部署,核心业务集群独立部署都是隔离的代表,但是如单元化数据分区实施起来并不容易。隔离粒度可以是线程级、CPU核心、服务器、集群、机架、机房,隔离的目标就是容灾。你负责的系统是否经常需要登录机器去清理过期数据或者旧日志?如果某些资源会持续积累直至物理上限,则必须有一个持续回收的机制,这就是我们说的稳态。特别是生命周期很长的服务,例如数据库,项目刚开始的数据量和请求量很小,等迈入快速成长的阶段,就可能经历磁盘空间不足、磁盘IO性能不够、响应时间抖动、连接耗尽等各种情况,极端情况下可能导致线上事故。此时需要自动定期清理过期数据,并且通过测试确保清理过期数据后系统功能完好。服务随着运行时间的不断增长,旧日志文件会持续累积,直至磁盘达到哨兵告警阈值,如果不及时处理很可能直接100%,情况好点可能是丢失日志,情况差点很可能服务直接就不响应了。更差的情况,一个服务遭遇依赖异常,异常堆栈疯狂打印,没几十分钟磁盘满了。再考虑缓存的情况,使用缓存前先思考一下容量上限是多少,满了会不会LRU逐出,逐出对业务有没有影响,对性能有没有影响(例如Redis单线程在应用上下文逐出),缓存item是不是没有配置失效时间;如果不允许逐出,缓存容量够不够我们存放这么多key,什么时候需要扩容。遵循稳态的设计模式,需要避免人工介入,服务应被设计为不需要进行人工磁盘清理或每隔一段时间重启一次,并且在应用逻辑中要考虑过期数据的清理问题,同时对缓存设定上限,对日志加上滚动和自动清理。我们可以容忍正常响应轻微变慢,绝不能容忍等了几秒钟最终给你个服务器异常。苦苦等到结果却是个异常,如果系统有一种主动发现错误直接返回失败能力的话,调用方就不用白白等待了。例如B服务依赖的Redis发现已知故障,上游A服务调用B服务却要等Redis超时,结果上游A拿到的结果还是个异常,实在没有等待的必要。我们应该持续检测依赖健康度,并尽早给出反馈。同样的,越上层的拦截,越快的错误,避免请求到最下游后才发现错误,所以如果能从请求参数就过滤出问题,那么可以直接做请求校验来节省链路上带来的系统消耗。“letitcrash"思想源于Erlang,大型分布式程序,代码中往往会遇到奇奇怪怪无法预知的异常,因此几乎没办法做防御性编程,那我们何不就直接让它崩溃,然后由一个管控进程去重启来恢复系统状态。这个思想对开发来说很新颖,对运维来说却是家常便饭,大部分开发面临一个问题点时总会死守问题点本身,一心想着解决这个问题,却忽略了要恢复系统状态其实还有“重启大法“这个捷径可走。程序最干净的状态就是刚启动时,如果故障恢复非常困难,那么我们先选择快速恢复系统状态。对于某些重度技术极客来说可能难以接受,为什么不能定位根因解决,不要忘了系统是给用户使用的,用户受损时的最高优先级是恢复业务,不要纠结于多花时间在抓取系统状态定位问题上。当然上述说的重启大法设计模式并不是真的人肉去重启,而是需要一套快速用新线程/进程/实例替换的机制。拿微服务来说,微服务容器就是一个很好的重启粒度。说到快速,多快算快,快到重启时不足以影响整体系统容量,快到调用方根本感知不到你在重启。极端一点如果一个实例重启花费时间在ms级别的,是不是就可以抵御任何每隔几分钟甚至几秒一次的实例自身故障。这一点值得思考,时间改变了我们对稳定性的认知,原因在于我们认识的服务启动时间通常是数十秒甚至数以分钟计的,这系统“健壮如牛“。握手在底层协议当中频繁使用,如TCP三次握手,在应用层相对没那么常见,而体现在我们工作当中最多的就是Nginx健康检查,Nginx通过异步发送HEAD/health/status去检查后端服务是否返回2xx3xx的响应码,以此来维护正常提供服务的后端节点列表,这一点在我们产品的不停服发布中起到了重要作用。握手在容量失衡当中能发挥巨大作用,如果服务提供方探测到自身容量不足,可以通过握手通知调用方去退避。熔断只能作为没有握手时的权宜之计,有了握手,几乎可以规避一切级联失败,握手在底层协议编程时非常有用。单元测试和集成测试仅测试逻辑与功能是否符合预期,并没有验证资源、依赖、网络等故障下的应用和业务表现。故障测试工具有多种类型,如系统层iptables、tc、kill,应用层JVM Sandbox或者有已开源的ChaosBlade进行故障模拟测试,这些测试工具非常有用。中间件既联结了系统中各个模块,同时又将他们解耦,通过中间件我们将同步转化为异步(不同时间,不同地点),这种方式通常能避免引发级联故障,例如对于一个弱依赖的缓存可以将同步写缓存可以改成异步写缓存(通过消息队列解耦),这样就不会出现缓存故障时影响主要业务逻辑的情况。我们无法控制用户的行为,一旦海量请求压过来,通常会出现由于资源竞争直接导致系统响应变慢或者故障的情况,而对于用户来讲,非常非常慢与故障没有差别,因此一旦负载过高,我们应当通过限流来快速失败去告诉用户做退避,而不是客户端疯狂重试最终导致整个系统崩溃失控。每个服务需要根据自身的SLA去做限流,引入缓冲队列通常也是一种workaround,但是同时引入了延时。几乎所有性能问题都始于队列堆积,可能是套接字的listen queue,也可能是系统运行队列亦或者是数据库的I/O队列,如果队列是无界的,可以耗尽所有内存。随着队列的增长,完成业务逻辑所需的时间也越来越长(详见Little's law)。一旦队列变得很长很长,请求的响应延时也会变得很长,因此我们应该设定一个有界队列,而这也引入了一个问题,一旦这个队列满了,应当如何处理后续的请求:不同的业务类型的处理策略不同,针对数据价值随时间递减的业务来说马上丢弃是正确的选择。让调用方等待实际上是一种流控,它给上游带来背压,并持续向上透传给客户端。背压带来了线程阻塞,因此通常应用在异步调用中,如果系统间RPC调用都是同步处理的,那么前三种策略更合适一些。通常在如Flink的流式数据处理系统中会看到背压的应用。控制面的自动化程序执行速度很快、快到可以瞬间生成无数个实例、快到IT成本成倍增加,也可以快到瞬间关停所有实例,如果不对自动化程序做约束或者人为管控,可能会产生灾难性的后果。安全与不安全之间存在一个弹性边界,同时达成了性能需要与成本控制,亦或者是性能需要与安全控制,正如高速公路限速120km/h一样,虽然汽车可以跑得更快,但是道路的设计时速有上限,并且汽车之间需要协同行驶。一些极端主义自动化信奉者很容易忽视人在系统中发挥的作用,程序设计很难十全十美考虑到所有场景,在一些badcase下程序快速决策执行将会带来灾难性后果,此时应预留人工介入卡点或适当放缓执行速度以便人工介入。
作者简介:马宏展,网易杭州研究院资深应用运维工程师,目前主要负责网易云音乐的稳定性体系建设等相关工作。2012年于中南大学计算机系毕业后入职迅雷,从事过系统运维、应用运维等工作,积累了丰富的大规模集群运维经验。2015年加入网易,曾负责网易新闻、网易云音乐等产品运维,有着丰富的分布式系统运维经验,同时经历过多年的产品一线救火,拥有对故障根因敏锐的洞察力,具备对性能和稳定性独到的见解。
相关推荐