背景
随着业务的快速变化和技术的不断发展,系统面临着诸多挑战,例如流量峰值、依赖服务故障、硬件故障、网络中断、软件缺陷等,这些因素都可能影响到系统的正常运行。在这种背景下,弹性设计(Resilience Design)应运而生。弹性设计是一种系统的设计和构建方法,系统的设计原则应该本着不信任外部资源(外部API服务、网络设备、存储、消息等)100%可用的原则,在关键处理路径上针对上述可能发生故障的点进行容错加固设计,保护系统自身的可用性。它的目标是使系统能够在面临压力和不确定性时,保持服务可用性和性能,而不是简单地在问题出现后进行修复。弹性设计考虑到了系统可能会遭受的各种攻击,包括物理攻击、网络攻击、软件错误等,并采取了相应的预防措施。
弹性设计的核心思想是预见并应对系统可能面临的风险和挑战,这需要对系统的需求、架构、组件和服务进行深入的理解和管理。弹性设计还强调了自动化和快速响应的重要性,以便在问题发生时,能够迅速地检测到问题并采取相应的措施。
总的来说,弹性设计为系统稳定性建设提供了一种新的视角和方法,它有助于提高系统的可用性、性能和安全性,同时也降低了维护和修复的成本和风险。
一、故障隔离标准
1、故障隔离概念
故障隔离是指当系统中某些模块或者组件出现异常故障时,把这些故障通过某种方式隔离起来,让其不和其他的系统产生联系,即使这个被隔离的应用或者系统出现了问题,也不会波及其他的应用。故障隔离方案是一个应用系统实现高可用的重要组成部分。
2、故障隔离原因
系统必须具备防止故障从一个系统/组件传播到另一个系统/组件的能力。故障从一个系统/组件传播到另一个系统/组件通常有以下两种原因。
- 系统/组件间强依赖:如果系统/组件间存在强依赖,当一个系统/组件发生故障时,强依赖它的组件将无法正常工作。防止强依赖引发的故障传播,通常的手段是将强依赖转化为弱依赖或最弱依赖,比如设置合适的超时、捕获异常、同步依赖转异步依赖、提供备份组件等。
- 系统/组件间共享资源:如果系统/组件间存在共享的资源(如线程池、数据库连接池、网络连接池、内存区等),当一个系统/组件因为故障耗尽了共享的资源后,所有依赖该资源的系统/组件也都会发生故障。防止共享资源引发的故障传播,通常的手段是对组件的资源使用建立配额体系,或者为重要组件提供专用资源。
二、访问量控制标准
访问量控制是指服务提供者或者服务使用者对服务资源有效的SLA控制,在做访问量控制设计时,需要关注以下几方面:
- 服务提供者必须给出本服务(包括系统调用服务、页面服务等)的访问策略,包括最大的访问能力、其它访问约束(如参数约束、单账户访问约束等),说明违反服务访问策略的后果。
- 服务提供者需要对违反服务访问策略的情况,实施管控措施。我们要求所有对外提供服务的系统(如对外服务的网关系统、对外服务的web系统等)必须具有防止外部访问过载的能力(即具备限流能力)。
- 渠道入口系统需要具备能够降级入口服务的能力,确保入口功能服务在出现异常时,在交易链路的最前段截断异常,防止影响扩大。
- 服务调用方需要对关键场景下的非关键服务访问进行容错设计,常用的手段包括(熔断、降级),确保在非关键服务访问出现异常的情况下,迅速切断该服务访问,保证关键交易成功率。
- 服务调用方在调用第三方服务时,需要明确外部服务能力,并具备相应手段可以进行访问控制。
- 原则上所有控制访问量的手段(如限流、熔断、降级)均应具备实时调整的能力,以保证在异常访问下系统的动态性能余量充足。
三、服务降级、限流、熔断
熔断和限流都可以认为是降级的一种方式
1、服务降级
服务降级是当出现系统/组件故障后,以牺牲某些业务功能或者牺牲某些客户群体为代价,保障更关键的业务、客户群体服务质量的应急措施。服务降级可以是人工触发的,也可以是系统自动执行的。所有核心交易场景下的非关键服务访问均应进行服务降级设计,以保证核心交易成功率。在有限的资源情况下,对系统做出一些牺牲,有点“弃卒保帅”的意思。放弃一些功能,保证整个系统能平稳运行。
降级的注意点
- 每个服务都需要制定自己的降级策略,根据服务不同的优先级来设定降级方案,紧急情况下可以通过启用降级开关关闭非核心功能,损失一定的客户体验,以确保核心关键业务的服务可用性。
- 对业务进行仔细的梳理和分析,哪些是核心流程必须保证的,哪些是可以牺牲的,干掉一些次要功能。比如电商功能大促期间可以把评论关闭或者简化评论流程
- 什么指标下能进行降级:吞吐量、响应时间、失败次数等达到一个阈值才进行降级处理
- 降级最简单的就是在业务代码中配置一个开关或者做成配置中心模式,直接在配置中心上更改配置,推送到相应的服务。比如DUCC开关技术。
2、服务限流
服务限流是分布式系统中一种常见的保护机制。当负载超出系统/组件的处理能力上限时,可能会造成系统响应时间增加或部分业务失败,需要通过业务限流来防止系统响应进一步严重恶化。例如,在一个分布式系统中,最大tps为2000。这意味着每秒最多只能处理2000个请求。为了防止系统过载,可以设置规则限制上游服务每秒调用的tps。当请求量超过2000tps时,可以通过随机或选择性抛弃一些请求来实现限流。
具体实现方式可以有很多种,比如:
- 随机抛弃一部分请求:当请求量超过2000tps时,从所有请求中随机选择一部分进行拒绝处理。这样可以确保每个请求都有平等的机会被处理,同时也可以在一定程度上避免单个请求对整个系统造成过大的影响。
- 选择性抛弃一部分请求:与随机抛弃不同,选择性抛弃是针对某些特定的请求进行处理。例如,可以根据请求的来源、参数等信息来判断是否需要抛弃该请求。这种方式可以更加灵活地控制限流策略,但也需要更多的资源和技术支持。
服务限流是一种有效的保护机制,可以帮助我们应对高并发场景下的挑战。通过合理设置业务限流规则,我们可以保证系统的稳定运行,提高用户体验和满意度。
主流的限流算法有:
(1)、计数器法
计数器法是限流算法里最简单也是最容易实现的一种算法。比如我们规定,对于A接口来说,我们1分钟的访问次数不能超过100个,分布式计数器一般会用自研计数服务或者 redis 等组件来做。
(2)、滑动时间窗口算法(JSF限流模式之一)
为了解决计数器法统计精度太低的问题,引入了滑动窗口算法。下面这张图,很好地解释了滑动窗口算法:
在上图中,整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分,比如图中,我们就将滑动窗口划成了6格,所以每格代表的是10秒钟。每过10秒钟,我们的时间窗口就会往右滑动一格。每一个格子都有自己独立的计数器counter,比如当一个请求 在0:35秒的时候到达,那么0:30~0:39对应的counter就会加1。
计数器算法其实就是滑动窗口算法。只是它没有对时间窗口做进一步地划分,所以只有1格。
由此可见,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
(3)、漏桶算法
漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。
从图中我们可以看到,整个算法其实十分简单。首先,我们有一个固定容量的桶,有水流进来,也有水流出去。对于流进来的水来说,我们无法预计一共有多少水会流进来,也无法预计水流的速度。但是对于流出去的水来说,这个桶可以固定水流出的速率。而且,当桶满了之后,多余的水将会溢出。
我们将算法中的水换成实际应用中的请求,我们可以看到漏桶算法天生就限制了请求的速度。当使用了漏桶算法,我们可以保证接口会以一个常速速率来处理请求。所以漏桶算法天生不会出现临界问题。
(4)、令牌桶算法(JSF限流模式之一)
令牌桶算法是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。典型情况下,令牌桶算法用来控制发送到网络上的数据的数目,并允许突发数据的发送。
从图中我们可以看到,令牌桶算法比漏桶算法稍显复杂。首先,我们有一个固定容量的桶,桶里存放着令牌
(token)。桶一开始是空的,token以 一个固定的速率r往桶里填充,直到达到桶的容量,多余的令牌将会被丢弃。每当一个请求过来时,就会尝试从桶里移除一个令牌,如果没有令牌的话,请求无法通过。
(5)、限流算法小结
计数器 VS 滑动窗口:
1 计数器算法是最简单的算法,可以看成是滑动窗口的低精度实现。
2 滑动窗口由于需要存储多份的计数器(每一个格子存一份),所以滑动窗口在实现上需要更多的存储空间。
3 如果滑动窗口的精度越高,需要的存储空间就越大。
漏桶算法 VS令牌桶算法:
1 漏桶算法和令牌桶算法最明显的区别是令牌桶算法允许流量一定程度的突发。
2 因为默认的令牌桶算法,取走token是不需要耗费时间的,也就是说,假设桶内有100个token时,那么可以瞬间允
3 当然我们需要具体情况具体分析,只有最合适的算法,没有最优的算法。
3、服务熔断
服务与服务之间的依赖性,故障会传播,会对整个微服务系统造成灾难性的严重后果,这就是服务故障的“雪崩”效应。为了解决这个问题,业界提出了断路器模型。
服务熔断是在分布式系统中避免从系统局部的、小规模的故障,最终导致全局性的后果的手段。它是通过快速失败(Fail Fast)的机制,避免请求大量阻塞,从而保护调用方。比如:一个服务A调用当下游B超时或失败时,会导致请求超时引起堆积队列,从而导致下游B系统压力越来越大而无法恢复。如果我们我们还是盲目地去请求,即时失败了多次,还是傻傻的去请求,去等待。这样一来增加了整个链路的请求时间,同时下游B系统本身就出现了问题,不断的请求又把系统问题加重了,恢复困难。如果使用了熔断功能,则当触发熔断后,到下游B服务的压力减小,从而保护了存活的系统。原理图如下:
业内目前流行的熔断器很多,例如Sentinel,以及最多人使用的Hystrix,同时京东内部的JSF也支持熔断
关于熔断原理详见这篇文章
【源码】SpringCloud-Hystrix服务熔断与降级工作原理&源码
四、容错设计
容错设计对系统稳定性至关重要,在容错设计中,首先应确定系统容错等级策略,策略分级可参考如下表
容错设计等级 | 等级描述 |
无容错性设计 | 所依赖的外部资源访问出错,本应用未能检测识别到,导致应用处理数据出错,造成脏数据的 |
弱容错性设计 | 所依赖的外部资源访问出错,本应用服务不可用且难以恢复的 |
基本容错性设计 | 所依赖的外部资源访问出错,本应用服务不可用,但是由人工操作后可恢复的 |
较强容错性设计 | 所依赖的外部资源访问出错,本应用服务不可用,但可自动恢复的 |
强容错性设计 | 所依赖的外部资源访问出错,本应用不受影响并正常对外提供服务的 |
(容错等级设计)
确定好容错策略分级之后,开始系统容错设计。系统需要提供充足的容错机制,以应对所依赖的外部服务或其他依赖资源发生故障情况;系统的设计原则应该本着不信任外部资源(外部服务、DB、网络设备、存储、消息等)100%可用的原则,在关键处理路径上针对上述可能发生故障的点进行容错加固设计,保护系统自身的可用性。
1、服务不可用容错设计
服务不可用容错设计:跨系统服务调用,调用端必须保障请求准确送达、服务端必须保障响应准确返回;基于此原则,某些场景下可能发生请求送达或响应返回丢失的,必须使用重试机制来弥补,如通过异步确保消息通知机制来解决跨系统、一次性调用场景下请求无法确保送达问题。服务提供方系统发布中、或其他不可预知的服务访问超时,都有可能导致客户端请求失败,此时客户端应用若无任何容错机制,则业务处理异常中断。
探活检测:是故障隔离的基础,通过应用的健康检查可以识别出异常的服务节点。负载均衡和服务注册中心可以及时地将这些异常节点剔除,从而阻止请求流量再次分发到这些节点,实现节点维度的故障隔离。同样地,数据库连接探活也是类似的思路。
重试逻辑:在分布式系统中,微服务系统的重试操作可能会触发多个其他请求或重试操作,并导致级联效应。为了避免这种影响,我们应该尽量减少重试的次数,并使用指数退避算法来逐渐增加重试之间的延迟时间,直到达到最大限制。重试的另外一个要求是服务的幂等设计。
故障转移:对于幂等服务,调用出现故障,系统不会立即向调用者返回失败结果,而是自动切换到其他服务副本,尝试其他副本能否返回成功调用的结果,从而保证了整体的高可用性。
快速失败:快速失败是指依赖方不可用时,不能耗费大量的宝贵系统资源(比如线程、连接数等),再等待其恢复,而是要在其达不到合理的功能和性能要求时快速失败,避免占用资源,甚至拖垮系统。
对于非幂等的服务,重复调用就可能产生脏数据,引起的麻烦远大于单纯的某次服务调用失败,此时就应该以快速失败作为首选的容错策略。我们可以使用基于操作的成功或失败统计次数的熔断模式,而不是使用超时。
同步转异步:同步调用意味着强依赖。消息队列起到很好的削峰填谷的作用,且一般都具备很大规模的消息堆积能力。
资源隔离:线程池隔离:我们可以通过线程池隔离的方式来实现资源的隔离,不同的请求使用相应的线程池来处理,即便出现请求资源time out的情况,最多影响当前线程池的资源,而不会影响整个服务的线程资源。类似船舱中的隔离区域。
信号量隔离:信号量主要是用来控制线程数的,规定好一些调用最大的并发量,超过指定的信号量后,可以将请求丢弃或者延时处理,防止线程的不断增长导致的服务异常。
降级、限流、熔断:参考上一章节
2、数据容错设计
数据库容错设计:业务在正常执行的时候,如果遇到某个数据库故障,需要具备快速的容错机制,保证后续业务的正常进行。这种容错机制包括数据库的灾难转移或切换方案。
缓存数据设计:依赖本地缓存是一种数据的降级方案,当依赖方(服务或数据库)出现故障时,可以一定程度地保障业务可用。一般是应用于核心、决不能出错的业务场景。缓存的问题是数据实时更新的一致性问题,但在很多场景下,对于客户的体验,读到旧的数据往往要比系统无法响应要好很多。
参考:
中国信通院分布式系统稳定性建设指南
本文所述技术仍有待进一步研究和探讨,希望能为相关领域的研究者提供一些启示。文章中难免会有不足之处,希望读者能给予宝贵的意见和建议。谢谢!