服务的处理能力是有客观上限的。当请求速度超过服务的处理速度时,服务就会过载。如果服务持续过载,会导致越来越多的请求积压,最终所有的请求都必须等待较长时间才能被处理,从而使整个服务处于瘫痪状态。
与之相对的,如果直接拒绝掉一部分请求,反而能够让服务“及时”处理更多的请求,对应的方法就是设置最大并发。自适应限流能动态调整服务的最大并发,在保证服务不过载的前提下,让服务尽可能多地处理请求。
通常情况下要让服务不过载,只需在上线前进行压力测试,并通过 little’s law 计算出 best_max_concurrency 就可以了。但在服务数量多、拓扑复杂,且处理能力会逐渐变化的局面下,使用固定的最大并发会带来巨大的测试工作量,很不方便。自适应限流就是为了解决这个问题。
使用前的准备
使用自适应限流前,建议为服务开启重试:这样当一个节点返回过载时,客户端可以向其他的节点发起重试,从而尽量不丢失流量。
目前只有 method 级别支持自适应限流。如果要为某个 method 开启自适应限流,只需要将它的最大并发设置为 “auto” 即可:
// Set auto concurrency limiter for all methods
brpc::ServerOptions options;
options.method_max_concurrency = "auto";
// Set auto concurrency limiter for specific method
server.MaxConcurrencyOf("example.EchoService.Echo") = "auto";
概念与理论基础
concurrency:同时处理的请求数,又被称为“并发度”。max_concurrency:设置的最大并发度。超过并发的请求会被拒绝(返回ELIMIT错误),在集群层面,client 应重试到另一台 server 上去。best_max_concurrency:并发的物理含义是任务处理槽位,天然存在上限,这个上限就是 best_max_concurrency。若 max_concurrency 设置得过大,则 concurrency 可能大于 best_max_concurrency,任务将无法被及时处理而暂存在各种队列中排队,系统也会进入拥塞状态;若 max_concurrency 设置得过小,则 concurrency 总是会小于 best_max_concurrency,限制系统达到本可以达到的更高吞吐。noload_latency:单纯处理任务的延时,不包括排队时间,另一种解释是低负载的延时。由于正确处理任务得经历必要的环节,其中会耗费 cpu 或等待下游返回,noload_latency 是一个服务固有的属性,但可能随时间逐渐改变(由于内存碎片、压力变化、业务数据变化等因素)。min_latency:实际测定的 latency 中较小值的 ema。当 concurrency 不大于 best_max_concurrency 时,min_latency 和 noload_latency 接近(可能轻微上升)。peak_qps:qps 的上限。注意是处理或回复的 qps 而不是接收的 qps。值取决于 best_max_concurrency / noload_latency,这两个量都是服务的固有属性,故 peak_qps 也是服务的固有属性,和拥塞状况无关,但可能随时间逐渐改变。max_qps:实际测定的 qps 中的较大值。由于 qps 具有上限,max_qps 总是会小于 peak_qps,不论拥塞与否。
在服务处于稳定状态时:concurrency = latency * qps,这是自适应限流的理论基础。
- 当服务没有超载时,随着流量的上升,latency 基本稳定(接近 noload_latency),qps 和 concurrency 呈线性关系一起上升。
- 当流量超过服务的 peak_qps 时,则 concurrency 和 latency 会一起上升,而 qps 会稳定在 peak_qps。
假如一个服务的 peak_qps 和 noload_latency 都比较稳定,那么它的 best_max_concurrency = noload_latency * peak_qps。自适应限流就是要找到服务的 noload_latency 和 peak_qps,并将最大并发设置为靠近两者乘积的一个值。
核心算法
自适应限流会不断地对请求进行采样,当采样窗口的样本数量足够时,会根据样本的平均延迟和服务当前的 qps 计算出下一个采样窗口的 max_concurrency:
max_concurrency = max_qps * ((2 + alpha) * min_latency - latency)
alpha:可接受的延时上升幅度,默认 0.3。latency:当前采样窗口内所有请求的平均 latency。max_qps:最近一段时间测量到的 qps 的极大值。min_latency:最近一段时间测量到的 latency 较小值的 ema,是 noload_latency 的估算值。
当服务处于低负载时,min_latency 约等于 noload_latency,此时计算出来的 max_concurrency 会高于 concurrency,但低于 best_max_concurrency,给流量上涨留出探索空间。而当服务过载时,服务的 qps 约等于 max_qps,同时 latency 开始明显超过 min_latency,此时 max_concurrency 则会接近 concurrency,并通过定期衰减避免远离 best_max_concurrency,保证服务不会过载。
探测 noload_latency 的变化
服务的 noload_latency 并非是一成不变的,自适应限流必须能够正确地探测 noload_latency 的变化。当 noload_latency 下降时,是很容易感知到的,因为这个时候 latency 也会下降。难点在于当 latency 上涨时,需要能够正确地辨别到底是服务过载了,还是 noload_latency 上涨了。
可选的方案有多种:比如用最近一段时间测得的 latency 极小值或均值来估计(方案 1、2),或统计请求在本服务内的排队等待时间(方案 3),以及定期缩小 max_concurrency、观察低负载下的延时(方案 4)。
方案 1 和方案 2 的问题在于:假如服务持续处于高负载,那么最近的所有 latency 都会高出 noload_latency,从而使得算法估计的 noload_latency 不断升高。
方案 3 的问题在于:假如服务的性能瓶颈在下游服务,那么请求在服务本身的排队等待时间无法反映整体的负载情况。
方案 4 是最通用的,也经过了大量实验的考验。缩小 max_concurrency 和公式中的 alpha 存在关联。让我们做个假想实验,若 latency 极为稳定并都等于 min_latency,那么公式简化为 max_concurrency = max_qps * latency * (1 + alpha)。根据 little’s law,qps 最多为 max_qps * (1 + alpha)。alpha 是 qps 的“探索空间”,若 alpha 为 0,则 qps 被锁定为 max_qps,算法可能无法探索到 peak_qps。但在 qps 已经达到 peak_qps 时,alpha 会使延时上升(已拥塞),此时测定的 min_latency 会大于 noload_latency,一轮轮下去最终会导致 min_latency 不收敛。定期降低 max_concurrency 就是阻止这个过程,并给 min_latency 下降提供“探索空间”。
每隔一段时间,自适应限流算法都会缩小 max_concurrency,并持续一段时间,然后将此时的 latency 作为服务的 noload_latency,以处理 noload_latency 上涨了的情况。测量 noload_latency 时,必须先让服务处于低负载的状态,因此对 max_concurrency 的缩小是难以避免的。
由于 max_concurrency < concurrency 时,服务会拒绝掉所有的请求,限流算法将“排空所有的经历过排队等待的请求的时间”设置为 latency * 2,以确保用于计算 min_latency 的样本绝大部分都是没有经过排队等待的。由于服务的 latency 通常都不会太长,这种做法所带来的流量损失也很小。
应对 latency 抖动
即使服务自身没有过载,latency 也会发生波动,根据 Little’s Law,latency 的波动会导致 server 的 concurrency 发生波动。
我们在设计自适应限流的计算公式时,考虑到了 latency 发生抖动的情况:当 latency 与 min_latency 很接近时,根据计算公式会得到一个较高的 max_concurrency 来适应 concurrency 的波动,从而尽可能减少“误杀”;同时,随着 latency 的升高,max_concurrency 会逐渐降低,以保护服务不会过载。
从另一个角度来说,当 latency 也开始升高时,通常意味着某处(不一定是服务本身,也有可能是下游服务)消耗了大量 CPU 资源,这个时候缩小 max_concurrency 也是合理的。
为了减少个别窗口的抖动对限流算法的影响,同时尽量降低计算开销,计算 min_latency 时会通过使用 EMA 来进行平滑处理:
if latency > min_latency:
min_latency = latency * ema_alpha + (1 - ema_alpha) * min_latency
else:
do_nothing
冷启动加速
当服务启动时,由于服务本身需要进行一系列的初始化,tcp 本身也有慢启动等一系列原因,服务在刚启动时的 qps 一定会很低,这就导致了服务启动时的 max_concurrency 也很低。而按照上面的计算公式,当 max_concurrency 很低的时候,预留给 qps 增长的冗余 concurrency 也很低(即 alpha * max_qps * min_latency),从而会影响当流量增加时,服务 max_concurrency 的增加速度。
假如从启动到打满 qps 的时间过长,这期间会损失大量流量。在这里我们采取了两项措施来加速这个过程。在进行了这两个处理之后,绝大部分情况下都能够在 2 秒左右将 qps 打满。
max_qps 的平滑
为了减少个别窗口的抖动对限流算法的影响,同时尽量降低计算开销,在计算 max_qps 时,会通过使用 EMA 来进行平滑处理:
if current_qps > max_qps:
max_qps = current_qps
else:
max_qps = current_qps * ema_alpha / 10 + (1 - ema_alpha / 10) * max_qps
将 max_qps 的 ema 参数置为 min_latency 的 ema 参数的十分之一的原因是:max_qps 下降了通常并不意味着极限 qps 也下降了,而 min_latency 下降了,通常意味着 noload_latency 确实下降了。
与 Netflix gradient 算法的对比
Netflix 中的 gradient 算法公式为:
max_concurrency = min_latency / latency * max_concurrency + queue_size
其中 latency 是采样窗口的最小 latency,min_latency 是最近多个采样窗口的最小 latency。min_latency / latency 就是算法中的“梯度”,当 latency 大于 min_latency 时,max_concurrency 会逐渐减少;反之,max_concurrency 会逐渐上升,从而让 max_concurrency 围绕在 best_max_concurrency 附近。这个公式可以和本文的算法进行类比。