阅读本文大概需要 7.3 分钟。

1. 我们为什么需要限流

在上一篇架构师成长之路之服务治理漫谈里面,我们已经谈到了高可用治理的部分。为了“反脆弱”,在微服务复杂拓扑的情况下,限流是保障服务弹性和拓扑健壮的重中之重。

想一想,如果业务推出了一个秒杀活动,而你没有任何的限流措施;当你搭建了一个账号平台,而完全没有对十几个业务方设定流量配额……这些很有可能在特定场合下给你的产品带来大量的业务损失和口碑影响。

我们通常重点关注产品业务层面正向和逆向功能的完成,而对于逆向技术保障,这一点则是企业发展过程中很容易忽视的,所以一旦业务快速增长,这将给你的产品带来很大的隐患。

当然,也不是所有的系统都需要限流,这取决于架构师对于当前业务发展的预判。

2. 我们常见的限流手段

我们来列举业内比较常见的一些限流手段。

2.1 信号量计数

信号量竞争是用来控制并发的一个常见手段。比如 C 和 Java 中都有 Semaphore 的实现可以让你方便地上手。鼎鼎大名的弹性框架 Hystrix 也默认选择了信号量来作为隔离和控制并发的办法。它的优点即在于简单可靠,但是只能在单机环境中使用。

2.2 线程池隔离

隔离舱技术中也大量使用了线程池隔离的方式来实现,通过限制使用的线程数来对流量进行限制,一般会用阻塞队列配合线程池来实现。如果线程池和队列都被打满,可以设计对应拒绝策略。需要谨慎调整其参数和线程池隔离的个数,以避免线程过多导致上下文切换带来的高昂成本。也是基于这个考虑,Hystrix 默认采用了信号量计数的方式来控制并发。同样,其也只能在单机环境中使用。

2.3 固定窗口计数

我们可以以第一次请求访问的时候开始进行计数,而不严格按照自然时间来计数。比如可以利用 Redis 的 INCR 和 EXPIRE 组合进行计数,如下伪代码所示:

count = redis.incrby(key)if count == 1    redis.expire(key,3600)if count >= threshold    println("exceed...")

这种实现方式简单粗暴,可以解决绝大部分分布式限流的问题。但是其存在的问题是:

  1. 该计数方式并不是准确计数,由于时间窗口一旦过期,则之前积累的数据就失效,这样可能导致比如本来希望限制“一分钟内访问不能超过 100 次”,但实际上做不到精准的限制,会存在误判放过本应拒绝的流量。

  2. 每次请求都将访问一次 Redis,可能存在大流量并发时候将缓存打崩最终拖垮业务应用的问题。这个在高并发场景中是非常严重的问题。当然,你可以选择按照业务进行适当的缓存集群切割来缓解这种问题,但是这仍然是治标不治本。当然,如果你选择单机限流的实现方式,则无需使用 Redis,进一步,单机限流情况下该问题不存在。

2.4 自然窗口计数

有的场景会需要以自然窗口为维度进行限制,实现方式即进行分桶计数。每个 slot 一般以时间戳为 key salt,以单 slot 时间长度内的计数值为 Value,我们可以根据实际的需求对单 slot 的时间长度进行限制,比如如果你需要限制一天发送短信数不超限,则以 1 个自然天为 1 个 slot,如果希望限制 QPS,则以 1s 为 1 个 slot。然后起定时任务获取 slot,进一步取出实际的分桶计算结果,进行判断是否达到阈值,如果超过阈值则执行对应的限制操作。

该策略如果应用在分布式限流环境下,则会碰到若干个问题。这个后面章节中会提到。另外,该策略本质上其实是也是一种特殊的固定窗口计数策略,那么固定窗口所存在的弊端,自然窗口计数也会存在。那么我们不禁会问,如果希望规避固定窗口的一大问题——“无法准确计数”的话,要怎么做呢?这时,“滑动窗口计数”方式应运而生。

2.5 滑动窗口计数

滑动窗口的出现,可以很好地解决精准计数的问题。随着时间窗口不断地滑动,动态地进行计数判断。可以规避自然窗口和固定窗口计数所存在的计数不准确的问题。以下有两种常见的滑动窗口计数的实现类别。

