熔断、隔离、重试、降级、超时、限流,一文帮你顺理高可用架构流量治理

共 20164字,需浏览 41分钟

 ·

2024-07-17 07:10

   



对于人类的身体健康来说,“三高”是个大忌,但在计算机界,系统的“三高”却是健康的终极目标。本文将介绍一下流量治理是如何维持这种“三高”系统的健康,保障数据流动的均衡与效率,就如同营养顾问在维持人类健康饮食中所起的作用一般。





分享概要

一、可用性的定义

二、流量治理的目的

三、流量治理的手段

四、总结



一、可用性的定义


在探讨高可用架构之前,让我们以 O2 系统为例,解释一下何谓可用性。O2 是腾讯内部的一个广告投放系统,专注于提升投放效率、分析广告效果,拥有自动化广告投放、AIGC 自动化素材生产等多种功能。

其整体架构概览如下:



一个完善的架构应该具备3个能力,也就是身体的“三高”:


  • 高性能;

  • 高可用;

  • 易扩展。


理解高可用时,通常参考两个关键指标:


  • 平均故障间隔(Mean Time Between Failure,简称 MTBF):表示两次故障的间隔时间,也就是系统正常运行的平均时间,这个时间越长,说明系统的稳定性越高;

  • 故障恢复时间(Mean Time To Repair,简称 MTTR):表示系统发生故障后恢复的时间,这个时间越短,说明故障对用户的影响越小。


可用性(Availability)的计算公式:Availability= MTBF / (MTBF + MTTR) * 100%


这个公式反映了一个简单的事实:只有当系统故障间隔时间越长,且恢复时间越短,系统的整体可用性才会更高。


因此,在设计高可用系统时,我们的核心目标是延长 MTBF,同时努力缩短 MTTR,以减少任何潜在故障对服务的影响。

3


二、流量治理的目的


在保障系统高可用性的过程中,流量治理扮演着关键角色:它不仅帮助平衡和优化数据流,还提高了系统对不同网络条件和故障情况的适应性,是确保服务高效连续运行的不可或缺的环节。


流量治理的主要目的包括:


  • 网络性能优化:通过流量分配、负载均衡等技术,确保网络资源的高效利用,减少延迟和避免拥塞;

  • 服务质量保障:确保关键应用和服务的流量优先级,以保障业务关键操作的流畅运行;

  • 故障容错和弹性:在网络或服务出现问题时,通过动态路由和流量重定向等机制,实现故障转移和自我恢复,以维持服务的持续可用性;

  • 安全性:实施流量加密、访问控制和入侵检测等措施,保护网络和数据不受未授权访问或攻击;

  • 成本效益:通过有效管理流量,降低带宽需求和相关成本,同时提高整体系统效率。



三、流量治理的手段


1. 熔断


微服务系统中,一个服务可能会依赖多个服务,并且有一些服务也依赖于它。



当“媒体中心”服务的其中一个依赖服务出现故障(比如用户服务),媒体中心只能被动地等待依赖服务报错或者请求超时;


  • 下游连接池会被逐渐耗光;

  • 入口请求大量堆积,CPU、内存等资源被逐渐耗尽,最终导致服务宕掉。


而依赖“媒体中心”服务的上游服务,也会因为相同的原因出现故障,一系列的级联故障最终会导致整个系统不可用;


  • 合理的解决方案是引入熔断器和优雅降级,通过尽早失败来避免局部不稳定而导致的整体雪崩。


1)传统熔断器


当请求失败比率达到一定阈值之后,熔断器开启,并休眠一段时间(由配置决定)。这段休眠期过后,熔断器将处于半开状态,在此状态下将试探性地放过一部分流量,如果这部分流量调用成功后,再次将熔断器关闭,否则熔断器继续保持开启并进入下一轮休眠周期。


引入传统熔断器的请求时序图:



