高并发系统限流常用方法攻略

在开发高并发系统时有四把利器用来保护系统:分流、缓存、降级和限流。本文结合作者的一些经验介绍限流的相关概念、算法和常规的实现方式。

概念解读

分流

分流是最常用到的,那就是扩容,然后通过负载均衡实现自己想要的分流策略。儿负载均衡分为硬负载,比如F5;软负载,比如nginx、apache、haproxy、LVS等。还可以分为客户端负载,比如springCloud的Ribbon;服务端负载,比如nginx。

缓存

使用缓存不单单能够提升系统访问速度、提高并发访问量,也是为了保护数据库、保护系统。如果网站的某些数据访问频率高、读多写少,并且一致性要求低,这时候一般是肯定会用到缓存了。另外在“写”场景中,我们可以利用缓存来提高系统性能,比如累积一些数据批量写入,内存里面的缓存队列(生产消费)等等也都是通过缓存提升系统的吞吐量或者实现系统的保护措施。但是使用缓冲,要做好规避缓存带来的问题,比如缓存穿透,缓存雪崩,缓存击穿等。

降级

服务降级是当服务器压力剧增或依赖的服务出现出账时,根据当前业务情况及流量对一些服务和页面有策略的降级,或者把出现故障的丢掉,换一个轻量级的方式响应,以此释放服务器资源以保证核心任务的正常运行。降级往往会指定不同的级别,面临不同的异常等级执行不同的处理。

根据服务方式:可以拒接服务,可以延迟服务,也有时候可以随机服务。

根据服务范围:可以砍掉某个功能,也可以砍掉某些模块。

总之服务降级需要根据不同的业务需求采用不同的降级策略。主要的目的就是服务虽然有损但是总比没有好。

限流

限流可以认为服务降级的一种,限流就是限制系统的输入和输出流量已达到保护系统的目的。一般来说系统的吞吐量是可以被测算的,为了保证系统的稳定运行,一旦达到的需要限制的阈值,就需要采取一些措施以完成限制流量的目的。比如:延迟处理,拒绝处理,或者部分拒绝处理等等。

限流场景

限流场景大体分三类

代理层

比如SLB、nginx或者业务层gateway等,都支持限流,通常是基于连接数(或者并发数)、请求数进行限流。限流的维度通常是基于比如IP地址、资源位置、用户标志等。更进一步,还可以根据自身负载情况动态调整限流的策略(基准)。

服务调用者

服务调用方,也可以叫做本地限流,客户端可以限制某个远端服务的调用速度,超过阈值,可以直接进行阻塞或者拒绝,是限流的协作方

服务接收方

基本同上,流量超过系统承载能力时,会直接拒绝服务。通常基于应用本身的可靠性考虑,属于限流的主体方。我们常说的限流,一般发生在此处。本文主要结合RateLimiter讨论基于限流主体方的使用方式,其他的都类似。

限流方法

限流方法最常用的三种是:计数器限流、漏桶算法、令牌桶算法、

计数器限流

可以通过原子类计数器AtomicIntegerSemaphore信号量来做简单的限流。

 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即可。

计数器限流,存在的问题是:

  1. 一是:首先假设第一秒服务请求就达到限制量,那么如果限制量设置的比较小的时候,流量会有起伏,限制量设置的较大的话,可能会对服务器造成暴击。
  2. 二是:在时间窗口内,如果第一秒就达到了限制量,那么时间窗口剩下的时间内,过来的所有请求都将被拒绝。

每种限流器都有缺点,但每种也都有自己的适用情况:比如限制某个接口、服务,每分钟,每天的请求数或调用量,那么就可以使用计数器限流。微信获取access_token接口,每日上限调用量是2000,使用这种方式恰恰好。

计数器限流缺陷的解决方案就是:采用漏桶算法或令牌桶算法,进行平滑限流。

漏桶算法

上边介绍的计数器限流法,比如60s内只能有10个请求。如果第一秒服务请求达到上限,那么剩下的59秒只能把所有的请求都拒绝掉,这是第一个存在的问题,第二个问题是流量走势会展示为每分钟的第一秒流量飙高,剩下的59秒平缓,这样就会造成流量欺负较大。而漏桶算法可以解决。

漏桶算法来源于生活中的漏斗,所以我们可以想想成一个漏斗,无论上边的水流有多大,也就是无论请求有多少,它都是以均匀的速度流出。当上边的水流速度大于流出速度时,漏斗就会慢慢充满,漏斗满了后之后的请求就会丢弃;当上边的水流速度小于流出速度时,漏斗永远不会被装满,也就一直可以流出。

漏桶算法的实现步骤是,

  1. 一个固定容量的漏桶,按照常量固定速率流出水滴;
  2. 如果桶是空的,则不需流出水滴;
  3. 可以以任意速率流入水滴到漏桶;
  4. 如果流入水滴超出了桶的容量,则流入的水滴溢出了(被丢弃),而漏桶容量是不变的。

代码实现

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 "";
    }
}

漏桶算法的缺陷在于:

  1. 对存在突发特性的流量来说缺乏效率。因为漏桶的漏出速率是固定的参数,所以,即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使流突发(burst)到端口速率。比如漏桶每秒漏出速率是2个,现在有1台服务器,每台服务器每秒可以处理一个,如果有突发流量过来,因为限定了处理速度,所以每秒只有两台工作,另外8台空闲,就会造成资源浪费

漏桶算法适用情况:

  1. 主要场景是,当我们调用第三方系统时,第三方系统有流量限制或者本身没有保护机制,那么此时,我们的调用速度就不能超过他的限制,这时候我们就需要在主调方控制,即使流量突发,也得限制,因为消费能力是第三方决定的。

令牌桶算法

令牌桶算法(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");
        }
    }

令牌桶限流适用情况:

  1. 令牌桶是可以做到保护自己的,如果我们的接口被别人调用,那么我们就可以限制调用者频率,这样就可以保护自己不被击垮。比如在流量突发的时候,我们可以使用流量突发限流,这样实际处理的速率就可以超过配置的限制。

漏桶和令牌桶的区别:

  1. 漏桶能够强行限制数据的传输速率。
  2. 令牌桶能够在限制数据的平均速率的同时,还可以允许流量突发传输。

小结

一句话总结漏桶和令牌桶就是:如果要让自己的系统不被打垮,用令牌桶。如果保证被别人的系统不被打垮,用漏桶算法。

Guava的RateLimiter只适用于单机的限流,在互联网分布式项目中可以借助其他中间件来实现限流的功能,比如redis。

Redis + Lua实现分布式限流,可以参考大佬的这篇文章:Redis + Lua 限流实现

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

木子松的猫

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值