微服务拆分之后,系统之间的调用关系错综复杂,平台的整体复杂熵升高,出错的概率、debug 问题的难度都高了好几个数量级。所以,服务治理便成了微服务的一个技术重点。服务治理本身的概念比较大,包括鉴权、限流、降级、熔断、监控告警等等,本文聚焦于限流,根据笔者的实战经验,分享一些对微服务接口限流的思考。
本文试图讲清楚以下问题,如果您对限流也有类似的疑问或对某一话题感兴趣,欢迎阅读本文。
文章的最后,还顺带介绍了笔者开源的限流框架: ratelimiter4j,欢迎大家交流使用。
微服务接口限流的背景
在应对秒杀、大促、双 11、618 等高性能压力的场景时,限流已经成为了标配技术解决方案,为保证系统的平稳运行起到了关键性的作用。不管应用场景是哪种,限流无非就是针对超过预期的流量,通过预先设定的限流规则选择性的对某些请求进行限流“熔断”。限于篇幅和作者的经验能力,本文主要讲微服务架构下,服务接口的限流。
对于微服务来说,特别是一些中台微服务,其接口请求可能来自很多系统,例如用户服务的接口会被很多内部系统调用,比如 CRM, 促销系统等。对于服务于众多调用系统和应对海量接口请求的微服务来说,接口限流除了应对上面提到的一些大促秒杀场景之外,在下面一些场景中也发挥着很大的作用。
作为提供接口服务的微服务系统,我们是无法限制调用方如何来使用我们的接口的,我们曾经就遇到过有一些调用方多线程并发跑 job 来请求我们的接口,也遇到到一些因为调用方的代码 bug 或者业务上面的突发流量,导致来自这个调用方的接口请求数量突增,过度争用服务线程资源,而来自其他调用方的接口请求因此来不及响应而排队等待,微服务整体的请求响应时间变长甚至超时。所以为了防止接口被过度调用,需要对每个调用方进行细粒度的访问限流。
除了对调用者的访问频率进行限制外,我们有的时候还需要对某些接口的访问频率做限制。比如一些慢接口,可能因为逻辑复杂,处理时间会比较长,如果对慢接口的访问频率不加限制,过多的慢接口请求会一直占用服务的线程资源不释放,导致无法响应其他接口请求,影响微服务系统整体的吞吐量和接口响应时间,甚至引起大量的接口超时。除了慢接口,有些核心接口,因为一旦异常访问对业务的影响比较大,除了做调用鉴权之外,还需要做非预期异常流量的限流。
综上所述,我们不仅仅需要针对大促秒杀场景的粗粒度的微服务接口限流功能:比如限制微服务集群单台机器每秒请求次数,我们还需要针对不同调用方甚至不同接口进行更加细粒度限流:比如限制 A 调用方对某个服务的某个的接口的每秒最大请求次数。
关于接口限流中“流”的定义
限流中的“流”字该如何解读呢?要限制的指标到底是什么?不同的场景对“流”的定义也是不同的,可以是网络流量,带宽,每秒处理的事务数 (TPS),每秒请求数 (hits per second),并发请求数,甚至还可能是业务上的某个指标,比如用户在某段时间内允许的最多请求短信验证码次数。
从保证系统稳定可用的角度考量,对于微服务系统来说,最好的一个限流指标是:并发请求数。通过限制并发处理的请求数目,可以限制任何时刻都不会有过多的请求在消耗资源,比如:我们通过配置 web 容器中 servlet worker 线程数目为 200,则任何时刻最多都只有 200 个请求在处理,超过的请求都会被阻塞排队。
上一节讲到,我们为了解决调用方对服务资源的过度争用问题,还需要针对不同调用方甚至不同接口做细粒度限流,所以,我们除了需要对系统整体的并发请求数做限制之外,还需要对每个调用方甚至不同接口的并发请求数做限制。但是,要想合理的设置某个调用方的最大允许并发数是比较困难的,这个值很难通过监控统计来获取,太小容易误杀,太大又起不了作用。所以我们还需要其他限流指标。
对比 TPS 和 hits per second 的两个指标,我们选择使用 hits per second 作为限流指标。因为,对 TPS 的限流实际上是无法做的,TPS 表示每秒处理事务数,事务的开始是接收到接口请求,事务的结束是处理完成返回,所以有一定的时间跨度,如果事务开始限流计数器加一,事务结束限流计数器减一,则就等同于并发限流。而如果把事务请求接收作为计数时间点,则就退化为按照 hits per second 来做限流,而如果把事务结束作为计数时间点,则计数器的数值并不能代表系统当下以及接下来的系统访问压力。
对 hits per second 的限流是否是一个有效的限流指标呢?答案是肯定的,这个值是可观察可统计的,所以方便配置限流规则,而且这个值在一定程度上反应系统当前和接下来的性能压力,对于这一指标的限流确实也可以达到限制对系统资源的使用。
有了流的定义之后,我们接下来看几种常用的限流算法:固定时间窗口,滑动时间窗口,令牌桶算法,漏桶算法以及他们的改进版本。
固定、滑动时间窗口限流算法
基于固定时间窗口的限流算法是非常简单的。首先需要选定一个时间起点,之后每次接口请求到来都累加计数器,如果在当前时间窗口内,根据限流规则(比如每秒钟最大允许 100 次接口请求),累加访问次数超过限流值,则限流熔断拒绝接口请求。当进入下一个时间窗口之后,计数器清零重新计数。
这种基于固定时间窗口的限流算法的缺点在于:限流策略过于粗略,无法应对两个时间窗口临界时间内的突发流量。我们举一个例子:假设我们限流规则为每秒钟不超过 100 次接口请求,第一个 1s 时间窗口内,100 次接口请求都集中在最后的 10ms 内,在第二个 1s 的时间窗口内,100 次接口请求都集中在最开始的 10ms 内,虽然两个时间窗口内流量都符合限流要求 (<=100 个请求),但在两个时间窗口临界的 20ms 内会集中有 200 次接口请求,如果不做限流,集中在这 20ms 内的 200 次请求就有可能压垮系统,如图 -1:
滑动时间窗口算法是对固定时间窗口算法的一种改进,流量经过滑动时间窗口算法整形之后,可以保证任意时间窗口内,都不会超过最大允许的限流值,从流量曲线上来看会更加平滑,可以部分解决上面提到的临界突发流量问题。对比固定时间窗口限流算法,滑动时间窗口限流算法的时间窗口是持续滑动的,并且除了需要一个计数器来记录时间窗口内接口请求次数之外,还需要记录在时间窗口内每个接口请求到达的时间点,对内存的占用会比较多。滑动时间窗口的算法模型如下:
滑动窗口记录的时间点 list = (t_1, t_2, …t_k),时间窗口大小为 1 秒,起点是 list 中最小的时间点。当 t_m 时刻新的请求到来时,我们通过以下步骤来更新滑动时间窗口并判断是否限流熔断:
STEP 1: 检查接口请求的时间 t_m 是否在当前的时间窗口 [t_start, t_start+1 秒) 内。如果是,则跳转到 STEP 3,否则跳转到 STEP 2.
STEP 2: 向后滑动时间窗口,将时间窗口的起点 t_start 更新为 list 中的第二小时间点,并将最小的时间点从 list 中删除。然后,跳转到 STEP 1。
STEP 3: 判断当前时间窗口内的接口请求数是否小于最大允许的接口请求限流值,即判断: list.size < max_hits_limit,如果小于,则说明没有超过限流值,允许接口请求,并将此接口请求的访问时间放入到时间窗口内,否则直接执行限流熔断。
滑动时间窗口限流算法可以部分解决固定时间窗口的临界问题,上面的例子通过滑动时间窗口算法整形之后,第一个 1 秒的时间窗口的 100 次请求都会通过,第二个时间窗口最开始 10ms 内的 100 个请求会被限流熔断。
即便滑动时间窗口限流算法可以保证任意时间窗口内接口请求次数都不会超过最大限流值,但是仍然不能防止在细时间粒度上面访问过于集中的问题,比如上面举的例子,第一个 1s 的时间窗口内 100 次请求都集中在最后 10ms 中。也就是说,基于时间窗口的限流算法,不管是固定时间窗口还是滑动时间窗口,只能在选定的时间粒度上限流,对选定时间粒度内的更加细粒度的访问频率不做限制。
为了应对上面的问题,对于时间窗口限流算法,还有很多改进版本,比如:
多层次限流,我们可以对同一个接口设置多条限流规则,除了 1 秒不超过 100 次之外,我们还可以设置 100ms 不超过 20 次 (这里需要设置的比 10 次大一些),两条规则同时限制,流量会更加平滑。除此之外,还有针对滑动时间窗口限流算法空间复杂度大的改进算法,限于篇幅,这里就不展开详说了。
令牌桶、漏桶限流算法
上面我们讲了两种基于时间窗口的限流算法:固定时间窗口和滑动时间窗口算法,两种限流算法都无法应对细时间粒度的突发流量,对流量的整形效果在细时间粒度上不够平滑。本节介绍两种更加平滑的限流算法:令牌桶算法和漏桶算法,在某些场景下,这两种算法会优于时间窗口算法成为首选。实际上令牌桶和漏桶算法的算法思想大体类似,可以把漏桶算法作为令牌桶限流算法的改进版本,所以我们以介绍令牌桶算法为主。
我们先来看下最基础未经过改进的令牌桶算法:
令牌桶算法看似比较复杂,每间隔固定时间都要放 token 到桶中,但并不需要专门起一个线程来做这件事情。每次在取 token 之前,根据上次放入 token 的时间戳和现在的时间戳,计算出这段时间需要放多少 token 进去,一次性放进去,所以在实现上面也并没有太大难度。
漏桶算法稍微不同与令牌桶算法的一点是:对于取令牌的频率也有限制,要按照 t/n 固定的速度来取令牌,所以可以看出漏桶算法对流量的整形效果更加好,流量更加平滑,任何突发流量都会被限流。因为令牌桶大小为 b,所以是可以应对突发流量的。当然,对于令牌桶算法,还有很多其他改进算法,比如:
对比基于时间窗口的限流算法,令牌桶和漏桶算法对流量整形效果比时间窗口算法要好很多,但是并不是整形效果越好就越合适,对于没有提前预热的令牌桶,如果做否决式限流,会导致误杀很多请求。上述算法中当 n 比较小时,比如 50,间隔 20ms 才会向桶中放入一个令牌,而接口的访问在 1s 内可能随机性很强,这就会出现:尽管从曲线上看对最大访问频率的限制很有效,流量在细时间粒度上面都很平滑,但是误杀了很多本不应该拒绝的接口请求。
所以令牌桶和漏桶算法比较适合阻塞式限流,比如一些后台 job 类的限流,超过了最大访问频率之后,请求并不会被拒绝,而是会被阻塞到有令牌后再继续执行。对于像微服务接口这种对响应时间比较敏感的限流场景,会比较适合选择基于时间窗口的否决式限流算法,其中滑动时间窗口限流算法空间复杂度较高,内存占用会比较多,所以对比来看,尽管固定时间窗口算法处理临界突发流量的能力较差,但实现简单,而简单带来了好的性能和不容易出错,所以固定时间窗口算法也不失是一个好的微服务接口限流算法。
限流算法分布式改造: 分布式限流算法
相对于单机限流算法,分布式限流算法的是指: 算法可以分布式部署在多台机器上面,多台机器协同提供限流功能,可以对同一接口或者服务做限流。分布式限流算法相较于单机的限流算法,最大的区别就是接口请求计数器需要中心化存储,比如我们开源限流项目 ratelimiter4j 就是基于 Redis 中心计数器来实现分布式限流算法。
分布式限流算法在引入 Redis 中心计数器这个独立的系统之后,系统的复杂度一下子高了很多,因为要解决一些分布式系统的共性技术问题:
1. 数据一致性问题
接口限流过程包含三步操作:
Step 1:“读”当前的接口访问计数 n;
Step 2:”判断”是否限流;
Step 3:“写”接口计数 n+1, if 接口限流验证通过
在并发情况下,这 3 步 CAS 操作 (compare and swap) 存在 race condition。在多线程环境下,可以通过线程的加锁或者 concurrent 开发包中的 Atomic 原子对象来实现。在分布式情况下,思路也是类似的,可以通过分布式锁,来保证同一时间段只有一个进程在访问,但是引入分布式锁需要引入新的系统和维护锁的代码,代价较大,为了简单,我们选择另一种思路:借助 Redis 单线程工作模式 +Lua 脚本完美的支持了上述操作的原子性。限于篇幅,不展开代码讨论,详细可以参看开源项目 ratelimiter4j.
2. 超时问题
对于 Redis 的各种异常情况,我们处理起来并不是很难,catch 住,封装为统一的 exception,向上抛,或者吞掉。但是如果 Redis 访问超时,会严重影响接口的响应时间甚至导致接口响应超时,这个副作用是不能接受的。所以在我们访问 Redis 时需要设置合理的超时时间,一旦超时,判定为限流失效,继续执行接口逻辑。Redis 访问超时时间的设置既不能太大也不能太小,太大可能会影响到接口的响应时间,太小可能会导致太多的限流失效。我们可以通过压测或者线上监控,获取到 Redis 访问时间分布情况,再结合服务接口可以容忍的限流延迟时间,权衡设置一个较合理的超时时间。
3. 性能问题
分布式限流算法的性能瓶颈主要在中心计数器 Redis,从我们开源的 ratelimiter4j 压测数据来看,在没有做 Redis sharding 的情况下,基于单实例 Redis 的分布式限流算法的性能要远远低于基于内存的单机限流算法,基于我们的压测环境,单机限流算法可以达到 200 万 TPS,而分布式限流算法只能做到 5 万 TPS。所以,在应用分布式限流算法时,一定要考量限流算法的性能是否满足应用场景,如果微服务接口的 TPS 已经超过了限流框架本身的 TPS,则限流功能会成为性能瓶颈影响接口本身的性能。
除了 TPS 之外,网络延迟也是一个需要特别考虑的问题,特别是如果中心计数器与限流服务跨机房跨城市部署,之间的网络延迟将会非常大,严重影响微服务接口的响应时间。
如何选择单机限流还是分布式限流
首先需要说明一下:这里所说的单机限流和分布式限流与之前提到的单机限流算法和分布式限流算法并不是一个概念!为了提高服务的性能和可用性,微服务都会多实例集群部署,所谓单机限流是指:独立的对集群中的每台实例进行接口限流,比如限制每台实例接口访问的频率为最大 1000 次 / 秒,单机限流一般使用单机限流算法;所谓的分布式限流是指:提供服务级的限流,限制对微服务集群的访问频率,比如限制 A 调用方每分钟最多请求 1 万次“用户服务”,分布式限流既可以使用单机限流算法也可以使用分布式限流算法。
单机限流的初衷是防止突发流量压垮服务器,所以比较适合针对并发做限制。分布式限流适合做细粒度限流或者访问配额,不同的调用方对不同的接口执行不同的限流规则,所以比较适合针对 hits per second 限流。从保证系统可用性的角度来说,单机限流更具优势,从防止某调用方过度竞争服务资源来说,分布式限流更加适合。
分布式限流与微服务之间常见的部署架构有以下几种:
1. 在接入层(api-gateway)集成限流功能
这种集成方式是在微服务架构下,有 api-gateway 的前提下,最合理的架构模式。如果 api-gateway 是单实例部署,使用单机限流算法即可。如果 api-gateway 是多实例部署,为了做到服务级别的限流就必须使用分布式限流算法。
2. 限流功能封装为 RPC 服务
当微服务接收到接口请求之后,会先通过限流服务暴露的 RPC 接口来查询接口请求是否超过限流阈值。这种架构模式,需要部署一个限流服务,增加了运维成本。这种部署架构,性能瓶颈会出现在微服务与限流服务之间的 RPC 通信上,即便单机限流算法可以做到 200 万 TPS,但经过 RPC 框架之后,做到 10 万 TPS 的请求限流就已经不错了。
3. 限流功能集成在微服务系统内
这种架构模式不需要再独立部署服务,减少了运维成本,但限流代码会跟业务代码有一些耦合,不过,可以将限流功能集成在切面层,尽量跟业务代码解耦。如果做服务级的分布式限流,必须使用分布式限流算法,如果是针对每台微服务实例进行单机限流,使用单机限流算法就可以。
针对不同业务使用不同限流熔断策略
这里所讲的熔断策略,就是当接口达到限流上限之后,如何来处理接口请求的问题。前面也有提到过一些限流熔断策略了,所谓否决式限流就是超过最大允许访问频率之后就拒绝请求,比如返回 HTTP status code 429 等,所谓阻塞式限流就是超过最大允许访问频率之后就排队请求。除此之外,还有其他一些限流熔断策略,比如:记录日志,发送告警,服务降级等等。
同一个系统对于不同的调用方也有可能有不同的限流熔断策略,比如对响应时间敏感的调用方,我们可能采用直接拒绝的熔断策略,对于像后台 job 这样对响应时间不敏感的调用方,我们可能采用阻塞排队处理的熔断策略。
我们再来看下其他熔断策略的一些应用场景:比如限流功能刚刚上线,为了验证限流算法的有效性及其限流规则的合理性,确保不误杀请求,可以先采用日志记录 + 告警的限流熔断策略,通过分析日志判定限流功能正常工作后,再进一步升级为其他限流熔断策略。
不同的熔断策略对于选择限流算法也是有影响的,比如令牌桶和漏桶算法就比较适合阻塞式限流熔断场景,如果是否决式的限流熔断场景就比较适合选择基于时间窗口的限流算法。
如何配置合理的限流规则
限流规则包含三个部分:时间粒度,接口粒度,最大限流值。限流规则设置是否合理直接影响到限流是否合理有效。
对于限流时间粒度的选择,我们既可以选择 1 秒钟不超过 1000 次,也可以选择 10 毫秒不超过 10 次,还可以选择 1 分钟不超过 6 万次,虽然看起这几种限流规则都是等价的,但过大的时间粒度会达不到限流的效果,比如限制 1 分钟不超过 6 万次,就有可能 6 万次请求都集中在某一秒内;相反,过小的时间粒度会削足适履导致误杀很多本不应该限流的请求,因为接口访问在细时间粒度上随机性很大。所以,尽管越细的时间粒度限流整形效果越好,流量曲线越平滑,但也并不是越细越合适。
对于访问量巨大的接口限流,比如秒杀,双十一,这些场景下流量可能都集中在几秒内,TPS 会非常大,几万甚至几十万,需要选择相对小的限流时间粒度。相反,如果接口 TPS 很小,建议使用大一点的时间粒度,比如限制 1 分钟内接口的调用次数不超过 1000 次,如果换算成:一秒钟不超过 16 次,这样的限制就有点不合理,即便一秒内超过 16 次,也并没有理由就拒绝接口请求,因为对于我们系统的处理能力来说,16 次 / 秒的请求频率太微不足道了。即便 1000 次请求都集中在 1 分钟内的某一秒内,也并不会影响到系统的稳定性,所以 1 秒钟 16 次的限制意义不大。
除了时间粒度之外,还需要根据不同的限流需求选择不同接口粒度,比如:
1)限制微服务每个实例接口调用频率
2)限制微服务集群整体的访问频率
2)限制某个调用方对某个服务的调用频率
3)限制某个调用方对某个服务的某个接口的访问频率
4)限制某服务的某个接口的访问频率
5)限制某服务的某类接口的访问频率
对于最大允许访问频率的设置,需要结合性能压测数据、业务预期流量、线上监控数据来综合设置,最大允许访问频率不大于压测 TPS,不小于业务预期流量,并且参考线上监控数据。
如何评判限流功能是否正确有效
这里所说的有效性包含两个方面:限流算法的有效性和限流规则的有效性。在大促,秒杀,或者其他异常流量到来之前,我们需要事先通过实验来验证限流功能的有效性,用数据来证明限流功能确实能够拦截非预期的异常流量。否则,就有可能会因为限流算法的选择不够合适或者限流规则设置不合理,导致真正超预期流量到来的时候,限流不能起到保护服务的作用,超出预期的异常流量压垮系统。
如何测试限流功能正确有效呢?尽管可以通过模拟流量或者线上流量回放等手段来测试,但是最有效的测试方法还是:通过导流的方式将流量集中到一小组机器上做真实场景的测试。对于测试结果,我们至少需要记录每个请求的如下信息:对应接口,请求时间点,限流结果 (通过还是熔断),然后根据记录的数据绘制成如下图表:
从图表中,我们可以一目了然的了解限流前与限流后的流量情况,可以清晰的看到限流规则和算法对流量的整形是否合理有效。
除了事先验证之外,我们还需要时刻监控限流的工作情况,实时了解限流功能是否运行正常。一旦发生限流异常,能够在不重启服务的情况下,做到热更新限流配置:包括开启关闭限流功能,调整限流规则,更换限流算法等等。
高容错高性能开源限流框架:ratelimiter4j
ratelimiter4j 是一个高性能高容错易集成的限流框架, 从功能的角度来看限流功能的实现并不复杂,而非功能性的需求是系统开发的技术难点:
1)低延迟:不能或者较小的影响接口本身的响应时间
每个微服务接口请求都需要检查是否超过了限定的访问频率,无疑会增加接口的响应时间,而响应时间对于微服务接口来说,是一个非常关注的性能指标,所以让限流延迟尽可能小,是我们在开发 ratelimiter4j 限流框架时特别考虑的。
2)高度容错:限流框架的异常不影响微服务的可用性
接入限流本身是为了提供系统的可用性稳定性,不能因为限流本身的异常反过来影响到微服务的可用性,这个副作用是不能接受的。比如分布式限流算法依赖的 Redis 挂掉了,限流操作无法进行,这个时候业务接口也要能继续正常服务。
3)高 TPS:限流框架的 TPS 至少要大于微服务本身的接口 TPS
对于大规模服务来说,接口访问频率比较高,几万甚至几十万的 TPS,限流框架支持的 TPS 至少要高于服务本身的 TPS,否则就会因为限流本身的性能问题反过来拖垮服务。
目前 ratelimiter4j 框架将限流规则组织成 trie tree 数据结构,可以实现快速查询请求对应的接口限流规则,实验证明 trie tree 这种数据结构非常适合像 url 这种具有分级目录且目录重复度高的接口格式。
针对分布式限流,目前 ratelimiter4j 压测得到的结果在响应时间可以接受的范围内最大支持 5 万 TPS,高并发对 TPS 的影响并不敏感,瓶颈主要在 Redis 中心计数器,接下来会通过改进算法及其中心计数器支持 sharding 的方式来优化性能。
ratelimiter4j GitHub 地址:https://github.com/wangzheng0822/ratelimiter4j
最后,总结
限流在很多微服务及其服务治理相关的技术文章中都有提到,限于篇幅和主题,可能会讲的不够深入,本文结合具体的实践经验,聚焦剖析微服务接口限流,希望读者在读完本文之后对微服务接口限流有个更加深刻的认识。