<commit_before>---
layout: post
title: （本文现在为草稿阶段勿看~~）公共代理服务如何做好过载保护
date: 2017-05-30
author: "gao-xiao-long"
catalog: true
tags:
    - 基础技术
---

如下图所示，假设有一个公共代理服务(proxy), 负责将上游请求按照一定规则转发给下游，并将下游返回结果再返回给上游，如何才能使proxy更好的自我保护及保护后端服务，防止出现过载，甚至出现雪崩呢？
![图](/img/in-post/proxy.png)

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

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

#### 过载案例分析  
下面通过一个具体案例来分析下过载现象。  
##### 基本情况:  
如下图所示， 模块A是一个使用[Reactor网络编程模式](http://gao-xiao-long.github.io/2017/05/21/network-io/)的纯转发系统，采用多线程并行的将用户的请求转发到模块B，并同步得到模块B的返回结果，返回给用户。
![图](/img/in-post/over_flow1.png)
上图中展现了的模块A大致的内部结构，其中
* 内核为每个连接都建立了一个Recv-Q和Send-Q
* Reactor负责与client建立连接、读取Recv-Q中的数据、发送响应数据到Send-Q
* thread pool负责对读取数据的解码、处理、编码等。

**对于单个请求，它的处理逻辑可以如下描述:**  
　　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中请求被处理的延迟
![图](/img/in-post/recv_queue.png)

* ”最短等待处理时间“指的是请求从"发送到模块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秒的平均响应时间。
![图](/img/in-post/client_1.png)

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

下面是保持相同的QPS几秒钟之后，基本上所有的请求都失败，**系统展现的可用性为0**
![图](/img/in-post/client_2.png)

同时，我们看到echo_server端的Recv-Q中有大量数据积压:
![图](/img/in-post/netstat_full.png)

下图是系统的对外表现与发送QPS的关系图：
![图](/img/in-post/overload_display.png)

PS: 上面为了方便分析，只列出了单连接的情况，多个长连接及短连接情况类似，只要请求量超过服务的处理量，并且这时候没有有效的处理措施，随着时间的积压，就一定会导致服务可用性急剧下降。

**过载根本原因：**

除了上面讲的因下游模块升级导致上游过载外，还有其他可能引起过载
* 下游模块B大规模故障：类似于模块A访问模块B的延迟由10ms变成了“超时时间”
* 上游请求模块A的流量剧增：比如因为cache击穿或者秒杀活动等导致流量剧增
* 同机其他模块占用过多CPU或者网卡资源，导致模块A的处理性能降低

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

#### Proxy层如何预防过载引起的雪崩

Proxy层设计预防过载及雪崩的目标是：在系统过载时，服务还能提供一个稳定的较高的处理能力。即，如下图所示，在系统过载时系统还能保持“处理成功QPS”的稳定性。
[图](/img/in-post/overload_perfect.png)

主要思路是从三个方面入手
1. 对Proxy模块进行保护，在Proxy层出现过载时，避免出现整体不可用。
2. 对Proxy的下游模块进行保护，尽量避免下游出现过载。
3. 当下游模块出现过载时，能保证下游及时恢复。
之所以要避免下游“出现过载”是由于下游模块的复杂性，我们不能保证所有的下游模块都具备过载保护功能，所以在Proxy要做的就是控制往下游发送的请求数，尽量避免下游出现过载的情况。
当下游出现过载时，我们也需要想办法让其尽快恢复正常。

下面一一阐述上面三个方面的具体办法
1. 如何对Proxy模块进行保护，在Proxy层出现过载时，避免出现整体不可用？

（1） 资源隔离
Proxy由多个下游组成，有可能出现某个下游模块因为升级导致平响升高，或者某个Bug引发服务不可用的情况。这样会间接影响导致Proxy整体的转发性能降低，出现过载，进而影响对整个下游的转发。
对应的解决思路就是“资源隔离”，资源隔离主要有两种方法，一种是线程等层面的隔离，一种是部署层面的隔离。
线程层面隔离如下图:
[图](/img/in-post/geli_2.png)
在线程层面，为下游模块分配好资源，比如Proxy一共开启了100个处理线程。我们规定下游模块A只能使用30个线程，模块B和模块C分别只能使用20个线程。当模块A使用的线程数达到阈值后，主动拒绝新来的请求。
这样，即使模块A出现故障，也不会影响整个Proxy。但是这种方案有个缺点：很多系统都是使用的通用的网络框架，如grpc, sofa-pbrpc，这种线程隔离的思路很难在网络框架层之上实现，即使实现了也很难做到高效转发(各种锁、资源争夺等)
部署层面的隔离如下图：
[图](/img/in-post/geli_3.png)
这种隔离的主要思路是轻重分离，为某些流量大或者对系统SLA要求比较高的服务单独部署Proxy，做到物理层的隔离。跟线程层面隔离相比，这种隔离比较简单，不需要改动代码，缺点是上游还需要根据不同的请求分发到不同的Proxy中。
但是在实际生产环境中，部署层面隔离是一个比较好的选择。

(2) 防止因自身转发性能不足引发的过载
实际生产环境下存在一种可能就是下游服务可以承担的很大的并发，但是Proxy本身由于实例数等各种原因导致自身成为转发瓶颈，引发过载，可能的解决思路有以下几种<commit_msg>new post<commit_after>---
layout: post
title: （本文现在为草稿阶段勿看~~）公共代理服务如何做好过载保护
date: 2017-05-30
author: "gao-xiao-long"
catalog: true
tags:
    - 基础技术
---

如下图所示，假设有一个公共代理服务(proxy), 负责将上游请求按照一定规则转发给下游，并将下游返回结果再返回给上游，如何才能使proxy更好的自我保护及保护后端服务，防止出现过载，甚至出现雪崩呢？
![图](/img/in-post/proxy.png)

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

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

#### 过载案例分析  
下面通过一个具体案例来分析下过载现象。  
##### 基本情况:  
如下图所示， 模块A是一个使用[Reactor网络编程模式](http://gao-xiao-long.github.io/2017/05/21/network-io/)的纯转发系统，采用多线程并行的将用户的请求转发到模块B，并同步得到模块B的返回结果，返回给用户。
![图](/img/in-post/over_flow1.png)
上图中展现了的模块A大致的内部结构，其中
* 内核为每个连接都建立了一个Recv-Q和Send-Q
* Reactor负责与client建立连接、读取Recv-Q中的数据、发送响应数据到Send-Q
* thread pool负责对读取数据的解码、处理、编码等。

**对于单个请求，它的处理逻辑可以如下描述:**  
　　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中请求被处理的延迟
![图](/img/in-post/recv_queue.png)

* ”最短等待处理时间“指的是请求从"发送到模块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秒的平均响应时间。
![图](/img/in-post/client_1.png)

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

如下图说示，当保持相同的QPS几秒钟之后，基本上所有的请求都失败，**系统展现的可用性为0**
![图](/img/in-post/client_2.png)

同时，我们看到echo_server端的Recv-Q中有大量数据积压:
![图](/img/in-post/netstat_full.png)

下图是系统的对外表现与发送QPS的关系图，当过载积累一段时间后，系统已经整体不可用：
![图](/img/in-post/overload_display.png)


PS: 上面为了方便分析，只列出了单连接的情况，多个长连接及短连接情况类似，只要请求量超过服务的处理量，并且这时候没有有效的处理措施，随着时间的积压，就一定会导致服务可用性急剧下降。

**过载根本原因：**　　
除了上面讲的因下游模块升级导致上游过载外，还有其他可能引起过载
* 下游模块B大规模故障：类似于模块A访问模块B的延迟由10ms变成了“超时时间”
* 上游请求模块A的流量剧增：比如因为cache击穿或者秒杀活动等导致流量剧增
* 同机其他模块占用过多CPU或者网卡资源，导致模块A的处理性能降低　　
以上所有原因都可以归结为两点：**一是处理能力的下降，　二是请求量的上升。**

#### Proxy层如何预防过载引起的雪崩

Proxy层设计预防过载及雪崩的目标是：在系统过载时，服务还能提供一个稳定的较高的处理能力。即，如下图所示，在系统过载时系统还能保持“处理成功QPS”的稳定性。
[图](/img/in-post/overload_perfect.png)

主要思路是从三个方面入手
1. 对Proxy模块进行保护，在Proxy层出现过载时，避免出现整体不可用。
2. 对Proxy的下游模块进行保护，尽量避免下游出现过载。
3. 当下游模块出现过载时，能保证下游及时恢复。
之所以要避免下游“出现过载”是由于下游模块的复杂性，我们不能保证所有的下游模块都具备过载保护功能，所以在Proxy要做的就是控制往下游发送的请求数，尽量避免下游出现过载的情况。
当下游出现过载时，我们也需要想办法让其尽快恢复正常。

下面一一阐述上面三个方面的具体办法
1. 如何对Proxy模块进行保护，在Proxy层出现过载时，避免出现整体不可用？

（1） 资源隔离
Proxy由多个下游组成，有可能出现某个下游模块因为升级导致平响升高，或者某个Bug引发服务不可用的情况。这样会间接影响导致Proxy整体的转发性能降低，出现过载，进而影响对整个下游的转发。
对应的解决思路就是“资源隔离”，资源隔离主要有两种方法，一种是线程等层面的隔离，一种是部署层面的隔离。
线程层面隔离如下图:
[图](/img/in-post/geli_2.png)
在线程层面，为下游模块分配好资源，比如Proxy一共开启了100个处理线程。我们规定下游模块A只能使用30个线程，模块B和模块C分别只能使用20个线程。当模块A使用的线程数达到阈值后，主动拒绝新来的请求。
这样，即使模块A出现故障，也不会影响整个Proxy。但是这种方案有个缺点：很多系统都是使用的通用的网络框架，如grpc, sofa-pbrpc，这种线程隔离的思路很难在网络框架层之上实现，即使实现了也很难做到高效转发(各种锁、资源争夺等)
部署层面的隔离如下图：
[图](/img/in-post/geli_3.png)
这种隔离的主要思路是轻重分离，为某些流量大或者对系统SLA要求比较高的服务单独部署Proxy，做到物理层的隔离。跟线程层面隔离相比，这种隔离比较简单，不需要改动代码，缺点是上游还需要根据不同的请求分发到不同的Proxy中。
但是在实际生产环境中，部署层面隔离是一个比较好的选择。

(2) 防止因自身转发性能不足引发的过载
实际生产环境下存在一种可能就是下游服务可以承担的很大的并发，但是Proxy本身由于实例数等各种原因导致自身成为转发瓶颈，引发过载，可能的解决思路有以下几种
{'fraction_non_alphanumeric': 0.12027379400260756, 'fraction_numerical': 0.05215123859191656, 'mean_word_length': 12.65727002967359, 'pattern_counts': {'":': 0, '<': 3, '<?xml version=': 0, '>': 3, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '357547', 'n_tokens_mistral': 8031, 'n_tokens_neox': 9301, 'n_words': 271}