传统熔断器实现 关闭、打开、半开 三个状态:


  • 关闭(Closed):默认状态。允许请求到达目标服务,同时统计在窗口时间内的成功和失败次数,如果达到错误率阈值将会切换为“打开”状态;


  • 打开(Open):对应用的请求会立即返回错误响应或执行预设的失败降级逻辑,而不调用目标服务;


  • 半开(Half-Open):进入“打开”状态会维护一个超时时间,到达超时时间后开始进入该状态,允许应用程序一定数量的请求去调用目标服务。

  • 熔断器会对成功执行的调用进行计数,达到配置的阈值后会认为目标服务恢复正常,此时熔断器回到“关闭”状态;

  • 如果有请求出现失败的情况,则回到“打开”状态,并重新启动超时计时器,再给系统一段时间来从故障中恢复。



  • 当进入 Open 状态时会拒绝所有请求;进入 Closed 状态时瞬间会有大量请求,这时服务端可能还没有完全恢复,会导致熔断器又切换到 Open 状态;而 Half-Open 状态存在的目的在于实现了服务的自我修复,同时防止正在恢复的服务再次被大量打垮;


  • 所以传统熔断器在实现上过于一刀切,是一种比较刚性的熔断策略。


2)Google SRE 熔断器


是否可以做到在熔断器 Open 状态下(但是后端未 Shutdown)仍然可以放行少部分流量呢?Google SRE 熔断器提供了一种算法:客户端自适应限流(client-side throttling)


解决的办法就是客户端自行限制请求速度,限制生成请求的数量,超过这个数量的请求直接在本地回复失败,而不会真正发送到服务端。


该算法统计的指标依赖如下两种,每个客户端记录过去两分钟内的以下信息(一般代码中以滑动窗口实现)。


  • 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


Google SRE 熔断器的工作流程:


  • 在通常情况下(无错误发生时) 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,客户端自适应限流结束。


客户端请求被拒绝的概率(Client request rejection probability,以下简称为 p)


p 基于如下公式计算(其中 K 为倍率 - multiplier,常用的值为 2)。



  • 当 requests − K∗accepts <= 0 时,p == 0,客户端不会主动丢弃请求;

  • 反之, p 会随着 accepts 值的变小而增加,即成功接受的请求数越少,本地丢弃请求的概率就越高。


客户端可以发送请求直到 requests = K∗accepts, 一旦超过限制, 按照 p 进行截流。


对于后端而言,调整 K 值可以使得自适应限流算法适配不同的服务场景。


  • 降低 K 值会使自适应限流算法更加激进(允许客户端在算法启动时拒绝更多本地请求);

  • 增加 K 值会使自适应限流算法变得保守一些(允许服务端在算法启动时尝试接收更多的请求,与上面相反)。



熔断本质上是一种快速失败策略。旨在通过及时中断失败或超时的操作,防止资源过度消耗和请求堆积,从而避免服务因小问题而引发的雪崩效应


2. 隔离

微服务系统中,隔离策略是流量治理的关键组成部分,其主要目的是避免单个服务的故障引发整个系统的连锁反应。


通过隔离,系统能够局部化问题,确保单个服务的问题不会影响到其他服务,从而维护整体系统的稳定性和可靠性。


常见的隔离策略:



1)动静隔离


动静隔离通常是指将系统的动态内容和静态内容分开处理。


动态内容:


  • 指需要实时计算或从数据库中检索的数据,通常由后端服务提供;

  • 可以通过缓存、数据库优化等方法来提高动态内容的处理速度。


静态内容:


  • 指可以直接从文件系统中获取的数据,例如图片、音视频、前端的 CSS、JS 文件等静态资源;

  • 可以存储到 OSS 并通过 CDN 进行访问加速。




2)读写隔离


读写隔离通常是指将读操作和写操作分离到不同的服务或实例中处理。


  • 大部分的系统里读写操作都是不均衡的,写数据可能远远少于读数据;

  • 读写隔离得以让读服务和写服务独立扩展。


