使用RateLimiter做限流

368 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第9天,点击查看活动详情

为什么要限流

当今的互联网火爆程度无法想象,在逢年过节的时候是否体会到一票难求,又或者在网购平台进行一些活动爆款抢购的时候,还有现在频频发生的疫情需要排长队做核酸。这些情况往往都是一大批的流量蜂拥而入去做一件事,对于做核酸来说排起长队也是做了限流措施,如果不控制那么所有人都上去医务人员也不知道先给谁做;对于互联网来说服务器也是一样有压力的,比如在某个活动力度较大的时间段,需要根据火爆程度去做限流,防止一下子流量过大导致服务器CPU负载过大出现宕机的可能。

限流有哪些方法

根据限流的场景可以分为几种:总并发数、瞬时并发数、时间窗口平均速率、MQ消费速率限制等,下面我们就简单做个对比。

  • 总并发数限制:数据库连接池、线程池,通过一个池子存放的线程数量来控制流量,最大仅允许多少个线程在运行。
  • 瞬时并发数:控制某个时刻最大的并发数,例如nginxlimit_conn模块以及多线程并发信号量控制Semaphore
  • 时间窗口平均速率:允许限制一个时间段内最大的通过数量,这也是我们今天要讲的RateLimiter

限流算法

  • 计数器限流法 简单粗暴的通过并发数进行计数统计,主要运用于总并发数的限制。

  • 漏斗算法 可以抽象的理解为放置一个漏斗,从上面开始倒水底部漏水,如果倒水的速率大于漏水的速率,那么一定时间后漏斗里的水会溢出,溢出的部分就可以作为请求被拒绝,从此达到限流的目的。

  • 令牌桶算法 令牌桶算法是一个固定大小的桶,按一定速率往桶内添加令牌,直到令牌放满桶时,超出的将会被丢弃或者拒绝。当请求进来时会从桶中获取一个令牌,当获取令牌速率大于添加令牌速率时,就会出现令牌数为0,此时后面进来的请求就会被拒绝,这也是RateLimiter采用的算法。

RateLimiter类分析

模块依赖

RateLimiterguava的并发包底下的一个限流类,本例以maven工程做展示,使用时需引入该工具包:

<dependency>
	<groupId>com.google.guava</groupId>
	<artifactId>guava</artifactId>
	<version>20.0</version>
</dependency>

方法解析

RateLimiter主要有以下几个方法:

  • 限流对象构建,指定每秒向桶里添加几个令牌。
public static RateLimiter create(double permitsPerSecond)
  • 限流对象构建,指定时间单位里向桶里添加几个令牌。
public static RateLimiter create(double permitsPerSecond, long warmupPeriod, TimeUnit unit)
  • 设置/获取时间单位内向桶里添加令牌数量。
public final void setRate(double permitsPerSecond)
public final double getRate()
  • 获取一个令牌,如果桶里没有令牌会进入阻塞,直到获取到令牌,返回获取令牌所花费的时间(单位:秒)。
public double acquire()
  • 获取指定数量的令牌,如果桶里令牌数不够则会进入阻塞,直到令牌数足够获取,返回获取令牌所花费的时间(单位:秒)。
public double acquire(int permits)
  • 立即获取一个令牌,不进入阻塞,如何成功获取令牌返回true,桶里令牌不够获取失败返回false
public boolean tryAcquire()
  • 立即获取指定数量的令牌,不进入阻塞,如何成功获取令牌返回true,桶里令牌不够获取失败返回false
public boolean tryAcquire(int permits)
  • 指定时间单位内获取一个令牌,如果桶里无令牌进入阻塞,若指定时间内成功获取令牌返回true,否则返回false
public boolean tryAcquire(long timeout, TimeUnit unit)
  • 指定时间范围内,获取指定数量的令牌,如果桶里令牌数不够进入阻塞,若指定时间内成功获取令牌返回true,否则返回false
public boolean tryAcquire(int permits, long timeout, TimeUnit unit)

原理分析

根据SmoothRateLimiter类,使用时间推移的方式,按每次获取令牌的时间计算出时间差,并根据时间差范围计算出能够生产的令牌数。如果令牌数足够则不阻塞直接返回true,如果令牌数不够则需根据所缺少的令牌数计算出生产这些令牌所要阻塞的时间,并将下次生产令牌的时间加上阻塞时间往后推移,如果在阻塞时间范围内能够产生令牌则返回true,否则返回false。