2.5.1 基于共享分布式内存

可以采用 Redis ZSet,存储结构如下图所示。Key 为功能 ID,Value 为 UUID,Score 也记为同一时间戳。整个过程简单概括为“添加记录、设置失效时间、计数、删除过期记录”四部分。使用 ZADD、EXPIRE、ZCOUNT 和 zremrangeScore 来实现,并同时注意开启 Pipeline 来尽可能提升性能。

16ac3ae28dd1eb6d?w=938&h=260&f=png&s=10048

伪代码如下:

// 开启pipepipeline = redis.pielined()// 增加一条请求pipeline.zadd(key, getUUID(), now)// 重新设置失效时间pipeline.expire(key, 3600)// 统计在滑动窗口内,有多少次的请求count = pipeline.zcount(key, expireTimeStamp, now)// 删除过期记录pipeline.zremrangeByScore(key, 0, expireTimeStamp - 1)pipeline.sync()if count >= threshold    println("exceed")

但是该方法,有一个比较突出的问题。就是这是一个重操作,将引发高 QPS 下 Redis 的性能瓶颈,也将消耗较多的资源和时间。一般我们可以付出秒级的时延,对其做多阶段异步化的处理。比如将计数、删除过期数据和新增记录分为三部分去进行异步处理。此处就不进一步展开了。

2.5.2 基于本地内存

第一个方案中,分布式滑动窗口的难度在于,不得不进行内存共享来达到窗口计数准确的目的。如果考虑分发时进行 Key Based Routing 是不是能解决这个问题?在付出非幂等、复杂度抬升等一定代价的情况下,引入基于本地内存的分布式限流实现方式。

实现方式有如下两种:

  1. 如果可以接受准实时计算的话,可以采用 Storm,使用 filedsGroup,指定 Key 到对应的 Bolt 去处理;

  2. 如果需要实时计算的话,那么就采用 RPC 框架的 LB 策略为指定 Key 的一致性 Hash。然后路由到对应的服务实例去处理。

以上两个实现方式,当到达 Bolt 或者服务实例后,即可基于本地内存进行处理,处理方式也有三种。

  1. 采用 Esper,用 DSL 语句即可简单实现滑动窗口。

  2. Storm 1.0 之后提供了滑动窗口的实现。

  3. 如果希望自实现滑动窗口(不推荐),实现思路也比较简单即:循环队列+自然窗口滑动计数。

循环队列来解决无限后延的时间里,计数空间重复利用的问题。而此处,我们看到了一个熟悉的名词——“自然窗口计数”。没错,底层仍然采用自然窗口计数,但是区别在于,我们会对自然窗口切分更细的粒度,每次批量超前获取多个分桶,来进行加和计算。这样就可以实现滑动窗口的效果,你可以认为,当分桶被细化到 10s、5s 甚至越来越细的时候,计数将趋近于更加准确。

2.6 令牌桶和漏桶算法计数

令牌桶的示意图如下:

16ac3ae28ecdaf7f?w=421&h=241&f=jpeg&s=12957

而漏桶的示意图如下:

16ac3ae2a50e5c6f?w=443&h=299&f=png&s=14658

这个在业内也是鼎鼎大名。基本谈起限流算法,这两个算法必然会被提起,令牌桶可以有流量应对突发流量,漏桶则强调对流量的整型。二者的模型是相反的。令牌桶和漏桶算法在单机限流中较为常见,而在分布式限流中罕见踪迹。

对于令牌桶来说,你可以采用定时任务去做投递令牌的动作,也可以采用算法的方式去进行简单的计算。Guava Ratelimiter 采用的是后者。

令牌桶的优势之一,在于可以有部分余量用以应对突发流量。但是在实际生产环境中,这不一定是安全的。如果我们的服务没有做好应对更高突发流量的准备,那么很有可能会引发服务雪崩。所以考虑到这一点,Guava 采用了令牌桶 + 漏桶结合的策略来进行限流。对于默认业务,采用标准令牌桶方式进行“可超支”限速,而对于无法突然应对高峰流量的业务,会采用缓慢提升投放令牌速率(即逐步缩短业务请求等待时间)的方式来进行热启动控制,具体见 Guava Ratelimiter 源码注释描述,此处不赘述,其效果如下图所示:

