在系统架构设计当中,限流是一个不得不说的话题,由于他太不起眼,可是也过重要了。这点有些像古代镇守边陲的将士,据守隘口,抵挡住外族的千军万马,一旦隘口失守,各类饕餮涌入城内,势必将咱们苦心经营的朝堂庙店洗劫一空,以前的全部努力都付之一炬。因此今天咱们点了这个话题,一方面是要对限流作下总结,另外一方面,抛砖引玉,看看你们各自的系统中,限流是怎么作的。redis
提到限流,映入脑海的确定是限制流量四个字,其重点在于如何限。并且这个限,还分为单机限和分布式限,单机限流,顾名思义,就是对部署了应用的docker机或者物理机,进行流量控制,以使得流量的涌入呈现可控的态势,防止过大过快的流量涌入形成应用的性能问题,甚至于失去响应。分布式限流,则是对集群的流量限制,通常这类应用的流量限制集中在一个地方来进行,好比redis,zk或者其余的可以支持分布式限流的组件中。这样当流量过大过快的时候,不至于由于集群中的一台机器被压垮而带来雪崩效应,形成集群应用总体坍塌。算法
下面咱们来细数一下各类限流操做。sql
1. 基于计数器的单机限流docker
此类限流,通常是经过应用中的计数器来进行流量限制操做。计数器能够用Integer类型的变量,也能够用Java自带的AtomicLong来实现。原理就是设置一个计数器的阈值,每当有流量进入的时候,将计数器递增,当达到阈值的时候,后续的请求将会直接被抛弃。代码实现以下:缓存
//限流计数器 private static AtomicLong counter = new AtomicLong(); //限流阈值 private static final long counterMax = 500; //业务处理方法 public void invoke(Request request) { try { //请求过滤 if (counter.incrementAndGet() > counterMax) { return; } //业务逻辑 doBusiness(request); } catch (Exception e) { //错误处理 doException(request,e); } finally { counter.decrementAndGet(); } }架构
上面的代码就是一个简单的基于计数器实现的单机限流。代码简单易行,操做方便,并且能够带来不错的效果。可是缺点也很明显,那就是先来的流量通常都能打进来,后来的流量基本上都会被拒绝。每一个请求被执行的几率实际上是不同的,这样就使得早来的用户反而获取不到执行机会,晚来的用户反而有被执行的可能。并发
因此总结一下此种限流优缺点:dom
优势:代码简洁,操做方便分布式
缺点:先到先得,先到的请求可执行几率为100%,后到的请求可执行几率小一些,每一个请求得到执行的机会是不平等的。ide
那么,若是想让每一个请求得到执行的机会是平等的话,该怎么作呢?
2. 基于随机数的单机限流
此种限流算法,使得请求可被执行的几率是一致的,因此相对于基于计数器实现的限流说来,对用户更加的友好一些。代码以下:
//获取随机数 private static ThreadLocalRandom ptgGenerator = ThreadLocalRandom.current(); //限流百分比,容许多少流量经过此业务,这里限定为10% private static final long ptgGuarder = 10; //业务处理方法 public void invoke(Request request) { try { //请求进入,获取百分比 int currentPercentage = ptgGenerator.nextInt(1, 100); if (currentPercentage <= ptgGuarder) { //业务处理 doBusiness(request); } else { return; } } catch (Exception e) { //错误处理 doException(request, e); } }
从上面代码能够看出来,针对每一个请求,都会先获取一个随机的1~100的执行率,而后和当前限流阈值(好比当前接口只容许10%的流量经过)相比,若是小于此限流阈值,则放行;若是大于此限流阈值,则直接返回,不作任何处理。和以前的计数器限流比起来,每一个请求得到执行的几率是一致的。固然,在真正的业务场景中,用户能够经过动态配置化阈值参数,来控制每分钟经过的流量百分比,或者是每小时经过的流量百分比。可是若是对于突增的高流量,此种方法则有点问题,由于高并发下,每一个请求之间进入的时间很短暂,致使nextInt生成的值,大几率是重复的,因此这里须要作的一个优化点,就是为其寻找合适的seed,用于优化nextInt生成的值。
优势:代码简洁,操做简便,每一个请求可执行的机会是平等的。
缺点:不适合应用突增的流量。
3. 基于时间段的单机限流
有时候,咱们的应用只想在单位时间内放固定的流量进来,好比一秒钟内只容许放进来100个请求,其余的请求抛弃。那么这里的作法有不少,能够基于计数器限流实现,而后判断时间,可是此种作法稍显复杂,可控性不是特别好。
那么这里咱们就要用到缓存组件来实现了。原理是这样的,首先请求进来,在guava中设置一个key,此key就是当前的秒数,秒数的值就是放进来的请求累加数,若是此累加数到100了,则拒绝后续请求便可。代码以下:
//获取guava实例 private static LoadingCache<Long, AtomicLong> guava = CacheBuilder.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .build(new CacheLoader<Long, AtomicLong>() { @Override public AtomicLong load(Long seconds) throws Exception { return null; } }); //每秒容许经过的请求数 private static final long requestsPerSecond = 100; //业务处理方法 public void invoke(Request request) { try { //guava key long guavaKey = System.currentTimeMillis() / 1000; //请求累加数 long guavaVal = guava.get(guavaKey).incrementAndGet(); if (guavaVal <= requestsPerSecond) { //业务处理 doBusiness(request); } else { return; } } catch (Exception e) { //错误处理 doException(request, e); } }
从上面的代码中能够看到,咱们巧妙的利用了缓存组件的特性来实现。每当有请求进来,缓存组件中的key值累加,到达阈值则拒绝后续请求,这样很方便的实现了时间段限流的效果。虽然例子中给的是按照秒来限流的实现,咱们能够在此基础上更改成按照分钟或者按照小时来实现的方案。
优势:操做简单,可靠性强
缺点:突增的流量,会致使每一个请求都会访问guava,因为guava是堆内内存实现,势必会对性能有一点点影响。其实若是怕限流影响到其余内存计算,咱们能够将此限流操做用堆外内存组件来实现,好比利用OHC或者mapdb等。也是比较好的备选方案。
4. 基于漏桶算法的单机限流
所谓漏桶( Leaky bucket ),则是指,有一个盛水的池子,而后有一个进水口,有一个出水口,进水口的水流可大可小,可是出水口的水流是恒定的。下图图示能够显示的更加清晰:
从图中咱们能够看到,水龙头至关于各端的流量,进入到漏桶中,当流量很小的时候,漏桶能够承载这种流量,出水口按照恒定的速度出水,水不会溢出来。当流量开始增大的时候,漏桶中的出水速度赶不上进水速度,那么漏桶中的水位一直在上涨。当流量再大,则漏桶中的水过满则溢。
因为目前不少MQ,好比rabbitmq等,都属于漏桶算法原理的具体实现,请求过来先入queue队列,队列满了抛弃多余请求,以后consumer端匀速消费队列里面的数据。因此这里再也不贴多余的代码。
优势:流量控制效果不错
缺点:不可以很好的应付突增的流量。适合保护性能较弱的系统,可是不适合性能较强的系统。若是性能较强的系统可以应对这种突增的流量的话,那么漏桶算法是不合适的。
5. 基于令牌桶算法的单机限流
所谓令牌桶( Token Bucket ),则是指,请求过来的时候,先去令牌桶里面申请令牌,申请到令牌以后,才能去进行业务处理。若是没有申请到令牌,则操做终止。具体说明以下图:
因为生成令牌的流量是恒定的,面对突增流量的时候,桶里有足够令牌的状况下,突增流量能够快速的获取到令牌,而后进行处理。从这里能够看出令牌桶对于突增流量的处理是允许的。
因为目前guava组件中已经有了对令牌桶的具体实现类:RateLimiter, 因此咱们能够借助此类来实现咱们的令牌桶限流。代码以下:
//指定每秒放1个令牌 private static RateLimiter limiter = RateLimiter.create(1); //令牌获取超时时间 private static final long acquireTimeout = 1000; //业务处理方法 public void invoke(Request request) { try { //拿到令牌则进行业务处理 if (limiter.tryAcquire(acquireTimeout, TimeUnit.MILLISECONDS)) { //业务处理 doBusiness(request); } //拿不到令牌则退出 else { return; } } catch (Exception e) { //错误处理 doException(request, e); } }
从上面代码咱们能够看到,一秒生成一个令牌,那么咱们的接口限定为一秒处理一个请求,若是感受接口性能能够达到1000tps单机,那么咱们能够适当的放大令牌桶中的令牌数量,好比800,那么当突增流量过来,会直接拿到令牌而后进行业务处理。可是当令牌桶中的令牌消费完毕以后,那么请求就会被阻塞,直到下一秒另外一批800个令牌生成出来,请求才开始继续进行处理。
因此利用令牌桶的优缺点就很明显了:
有点:使用简单,有成熟组件
缺点:适合单机限流,不适合分布式限流。
6. 基于redis lua的分布式限流
因为上面5中限流方式都是单机限流,可是在实际应用中,不少时候咱们不只要作单机限流,还要作分布式限流操做。因为目前作分布式限流的方法很是多,我就再也不一一赘述了。咱们今天用到的分布式限流方法,是redis+lua来实现的。
为何用redis+lua来实现呢?缘由有两个:
其一:redis的性能很好,处理能力强,且容灾能力也不错。
其二:一个lua脚本在redis中就是一个原子性操做,能够保证数据的正确性。
因为要作限流,那么确定有key来记录限流的累加数,此key能够随着时间进行任意变更。并且key须要设置过时参数,防止无效数据过多而致使redis性能问题。
来看看lua代码:
--限流的key local key = 'limitkey'..KEYS[1] --累加请求数 local val = tonumber(redis.call('get', key) or 0) --限流阈值 local threshold = tonumber(ARGV[1]) if val>threshold then --请求被限 return 0 else --递增请求数 redis.call('INCRBY', key, "1") --5秒后过时 redis.call('expire', key, 5) --请求经过 return 1 end
以后就是直接调用使用,而后根据返回内容为0仍是1来断定业务逻辑能不能走下去就好了。这样能够经过此代码段来控制整个集群的流量,从而避免出现雪崩效应。固然此方案的解决方式也能够利用zk来进行,因为zk的强一致性保证,不失为另外一种好的解决方案,可是因为zk的性能没有redis好,因此若是在乎性能的话,仍是用redis吧。
优势:集群总体流量控制,防止雪崩效应
缺点:须要引入额外的redis组件,且要求redis支持lua脚本。
总结
经过以上6种限流方式的讲解,主要是想起到抛砖引玉的做用,期待你们更好更优的解决方法。
以上代码都是伪代码,使用的时候请进行线上验证,不然带来了反作用的话,就得不偿失了
欢迎工做一到五年的Java工程师朋友们加入Java架构开发: 855835163
群内提供免费的Java架构学习资料(里面有高可用、高并发、高性能及分布式、Jvm性能调优、Spring源码,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多个知识点的架构资料)合理利用本身每一分每一秒的时间来学习提高本身,不要再用"没有时间“来掩饰本身思想上的懒惰!趁年轻,使劲拼,给将来的本身一个交代!