DDD中有一种常用的模式:CQRS(Command Query Responsibility Segregation,命令查询职责分离)来实现读写隔离。


写服务:


  • 负责处理所有的写操作,例如创建、更新和删除数据;

  • 通常会有一个或多个数据库或数据存储,用于保存系统的数据。


读服务:


  • 负责处理所有的读操作,例如查询和检索数据;

  • 可以有独立的数据库或数据存储,也可以使用缓存来提高查询的性能。


事件驱动:


  • 当写服务处理完一个写操作后,通常会发布一个事件,通知读服务数据已经发生变化;

  • 读服务可以监听这些事件,并更新其数据库或缓存,以保证数据的一致性。


独立扩展:


通过 CQRS 模式,读服务和写服务可以独立地进行扩展;

如果系统的读负载较高,可以增加读服务的实例数量;如果写负载较高,可以增加写服务的实例数量。



3)核心隔离


核心隔离通常是指将资源按照 “核心业务”与 “非核心业务”进行划分,优先保障“核心业务”的稳定运行AI助手。


  • 核心/非核心故障域的差异隔离(机器资源、依赖资源);

  • 核心业务可以搭建多集群通过冗余资源来提升吞吐和容灾能力;

  • 按照服务的核心程度进行分级。


  • 1级:系统中最关键的服务,如果出现故障会导致用户或业务产生重大损失;

  • 2级:对于业务非常重要,如果出现故障会导致用户体验受到影响,但不会导致系统完全无法使用

  • 3级:会对用户造成较小的影响,不容易注意或很难发现;

  • 4级:即使失败,也不会对用户体验造成影响。

    

4)热点隔离


热点隔离通常是指一种针对高频访问数据(热点数据)的隔离策略。


  • 可以帮助微服务系统更高效地处理热点数据的访问请求

  • 需要有机制来识别和监控热点数据

  •     分析系统的历史访问记录;

  •     观察系统的监控告警信息等。

  • 访问频次最高的 Top K 数据缓存起来,可以显著减少对后端存储服务的访问压力,同时提高数据访问的速度;

  • 可以创建一个独立的缓存服务来存储和管理热点数据,实现热点数据的隔离。


5)用户隔离


用户隔离通常是指按照不同的分组形成不同的服务实例。这样某个服务实例宕机了也只会影响对应分组的用户,而不会影响全部用户。


基于 O2-SAAS 系统的租户概念,按照隔离级别的从高到低有如下几种隔离方式:


  • 每个租户有独立的服务与数据库:网关根据 tenant_id 识别出对应的服务实例进行转发



  • 每个租户有共享的服务与独立的数据库:用户服务根据 tenant_id 确定操作哪一个数据库



  • 每个租户有共享的服务与数据库:用户服务根据 tenant_id 确定操作数据库的哪一行记录



6)进程隔离


进程隔离通常是指系统中每一个进程拥有独立的地址空间,提供操作系统级别的保护区。一个进程出现问题不会影响其他进程的正常运行,一个应用出错也不会对其他应用产生副作用。


容器化部署便是进程隔离的最佳实践:



7)线程隔离


线程隔离通常是指线程池的隔离,在应用系统内部,将不同请求分类发送给不同的线程池,当某个服务出现故障时,可以根据预先设定的熔断策略阻断线程的继续执行。



  • 如图,接口A 和 接口B 共用相同的线程池,当 接口A 的访问量激增时,接口C 的处理效率就会被影响,进而可能产生雪崩效应;

  • 使用线程隔离机制,可以将 接口A 和 接口B 做一个很好的隔离。 


8)集群隔离


集群隔离通常是指将某些服务单独部署成集群,或对于某些服务进行分组集群管理。


具体来说就是每个服务都独立成一个系统,继续拆分模块,将功能微服务化:



9)机房隔离


