# rate limiter关于限速器所需要了解的知识

RateLimiter速率限流限速保护器是保证服务稳定性的一种重要手段,其他的稳定性保护手段还有熔断、并发限流等。 比如我们的一个机器能够支持的最大的接口请求量为1000每秒,为了避免请求量过高导致服务请求积压,服务处理速度变慢最终无法对外响应。可以在服务的入口处增加 一个限流保护器,当请求的QPS超过我们配置的值时,会直接抛出异常,避免增加请求压力超过自身处理能力。 另外加入我们的服务要调用另外一个被调服务,该被调服务也有处理能力的速度上限,则我们也可以在调用这个被调服务时增加限流保护,避免超载。 比如我们在开发一个数据迁移导入功能,把一批数据导入到新的数据库,导入程序通过增加限流,控制每秒导入的最大数据量,避免数据库超载。

本文内容

  • 限流的实现
    • 简单限流、guava限流。
  • 限流的用途
  • 单机限流、分布式限流
    • redis、批量

# 实现一个简单的rate limiter

现在假设限流器每秒允许执行N个操作,一种直观的实现是控制每次操作间的间隔超过1/N秒,因此一个RateLimiter 只需要记录上次允许操作的时间即可。每次acquire时,判断距离上次允许操作的时间,是否超过了最小间隔,如果是 则允许通过并且更新操作时间,否则等待到指定的时间再判断。

如下代码使用kotlin实现了acquire和tryAcquire功能。tryAcquire类似Lock里的tryLock,会判断当前是否能acquire,如果不能直接返回不等待。 否则会通过限流器。acquire方法在不能通过的情况下会等待重试。

class RateLimiter(ratePerSecond: Double) {
    var intervalMillis: Long;
    @Volatile private var lastTimeMillis: Long = 0
    private val lock = Object()

    init {
        intervalMillis = (TimeUnit.SECONDS.toMillis(1) / ratePerSecond).toLong();
    }

    fun acquire() {
        synchronized(lock) {
            while (!tryAcquire()) {
                val sleepMillis = lastTimeMillis + intervalMillis - System.currentTimeMillis()
                if (sleepMillis > 0) {
                    lock.wait(sleepMillis)
                }
            }
        }
    }

    fun tryAcquire(): Boolean {
        if (System.nanoTime() - lastTimeMillis >= intervalMillis) {
            synchronized(lock) {
                if (System.nanoTime() - lastTimeMillis >= intervalMillis) {
                    lastTimeMillis = System.nanoTime()
                    return true
                }
            }
        }
        return false
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

# guava rate limiter

我们上面实现的限速器的优点是实现简单, guava中的RateLimiter,增加了更多的设计考虑,针对RateLimiter一段时间没有请求的情况, RateLimiter会积累一个storedPermits的值,在有请求来了之后就直接允许storedPermits个请求通过,而不需要等待, 这是针对类似空闲资源的更有限方式,比如带宽。 另外常听说的限流实现还有令牌桶、漏桶等。

# 分布式限流

前面提到的限流都是一个Java进程内的限流,而有时我们的分布式微服务会有多个实例,那么如何实现分布式的限流呢?

第一个方案,假如我们已知服务实例的数量,则可以按照整体限流值除以实例数量的方式为每个进程计算限流值

第二个方案,通过redis的incr实现,比如为每一秒作为一个redis的key设置一定的过期时间,然后每次acquire时都去incr,判断返回的结果是否超过限流值。 这种方式在请求量比较高时,会产生比较高的redis请求量,如果使用单个key,则单个key都请求到同一个redis实例,则会有热点问题,可以通过将key拆分成 多个子key(比如通过机器名hash的方式),再拆分到多个子key上,降低单个redis的压力。还可以通过聚合的方式请求,比如每次incr的增量不是1,而是 100,获取完100个permit后,可以在进程内acquire成功100次,从而能极大降低redis的请求量。在超过阈值后还能保存一个本地缓存避免同时间粒度内再次请求redis继续降低请求量。

分布式限流还可以使用sentinel的集群流控规则 (opens new window)

# 限流的速率动态调整

有时候我们的限速器的配置希望能动态调整,比如服务下游出现问题,希望我们把调用量调小。 有时候我们发现服务能够支持的调用量比预估的要高,希望调大限流值。 这种需要调整限速值的情况,如果还需要修改代码重新部署,就非常不方便。那么如何实现一个能动态调整的限速器呢?

对于前面的简单间隔限速器和分布式redis限速器,实现比较简单,只需要在配置值变化之后调整对应的最小间隔时间和redis incr结果的最大值即可。 对于guava的RateLimiter,稍微麻烦点,需要我们把RateLimiter封装到一个封装类中,这个封装类中维护一个guava RateLimiter对象,当限流值变化时, 创建一个新的RateLimiter对象并替换,调用acquire时从封装类获取最新的RateLimiter进行acquire。

# 分布式频次限制器

另外一种常见的需求是频次限制,类似限速器,但是要求更精准,比如为了防止接口被刷,业务上要求限制一个用户一天只能修改一次用户名,那么在修改用户名前 就要判断今天修改了几次。类似这种需求可以通过频次限制实现。

在redis中保存次数,每次获取频次时,incr并获取该key的ttl(即剩余过期时间),如果过期时间没有设置,则通过expire设置过期时间(频次限制的周期比如一天)。 如果incr返回的值超过阈值,说明超过次数限制。