16ac3ae2a510235e?w=1080&h=450&f=jpeg&s=32012

3. 微服务限流几个考虑的点

以上的限流手段,有的能应用在单机环境,有的能应用在分布式环境。而在高并发的分布式环境中,我们需要考虑清楚如下几个问题如何解决。

3.1 机器时钟不一致或者时钟回退问题

一旦出现这种问题,则可能导致收集的数据相互污染而导致判断出错。所以一方面,在运维层面需要确保机器时钟能够按期同步。另一方面,需要有准实时检测的手段,及时发现时钟偏差太大或者时钟回退的机器,基于一定策略筛选出不合格的数据来源,将其刨除出计算范围并发出警告。

3.2 在 SDK 还是 Server 端做限流逻辑

你需要考虑你的限流策略迭代的频繁程度,推动业务方改造的成本,语言/技术栈异构情况,是否有需要进行立多系统联合限流的场景,以此来进行决策。如果采用 SDK 方式,你需要做好碰到这几个棘手问题的心理准备。

而如果采用 Server 方式,你则需要更多考虑高并发下数据堆积,机器资源消耗,以及对业务方性能的影响问题。一般业内采用的是富 SDK 的方式来做,但是对于上述的 SDK 会面临的几个问题没有很好的解决方案。而 ServiceMesh 领军人物 Istio 采用了 Mixer 来实现 Server 端限流的方式,但是碰到了很严重的性能问题。所以这是一个很困难的选择。

回顾下架构师成长之路之服务治理漫谈一篇中所讲到的服务治理发展路径,是不是有点惊人的相似?是不是也许限流的未来,不在 SDK 也不在 Server,而在于 ServiceMesh?我不确定,但我觉得这是一个很好的探索方向。

3.3 限流是不是会让你的系统变得不可控

这是一个很有意思的问题,限流本身是为了“反脆弱”而存在的,但是如果你的分布式复杂拓扑中遍布限流功能,那么以后你每个服务的扩容,新的功能上线,拓扑结构的变更,都有可能会导致局部服务流量的骤增,进一步引发限流导致业务有损问题。这就是“反脆弱”的本身也有可能会导致“脆弱”的出现。所以,当你进行大规模限流能力扩张覆盖的时候,需要谨慎审视你的限流能力和成熟度是否能够支撑起如此大规模的应用。

3.4 拓扑的关联性能给限流带来什么

我们置身于复杂服务拓扑和各种调用链路中,这一方面确实给限流带来了很大的麻烦,但另一方面,我们是不是可以思考一下,这些复杂度,本身是不是可以带给我们什么样的利好?比如:底层服务扛不住,那么是不是可以在更上层的调用方入口进行限流?如此是不是可以给予用户更友好提示的同时,也可避免链路上服务各自限流后带来的系统级联处理压力?微服务的本质是自治没错,但是我们是不是可以更好地对各个服务的限流自治能力进行编排,以达到效率、体验、资源利用的优化?

相信大家都会有自己的答案。这件事情本身的难度是在于决策的准确性,但如果能很好地进行落地实现,则意味着我们的限流从自动化已经逐步转向了智能化。这也将是更高一层次的挑战和机遇。

3.5 准确性和实时性的权衡

在高并发限流场景下,准确性和实时性理论上不可兼得。在特定的场景中,你需要作出你的选择,比如前文介绍的基于 Redis ZSet 实现的滑动窗口实时计算方式可以满足实时性和准确性,但其会带来很明显的性能问题。所以我们需要作出我们的权衡,比如牺牲准确性将滑动窗口退化为固定窗口来保障性能;或者牺牲实时性,对滑动窗口多阶段去做异步化,分析和决策两阶段分离,来保障性能。这取决于你的判断。

4. 总结

限流是高可用治理中核心的一环,实现方式也五花八门,每种方式也都有各自的问题,本文只是做了一个简单的回顾。希望随着 ServiceMesh、AIOps 等理论的兴起,我们对于限流是什么,能做什么,怎么实现,能够释放出更大的空间去想象。

首发:https://cloud.tencent.com/developer/article/1408380

往期精彩回顾

16ab93c088be8423?w=500&h=278&f=jpeg&s=27769