第3章通用的服务可用性治理手段——3.4 限流

第3章通用的服务可用性治理手段——3.4 限流
John Yaml3.2节和3.3节介绍的重试、熔断、资源隔离,都是上游服务为了提高自身服务质量和适当保护下游服务而采用的策略。本节将介绍作为下游服务,为了应对多个上游服务的请求访问,以防被上游服务打垮应做好的预防机制,这个预防机制就是老生常谈的“限流”。
在现实生活中有大量应用限流的场景,比如某些著名景区在劳动节、国庆节等节假日期间往往人满为患,不仅容易破坏景区环境,而且容易发生踩踏事故,游客的体验也非常差,于是景区管理部门就会通过一系列手段对景区限流,如限定每日票量、限制游玩项目同时参与的人数等。在互联网场景中,这样的例子也随处可见,比如“双十一”电商秒杀抢购、火车票抢票等场景,都通过限流策略来防止服务被海量请求打垮。限流的表现形式主要包括如下几种。
- 频控:控制用户在N秒内只可执行M次操作,比如限制用户在30s内只能下载1次文件、在1h内最多只能发布5条动态。
- 单机限流+固定阈值:某服务的每台服务器在1s内最多可处理M个请求,M值是预先设置好的。
- 全局限流+固定阈值:某服务在1s内总共可处理M个请求,与前者的主要区别在于限流范围是某服务的全部服务实例。
- 单机自适应限流:某服务的每台服务器根据自身服务状况,使用各种自动化算法动态判断是否限制请求,不再预先设置限流阈值。
3.4.1 频控
我们可以借助Redis实现频控,不过,用户在N秒内只能执行1次操作和最多执行M次操作可以用两种不同的方案来实现。
(1)用户在N秒内只能执行1次操作的频控场景
对于用户在N秒内只能执行1次操作的频控场景,可以使用Redis SET命令结合EX参数实现,并设置N秒作为Key的过期时间。以限制用户在30s内只能下载1次文件为例,判断用户是否可以下载文件的Go语言代码实现如下:
1 | func canDownload(ctx context.Context, userID int64) bool { |
在用户执行下载文件操作前,调用canDownload函数判断一个用户ID是否可以下载文件。此函数先根据用户ID拼接Redis Key(download.{userID}
)表示此用户的下载行为,然后尝试执行Redis命令SET download, {userid} 0 EX 30 NX
为用户的下载行为设置键值。
- 如果设置失败,则说明Key已经存在且未过期,即在最近30s内此用户已经下载过文件,于是函数返回false;
- 如果设置成功,则说明在最近30s内用户无下载行为,于是函数返回true,表示用户可以执行此次文件下载操作。
(2)用户在N秒内最多执行M次操作的频控场景
对于用户在N秒内最多执行M次操作的频控场景,由于涉及具体的操作次数M,因此只能通过维护用户操作计数并通过INCR命令更新计数来实现。这里包括两个细节:
- 如果INCR的结果大于则表示用户操作次数已经达到阈值,应该被频控;
- 用户操作计数数据应该在首次设置时被设置过期时间为N秒,首次设置意味着INCR返回值为1。
由于设置过期时间需要先判断INCR返回值,所以这时最多涉及两个Redis命令:INCR和EXPIRE(设置过期时间)。为了确保这两个操作的并发安全性,设置过期时间需要通过Lua脚本来实现。我们以用户在1h内最多只能发布5条动态为例,其Lua脚本实现如下:
1 | local count = redis.call('incr', 'publish_{userID}') |
而用户是否可以发布动态,需要根据INCR返回值是否大于M来判断。Go语言代码实现如下:
1 | // limit参数表示频控M值 |
在用户发布动态前,调用canPublishContent函数判断其是否被频控。此函数先根据用户ID拼接Redis Key(publish_{userID}
)表示此用户的动态发布行为,再执行上面所示的Lua脚本并获取INCR返回值(这样可以保证在首次设置计数时顺便设置过期时间)。如果 INCR返回值小于频控阈值5,则说明用户在1h内发布动态不足5条,因此允许发布,此函数返回true。
3.4.2 单机限流1:时间窗口
一种简单的限流算法是通过固定时间窗口来实现的,比如限制在1s内请求量不超过100个,可以将时间线切分为以1s为单位的时间窗口,每个时间窗口都维护这1s内允许通过的计数100。
如图3-15所示,当请求到来时,先向当前所处的时间窗口申请1个计数,如果该时间窗口内的计数大于0,则计数减1并允许请求通过;否则,请求被限流器丢弃。
固定时间窗口最大的缺点是无法防止时间窗口边界附近的流量暴增,可能会有2倍限流阈值的请求通过限流器,不符合限流器的预期。比如在11:30:06的前500ms内没有任何请求进入,在后500ms内有100个请求进入,它们全部被允许通过限流器;而在11:30:07的前500ms内也有100个请求进入,它们也全部被允许通过限流器。这样一来,在11:30:06:500 ~ 11:30:07:500这1s内实际上有200个请求通过了限流器,不符合限制在1s内请求量不可超过100个的设定,因此限流效果比较差。
我们可以使用滑动时间窗口的方式来弥补上述缺点。滑动时间窗口把时间线以更小的时间粒度(如50ms)划分为一个个槽,将限流计数均摊到每个槽,一个时间窗口就是最近时间的20个槽的总和,如图3-16所示。
这个时间窗口会随着时间的推移不断向后滑动。 当请求到来时,先在当前时间最近的20个槽中查找第一个计数大于0的槽,如果找到了,则说明在这个时间窗口内依然有限流计数可用,将该槽计数减1,请求被允许通过限流器;如果在整个时间窗口内所有的槽计数都为0,则说明已经达到限流阈值,请求将被限流器丢弃。
滑动时间窗口限流器可以有效提高限流的准确性,且槽的时间粒度越小,限流的准确性越高。不过,由于需要维护较多的槽信息,判断限流需要在时间窗口内遍历查找可用槽,此实现方式有较大的内存与CPU开销。为了对海量请求进行限流设定,限流器应该有更加轻量级的实现方式。
3.4.3 单机限流2:漏桶算法
漏桶算法很好理解。如图3-17所示。
一个漏桶承接水流,并通过一个出水口匀速出水,当水流过大、漏桶已满时会导致水溢出。水流被视为进入服务器的请求,出水口匀速出水可被视为服务器处理请求的固定速率,当请求过多导致漏桶满了时,将开始拒绝新来的请求。
漏桶算法是先进先出概念的体现,可以使用队列实现漏桶。每个新来的请求都先尝试进入漏桶,如果漏桶已满,则拒绝请求;否则,请求在漏桶中排队,服务器按照固定的时间间隔从漏桶中获取第一个请求并对其进行处理。使用漏桶算法,请求可以以任意速率进入服务器,而服务器永远以固定的速率处理排队的请求。
我们以一个完整的例子来描述请求是怎样在漏桶中排队的。假设设置漏桶的限流阈值为每秒处理10个请求(即请求处理速率是1req/100ms),漏桶的容量为10,此时同时有5 个请求流入漏桶中:
- 第一个请求进入漏桶,由于此时漏桶为空,所以请求无须排队,可以直接被处理。
- 第二个请求进入漏桶,由于第一个请求刚刚被处理,所以此请求需要排队,严格按照请求处理速率的设置,即需要排队100ms。
- 第三个请求,需要等待第二个请求被处理后再排队100ms,所以排队时间是200ms。
- 以此类推,第四个请求排队300ms,第五个请求排队400ms,如图3-18所示。
漏桶算法可以保证无论有多少个请求涌入服务器,它们都将按照固定的速率被处理,有效保障了服务的稳定性。不过,漏桶算法的缺点也非常明显:
- 为了按照固定的速率处理请求,漏桶算法强行要求请求排队等待,从上游服务的视角来看,请求的响应时间会有一定程度的增加。
- 漏桶算法应对流量不够灵活,不支持突增流量,这些突增流量对服务来说可能完全没有处理压力,但请求却在缓慢排队,其实这是对服务性能的浪费。
3.4.4 单机限流3:令牌桶算法
令牌桶算法是一种高效且易于实现的限流算法,并消除了漏桶算法的缺点。令牌桶算法的原理是系统会以一个恒定的速率向令牌桶中放入令牌,在处理请求前,需要先从令牌桶中获取一个令牌,如果桶中没有令牌可取,则拒绝请求。如图3-19所示。
令牌桶算法的基本工作流程如下。
- 每秒向令牌桶中放入r个令牌,即每1/r秒新增一个令牌。
- 令牌桶容量为b,即最多存放b个令牌,桶满时新放入的令牌会被丢弃。
- 当一个请求到来时,从令牌桶中获取一个令牌。如果桶中有令牌,则令牌总数减1,请求被允许执行。
- 如果桶中没有可用令牌,则该请求被限流。
令牌桶算法可以容忍一定程度的突增流量,这个特性体现在两个方面。
- 首先,令牌桶的容量力保证此算法允许在1s内最多有力个并发请求访问服务;
- 其次,令牌在不断生成,并且只有请求访问服务时才扣减令牌的形式,对请求波动的限流更加灵活。如果我们预期的限流阈值是1s处理100个请求,那么令牌桶算法每秒会向桶中放入100个令牌。假设第1秒只有80个请求访问服务,还剩下20个令牌,那么第2秒可以允许120个请求通过限流器。
从长期运行结果的表现来看,令牌桶算法依然是将1s内请求并发数限制为r,只不过它允许一定程度的流量突增。由于令牌桶算法既能支持高并发场景,又能平滑控制流量,所以大部分业务场景会采用此限流算法。
令牌桶算法的Go语言代码实现如下:
1 | type TokenBucket struct { |
3.4.5 全局限流
与单机限流不同,全局限流旨在对一个服务的所有实例统一限流,所有服务实例共用一个限流配额,这个限流配额一般由专门的限流服务器下发。当一个服务的任意一个实例收到请求时,服务实例的限流模块会先访问限流服务器,查询此请求是否被允许通过,而限流服务器根据自身规则决定是否触发对此请求的限流,如图3-20所示。
限流服务器可以使用Redis实现,实现细节与3.4.1节的“在N秒内最多执行M次操作的频控场景”的频控方案一样,即使用Redis INCR命令,这里不再赘述。全局限流的特点是,由统一的限流服务器对各服务实例统一限流,服务实例数量不会影响限流阈值,但是限流服务器的引入为请求的执行增加了一次额外网络调用,且在请求量较大的情况下,限流服务器单点很容易成为影响业务服务的瓶颈。所以,这种全局限流方案不是很适合请求量巨大的服务。
不过,如果对限流阈值的精确性和实时性的要求适当放宽,那么我们可以换一种交互方式:
- 服务的各个实例周期性地将一段时间的流量统计上报到限流服务器。
- 限流服务器根据各服务实例的流量情况重新计算最新限流配额,然后下发到各个服务实例。
- 每个服务实例都根据收到的限流配额本地决定请求是否允许被处理。
接下来具体介绍一种可行的技术实现:时间片统计。
- 将时间划分为连续的时间片,每个服务实例都记录每个时间片内的请求总数、请求调用延迟时间,并在每个时间片结束时将统计日志发送到消息队列。
- 创建一个日志收集器服务,作为消息队列的消费者。当一个时间片下的所有服务实例的统计日志都已到达日志收集器后,汇总计算此时间片的实际QPS:QPS = 总请求数 / 时间片长度。
- 日志收集器将汇总统计数据上报给限流服务器。
- 限流服务器计算最新的限流比例dropRate。如果一个时间片的实际QPS=1200,限流阈值=1000,则dropRate=0.167。这里:dropRate = (实际QPS - 限流阈值) / 实际QPS。
- 限流服务器下发最新的限流比例到各个服务实例。
- 服务实例根据限流比例限流:每个请求有16.7%的概率被丢弃。
需要注意的是,时间片长度不宜过长,否则限流调整实时性过差;时间片长度也没必要太短,否则会影响服务性能,一般10s、30s都是比较合适的时间片选择。
此方案架构如图3-21所示。
使用Redis频控方案进行全局限流,限流效果精准、时效性强,但是由于Redis会成为业务服务的额外单点,反而影响了业务服务的稳定性,所以此方案只适合请求量不大的服务。而使用时间片统计方案,注定不会有精准的限流阈值控制,时效性也没有绝对保障,所以它更适合请求量大的服务,对请求进行粗略筛选。
实际上,并不是所有的业务场景都需要进行全局限流。
- 如果限流的目的是保护服务不被大量请求击垮,那么进行单机限流即可,没有必要上升到全局限流;
- 如果限流的目的是对某个共享资源的全局访问控制,则可以进行全局限流,比如秒杀场景可以参考使用商品的总库存作为限流阈值,对用户抢购请求进行全局限流。
总结
什么是限流?
- 为了应对多个上游服务的请求访问,以防被上游服务打垮应做好的预防机制,这种预防机制就是限流。
限流的表现形式有哪些?
- 频控:控制用户在N秒内只可执行M次操作,比如限制用户在30s内只能下载1次文件、在1h内最多只能发布5条动态。
- 单机限流+固定阈值:某服务的每台服务器在1s内最多可处理M个请求,M值是预先设置好的。
- 全局限流+固定阈值:某服务在1s内总共可处理M个请求,与前者的主要区别在于限流范围是某服务的全部服务实例。
- 单机自适应限流:某服务的每台服务器根据自身服务状况,使用各种自动化算法动态判断是否限制请求,不再预先设置限流阈值。
单机限流的算法有哪些?
- 滑动时间窗口算法
- 漏桶算法
- 令牌桶算法
滑动时间窗口算法的基本原理?
- 滑动时间窗口把时间线以更小的时间粒度(如50ms)划分为一个个槽,将限流计数均摊到每个槽,一个时间窗口就是最近时间的20个槽的总和。
- 这个时间窗口会随着时间的推移不断向后滑动。
- 当请求到来时,先在当前时间最近的20个槽中查找第一个计数大于0的槽。
- 如果找到了,则说明在这个时间窗口内依然有限流计数可用,将该槽计数减1,请求被允许通过限流器。
- 如果在整个时间窗口内所有的槽计数都为0,则说明已经达到限流阈值,请求将被限流器丢弃。
滑动时间窗口算法的缺点?
- 有较大的CPU和内存开销。
漏桶算法的基本原理?
- 每个新来的请求都先尝试进入漏桶,如果漏桶已满,则拒绝请求;否则,请求在漏桶中排队,服务器按照固定的时间间隔从漏桶中获取第一个请求并对其进行处理。
- 使用漏桶算法,请求可以以任意速率进入服务器,而服务器永远以固定的速率处理排队的请求。
漏桶算法有哪些缺点?
- 为了按照固定的速率处理请求,漏桶算法强行要求请求排队等待,从上游服务的视角来看,请求的响应时间会有一定程度的增加。
- 漏桶算法应对流量不够灵活,不支持突增流量,这些突增流量对服务来说可能完全没有处理压力,但请求却在缓慢排队,其实这是对服务性能的浪费。
令牌桶算法的基本原理?
- 系统会以一个恒定的速率向令牌桶中放入令牌,在处理请求前,需要先从令牌桶中获取一个令牌,如果桶中没有令牌可取,则拒绝请求。
令牌桶算法的基本工作流程?
- 每秒向令牌桶中放入r个令牌,即每1/r秒新增一个令牌。
- 令牌桶容量为b,即最多存放b个令牌,桶满时新放入的令牌会被丢弃。
- 当一个请求到来时,从令牌桶中获取一个令牌。如果桶中有令牌,则令牌总数减1,请求被允许执行。
- 如果桶中没有可用令牌,则该请求被限流。
为什么令牌桶算法可以容忍一定程度的突增流量?
- 首先,令牌桶的容量力保证此算法允许在1s内最多有力个并发请求访问服务;
- 其次,令牌在不断生成,并且只有请求访问服务时才扣减令牌的形式,对请求波动的限流更加灵活。如果我们预期的限流阈值是1s处理100个请求,那么令牌桶算法每秒会向桶中放入100个令牌。假设第1秒只有80个请求访问服务,还剩下20个令牌,那么第2秒可以允许120个请求通过限流器。
什么是全局限流?
- 全局限流旨在对一个服务的所有实例统一限流,所有服务实例共用一个限流配额,这个限流配额一般由专门的限流服务器下发。
全局限流的工作流程?
- 当一个服务的任意一个实例收到请求时,服务实例的限流模块会先访问限流服务器,查询此请求是否被允许通过,而限流服务器根据自身规则决定是否触发对此请求的限流。
什么时候使用单机限流和全局限流?
- 如果限流的目的是保护服务不被大量请求击垮,那么进行单机限流即可,没有必要上升到全局限流;
- 如果限流的目的是对某个共享资源的全局访问控制,则可以进行全局限流,比如秒杀场景可以参考使用商品的总库存作为限流阈值,对用户抢购请求进行全局限流。