熔断、隔离、重试、降级、超时、限流,高可用架构流量治理核心策略全掌握
👉导读
对于人类的身体健康来说,“三高”是个大忌,但在计算机界,系统的“三高”却是健康的终极目标。本文将介绍一下流量治理是如何维持这种“三高”系统的健康,保障数据流动的均衡与效率,就如同营养顾问在维持人类健康饮食中所起的作用一般。👉目录
1 可用性的定义2 流量治理的目的3 流量治理的手段4 总结01
高性能; 高可用; 易扩展。
平均故障间隔(Mean Time Between Failure,简称 MTBF):表示两次故障的间隔时间,也就是系统正常运行的平均时间,这个时间越长,说明系统的稳定性越高; 故障恢复时间(Mean Time To Repair,简称 MTTR):表示系统发生故障后恢复的时间,这个时间越短,说明故障对用户的影响越小。
02
在保障系统高可用性的过程中,流量治理扮演着关键角色:它不仅帮助平衡和优化数据流,还提高了系统对不同网络条件和故障情况的适应性,是确保服务高效连续运行的不可或缺的环节. 流量治理的主要目的包括: 网络性能优化:通过流量分配、负载均衡等技术,确保网络资源的高效利用,减少延迟和避免拥塞; 服务质量保障:确保关键应用和服务的流量优先级,以保障业务关键操作的流畅运行; 故障容错和弹性:在网络或服务出现问题时,通过动态路由和流量重定向等机制,实现故障转移和自我恢复,以维持服务的持续可用性; 安全性:实施流量加密、访问控制和入侵检测等措施,保护网络和数据不受未授权访问或攻击; 成本效益:通过有效管理流量,降低带宽需求和相关成本,同时提高整体系统效率。
03
3.1 熔断
当“媒体中心”服务的其中一个依赖服务出现故障(比如用户服务),媒体中心只能被动地等待依赖服务报错或者请求超时; 下游连接池会被逐渐耗光; 入口请求大量堆积,CPU、内存等资源被逐渐耗尽,最终导致服务宕掉。 而依赖“媒体中心”服务的上游服务,也会因为相同的原因出现故障,一系列的级联故障最终会导致整个系统不可用; 合理的解决方案是引入熔断器和优雅降级,通过尽早失败来避免局部不稳定而导致的整体雪崩。
传统熔断器实现 关闭、打开、半开 三个状态; 关闭(Closed):默认状态。允许请求到达目标服务,同时统计在窗口时间内的成功和失败次数,如果达到错误率阈值将会切换为“打开”状态; 打开(Open):对应用的请求会立即返回错误响应或执行预设的失败降级逻辑,而不调用目标服务; 半开(Half-Open):进入“打开”状态会维护一个超时时间,到达超时时间后开始进入该状态,允许应用程序一定数量的请求去调用目标服务。 熔断器会对成功执行的调用进行计数,达到配置的阈值后会认为目标服务恢复正常,此时熔断器回到“关闭”状态; 如果有请求出现失败的情况,则回到“打开”状态,并重新启动超时计时器,再给系统一段时间来从故障中恢复。
当进入 Open 状态时会拒绝所有请求;进入 Closed 状态时瞬间会有大量请求,这时服务端可能还没有完全恢复,会导致熔断器又切换到 Open 状态;而 Half-Open 状态存在的目的在于实现了服务的自我修复,同时防止正在恢复的服务再次被大量打垮; 所以传统熔断器在实现上过于一刀切,是一种比较刚性的熔断策略。
Google SRE 熔断器
requests:客户端请求总量 注:The number of requests attempted by the application layer(at the client, on top of the adaptive throttling system) accepts:成功的请求总量 - 被 accepted 的量 注:The number of requests accepted by the backend
在通常情况下(无错误发生时) requests == accepts ; 当后端出现异常情况时,accepts 的数量会逐渐小于 requests; 当后端持续异常时,客户端可以继续发送请求直到 requests = K∗accepts,一旦超过这个值,客户端就启动自适应限流机制,新产生的请求在本地会被概率(以下称为p)丢弃; 当客户端主动丢弃请求时,requests 值会一直增大,在某个时间点会超过 K∗accepts,使 p 计算出来的值大于 0,此时客户端会以此概率对请求做主动丢弃; 当后端逐渐恢复时,accepts 增加,(同时 requests 值也会增加,但是由于 K 的关系,K*accepts的放大倍数更快),使得 (requests − K×accepts) / (requests + 1) 变为负数,从而 p == 0,客户端自适应限流结束。
当 requests − K∗accepts <= 0 时,p == 0,客户端不会主动丢弃请求; 反之, p 会随着 accepts 值的变小而增加,即成功接受的请求数越少,本地丢弃请求的概率就越高。
降低 K 值会使自适应限流算法更加激进(允许客户端在算法启动时拒绝更多本地请求); 增加 K 值会使自适应限流算法变得保守一些(允许服务端在算法启动时尝试接收更多的请求,与上面相反)。
3.2 隔离
3.2.1 动静隔离
指需要实时计算或从数据库中检索的数据,通常由后端服务提供; 可以通过缓存、数据库优化等方法来提高动态内容的处理速度。
指可以直接从文件系统中获取的数据,例如图片、音视频、前端的 CSS、JS 文件等静态资源; 可以存储到 OSS 并通过 CDN 进行访问加速。
3.2.2 读写隔离
大部分的系统里读写操作都是不均衡的,写数据可能远远少于读数据; 读写隔离得以让读服务和写服务独立扩展。
负责处理所有的写操作,例如创建、更新和删除数据; 通常会有一个或多个数据库或数据存储,用于保存系统的数据。
负责处理所有的读操作,例如查询和检索数据; 可以有独立的数据库或数据存储,也可以使用缓存来提高查询的性能。
当写服务处理完一个写操作后,通常会发布一个事件,通知读服务数据已经发生变化; 读服务可以监听这些事件,并更新其数据库或缓存,以保证数据的一致性。
通过 CQRS 模式,读服务和写服务可以独立地进行扩展; 如果系统的读负载较高,可以增加读服务的实例数量;如果写负载较高,可以增加写服务的实例数量。
3.2.3 核心隔离
核心/非核心故障域的差异隔离(机器资源、依赖资源); 核心业务可以搭建多集群通过冗余资源来提升吞吐和容灾能力; 按照服务的核心程度进行分级。 1级:系统中最关键的服务,如果出现故障会导致用户或业务产生重大损失; 2级:对于业务非常重要,如果出现故障会导致用户体验受到影响,但不会导致系统完全无法使用; 3级:会对用户造成较小的影响,不容易注意或很难发现; 4级:即使失败,也不会对用户体验造成影响。
3.2.4 热点隔离
可以帮助微服务系统更高效地处理热点数据的访问请求; 需要有机制来识别和监控热点数据; 分析系统的历史访问记录; 观察系统的监控告警信息等。 将访问频次最高的 Top K 数据缓存起来,可以显著减少对后端存储服务的访问压力,同时提高数据访问的速度; 可以创建一个独立的缓存服务来存储和管理热点数据,实现热点数据的隔离。
3.2.5 用户隔离
网关根据 tenant_id 识别出对应的服务实例进行转发
用户服务根据 tenant_id 确定操作哪一个数据库
用户服务根据 tenant_id 确定操作数据库的哪一行记录
3.2.6 进程隔离
3.2.7 线程隔离
如图,接口A 和 接口B 共用相同的线程池,当 接口A 的访问量激增时,接口C 的处理效率就会被影响,进而可能产生雪崩效应; 使用线程隔离机制,可以将 接口A 和 接口B 做一个很好的隔离。
3.2.8 集群隔离
3.2.9 机房隔离
解决数据容量大、计算和 I/O 密集度高的问题。将不同区域的用户隔离到不同的地区,比如将湖北的数据存储在湖北的服务器,浙江的数据存储在浙江的服务器,这种区域化的数据管理能有效地分散流量和系统负载; 增强数据安全性和灾难恢复能力。通过在不同地理位置建立服务的完整副本(包括计算服务和数据存储),系统可以实现异地多活或冷备份。这样,即使一个机房因自然灾害或其他紧急情况受损,其他机房仍能维持服务,确保数据安全和业务连续性。
3.3 重试
通过不同的错误码来识别不同的错误,在 HTTP 中 status code 可以用来识别不同类型的错误。
这一步主要用来减少不必要的重试,比如 HTTP 的 4xx 的错误,通常 4xx 表示的是客户端的错误,这时候客户端不应该进行重试操作,或者在业务中自定义的一些错误也不应该被重试。根据这些规则的判断可以有效的减少不必要的重试次数,提升响应速度。
重试策略就包含了重试间隔时间,重试次数等。如果次数不够,可能并不能有效的覆盖这个短时间故障的时间段,如果重试次数过多,或者重试间隔太小,又可能造成大量的资源(CPU、内存、线程、网络)浪费。
对冲是指在不等待响应的情况主动发送单次调用的多个请求,然后取首个返回的回包。
3.3.1 重试方式
同步重试
程序在调用下游服务失败的时候重新发起一次; 实现简单,能解决大部分网络抖动问题,是比较常用的一种重试方式。
异步重试
将请求信息丢到消息队列中,由消费者消费请求信息进行重试; 上游服务可以快速响应请求,由消费者异步完成重试。
3.3.2 最大重试次数
设置过低,可能无法有效地处理该错误; 设置过高,同样可能造成系统资源的浪费。
3.3.3 退避策略
一方面要考虑到本次请求时长过长而影响到的业务的忍受度; 一方面要考虑到重试对下游服务产生过多请求带来的影响。
线性间隔(Linear Backoff)
每次重试间隔时间是固定的,比如每 1s 重试一次。
线性间隔+随机时间(Linear Jitter Backoff)
有时候每次重试间隔时间一致可能会导致多个请求在同一时间请求; 加入随机时间可以在线性间隔时间的基础上波动一个百分比的时间。
指数间隔(Exponential Backoff)
间隔时间是指数型递增,例如等待 3s、9s、27s 后重试。
指数间隔+随机时间(Exponential Jitter Backoff)
与 Linear Jitter Backoff 类似,在指数递增的基础上添加一个波动时间。
/* 伪代码 */
ConnectWithBackoff()
current_backoff = INITIAL_BACKOFF
current_deadline = now() + INITIAL_BACKOFF
while (TryConnect(Max(current_deadline, now() + MIN_CONNECT_TIMEOUT))
!= SUCCESS)
SleepUntil(current_deadline)
current_backoff = Min(current_backoff * MULTIPLIER, MAX_BACKOFF)
current_deadline = now() + current_backoff +
UniformRandom(-JITTER * current_backoff, JITTER * current_backoff)
INITIAL_BACKOFF:第一次重试等待的间隔; MULTIPLIER:每次间隔的指数因子; JITTER:控制随机的因子; MAX_BACKOFF:等待的最大时长,随着重试次数的增加,我们不希望第N次重试等待的时间变成几十分钟这样不切实际的值; MIN_CONNECT_TIMEOUT:一次成功的请求所需要的时间,即使是正常的请求也会有响应时间,重试时间间隔需要大于这个响应时间才不会出现请求明明已经成功,但却进行重试的操作。
3.3.4 重试风暴
通过一张图来简单介绍下重试风暴:
DB 负载过高时,Service C 对 DB 的请求出现失败;
因为配置了重试机制,Service C 对 DB 发起了最多 3 次请求;
链路上为了避免网络抖动,上游的服务均设置了超时重试 3 次的策略;
这样在一次业务请求中,对 DB 的访问可能达到 3^(n) 次。
此时负载高的 DB 便被卷进了重试风暴中,最终很可能导致服务雪崩。
应该怎么避免重试风暴呢?笔者整理了如下几种方式:
1、限制单点重试
一个服务不能不受限制地重试下游,很容易造成下游服务被打挂;
除了设置最大重试次数,还需要限制重试请求的成功率。
2、引入重试窗口
基于断路器的思想,限制 请求失败/请求成功 的比率,给重试增加熔断功能;
常见的实现方式是引入滑动窗口。
这里介绍一下重试窗口:
内存中为每一类 RPC 调用维护一个滑动窗口,窗口分多个 bucket;
bucket 每秒生成 1 个,记录 1 秒内 RPC 的请求结果数据(成功/失败 次数);
新的 bucket 生成时,淘汰最早的一个 bucket;
新的请求到达该 RPC 服务并且失败时,根据窗口内 失败/成功 比率以及失败次数是否超过阈值来判断是否可以重试。比如阈值设置 0.1,即失败率超过 10% 时不进行重试。
3、限制链路重试
多级链路中如果每层都配置重试可能导致调用量指数级扩大;
核心是限制每层都发生重试,理想情况下只有最下游服务发生重试;
Google SRE 中指出了 Google 内部使用特殊错误码的方式来实现。
关于 Google SRE 的实现方式,大致细节如下:
统一约定一个特殊的 status code ,它表示:调用失败,但别重试;
任何一级重试失败后,生成该 status code 并返回给上层;
上层收到该 status code 后停止对这个下游的重试,并将错误码再传给自己的上层。
该方法可以有效避免重试风暴,但请求链路上需要上下游服务约定好重试状态码并耦合对于的逻辑,一般需要在框架层面上做出约束。
3.3.5 对冲策略
请求流程
第一次正常的请求正常发出;
在等待固定时间间隔后,没有收到正确的响应,第二个对冲请求会被发出;
再等待固定时间间隔后,没有收到任何前面两个请求的正确响应,第三个会被发出;
一直重复以上流程直到发出的对冲请求数量达到配置的最大次数;
一旦收到正确响应,所有对冲请求都会被取消,响应会被返回给应用层。
与普通重试的区别
对冲在超过指定时间没有响应就会直接发起请求,而重试则必须要服务端响应后才会发起请求。所以对冲更像是比较激进的重试策略。
使用对冲的时候需要注意一点是,因为下游服务可能会做负载均衡策略,所以要求请求的下游服务一般是要求幂等的,能够在多次并发请求中是安全的,并且是符合预期的。
3.4 降级
目的是为了提升系统的可用性,同时要寻找到用户体验与降级成本的平衡点; 降级属于有损操作。简而言之,弃卒保帅。
3.4.1 降级策略
3.4.2 自动降级
适合触发条件明确可控的场景,比如请求调用失败次数大于一定的阈值,服务接口超时等情况; 对于一些旁路服务,服务负载过高也可以直接触发自动降级。
3.4.3 手动降级
降级操作都是有损的,部分情况下需要根据对业务的影响程度进行手动降级; 通常需要先制定降级的分级策略,影响面由浅至深。
3.4.4 执行降级
首先,将一部分判断条件简单的降级通过自动化手段去实现; 其次,根据对业务的影响程度,对降级进行分级,达到有层次的降级效果; 最后,通过高频演练,确保降级的有效性。
3.4.5 与限流的区别
降级依靠牺牲一部分功能或体验保住容量,而限流则是依靠牺牲一部分流量来保住容量。 一般来说,限流的通用性会更强一些,因为每个服务理论上都可以设置限流,但并不是每个服务都能降级,比如 O2 系统中的登录服务和用户服务,就不可能被降级(没有这两个服务,用户都没法使用系统了)。
3.5 超时
超时是一件很容易被忽视的事情
早期架构发展阶段,大家或多或少有过遗漏设置超时或者超时设置太长导致系统被拖慢甚至挂起的经历
随着微服务架构的演进,超时逐渐被标准化到 RPC 中,并可通过微服务治理平台快捷调整超时参数
传统超时会设定一个固定的阈值,响应时间超过阈值就返回失败。在网络短暂抖动的情况下,响应时间增加很容易产生大规模的成功率波动
3.5.1 超时策略
固定超时时间; EMA 动态超时。
3.5.2 超时控制
服务间超时传递
A -> B,设置的超时时间为 3s; B 处理耗时为 2s,并继续请求 C; 如果使用了超时传递那么 C 的超时时间应该为 1s,这里不采用所以超时时间为配置的 3s; C 继续执行耗时为 2s,此时最上层(A)设置的超时时间已截止; C -> D的请求对 A 来说已经失去了意义。
一个进程内串行调用了 MySQL、Redis 和 Service B,设置总的请求时间为 3s; 请求 MySQL 耗时 1s 后再请求 Redis,这时的超时时间为 2s,Redis 执行耗时 500 ms; 再请求 Service B,这时超时时间为 1.5s。
3.5.3 EMA 动态超时
算法实现
当平均响应时间(EMA)大于超时时间限制(Thwm),说明平均情况表现很差,动态超时时长(Tdto)就会趋近于超时时间限制(Thwm),降低弹性; 当平均响应时间(EMA)小于超时时间限制(Thwm),说明平均情况表现很好,动态超时时长(Tdto)就可以超出超时时间限制(Thwm),但会低于最大弹性时间(Tmax),具备一定的弹性。
总体情况不能超标; 平均情况表现越好,弹性越大; 平均情况表现越差,弹性越小。
固定业务逻辑,循环执行; 程序大部分时间在等待响应,而不是 CPU 计算或者处理 I/O 中断; 服务是串行处理模式,容易受异常、慢请求阻塞; 响应时间不宜波动过大; 服务可以接受有损。
3.5.4 超时策略的选择
关键路径选择固定超时; 非关键路径开启 EMA 动态超时,防止一直出问题导致服务耗时增加、吞吐量降低。
3.5.5 超时时间的选择
合理的设置超时可以减少服务资源消耗、避免长时间阻塞、降低服务过载的概率; 超时时间过长容易引起降级失效、系统崩溃; 超时时间过短因⽹络抖动⽽告警频繁,造成服务不稳定。
被调服务的重要性; 被调服务的耗时 P99、P95、P50、平均值; 网络波动; 资源消耗; 用户体验。
3.6 限流
预期外的突发流量总会出现,对我们系统可承载的容量造成巨大冲击,极端情况下甚至会导致系统雪崩
当系统的处理能力有限时,如何阻止计划外的请求继续对系统施压,这便是限流的作用之处
限流可以帮助我们应对突发流量,通过限制服务的请求率来保护服务不被过载
3.6.1 客户端限流
容量评估:通过单机压测确定服务的单机容量模型,并与下游服务协商以了解他们的限流阈值 容量规划:根据日常运行、运营活动和节假日等不同场景,提前进行容量评估和规划 全链路压测:通过模拟真实场景的压测,评估现有限流值的合理性
3.6.2 服务端限流
资源使用率; 请求成功率; 响应时间; 请求排队时间,
按照主调方(客户端)的重要性来划分优先级; 根据用户的重要性进行区分。
04
熔断 机制,包括传统熔断器和 Google SRE 模型,作为防止系统过载的重要工具 隔离 策略,如动静隔离、读写隔离和机房隔离,通过物理或逻辑上分离资源和请求,减少单点故障的影响 重试 策略,包括同步和异步重试,以及各种退避机制,帮助在失败时优雅地恢复服务。 降级 操作,区分自动和手动降级,作为服务负载过重时的应急措施 超时 控制,通过精细的策略来避免长时间等待和资源浪费 限流 包括客户端和服务端限流,确保系统在高负载下仍能稳定运行