反向代理服务如何做好过载保护

Posted by gao-xiao-long on May 30, 2017

如下图所示,假设有一个反向代理服务(proxy), 负责将上游请求按照一定规则转发给下游,并将下游返回结果再返回给上游,如何才能使proxy更好的自我保护及保护后端服务,防止出现过载,甚至出现雪崩呢? 图

过载介绍

什么是过载:
在服务器开发中,过载指的是外部请求量已经超过了系统的最大处理能力。比如,假设某系统每秒最多处理100条请求,但是它每秒收到的请求有200条,这时,我们就说系统已经过载

过载时表现:
过载时,每个请求都需要比以往更长的时间才能得到响应,如果系统在过载的时候没有做到相应保护,导致历史积累的超时请求达到一定规模,像雪球一样形成恶性循环,会导致系统处理的每个请求都因为超时而无效,系统对外呈现的服务能力为0,且这种情况下不能自动恢复。近一步,如果整个系统由多个相关联的子系统组成,某子系统的故障通过耦合关系会引起其他子系统发生故障,最终会导致整个系统可用性严重下降甚至完全不可用。(我们称这种现象为相继故障,或级联故障,英文名cascading failure, 有时候也称为雪崩。)

过载案例分析

下面通过一个具体案例来分析下过载现象。

基本情况:

如下图所示, 模块A是一个使用比较流行的Reactor网络编程模式的纯转发系统,采用多线程并行的将用户的请求转发到模块B,并同步得到模块B的返回结果,返回给用户。 图 上图中展现了我们这次分析中需要知道的相关内部结构,其中

  • 内核为每个连接都建立了一个Recv-Q和Send-Q
  • (IO多路复用+非阻塞)组件为网络框架的内部实现方式

对于单个请求,它的处理逻辑可以如下描述:
  Step1: 从Socket缓冲区(Recv-Q)接受用户请求并解析
  Step2: 进行本地逻辑处理
  Step3: 发送请求到后端模块B
  Step4: 同步等待后端模块B返回
  Step5: 接收后端模块B的应答
  Step6: 应答前端用户

正常情况下,假设:

  • 用户请求模块A的报文大小100Bytes,为简单起见,只有一个用户请求模块A,采用长连接形式,请求峰值QPS为800
  • 模块A采用10个线程并行处理请求,每个连接设置的接受缓冲区(Recv-Q)大小为:229376Bytes(此值为某线上机器的默认值)
  • 模块A在处理请求时,做纯转发操作,本地运算耗时非常少,可以忽略不计
  • 后端模块B并行能力很高,可以处理的极限QPS为10000次以上,且请求处理延迟不超过10ms
  • 上游对模块A定义的读时间为2s,模块A对模块B定义的读超时时间为1s

根据前面的假设,我们可以得到以下数据:

  • 模块A在正常情况下可以处理的极限QPS为:1000。计算方法:单线程每秒钟可以处理1000(1s) / 10 = 100个请求,10个线程并行处理则可以处理10 * 100 = 1000
  • 模块A的Recv-Q平均可以缓存的请求数为:22937个。计算方法:Recv-Q大小 / 每个请求包大小
过载分析:

导火索:
某天模块B进行了新特性发布,每个请求处理延时从10ms增长到40ms,这时,随着时间推移,发现所有经过模块A的请求都超时, 模块A的对外处理能力变为0。

分析:
正常情况下模块A最大处理QPS为1000, 而用户的请求峰值QPS是800,模块A足以将用户每秒800峰值处理完成。当模块B的每个请求处理延时从10ms增长到40ms时,这时候模块A的最大处理QPS变成了(1000 / 40) * 10 = 250。远小于800qps。因为请求量和处理能力的差距,每秒钟有550个(800-250)请求无法及时处理,被缓存到Recv-Q,并且使得缓冲区在4s内被填满(每秒550个积压请求,每个请求100占100字节,缓冲区一共229376字节,229376 / (550*100) = 4s)。在压力不变的情况下,模块A的缓冲区将一直保持满的状态。 这意味着,一个请求被追加到缓冲区里后,要等待91秒(缓存22937个请求,每秒处理250个,需要91秒)才能被模块A取出来处理,这时候用户早就已经超时了,也就是说,进程A每次处理的请求,都已经是91s以前产生的,模块A一直在做无用功。对外处理能力表现为0。下图可以比较直观的展现Recv-Q中请求被处理的延迟 图

  • ”最短等待处理时间“指的是请求从”发送到模块A”到”被模块A开始处理”时等待的最短时间,比如:第1-250条请求最短等待时间为0,如果请求是同一时刻发送过来的,那么理论上前10条请求等待时间为0s(10个线程同时处理),第10到20个请求等待处理时间为20ms(j每个线程处理耗时为20ms,处理完后再取新的请求),20到20个请求等待处理时间为40ms,以此类推。
  • 缓冲区被填满的情况 可以用水库做比较,如果进入水库的如水量大于水库闸门放水的出水量,随着时间的推移,水库一定会被填满。