机房隔离通常是指在不同的机房或数据中心部署和运行服务,实现物理层面的隔离。


机房隔离的主要目的有两个:


  • 解决数据容量大、计算和 I/O 密集度高的问题。将不同区域的用户隔离到不同的地区,比如将湖北的数据存储在湖北的服务器,浙江的数据存储在浙江的服务器,这种区域化的数据管理能有效地分散流量和系统负载;

  • 增强数据安全性和灾难恢复能力。通过在不同地理位置建立服务的完整副本(包括计算服务和数据存储),系统可以实现异地多活或冷备份。这样,即使一个机房因自然灾害或其他紧急情况受损,其他机房仍能维持服务,确保数据安全和业务连续性。



3. 重试

如何在不可靠的网络服务中实现可靠的网络通信,这是计算机网络系统中避不开的一个问题。


微服务架构中,一个大系统被拆分成多个小服务,小服务之间大量的 RPC 调用,过程十分依赖网络的稳定性。


网络是脆弱的,随时都可能会出现抖动,此时正在处理中的请求有可能就会失败。场景:O2 Marketing API 服务调用媒体接口拉取数据。


对于网络抖动这种情况,解决的办法之一就是重试。但重试存在风险,它可能会解决故障,也可能会放大故障。


对于网络通信失败的处理一般分为以下几步:


  • 感知错误:通过不同的错误码来识别不同的错误,在 HTTP 中 status code 可以用来识别不同类型的错误。

  • 重试决策:这一步主要用来减少不必要的重试,比如 HTTP 的 4xx 的错误,通常 4xx 表示的是客户端的错误,这时候客户端不应该进行重试操作,或者在业务中自定义的一些错误也不应该被重试。根据这些规则的判断可以有效的减少不必要的重试次数,提升响应速度。

  • 重试策略:重试策略就包含了重试间隔时间,重试次数等。如果次数不够,可能并不能有效的覆盖这个短时间故障的时间段,如果重试次数过多,或者重试间隔太小,又可能造成大量的资源(CPU、内存、线程、网络)浪费。

  • 对冲策略:对冲是指在不等待响应的情况主动发送单次调用的多个请求,然后取首个返回的回包。


如果重试之后还是不行,说明这个故障不是短时间的故障,而是长时间的故障。那么可以对服务进行熔断降级,后面的请求不再重试,这段时间做降级处理,减少没必要的请求,等服务端恢复了之后再进行请求,这方面的工程实现很多,比如 go-zero 、 sentinel 、hystrix-go。


1)重试方式


如何在不可靠的网络服务中实现可靠的网络通信,这是计算机网络系统中避不开的一个问题。


同步重试:


  • 程序在调用下游服务失败的时候重新发起一次;

  • 实现简单,能解决大部分网络抖动问题,是比较常用的一种重试方式。


异步重试:


  • 如果服务追求数据的强一致性,并且希望在下游服务故障的时候不影响上游服务的正常运行,此时可以考虑使用异步重试;

  • 将请求信息丢到消息队列中,由消费者消费请求信息进行重试;

  • 上游服务可以快速响应请求,由消费者异步完成重试。


2)最大重试次数


无限重试可能会导致系统资源(网络带宽、CPU、内存)的耗尽,甚至引发重试风暴。


应评估系统的实际情况和业务需求来设置最大重试次数:


  • 设置过低,可能无法有效地处理该错误;

  • 设置过高,同样可能造成系统资源的浪费。


3)退避策略


我们知道重试是一个 trade-off 问题:


  • 一方面要考虑到本次请求时长过长而影响到的业务的忍受度;

  • 一方面要考虑到重试对下游服务产生过多请求带来的影响。


退避策略基于重试算法实现。重试算法有多种,思路都是在重试之间加上一个间隔时间。


① 线性间隔(Linear Backoff)


  • 每次重试间隔时间是固定的,比如每 1s 重试一次。


