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

假设有一个反向代理服务(如下图所示), 负责将上游请求按照一定规则转发给下游,并将下游返回结果再返回给上游,如何才能使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足以将其处理完成。当模块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(每个线程处理耗时为20ms,处理完后再取新的请求),20到20个请求等待处理时间为40ms,以此类推。


QA:


问: 一般的Reactor网络框架中都会有IO线程和Worker线程,IO线程recv数据时应该很快啊,为什么Recv-Q(接受缓冲区)还会满呢?


答: 需要理解Reactor本质及各种模式:在类似per thread one loop模式中IO线程同时也是Worker线程,当Worker处理阻塞时,自然没法及时的IO;如果框架在实现时将IO线程独立开来,IO线程负责recv并解析数据之后发送给消息队列,并且在队列满时丢弃消息,这种方式可以避免Recv-Q阻塞导致系统完全不可用,但是还没有发现哪个框架采用这种方式


【过载的根本原因】


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


  • 下游模块B大规模故障:类似于模块A访问模块B的延迟由10ms变成了“超时时间”。

  • 上游请求模块A的流量剧增:比如因为cache击穿或者秒杀活动等导致流量剧增。

  • 同机其他模块占用过多CPU或者网卡资源,导致模块A的处理性能降低。


以上所有原因都可以归结为一点:请求量大于处理能力


【Proxy过载保护设计】


目标: 


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


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


思路:


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


  • 对Proxy模块自身进行保护,避免在Proxy层出现“雪崩”: Proxy模块需要做到在某个下游出现大规模故障或整体不可用时,对其他模块的转发不影响。

  • 对Proxy的下游模块进行保护,尽量避免下游出现过载: 鉴于Proxy的下游模块的复杂性,不能保证所有的下游模块都具备过载保护能力,所以需要在Proxy层进行保护,避免下游模块出现过载。

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


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


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


Proxy由多个下游组成,有可能出现某个下游模块因为功能升级导致平响升高,或者某个Bug引发服务不可用的情况。这样会导致Proxy整体的转发性能降低,并引发过载或者雪崩,影响整个下游的转发。“资源隔离”可以防止这种情况的发生。资源隔离主要有两种方法,一种是线程层面的隔离,一种是部署层面的隔离。


  • 线程层面隔离如下图:

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

它的主要思想是在线程层面,为下游模块分配好资源。假设Proxy一共开启了100个处理线程,我们规定下游模块A只能使用30个线程,模块B和模块C分别只能使用20个线程。当模块A使用的线程数达到阈值后,主动拒绝新来的请求。这样即使模块A出现故障,也不会影响整个Proxy。


缺点: 
很多系统都是使用的通用网络框架,而多数通用网络框架本身就使用了线程池,这种线程隔离的思路需要在网络框架层之上再次封装另一个线程池,很难做到高性能的实现。


  • 部署层面的隔离如下图:


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

这种隔离的主要思路是轻重分离,为某些流量大或者对系统SLA要求比较高的下游单独部署Proxy,做到物理层的隔离。跟线程层面隔离相比,这种隔离比较简单,不需要改动代码。


缺点:上游还需要根据不同的请求分发到不同的Proxy中。


在实际生产环境中,部署层面隔离是一个比较好的选择。



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


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


  • 选择好的负载均衡策略:在线上环境中,同一模块的不同实例(或副本)所在机器的型号及负载各不相同,这就导致不同实例的处理性能不同。好的负载均衡策略可以将上游流量更多的分担到性能更好的机器上,并在某个实例出现异常时,能很快的将其压力分担到其他实例中,最大化避免下游故障。


  • 合理配置重试策略:重试最好只在连接出错时发起,防止系统读写超时时频繁重试导致流量加剧。


  • 合理选择并发控制策略: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实例实时上报并发信息到全局并发计数模块,实现起来有一定复杂度(比如时效性因素、单点压力因素等)。



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


前面讲过,过载恢复的条件只有一个:请求量低于处理能力。当请求量低于处理能力时,“缓冲区”才会排空,系统才能恢复正常。


降低请求量的方法有两种,一种是暴力的采用降级方式,按一定的比例丢弃流量;另一种是采用类似断路器的方式,根据下游系统的状态自动进行调整。


下面详细介绍断路器方式。如下图所示,是传统的断路器方式示意图:

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

它有三个状态,分别为:断路器关(正常状态)、断路器开、断路器半开。


系统会实时对下游的响应时间进行监控:


  • 当下游响应时间正常时,断路器处于“关闭状态”,此时所有流量都可以访问下游。

  • 当下游平响异常到一定比例(比如50%请求超时)时,断路器打开,所有的请求直接拒绝,不再访问后端。

  • 当经过一段断路时间间隔后,断路器尝试进入半开状态,只允许少量请求访问下游,如果发现下游恢复,则关闭断路器,否则断路器继续打开。


但是这种断路器的实现方式有个比较明显的问题是当下游出现过载时,断路器会拒绝所有的流量,容易引发短暂的服务不可用。


可以基于此方法进行改进:


  • 当断路器打开时,降低向下游发送的速度,而不是不再访问下游。

  • 经过一段时间后当系统监控发现平响时间变得正常时,尝试将断路器设置成半开状态并根据响应时间的变化,灰度的恢复向下游发送的速度。


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


【其他过载保护思路】


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


【本文版权归“百度地图开放平台”所有,转载请与百度地图开放平台取得联系】

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