Fork me on Gitee

高并发限流

高并发限流

问题描述

突然发现自己的接口请求量突然涨到之前的10倍,带宽被占满,没多久该接口几乎不可使用,并引发连锁反应导致整个系统崩溃。

计数器(固定窗口)算法

计数器算法是使用计数器在周期内累加访问次数,当达到设定的限流值时,触发限流策略。下一个周期开始时,进行清零,重新计数。

此算法在单机还是分布式环境下实现都非常简单,使用redis的incr原子自增性和线程安全即可轻松实现。

这个算法通常用于QPS限流和统计总访问量,对于秒级以上的时间周期来说,会存在一个非常严重的问题,那就是临界问题,如下图:

假设1min内服务器的负载能力为100,因此一个周期的访问量限制在100,然而在第一个周期的最后5秒和下一个周期的开始5秒时间段内,分别涌入100的访问量,虽然没有超过每个周期的限制量,但是整体上10秒内已达到200的访问量,已远远超过服务器的负载能力,由此可见,计数器算法方式限流对于周期比较长的限流,存在很大的弊端。

滑动窗口算法

滑动窗口算法是将时间周期分为N个小周期,分别记录每个小周期内访问次数,并且根据时间滑动删除过期的小周期。

如下图,假设时间周期为1min,将1min再分为2个小周期,统计每个小周期的访问数量,则可以看到,第一个时间周期内,访问数量为75,第二个时间周期内,访问数量为100,超过100的访问则被限流掉了

由此可见,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。

此算法可以很好的解决固定窗口算法的临界问题。

使用Redis中Zset方法可以实现滑动窗口,在一个列表中,value可以是随机值,但是score是时间戳,zset中range方法可以拿到两个时间戳间隔内的个数,如果超过则直接返回。

漏桶算法

漏桶算法是访问请求到达时直接放入漏桶,如当前容量已达到上限(限流值),则进行丢弃(触发限流策略)。漏桶以固定的速率进行释放访问请求(即请求通过),直到漏桶为空。

令牌桶算法

一个存放固定容量令牌的桶,按照固定速率(每秒/或者可以自定义时间)往桶里添加令牌,然后每次获取一个令牌,当桶里没有令牌可取时,则拒绝服务
令牌桶分为2个动作,动作1(固定速率往桶中存入令牌)、动作2(客户端如果想访问请求,先从桶中获取token)

RateLimiter

create(double permitsPerSecond)根据指定的稳定吞吐率创建RateLimiter,这里的吞吐率是指每秒多少许可数(通常是指QPS,每秒多少查询)

create(double permitsPerSecond, long warmupPeriod, TimeUnit unit)根据指定的稳定吞吐率和预热期来创建RateLimiter,这里的吞吐率是指每秒多少许可数(通常是指QPS,每秒多少个请求量),在这段预热时间内,RateLimiter每秒分配的许可数会平稳地增长直到预热期结束时达到其最大速率。(只要存在足够请求数来使其饱和)

两个方法区别:第一个方法固定速率生成令牌数,第二个方法有预热时间段,在预热阶段内不超过设定的令牌数,超过预热期后以固定时间速率生成令牌,当出现突然出现大量数据。
明显区别:使用第一种方法可能会使消费方(后台服务)没有消费完成,一直往系统塞数据导致服务器不可用,使用第二种方法将流量比较平滑的过渡,从而降低后台服务down掉的风险(就是预热期内设置的令牌数少,不容易一下子把系统攻破)

RateLimiter是一个抽象类,限流器有两个实现类:1、SmoothBursty;2、SmoothWarmingUp

SmoothBursty是以稳定的速度生成permit。SmoothWarmingUp是渐进的生成,最终达到最大值趋于稳定。

偿还机制:当前请求的债务(请求的令牌大于限流器存储的令牌数)由下一个请求来偿还(上个请求亏欠的令牌,下个请求需要等待亏欠令牌生产出来以后才能被授权)acquire多个token时生效。

stableIntervalMircos //稳定生成令牌的时间间隔。

maxBurstSeconds  //1秒生产的令牌。

maxPermits //最大存储令牌数。

nextFreeTicketMicros //下个请求可被授权令牌的时间(不管请求多少令牌),实现当前债务由下一个请求来偿还机制关键。

storedPermits //已存储的令牌,生产过剩的令牌存储小于等于maxPermits,是应对突发流量的请求的关键。
    
//从RateLimiter中获取一个permit,阻塞直到请求可以获得为止。
public double acquire(){
	Return acquire(1);
}