public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {
	// 时间折算成微秒(1秒=1000000微秒)
	long timeoutMicros = Math.max(unit.toMicros(timeout), 0L);
	checkPermits(permits);
	// 休眠等待微秒数
	long microsToWait;
	// 添加互斥锁
	synchronized(this.mutex()) {
		// 当前时间微秒数
		long nowMicros = this.stopwatch.readMicros();
		// 下个令牌生成时间超过阻塞时间则返回false,否则返回true
		if (!this.canAcquire(nowMicros, timeoutMicros)) {
			return false;
		}

		microsToWait = this.reserveAndGetWaitLength(permits, nowMicros);
	}
	// 根据计算的阻塞时间进入阻塞
	this.stopwatch.sleepMicrosUninterruptibly(microsToWait);
	return true;
}

// 计算下一个令牌产生的时间是否在阻塞时间范围内
private boolean canAcquire(long nowMicros, long timeoutMicros) {
	return this.queryEarliestAvailable(nowMicros) - timeoutMicros <= nowMicros;
}

final long reserveAndGetWaitLength(int permits, long nowMicros) {
	long momentAvailable = this.reserveEarliestAvailable(permits, nowMicros);
	return Math.max(momentAvailable - nowMicros, 0L);
}

final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
	// 刷新计算令牌数
	this.resync(nowMicros);
	// 本次获取令牌的时间
	long returnValue = this.nextFreeTicketMicros;
	// 发放令牌数
	double storedPermitsToSpend = Math.min((double)requiredPermits, this.storedPermits);
	// 剩余所需的令牌数,如果大于0表示需要继续等待获取令牌,否则无需等待
	double freshPermits = (double)requiredPermits - storedPermitsToSpend;
	// 根据还需要的令牌数,计算阻塞时间
	long waitMicros = this.storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend) + (long)(freshPermits * this.stableIntervalMicros);
	// 根据阻塞时间,将时间往后平推,标记下次产生令牌要等到什么时候
	this.nextFreeTicketMicros = LongMath.saturatedAdd(this.nextFreeTicketMicros, waitMicros);
	// 消耗存储令牌数
	this.storedPermits -= storedPermitsToSpend;
	return returnValue;
}

// 每次获取令牌时,按时间推移的方式,根据每次获取令牌时间间隔计算出产生的令牌数
void resync(long nowMicros) {
	if (nowMicros > this.nextFreeTicketMicros) {
		// 计算当前时间与上一次产生令牌的时间差,并算出这个时间内能产生多少令牌
		double newPermits = (double)(nowMicros - this.nextFreeTicketMicros) / this.coolDownIntervalMicros();
		// 存储令牌数
		this.storedPermits = Math.min(this.maxPermits, this.storedPermits + newPermits);
		// 时间往后推移
		this.nextFreeTicketMicros = nowMicros;
	}
}

如何使用RateLimiter实现限流

  • 使用RateLimiter定义一个测试下单接口如下:
@RestController
@RequestMapping("/order")
public class OrderController {
    // 每秒发放5个令牌,QPS
    private RateLimiter rateLimiter = RateLimiter.create(5);
	
    @PostMapping("/place")
    public String placeAnOrder() {
		// 更多获取令牌的方法可自行测试使用
        if (!rateLimiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
            // 服务降级直接返回不做业务
            log.debug("err-001:{}", "活动太火爆了,请稍后再来!");
            return "活动太火爆了,请稍后再来!";
        }

        // 进入业务逻辑处理
        log.debug("下单成功:" + UUID.randomUUID().toString().replaceAll("-", ""));
        return "success";
    }
}

如果有多个业务接口需要进行限流,也可以结合AOP添加注解配置,使用切面扫描注解对接口做独立的限流配置。

rete_limiter_qps.jpg

  • 运行结果如下:

rate_limiter_result.jpg

总结

通常互联网项目面对的使用人群比较广,随着流量的增长,在上线时也需要对系统进行一系列的压测评估出系统的性能阀值,再根据阀值合理的配置一些限流参数以防止大流量压垮系统。一般大型的系统也都会以层层加码做多层保护措施,也可以通过nginx网关层面进行限流的配置,后端还可以结合一些服务降级措施。最终目的是通过并发请求数或者一个时间窗口内的请求数来进行限制,以达到流量整形来保证服务器能够安全稳定运行。