为了更直观的观察过载发生时的情况,写了一个小程序来模拟。

  1. echo_server为服务器,极限QPS大概在60左右,每个请求响应时间为100ms。
  2. echo_client为客户端,通过长连接方式访问echo_server,设置的读超时时间为300ms,网络耗损在1ms左右。 程序通过自动增加echo_client的QPS,直到超出echo_server的极限,来模拟过载时发生的状况。

下图是echo_client打印的请求日志: every_5s_cuss指的是最近5秒内请求成功的个数,every_5s_fail指的是最近5秒内请求失败的个数,every_5s_latency_avg指的是最近5秒的平均响应时间。 图

从图中可以看出,当请求的QPS从50调整到98之后,超出echo_server的极限性能,这时候在QPS基本不变的情况下every_5s_fail明显增多,从0直接跳变到300以上。

如下图说示,当保持相同的QPS几秒钟之后,基本上所有的请求都失败,系统展现的可用性为0 图

同时,我们看到echo_server端的Recv-Q中有大量数据积压 图

下图是系统的对外表现与发送QPS的关系图,当过载积累一段时间后,系统已经整体不可用: 图

QA:
问: 一般的Reactor网络框架中都会有IO线程和Worker线程,IO线程recv数据时应该很快啊,为啥Recv-Q(接受缓冲区)还会满呢?
答: 需要理解Reactor本质及各种模式:在类似per thread one loop模式中IO线程同时也是Worker线程,当Worker处理阻塞时,自然没法及时的IO;如果框架在实现时将IO线程(reactor线程)独立开来,IO线程负责recv并解析数据之后发送给消息队列,并且在队列满时丢弃消息,这种方式可以避免Recv-Q阻塞导致系统完全不可用,但是在无论是公司内部还是开源的网络框架(比如muduo),还没有发现哪个框架采用这种方式实现,个人猜测主要原因是网络框架更强调通用性及转发性能,没有特别的考虑“过载保护”。

过载根本原因  

除了上面讲的因下游模块升级导致过载外,还有其他可能引起过载的原因,比如:

  • 下游模块B大规模故障:类似于模块A访问模块B的延迟由10ms变成了“超时时间”
  • 上游请求模块A的流量剧增:比如因为cache击穿或者秒杀活动等导致流量剧增
  • 同机其他模块占用过多CPU或者网卡资源,导致模块A的处理性能降低

以上所有原因都可以归结为两点:一是处理能力的下降, 二是请求量的上升。

Proxy过载保护设计

目标:
Proxy进行预防过载保护的目标是:在系统过载时,服务还能提供一个稳定的较高的处理能力。如下图所示,期望在发生过载时系统还能保持“处理成功QPS”的稳定性。 图

思路:
各个层级都要首先做好自我保护,然后再考虑对关联系统的保护,主要思路是从三个方面入手:

  1. 对Proxy模块自身进行保护,避免在Proxy层出现“雪崩”: Proxy模块需要做到(1)在某个下游出现大规模故障或整体不可用时,对其他模块的转发不受影响。(2)当Proxy层成为整个系统的瓶颈时,也就是转发能力不足时,请求不会堆积,避免引发整体不可用。
  2. 对Proxy的下游模块进行保护,尽量避免下游出现过载: 鉴于Proxy的下游模块的复杂性,不能保证所有的下游模块都具备过载保护能力,所以需要在proxy层进行保护,避免下游出现过载。
  3. 当下游模块出现过载时,能保证下游及时恢复

下面对以上三个方面展开讨论。

对Proxy模块自身进行保护,避免在Proxy层出现“雪崩”

1.资源隔离:
Proxy由多个下游组成,有可能出现某个下游模块因为功能升级导致平响升高,或者某个Bug引发服务不可用的情况。这样会导致Proxy整体的转发性能降低,并引起过载或者雪崩的发生,影响整个下游的转发。“资源隔离”可以防止这种情况的发生,资源隔离主要有两种方法,一种是线程等层面的隔离,一种是部署层面的隔离。
(1)线程层面隔离如下图: 图 它的主要思想是在线程层面,为下游模块分配好资源。假设Proxy一共开启了100个处理线程。我们规定下游模块A只能使用30个线程,模块B和模块C分别只能使用20个线程。当模块A使用的线程数达到阈值后,主动拒绝新来的请求。这样,即使模块A出现故障,也不会影响整个Proxy。
缺点: 很多系统都是使用的通用的网络框架,如grpc, sofa-pbrpc,他们底层实现时本身就是用了线程池,这种线程隔离的思路需要再网络框架层只上再添加另一个线程池,很难做到高性能的实现。
(2)部署层面的隔离如下图: 图 这种隔离的主要思路是轻重分离,为某些流量大或者对系统SLA要求比较高的下游单独部署Proxy,做到物理层的隔离。跟线程层面隔离相比,这种隔离比较简单,不需要改动代码。
缺点:上游还需要根据不同的请求分发到不同的Proxy中。
在实际生产环境中,部署层面隔离是一个比较好的选择。