② 线性间隔+随机时间(Linear Jitter Backoff)


  • 有时候每次重试间隔时间一致可能会导致多个请求在同一时间请求;

  • 加入随机时间可以在线性间隔时间的基础上波动一个百分比的时间。


③ 指数间隔(Exponential Backoff)


  • 间隔时间是指数型递增,例如等待 3s、9s、27s 后重试。


④ 数间隔+随机时间(Exponential Jitter Backoff)


  • 与 Linear Jitter Backoff 类似,在指数递增的基础上添加一个波动时间。


上面有两种策略都加入了 扰动(jitter),目的是防止 惊群问题 (Thundering Herd Problem) 的发生。


所谓惊群问题当许多进程都在等待被同一事件唤醒的时候,当事件发生后最后只有一个进程能获得处理。其余进程又造成阻塞,这会造成上下文切换的浪费所以加入一个随机时间来避免同一时间同时请求服务端还是很有必要的。


⑤ gRPC 实现


gRPC 便是使用了 指数间隔+随机时间 的退避策略进行重试:GRPC Connection Backoff Protocol (github.com/grpc/grpc/blob/master/doc/connection-backoff.md


/* 伪代码 */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:一次成功的请求所需要的时间,即使是正常的请求也会有响应时间,重试时间间隔需要大于这个响应时间才不会出现请求明明已经成功,但却进行重试的操作。


4)重试风暴



通过一张图来简单介绍下重试风暴:


  • DB 负载过高时,Service C 对 DB 的请求出现失败;

  • 因为配置了重试机制,Service C 对 DB 发起了最多 3 次请求;

  • 链路上为了避免网络抖动,上游的服务均设置了超时重试 3 次的策略;

  • 这样在一次业务请求中,对 DB 的访问可能达到 3^(n) 次。


此时负载高的 DB 便被卷进了重试风暴中,最终很可能导致服务雪崩。


应该怎么避免重试风暴呢?笔者整理了如下几种方式:


① 限制单点重试:


  • 一个服务不能不受限制地重试下游,很容易造成下游服务被打挂;

  • 除了设置最大重试次数,还需要限制重试请求的成功率。


② 引入重试窗口:


  • 基于断路器的思想,限制 请求失败/请求成功 的比率,给重试增加熔断功能;

  • 常见的实现方式是引入滑动窗口。


这里介绍一下重试窗口:



  • 内存中为每一类 RPC 调用维护一个滑动窗口,窗口分多个 bucket;

  • bucket 每秒生成 1 个,记录 1 秒内 RPC 的请求结果数据(成功/失败 次数);

  • 新的 bucket 生成时,淘汰最早的一个 bucket;

  • 新的请求到达该 RPC 服务并且失败时,根据窗口内 失败/成功 比率以及失败次数是否超过阈值来判断是否可以重试。比如阈值设置 0.1,即失败率超过 10% 时不进行重试。


③ 限制链路重试


  • 多级链路中如果每层都配置重试可能导致调用量指数级扩大;

  • 核心是限制每层都发生重试,理想情况下只有最下游服务发生重试;

  • Google SRE 中指出了 Google 内部使用特殊错误码的方式来实现。


关于 Google SRE 的实现方式,大致细节如下:


  • 统一约定一个特殊的 status code ,它表示:调用失败,但别重试;

  • 任何一级重试失败后,生成该 status code 并返回给上层;

  • 上层收到该 status code 后停止对这个下游的重试,并将错误码再传给自己的上层。


该方法可以有效避免重试风暴,但请求链路上需要上下游服务约定好重试状态码并耦合对于的逻辑,一般需要在框架层面上做出约束。


5)对冲策略


有时候我们接口只是偶然会出问题,并且我们的下游服务并不在乎多请求几次,那么我们可以考虑对冲策略AI助手。


对冲是指在不等待响应的情况下主动发送单次调用的多个请求,然后取首个返回的回包。


