在分布式系统中,应对高并发访问时,缓存、限流、降级是保护系统正常运行的常用方法。当请求量突发暴涨时,如果不加以限制访问,则可能导致整个系统崩溃,服务不可用。同时有一些业务场景,比如短信验证码,或者其它第三方API调用,也需要提供必要的访问限制支持。还有一些资源消耗过大的请求,比如数据导出等(参考 记一次线上Java服务CPU 100%处理过程 ),也有限制访问频率的需求。
常见的限流算法有令牌桶算法,漏桶算法,与计数器算法。本文主要对三个算法的基本原理及Google Guava包中令牌桶算法的实现RateLimiter进行介绍,下一篇文章介绍最近写的一个以RateLimiter为参考的分布式限流实现及计数器限流实现。
令牌桶算法
令牌桶算法的原理就是以一个恒定的速度往桶里放入令牌,每一个请求的处理都需要从桶里先获取一个令牌,当桶里没有令牌时,则请求不会被处理,要么排队等待,要么降级处理,要么直接拒绝服务。当桶里令牌满时,新添加的令牌会被丢弃或拒绝。
令牌桶算法的处理示意图如下(图片来自网络)
令牌桶算法主要是可以控制请求的平均处理速率,它允许预消费,即可以提前消费令牌,以应对突发请求,但是后面的请求需要为预消费买单(等待更长的时间),以满足请求处理的平均速率是一定的。
漏桶算法
漏桶算法的原理是水(请求)先进入漏桶中,漏桶以一定的速度出水(处理请求),当水流入速度大于流出速度导致水在桶内逐渐堆积直到桶满时,水会溢出(请求被拒绝)。
漏桶算法的处理示意图如下(图片来自网络)
漏桶算法主要是控制请求的处理速率,平滑网络上的突发流量,请求可以以任意速度进入漏桶中,但请求的处理则以恒定的速度进行。
计数器算法
计数器算法是限流算法中最简单的一种算法,限制在一个时间窗口内,至多处理多少个请求。比如每分钟最多处理10个请求,则从第一个请求进来的时间为起点,60s的时间窗口内只允许最多处理10个请求。下一个时间窗口又以前一时间窗口过后第一个请求进来的时间为起点。常见的比如一分钟内只能获取一次短信验证码的功能可以通过计数器算法来实现。
Guava RateLimiter解析
Guava是Google开源的一个工具包,其中的RateLimiter是实现了令牌桶算法的一个限流工具类。在pom.xml中添加guava依赖,即可使用RateLimiter
1 | <dependency> |
如下测试代码示例了RateLimiter的用法,
1 | public static void main(String[] args) { |
运行后,输出如下,
1 | acquire:1 waitTime:0.0 |
第一次获取一个令牌时,等待0s立即可获取到(这里之所以不需要等待是因为令牌桶的预消费特性),第二次获取两个令牌,等待时间1s,这个1s就是前面获取一个令牌时因为预消费没有等待延到这次来等待的时间,这次获取两个又是预消费,所以下一次获取(取3个时)就要等待这次预消费需要的2s了,依此类推。可见预消费不需要等待的时间都由下一次来买单,以保障一定的平均处理速率(上例为1s一次)。
RateLimiter有两种实现:
- SmoothBursty: 令牌的生成速度恒定。使用
RateLimiter.create(double permitsPerSecond)
创建的是 SmoothBursty 实例。 - SmoothWarmingUp:令牌的生成速度持续提升,直到达到一个稳定的值。WarmingUp,顾名思义就是有一个热身的过程。使用
RateLimiter.create(double permitsPerSecond, long warmupPeriod, TimeUnit unit)
时创建就是 SmoothWarmingUp 实例,其中 warmupPeriod 就是热身达到稳定速度的时间。
类结构如下
关键属性及方法解析(以 SmoothBursty 为例)
1.关键属性
1 | /** 桶中当前拥有的令牌数. */ |
2.关键方法
调用 RateLimiter.create(double permitsPerSecond)
方法时,创建的是 SmoothBursty 实例,默认设置 maxBurstSeconds 为1s。SleepingStopwatch 是guava中的一个时钟类实现。
1 |
|
并通过调用 SmoothBursty.doSetRate(double, long)
方法进行初始化,该方法中:
- 调用
resync(nowMicros)
对 storedPermits 与 nextFreeTicketMicros 进行了调整——如果当前时间晚于 nextFreeTicketMicros,则计算这段时间内产生的令牌数,累加到 storedPermits 上,并更新下次可获取令牌时间 nextFreeTicketMicros 为当前时间。 - 计算 stableIntervalMicros 的值,1/permitsPerSecond。
- 调用
doSetRate(double, double)
方法计算 maxPermits 值(maxBurstSeconds*permitsPerSecond),并根据旧的 maxPermits 值对 storedPermits 进行调整。
源码如下所示
1 |
|
调用 acquire(int)
方法获取指定数量的令牌时,
- 调用
reserve(int)
方法,该方法最终调用reserveEarliestAvailable(int, long)
来更新下次可取令牌时间点与当前存储的令牌数,并返回本次可取令牌的时间点,根据该时间点计算需要等待的时间 - 阻塞等待1中返回的等待时间
- 返回等待的时间(秒)
源码如下所示
1 | /** 获取指定数量(permits)的令牌,阻塞直到获取到令牌,返回等待的时间*/ |
acquire(int)
方法是获取不到令牌时一直阻塞,直到获取到令牌,tryAcquire(int,long,TimeUnit)
方法则是在指定超时时间内尝试获取令牌,如果获取到或超时时间到则返回是否获取成功
- 先判断是否能在指定超时时间内获取到令牌,通过
nextFreeTicketMicros <= timeoutMicros + nowMicros
是否为true来判断,即可取令牌时间早于当前时间加超时时间则可取(预消费的特性),否则不可获取。 - 如果不可获取,立即返回false。
- 如果可获取,则调用
reserveAndGetWaitLength(permits, nowMicros)
来更新下次可取令牌时间点与当前存储的令牌数,返回等待时间(逻辑与前面相同),并阻塞等待相应的时间,返回true。
源码如下所示
1 | public boolean tryAcquire(int permits, long timeout, TimeUnit unit) { |
以上就是 SmoothBursty 实现的基本处理流程。注意两点:
- RateLimiter 通过限制后面请求的等待时间,来支持一定程度的突发请求——预消费的特性。
- RateLimiter 令牌桶的实现并不是起一个线程不断往桶里放令牌,而是以一种延迟计算的方式(参考
resync
函数),在每次获取令牌之前计算该段时间内可以产生多少令牌,将产生的令牌加入令牌桶中并更新数据来实现,比起一个线程来不断往桶里放令牌高效得多。(想想如果需要针对每个用户限制某个接口的访问,则针对每个用户都得创建一个RateLimiter,并起一个线程来控制令牌存放的话,如果在线用户数有几十上百万,起线程来控制是一件多么恐怖的事情)
总结
本文介绍了限流的三种基本算法,其中令牌桶算法与漏桶算法主要用来限制请求处理的速度,可将其归为限速,计数器算法则是用来限制一个时间窗口内请求处理的数量,可将其归为限量(对速度不限制)。Guava 的 RateLimiter 是令牌桶算法的一种实现,但 RateLimiter 只适用于单机应用,在分布式环境下就不适用了。虽然已有一些开源项目可用于分布式环境下的限流管理,如阿里的Sentinel,但对于小型项目来说,引入Sentinel可能显得有点过重,但限流的需求在小型项目中也是存在的,下一篇文章就介绍下基于 RateLimiter 的分布式下的限流实现。