2. 防止因自身转发性能不足引发的过载:
实际生产环境下存在一种可能就是下游服务可以承担的很大的并发,但是Proxy本身由于实例数等各种原因导致自身成为转发瓶颈,引发过载,可能的解决思路有以下两种:
(1)检测请求从”发送出去”到”开始被处理“之间的时延,超过一定阈值直接丢弃。
   此方法主要的思想就是避免处理无用的请求,假设上游向Proxy发送请求,定义的超时时间为2s,Proxy在处理某个请求时发现它是2秒之前发送过来的,则可以直接丢弃,防止Recv-Q中消息的堆积。 具体的实现上有两种方式:

  • 协议层加上时间戳,即每个消息都带有发送时间,Proxy在处理时根据过期时间选择是否丢弃。此种实现方式比较简单,但是最大的问题在于可能存在“两台机器时钟时间不一致”情况,比如发送方的机器时钟比Proxy的机器时钟本身就慢2秒。这时候即使是新发送到Proxy的请求,也会被Proxy认为是2秒前发送的,有可能直接丢弃。
  • 使用socket选项SO_TIMESTAMP,通过带外数据获取到数据到达系统缓冲区的时间。这种实现思路虽然也有本机时钟时间修改问题,但是每次修改只影响缓冲区中的数据,所以影响不大。问题是它需要框架层(比如grpc等)支持,但是目前最主要问题是要Server框架层基本上不支持此功能。

(2)为消息建立任务队列:当Socket中有可读消息时,将消息读到任务队列中,然后分发给工作线程处理。任务队列的大小根据实际的处理性能决定,如果任务队列已满,则直接丢弃该消息。此类实现方式也是最好在框架层支持,避免在应用层实现影响转发效率(各种资源、锁的争夺等)

对Proxy的下游模块进行保护,尽量避免下游出现过载

可以通过以下几点避免下游模块出现过载:

  1. 选择好的负载均衡策略:在线上环境中,同一模块的不同实例所在的机器负载或者配置各不相同,这就导致不同实例的处理性能不同,好的负责均衡策略可以将上游流量更多的分担到性能更好的机器,并在某实例出现异常时,能很快的将其压力分担到其他实例中。进最大可能避免下游故障。
  2. 合理配置重试:重试最好只在连接出错时发起,防止系统读写超时频繁重试导致流量加剧。重试时机根据业务特点可以选择立即重试或者采用指数退避算法重试.
  3. 并发控制:Proxy根据后端负载能力设置一个最大并发值,超过最大并发时降低向下转发速率或者直接拒绝。设置最大并发的方法有两种,一种是全局并发控制,另一种方式是单机并发控制。假设Proxy有2个实例proxy-1,proxy-2;对应的下游A有三个实例A-1,A-2,A-3。A的每个实例可以处理的最大QPS为10,那么模块A可以处理的最大QPS为20。所谓单机并发控制,就是对每个proxy实例进行并发设置,分别设置proxy-1和proxy-2往下游转发的最高QPS位30/2 = 15。所谓全局并发控制是在Proxy层引入一个并发的全局计数(比如使用redis或mmap共享内存),每次proxy往下游转发时都检查此全局计数是否达到30。如果达到30,则认为超过最大并发。这两种控制方式各有优缺点:
    • 单机并发控制:优点:并发配置简单,无需与其他实例耦合。缺点:它的前提条件是到达Proxy每个实例的流量是均匀的,只适合上游访问每个Proxy实例的概率相同时,当上游采用其他非随机算法访问Proxy实例时,此方法会失效。
    • 全局并发控制:优点:集中式并发控制,无需限定上游访问Proxy采用的负载均衡策略;缺点:Proxy每次请求均需要访问全局并发计数模块,并且需要各Proxy实例实时上报并发信息到全局并发计数模块,实现起来有一定复杂度。

PS:一种基于Little‘s law的单机并发控制的方法:
常见的单机并发控制一般使用令牌桶或者漏桶方式,这两种方式只能够对单一维度进行控制,比如限制下发QPS。当下游系统由于某种原因导致处理时间变长后,可以处理的QPS也会随之降低,但是令牌桶或者漏斗的方式不能很好的反应这种变化。这时候可以考虑采用基于排队理论中的Little’s law (利特儿法则)方式进行并发控制。考虑一个带有输入和输出的任意系统,Little law指出:系统中物体的平均数量等于物体离开系统的平均速率和每个物体在系统中停留的平均时间的乘积。它的公式为 L = λW。应用在计算机工程的队列中,可以认为:

  • L:正在被处理的请求个数
  • Lambda:平均的吞吐率
  • W:平均响应时间

