在开发高并发系统时有四把利器用来保护系统:分流、缓存、降级和限流。本文结合作者的一些经验介绍限流的相关概念、算法和常规的实现方式。
概念解读
分流
分流是最常用到的,那就是扩容,然后通过负载均衡实现自己想要的分流策略。儿负载均衡分为硬负载,比如F5;软负载,比如nginx、apache、haproxy、LVS等。还可以分为客户端负载,比如springCloud的Ribbon;服务端负载,比如nginx。
缓存
使用缓存不单单能够提升系统访问速度、提高并发访问量,也是为了保护数据库、保护系统。如果网站的某些数据访问频率高、读多写少,并且一致性要求低,这时候一般是肯定会用到缓存了。另外在“写”场景中,我们可以利用缓存来提高系统性能,比如累积一些数据批量写入,内存里面的缓存队列(生产消费)等等也都是通过缓存提升系统的吞吐量或者实现系统的保护措施。但是使用缓冲,要做好规避缓存带来的问题,比如缓存穿透,缓存雪崩,缓存击穿等。
降级
服务降级是当服务器压力剧增或依赖的服务出现出账时,根据当前业务情况及流量对一些服务和页面有策略的降级,或者把出现故障的丢掉,换一个轻量级的方式响应,以此释放服务器资源以保证核心任务的正常运行。降级往往会指定不同的级别,面临不同的异常等级执行不同的处理。
根据服务方式:可以拒接服务,可以延迟服务,也有时候可以随机服务。
根据服务范围:可以砍掉某个功能,也可以砍掉某些模块。
总之服务降级需要根据不同的业务需求采用不同的降级策略。主要的目的就是服务虽然有损但是总比没有好。
限流
限流可以认为服务降级的一种,限流就是限制系统的输入和输出流量已达到保护系统的目的。一般来说系统的吞吐量是可以被测算的,为了保证系统的稳定运行,一旦达到的需要限制的阈值,就需要采取一些措施以完成限制流量的目的。比如:延迟处理,拒绝处理,或者部分拒绝处理等等。
限流场景
限流场景大体分三类
代理层
比如SLB、nginx或者业务层gateway等,都支持限流,通常是基于连接数(或者并发数)、请求数进行限流。限流的维度通常是基于比如IP地址、资源位置、用户标志等。更进一步,还可以根据自身负载情况动态调整限流的策略(基准)。
服务调用者
服务调用方,也可以叫做本地限流,客户端可以限制某个远端服务的调用速度,超过阈值,可以直接进行阻塞或者拒绝,是限流的协作方。
服务接收方
基本同上,流量超过系统承载能力时,会直接拒绝服务。通常基于应用本身的可靠性考虑,属于限流的主体方。我们常说的限流,一般发生在此处。本文主要结合RateLimiter讨论基于限流主体方的使用方式,其他的都类似。
限流方法
限流方法最常用的三种是:计数器限流、漏桶算法、令牌桶算法、
计数器限流
可以通过原子类计数器AtomicInteger
、Semaphore
信号量来做简单的限流。
public static void main(String[] args) {
CurrentLimiting c = new CurrentLimiting();
// 限流的个数
int maxCount = 10;
/ 指定的时间内
long interval = 60;
c.limit(maxCount,interval);
}
// 原子类计数器
private AtomicInteger atomicInteger = new AtomicInteger(0);
// 起始时间
private long startTime = System.currentTimeMillis();
public boolean limit(int maxCount, long interval) {
atomicInteger.addAndGet(1);
if (atomicInteger.get() == 1) {
startTime = System.currentTimeMillis();
atomicInteger.addAndGet(1);
return true;
}
// 超过了间隔时间,直接重新开始计数
if (System.currentTimeMillis() - startTime > interval * 1000) {
startTime = System.currentTimeMillis();
atomicInteger.set(1);
return true;
}
// 还在间隔时间内,check有没有超过限流的个数
if (atomicInteger.get() > maxCount) {
return false;
}
return true;
}
上边只是单机版的一个限流实现,如果想实现分布式限流,用redis即可。
计数器限流,存在的问题是:
- 一是:首先假设第一秒服务请求就达到限制量,那么如果限制量设置的比较小的时候,流量会有起伏,限制量设置的较大的话,可能会对服务器造成暴击。
- 二是:在时间窗口内,如果第一秒就达到了限制量,那么时间窗口剩下的时间内,过来的所有请求都将被拒绝。
每种限流器都有缺点,但每种也都有自己的适用情况:比如限制某个接口、服务,每分钟,每天的请求数或调用量,那么就可以使用计数器限流。微信获取access_token接口,每日上限调用量是2000,使用这种方式恰恰好。
计数器限流缺陷的解决方案就是:采用漏桶算法或令牌桶算法,进行平滑限流。
漏桶算法
上边介绍的计数器限流法,比如60s内只能有10个请求。如果第一秒服务请求达到上限,那么剩下的59秒只能把所有的请求都拒绝掉,这是第一个存在的问题,第二个问题是流量走势会展示为每分钟的第一秒流量飙高,剩下的59秒平缓,这样就会造成流量欺负较大。而漏桶算法可以解决。
漏桶算法来源于生活中的漏斗,所以我们可以想想成一个漏斗,无论上边的水流有多大,也就是无论请求有多少,它都是以均匀的速度流出。当上边的水流速度大于流出速度时,漏斗就会慢慢充满,漏斗满了后之后的请求就会丢弃;当上边的水流速度小于流出速度时,漏斗永远不会被装满,也就一直可以流出。
漏桶算法的实现步骤是,
- 一个固定容量的漏桶,按照常量固定速率流出水滴;
- 如果桶是空的,则不需流出水滴;
- 可以以任意速率流入水滴到漏桶;
- 如果流入水滴超出了桶的容量,则流入的水滴溢出了(被丢弃),而漏桶容量是不变的。
代码实现
public class LeakyBucket1 {
// 桶的容量
private int capacity = 100;
// 木桶剩余的水滴的量(初始化的时候的空的桶)
private AtomicInteger water = new AtomicInteger(0);
// 水滴的流出的速率 每1000毫秒流出1滴
private int leakRate;
// 第一次请求之后,木桶在这个时间点开始漏水
private long leakTimeStamp;
public LeakyBucket1(int leakRate) {
this.leakRate = leakRate;
}
public boolean acquire() {
// 如果是空桶,就当前时间作为桶开始漏出的时间
if (water.get() == 0) {
leakTimeStamp = System.currentTimeMillis();
water.addAndGet(1);
return capacity == 0 ? false : true;
}
// 先执行漏水,计算剩余水量
int waterLeft = water.get() - ((int) ((System.currentTimeMillis() - leakTimeStamp) / 1000)) * leakRate;
water.set(Math.max(0, waterLeft));
// 重新更新leakTimeStamp
leakTimeStamp = System.currentTimeMillis();
// 尝试加水,并且水还未满
if ((water.get()) < capacity) {
water.addAndGet(1);
return true;
} else {
// 水满,拒绝加水
return false;
}
}
}
@RestController
public class LeakyBucketController {
//漏桶:水滴的漏出速率是每秒 1 滴
private LeakyBucket1 leakyBucket = new LeakyBucket1(1);
//漏桶限流
@RequestMapping("/searchCustomerInfoByLeakyBucket")
public Object LeakyBucket() {
// 1.限流判断
boolean acquire = leakyBucket.acquire();
if (!acquire) {
System.out.println("稍后再试!");
return "稍后再试!";
}
// 2.如果没有达到限流的要求,直接调用接口查询
System.out.println("直接调用接口查询");
return "";
}
}
漏桶算法的缺陷在于:
- 对存在突发特性的流量来说缺乏效率。因为漏桶的漏出速率是固定的参数,所以,即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使流突发(burst)到端口速率。比如漏桶每秒漏出速率是2个,现在有1台服务器,每台服务器每秒可以处理一个,如果有突发流量过来,因为限定了处理速度,所以每秒只有两台工作,另外8台空闲,就会造成资源浪费
漏桶算法适用情况:
- 主要场景是,当我们调用第三方系统时,第三方系统有流量限制或者本身没有保护机制,那么此时,我们的调用速度就不能超过他的限制,这时候我们就需要在主调方控制,即使流量突发,也得限制,因为消费能力是第三方决定的。
令牌桶算法
令牌桶算法(Token Bucket)和 Leaky Bucket 效果一样但方向相反的算法,更加容易理解.随着时间流逝,系统会按恒定1/QPS时间间隔(如果QPS=100,则间隔是10ms)往桶里加入Token(想象和漏洞漏水相反,有个水龙头在不断的加水),如果桶已经满了就不再加了。新请求来临时,会各自拿走一个Token,如果没有Token可拿了就阻塞或者拒绝服务。
Guava-RateLimiter 限流神器
Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法(Token Bucket)来完成限流,非常易于使用。而且RateLimiter是线程安全的,所以在并发环境中可以直接使用,而无需额外的lock或同步锁。
令牌桶算法有两种实现方式:平滑突发限流和平滑预热限流。
平滑突发限流
可以支持突发流量的限流器,既当限流器不被使用时,可以额外存储一些令牌以备突发流量,当突发流量发生时可以更快更充分的使用资源。其重点就是,冷却器间,令牌会积累,且在突发流量时,可以消耗此前积累的令牌而无需任何等待。就像一个人,奔跑之后休息一段时间,再次起步可以有更高的速度。
/**
* 每秒产生5个,基本都是0.2秒一个,做到平滑限流,不会出现流量起伏
* 每秒产生5个,这样就可以保证一秒内不会给超过5个令牌,并且以固定速率进行放置,达到平滑输出的效果,基本都是0.2秒一个
*
* 打印結果
* get 1 tokens:0.0s
* get 1 tokens:0.199556s
* get 1 tokens:0.187896s
* get 1 tokens:0.199865s
* get 1 tokens:0.199369s
* get 1 tokens:0.200024s
* get 1 tokens:0.19999s
* get 1 tokens:0.199315s
* get 1 tokens:0.200035s
*/
public void testSmothBursty1(){
//每秒产生5个
RateLimiter r = RateLimiter.create(5);
while (true){
//acquire()方法就是获取一个令牌(源码中使用permit,许可证),如果permit(资源或令牌)足够,则直接返回而无需等待,如果不足,则等待1/QPS秒。
System.out.println("get 1 tokens:" + r.acquire() + "s");
}
}
/**
* 支持突发
* RateLimiter使用令牌桶算法,会进行令牌的累积,如果获取令牌的频率比较低,则不会导致等待,直接获取令牌。
*
* 打印结果:
* get 1 tokens:0.0s
* get 1 tokens:0.0s
* get 1 tokens:0.0s
* get 1 tokens:0.0s
* end
* get 1 tokens:0.499305s
* get 1 tokens:0.0s
* get 1 tokens:0.0s
* get 1 tokens:0.0s
* end
*/
public void testSmothBursty2(){
RateLimiter r = RateLimiter.create(2);
while (true){
System.out.println("get 1 tokens:" + r.acquire(1) + "s");
try {
Thread.sleep(2000);
}catch (Exception e){ }
System.out.println("get 1 tokens:" + r.acquire(1) + "s");
System.out.println("get 1 tokens:" + r.acquire(1) + "s");
System.out.println("get 1 tokens:" + r.acquire(1) + "s");
System.out.println("end");
}
}
/**
* 支持滞后处理
* RateLimiter由于会累积令牌,所以可以应对突发流量。在下面代码中,有一个请求会直接请求5个令牌,但是由于此时令牌桶中有累积的令牌,足以快速响应。
* RateLimiter在没有足够令牌发放时,采用滞后处理的方式,也就是前一个请求获取令牌所需等待的时间由下一次请求来承受,也就是代替前一个请求进行等待。
*
* 结果打印:
* get 5 tokens:0.0s
* get 1 tokens:0.999468s ---- 滞后效应,需要替前一个请求进行等待
* get 1 tokens:0.196924s
* get 1tokens:0.200056s
* end
* get 5 tokens:0.199583s
* get 1 tokens:0.999156s ---- 滞后效应,需要替前一个请求进行等待
* get 1 tokens:0.199329s
* get 1tokens:0.199907s
* end
*/
public void testSmothBursty3(){
RateLimiter r = RateLimiter.create(5);
while (true){
System.out.println("get 5 tokens:" + r.acquire(5) + "s");
System.out.println("get 1 tokens:" + r.acquire(1) + "s");
System.out.println("get 1 tokens:" + r.acquire(1) + "s");
System.out.println("get 1 tokens:" + r.acquire(1) + "s");
System.out.println("end");
}
}
平滑预热限流
是带有预热期的平滑限流,它启动后会有一段预热期,逐步将分发频率提升到配置的速率。即突发流量发生时,不能立即达到最大速率,而是需要指定的“预热时间”内逐步上升最终达到阈值;
它的设计哲学,与SmoothBursty相反,当突发流量发生时,以可控的慢速、逐步使用资源(直到最高速率),流量平稳后速率处于限制状态。比如下面代码中的例子,创建一个平均分发令牌速率为2,预热期为3秒钟。由于设置了预热时间是3秒,令牌桶一开始并不会0.5秒发一个令牌,而是形成一个平滑线性下降的坡度,频率越来越高,在3秒钟之内达到原本设置的频率,以后就以固定的频率输出。这种功能适合系统刚启动,需要一点时间来“热身”的场景。
/**
*
*
* 打印结果:
* get 1 tokens: 0.0s
* get 1 tokens: 1.332745s
* get 1 tokens: 0.997318s
* get 1 tokens: 0.666174s --- 上边三次获取的时间相加正好为3秒
* end
* get 1 tokens: 0.5005s --- 正常速率0.5秒一个令牌
* get 1 tokens: 0.499127s
* get 1 tokens: 0.499298s
* get 1 tokens: 0.500019s
* end
*/
public void testSmoothwarmingUp(){
RateLimiter r = RateLimiter.create(2,3, TimeUnit.SECONDS);
while (true){
System.out.println("get 1 tokens: " + r.acquire(1) + "s");
System.out.println("get 1 tokens: " + r.acquire(1) + "s");
System.out.println("get 1 tokens: " + r.acquire(1) + "s");
System.out.println("get 1 tokens: " + r.acquire(1) + "s");
System.out.println("end");
}
}
令牌桶限流适用情况:
- 令牌桶是可以做到保护自己的,如果我们的接口被别人调用,那么我们就可以限制调用者频率,这样就可以保护自己不被击垮。比如在流量突发的时候,我们可以使用流量突发限流,这样实际处理的速率就可以超过配置的限制。
漏桶和令牌桶的区别:
- 漏桶能够强行限制数据的传输速率。
- 令牌桶能够在限制数据的平均速率的同时,还可以允许流量突发传输。
小结
一句话总结漏桶和令牌桶就是:如果要让自己的系统不被打垮,用令牌桶。如果保证被别人的系统不被打垮,用漏桶算法。
Guava的RateLimiter只适用于单机的限流,在互联网分布式项目中可以借助其他中间件来实现限流的功能,比如redis。
Redis + Lua实现分布式限流,可以参考大佬的这篇文章:Redis + Lua 限流实现