//从RateLimiter中获取指定数量的permits,阻塞直到请求可以获得为止
public double acquire(int permits) {
	//计算获得这些数量需等待时间
        long microsToWait = reserve(permits);
		//不可被打断的等待
        stopwatch.sleepMicrosUninterruptibly(microsToWait);
		//单位转换为秒
        return 1.0 * microsToWait / SECONDS.toMicros(1L);
    }

//预订给定数量的permits来使用,计算需要这些数量permits等待时间。
final long reserve(int permits) {
		//校验负数
        checkPermits(permits);
		//抢占锁,这里的锁使用单例模式获得
        synchronized (mutex()) {
			//计算等待时间
            return reserveAndGetWaitLength(permits, stopwatch.readMicros());
        }
}

//具体计算等待时间的逻辑(继承上一次债务,并且透支本次所需要的所有permits)
//注意这里返回的是时间点
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
		
		//	同步时间轴
        resync(nowMicros); 
        //	继承上次债务
        long returnValue = nextFreeTicketMicros;
		//	跟桶内存储量比,本次可以获取到的permit数量,如果存储的permit大于本次需要的permit数量则此处是0,否则是一个正数
        double storedPermitsToSpend = min(requiredPermits, this.storedPermits); 
        
        //	还缺少的permits数量
        double freshPermits = requiredPermits - storedPermitsToSpend;    

        //	计算需要等待的时间(微秒)
        long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend) + (long) (freshPermits * stableIntervalMicros);    
		 //	继承上一次债务时间点+这次需要等待的时间,让下一次任务去等待
        this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
        //	减去本次消费的permit数
        this.storedPermits -= storedPermitsToSpend;    
        //	本次只需要等待到上次欠债时间点即可
        return returnValue; 
}

源码示例

/**
<dependency>
   <groupId>com.google.guava</groupId>
   <artifactId>guava</artifactId>
   <version>25.1-jre</version>
</dependency>
**/

@RestController
public class HelloController {

    private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");

    private static final RateLimiter rateLimiter = RateLimiter.create(2);

    /**
     * tryAcquire尝试获取permit,默认超时时间是0,意思是拿不到就立即返回false
     */
    @RequestMapping("/sayHello")
    public String sayHello() {
        if (rateLimiter.tryAcquire()) { //  一次拿1个
            System.out.println(sdf.format(new Date()));
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }else {
            System.out.println("limit");
        }
        return "hello";
    }

    /**
     * acquire拿不到就等待,拿到为止
     */
    @RequestMapping("/sayHi")
    public String sayHi() {
        rateLimiter.acquire(5); //  一次拿5个
        System.out.println(sdf.format(new Date()));
        return "hi";
    }

}

各算法比较

漏桶

漏桶的出水速度是恒定的,那么意味着如果瞬时大流量的话,将有大部分请求被丢弃掉(也就是所谓的溢出)。

令牌桶

生成令牌的速度是恒定的,而请求去拿令牌是没有速度限制的。这意味,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,而且拿令牌的过程并不是消耗很大的事情。

最后,不论是对于令牌桶拿不到令牌被拒绝,还是漏桶的水满了溢出,都是为了保证大部分流量的正常使用,而牺牲掉了少部分流量,这是合理的,如果因为极少部分流量需要保证的话,那么就可能导致系统达到极限而挂掉,得不偿失。

并不能说明令牌桶一定比漏洞好,她们使用场景不一样。令牌桶可以用来保护自己,主要用来对调用者频率进行限流,为的是让自己不被打垮。所以如果自己本身有处理能力的时候,如果流量突发(实际消费能力强于配置的流量限制),那么实际处理速率可以超过配置的限制。

而漏桶算法,这是用来保护他人,也就是保护他所调用的系统。主要场景是,当调用的第三方系统本身没有保护机制,或者有流量限制的时候,我们的调用速度不能超过他的限制,由于我们不能更改第三方系统,所以只有在主调方控制。这个时候,即使流量突发,也必须舍弃。因为消费能力是第三方决定的。

如果要让自己的系统不被打垮,用令牌桶。如果保证别人的系统不被打垮,用漏桶算法

这是单机(单进程)的限流,是JVM级别的的限流,所有的令牌生成都是在内存中,在分布式环境下不能直接这么用。

如果我们能把permit放到Redis中就可以在分布式环境中用了。

参考资料

posted @ 2021-08-03 10:35  明叶师兄。  阅读(109)  评论(0编辑  收藏  举报