举个例子: 假设某个系统A每处理一个请求需要10分钟,每分钟过来5个请求,每个请求过来后都能立即被系统A处理。那么经过一段时间后,系统A会稳定在同时处理50个请求(并发度为50)。
将Little‘s law应用到proxy限流中:假定proxy模块转发性能充裕,每当过来一个请求后,proxy都会立即的转发到下游系统,那我们可以通过公式:最大并发度 = (QPS * 下游平均响应时间) 来进行限流。 当下游的RT(响应时间)比较稳定时,限制最大并发的效果等价于限制QPS。比如,后端系统响应时间=0.2s,可以处理的QPS为200。那么设置max_concurrency = 40。在具体实现时,可以将max_concurrency定义为一个int类型的值。并且有一个当前并发的计数: curr_concurrency。每来一个请求,判断curr_concurrency是否已经达到max_concurrency,如果是直接拒绝请求,否则curr_concurrency原子性的加1,当请求处理结束(包括超时)时,curr_concurrency原子性减1。
这种并发控制方式有如下优点

  • 实现起来简单,并且性能非常高(只需要原子性的对curr_concurrency加1或者减1)
  • 设置好了max_concurrency后,当系统的处理时间(RT)变化时,允许的QPS也会跟着变化,比如当系统升级导致后端RT上升后,后端系统理论上可以承担的QPS也会降低,这种max_concurrency则可以正好吻合这种变化,有效的保护后端系统。

但是这种实现方式也有一定的缺陷就是“灵敏度太高”:我们是根据QPS * RT来计算max_concurrency。当系统“瞬时”并发过高时,很有可能达到并发控制阈值而被“限流”。比如, 假设我们的计算出下游可以承担的QPS=100, RT=0.1s。我们设置了max_concurrency = 100*0.1 = 10。但是在1秒内,如果某一时刻的瞬时并发达到20(但是平均下来QPS还是100)那么这一时刻的请求很多会因超并发被拦截。在实际业务中,我们可以判断下是否接受这种”缺陷”。如果不接受的话,可以通过增加“统计窗口”解决,具体方法就不再展开。

在下游模块出现过载时,能保证下游及时恢复

首先需要明确一点,过载恢复的条件只有一个:请求量低于处理能力。当请求量低于处理能力时,“缓冲区”才会排空,系统才能恢复正常。
降低请求量的方法有两种,一种是暴力的采用降级方式,按比例丢弃一定的流量。另一种是采用类似断路器的方式,根据下游系统的状态自动进行调整,下面详细介绍断路器方式。如下所示,是传统的断路器方式示意图: 图 它有三个状态,分别为:正常状态、断路器开、断路器半开。
系统会实时对下游的响应时间进行监控:

  1. 当下游响应时间一切正常时,断路器处于“关闭状态”,此时所有流量都可以访问下游。
  2. 当系统监控发现下游平响异常到一定程度(比如50%请求超时)时,断路器打开,所有的请求直接拒绝,不再访问后端。
  3. 当经过一段断路时间间隔后,断路器尝试进入半开状态,只允许少量请求访问下游,如果发现下游恢复,则关闭断路器,否则断路器继续打开。

通过上面的方式,来实现下游过载时对下游流量的控制,让下游流量尽快恢复。但是这种断路器的实现方式有个比较明显的问题是当下游出现过载时,断路器会拒绝所有的流量,容易引发短暂的服务不可用。
可以基于此方法进行改进:

  1. 当断路器打开时,降低向下游发送的速度,而不是不再访问后端。
  2. 经过一段时间后当系统监控发现平响时间变得正常时,尝试将断路器设置成半开状态,根据响应时间的变化,灰度的恢复向下游发送的速度、

这种改进的断路器思路仅仅还停留在理论层面,还没有在生产环境中验证过,有心的读者可以尝试下。

其他过载保护思路

前面是从代码层面说明了如何进行过载保护,但是过载保护是一个系统性的工程,除了代码层面,还需要从产品和运维层面下功夫,在产品层面,当系统过载时,需要给用户一个良好的引导,防止用户不停的人为重试导致系统压力加剧。在运维层面,需要对系统建立全方面的报警机制,当系统请求量达到容量的某些阈值后能够快速平滑的扩容。

参考:

  1. google的基于RTT的拥塞避免算法
  2. Stop Rate Limiting! Capacity Management
  3. 腾讯后台开发技术总监浅谈过载保护
  4. 服务器过载保护(上篇)——过载介绍
  5. 服务器过载保护(下篇)——过载处理新方案
  6. Exponential Backoff And Jitter

Custom Theme JavaScript