请求流程


  • 第一次正常的请求正常发出;

  • 在等待固定时间间隔后,没有收到正确的响应,第二个对冲请求会被发出;

  • 再等待固定时间间隔后,没有收到任何前面两个请求的正确响应,第三个会被发出;

  • 一直重复以上流程直到发出的对冲请求数量达到配置的最大次数;

  • 一旦收到正确响应,所有对冲请求都会被取消,响应会被返回给应用层。


与普通重试的区别


  • 对冲在超过指定时间没有响应就会直接发起请求,而重试则必须要服务端响应后才会发起请求。所以对冲更像是比较激进的重试策略。

  • 使用对冲的时候需要注意一点是,因为下游服务可能会做负载均衡策略,所以要求请求的下游服务一般是要求幂等的,能够在多次并发请求中是安全的,并且是符合预期的。

普通重试时序图:


对冲重试时序图:


4.降级


降级是从系统功能角度出发,人为或自动地将某些不重要的功能停掉或者简化,以降低系统负载,这部分释放的资源可以去支撑更核心的功能。


  • 目的是为了提升系统的可用性,同时要寻找到用户体验与降级成本的平衡点;

  • 降级属于有损操作。简而言之,弃卒保帅。

1)降级策略


以 O2 系统举例,有以下几类降级策略:




虽说故障是不可避免的,要达到绝对高可用一般都是使用冗余+自动故障转移,这个时候其实也不需要降级措施了。


但是这样带来的成本较高,而且可用性、成本、用户体验3者本身之间是需要权衡的,一般来说他们之前会是这样的关系:



2)自动降级


  • 适合触发条件明确可控的场景,比如请求调用失败次数大于一定的阈值,服务接口超时等情况;

  • 对于一些旁路服务,服务负载过高也可以直接触发自动降级。


3)手动降级


  • 降级操作都是有损的,部分情况下需要根据对业务的影响程度进行手动降级;

  • 通常需要先制定降级的分级策略,影响面由浅至深。


4)执行降级




降级的策略还是比较丰富的,因此需要从多个角度去化简

  • 首先,将一部分判断条件简单的降级通过自动化手段去实现;

  • 其次,根据对业务的影响程度,对降级进行分级,达到有层次的降级效果;

  • 最后,通过高频演练,确保降级的有效性。


5)与限流的区别


  • 降级依靠牺牲一部分功能或体验保住容量,而限流则是依靠牺牲一部分流量来保住容量

  • 一般来说,限流的通用性会更强一些,因为每个服务理论上都可以设置限流,但并不是每个服务都能降级,比如 O2 系统中的登录服务和用户服务,就不可能被降级(没有这两个服务,用户都没法使用系统了)。


5. 超时

超时是一件很容易被忽视的事情。


早期架构发展阶段,大家或多或少有过遗漏设置超时或者超时设置太长导致系统被拖慢甚至挂起的经历。


随着微服务架构的演进,超时逐渐被标准化到 RPC 中,并可通过微服务治理平台快捷调整超时参数。


传统超时会设定一个固定的阈值,响应时间超过阈值就返回失败。在网络短暂抖动的情况下,响应时间增加很容易产生大规模的成功率波动。


服务的响应时间并不是恒定的,在某些长尾条件下可能需要更多的计算时间,为了有足够的时间等待这种长尾请求响应,我们需要把超时设置足够长,但超时设置太长又会增加风险,超时的准确设置经常困扰我们。



1)超时策略


目前业内常用的超时策略有:


  • 固定超时时间;

  • EMA 动态超时。


2)超时控制



超时控制的本质是 fail fast,良好的超时控制可以尽快清空高延迟的请求,尽快释放资源避免请求堆积。


服务间超时传递


一个请求可能由一系列 RPC 调用组成,每个服务在开始处理请求前应检查是否还有足够的剩余时间处理,也就是应该在每个服务间传递超时时间。



如果都使用每个 RPC 服务设置的固定超时时间,这里以上图为例:


  • 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。


由于每个组件或服务都会在配置文件中配置固定的超时时间,使用时应该取实际剩余时间与配置的超时时间中的最小值。


Context 实现超时传递



3)EMA 动态超时


如果我们的微服务系统对这种短暂的时延上涨具备足够的容忍能力,可以考虑基于 EMA 算法动态调整超时时长。


EMA 算法引入“平均超时”的概念,用平均响应时间代替固定超时时间,只要平均响应时间没有超时即可,而不是要求每次请求都不能超时。


算法实现



  • 当平均响应时间(EMA)大于超时时间限制(Thwm),说明平均情况表现很差,动态超时时长(Tdto)就会趋近于超时时间限制(Thwm),降低弹性;

  • 当平均响应时间(EMA)小于超时时间限制(Thwm),说明平均情况表现很好,动态超时时长(Tdto)就可以超出超时时间限制(Thwm),但会低于最大弹性时间(Tmax),具备一定的弹性。


算法实现参考:github.com/jiamao/ema-timeout





总而言之:


  • 总体情况不能超标;

  • 平均情况表现越好,弹性越大;

  • 平均情况表现越差,弹性越小。


适用条件:


  • 固定业务逻辑,循环执行;

  • 程序大部分时间在等待响应,而不是 CPU 计算或者处理 I/O 中断;

  • 服务是串行处理模式,容易受异常、慢请求阻塞;

  • 响应时间不宜波动过大;

  • 服务可以接受有损。


使用方法


EMA 动态超时根据业务的请求链路有两种用法:


  • 用于非关键路径:Thwm 设置的相对小,当非关键路径频繁耗时增加甚至超时时,降低超时时间,减少非关键路径异常带来的资源消耗,提升服务吞吐量。

  • 用于关键路径:Thwm 设置的相对大,用于长尾请求耗时比较多的场景,提高关键路径成功率。


在2)小节有提到,一般超时时间会在链路上传递,避免上游已经超时,下游继续浪费资源请求的情况。


这个传递的超时时间一般是没有考虑网络耗时或不同服务器的时钟不一致的,所以会存在一定的偏差。


4)超时策略的选择


超时策略的选择:剩余资源 = 资源容量 - QPS 单次请求消耗资源请求持续时长 – 资源释放所需时长



  • 关键路径选择固定超时

  • 非关键路径开启 EMA 动态超时,防止一直出问题导致服务耗时增加、吞吐量降低。


5)超时时间的选择


  • 合理的设置超时可以减少服务资源消耗、避免长时间阻塞、降低服务过载的概率;

  • 超时时间过长容易引起降级失效、系统崩溃;

  • 超时时间过短因⽹络抖动⽽告警频繁,造成服务不稳定。


如何选择合适的超时阈值?超时时间选择需要考虑的几个点:


  • 被调服务的重要性;

  • 被调服务的耗时 P99、P95、P50、平均值;

  • 网络波动;

  • 资源消耗;

  • 用户体验。


6. 限流


预期外的突发流量总会出现,对我们系统可承载的容量造成巨大冲击,极端情况下甚至会导致系统雪崩。


当系统的处理能力有限时,如何阻止计划外的请求继续对系统施压,这便是限流的作用之处。


限流可以帮助我们应对突发流量,通过限制服务的请求率来保护服务不被过载。


除了控制流量,限流还有一个应用目的是用于控制用户行为,避免无用请求,比如频繁地下载系统中的数据表格。


限流一般来说分为客户端限流和服务端限流两类。


1)客户端限流


在客户端限流中,由于请求方和被请求方的关系明确,通常采用较为简单的限流策略,如结合分布式限流和固定的限流阈值。


客户端的限流阈值可被视作被调用方对主调方的配额。


合理设定限流阈值的方法包括:


  • 容量评估:通过单机压测确定服务的单机容量模型,并与下游服务协商以了解他们的限流阈值

  • 容量规划:根据日常运行、运营活动和节假日等不同场景,提前进行容量评估和规划

  • 全链路压测:通过模拟真实场景的压测,评估现有限流值的合理性


在限流算法方面,大家也都已经耳熟能详。像滑动窗口漏桶令牌桶均是常用的限流算法。


这些算法各有特点,能有效管理客户端的请求流量,保障系统的稳定运行。


这里笔者简单梳理了一张常用的限流算法的思维导图,主要阐述每个算法的局限性,需要根据实际应用场景选择合适的算法:




2)服务端限流


服务端限流旨在通过主动丢弃或延迟处理部分请求,以应对系统过载的情况。


服务端限流实现的两个关键点:


① 如何判断系统是否过载,常用的判断依据包括:


  • 资源使用率;

  • 请求成功率;

  • 响应时间;

  • 请求排队时间。


② 过载时如何选择要丢弃的请求,常用的判断依据包括:


  • 按照主调方(客户端)的重要性来划分优先级;

  • 根据用户的重要性进行区分。


关于服务端限流在业界内的实践应用,笔者这里整理了两个示例:


开源的 Sentinel 采用类似 TCP BBR 的限流方法。它基于利特尔法则,计算时间窗口内的最大成功请求数 (MaxPass) 和最小响应时间(MinRt)。当 CPU 使用率超过 80% 时,根据 MaxPass 和 MinRt 计算窗口内理论上可以通过的最大请求量,进而确定每秒的最大请求数。如果当前处理中的请求数超过此计算值,则进行请求丢弃。


微信后台则使用请求的平均排队时间作为系统过载的判断标准。当平均等待时间超过 20 毫秒时,它会以一定的降速因子来过滤部分请求。相反,如果判断平均等待时间低于 20 毫秒,则会逐渐提高请求的通过率。这种“快速降低,缓慢提升”的策略有助于防止服务的大幅波动。



四、总结


想要让系统长期“三高”,流量治理只是众多策略的其中一个,其他还有像存储高可用、缓存、负载均衡、故障转移、冗余设计、可回滚设计等等均是确保系统长期稳定运行的关键因素,笔者也期待在后续就这些策略再和大家进行分享。


本文在介绍高可用架构中流量治理部分时,我们详细讨论了从熔断机制到隔离策略、重试逻辑、降级方案,以及超时和限流控制等多种手段,这里简单归纳一下:


  • 熔断 机制,包括传统熔断器和 Google SRE 模型,作为防止系统过载的重要工具

  • 隔离 策略,如动静隔离、读写隔离和机房隔离,通过物理或逻辑上分离资源和请求,减少单点故障的影响

  • 重试 策略,包括同步和异步重试,以及各种退避机制,帮助在失败时优雅地恢复服务。

  • 降级 操作,区分自动和手动降级,作为服务负载过重时的应急措施

  • 超时 控制,通过精细的策略来避免长时间等待和资源浪费

  • 限流 包括客户端和服务端限流,确保系统在高负载下仍能稳定运行


综合这些策略,我们可以构建出一个既高效又稳健的系统,它能够在各种网络条件和负载情况下保持高性能、高可用和易扩展。这些流量治理的手段不仅确保了服务的连续性和可靠性,还提高了用户体验和系统的整体效率。


最后想说,高可用的本质就是面向失败设计。它基于一个现实且务实的前提:系统中的任何组件都有可能出现故障。


因此,在架构设计时,我们不仅要接受故障的可能性,而且要学会拥抱故障。这意味着从一开始就将容错和恢复能力纳入设计考虑,通过增强系统的弹性、自适应性和恢复机制来应对可能出现的故障和变化。这种方法确保了在面对各种挑战时,系统能够保持持续的运行和服务质量。



浏览 40
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报