如何使用 redis 实现限流
背景
在工作中时常会遇到需要对接口或者某个调用进行限流的情况。也会遇到在限流的同时对 redis
数据进行一些处理,在涉及到分布式的情景下,就需要操作的原子性。
限流算法
主流的限流算法为以下四种:
- 计数器(固定窗口)
- 滑动窗口(分割计数器)
- 漏桶算法
- 令牌桶算法
对于算法的解释,网上有很多好文章,在这里贴上常用四种限流算法。
在本文中,讨论前两种也就是 计数器 以及 滑动窗口。
业务解释
限流,是在业务中经常遇到的场景。例如:对接口的限流、对调用的限流,etc。
以对接口限流为例,流程如下:
请求打到服务器之后,需要先判断当前接口是否达到阈值,若:
- 达到阈值,则结束本次请求。
- 未达到阈值,则 计数++,继续下一步调用。
限流可以有很多中办法,如果只是小型的单机部署应用,则可以考虑在内存中进行计数与操作。若是复杂的项目且分布式部署的项目,可以考虑使用redis
进行计数。且限流的逻辑不一定要限于Java
代码中,也可以使用lua
在nginx
进行操作,例如大名鼎鼎的openresty
,同理其他网关服务也可实现。
分布式业务中的限流
首先分析业务场景,在分布式部署的api场景中需要注意以下几点:
- 使用网关对api进行负载均衡,部署在不同服务器上的进行之间内存很难做到共享。
- 基于限流的业务,是对整个系统的某一个或者某一些接口进行限流,所以计数必须做到不同的进程都可以读取。
- 对于计数的触发,是请求达到服务器上之后发生的,所以需要考虑原子性。即:同一时刻,只有一个请求可以触发计数。这就对计数服务的要求提出了很高的并发要求。
分析 nginx + lua 的可行性
nginx
常用于请求的入口,在使用它的负载均衡之后,可以实现将请求分发到不同的服务上。使用 lua
对内存进行操作,似乎可以实现上述要求(可行性待验证)。
但是,在实际情况中,一个系统并一定只会部署一个 nginx
作为入口。一方面是单机风险,另一方面是地理位置的不同,网络的不同对同一台机器的访问速度可能会有天差地别。所以,大家更喜欢使用 DNS 或者其他将请求达到多态 nginx
先做一层负载均衡。所以,单是 nginx
+ lua
并不能达到我们的需求。
分析 redis 的可行性
redis
是基于内存的一种非关系型数据库,它的并发是经得住考验的,同时它也可以满足不同进程对相同数据读取、修改的需求。
对于原子性,redis
操作天生支持原子性,而且 string 类型的 INCR(原子累加) 操作与 限流 业务又十分的契合。
redis 实现限流
让我们再回到一开始的流程,计数限流的操作有:
- 查询当前计数
- 累加当前计数
在分布式系统中,必须要时刻注意 原子性。在单一进程中,我们保持数据线程安全的办法是加锁,无论是可重入锁还是synchronized
,其语义都是告诉其他线程,这个数据(代码块)我现在征用了,你们等会再来。那在分布式系统中,我们自然而然的可以想到分布式锁。
伪代码如下:
Lock lock = getDistributedLock();
try{
lock.lock();
// 从 redis 中获取计数
Integer count = getCountFromRedis();
if(count >= limit){
// 超过阈值,不予调用
return false;
}
// 未超过阈值,允许调用
incrRedisCount();
return true;
}catch{
...
}finally{
lock.unlock();
}
乍一看,这种逻辑没有问题,但其实问题很大:
- 使用分布式锁明显会拖慢整个系统,浪费很多资源。
- redis incr 操作会返回累加之后的值,所以查询操作是不必须的。
伪代码如下:
Integer count = incrRedisCount();
if (count >= limit){
return false;
}
return true;
是不是变的简单了很多。但是随之而来的有其他的问题,大部分的业务都不是要求我们只对次数进行限制,更多的是要求我们限制接口在一段时间内的请求次数----滑动窗口。
滑动窗口的实现
顾名思义,滑动窗口就是将一个固定的窗口滑动起来。用于限流上来说就是,一段时间内进行计数,时间一过,立马开始新的计数。
如何实现 一段时间 这个逻辑?
其实很简单,我们完全可以使用 时间戳 来实现这一功能。
// 秒级时间戳
long timestamp = System.currentTimeMillis() / 1000;
Long aLong = redisTemplate.opsForValue().increment(RedisKeyEnum.SYSTEM_FLOW_LIMIT.getKey() + timestamp);
return aLong;
此时会有一个问题,如果按以上代码来看,每秒创建一个键,那redis 内存迟早会被撑爆。我们需要一个策略来删除这个键。
笨的方法,可以记录这些键,然后异步去删除这些键。但是更好的方法是,在键第一次创建的时候设置一个稍大于窗口的过期值。所以,代码如下:
/**
* 按秒统计发送消息数量
*
* @return
*/
public Long getSystemMessageCountAtomic() {
// 秒级时间戳
long timestamp = System.currentTimeMillis() / 1000;
Long aLong = redisTemplate.opsForValue().increment(RedisKeyEnum.SYSTEM_FLOW_LIMIT.getKey() + timestamp);
if (aLong != null && aLong == 1) {
redisTemplate.expire(RedisKeyEnum.SYSTEM_FLOW_LIMIT.getKey() + timestamp, 2, TimeUnit.SECONDS);
}
return aLong;
}
只有在第一次计数的时候才会执行 expire
命令。为什么需要设置稍大于窗口的时间呢?
想象一下,如果设置和窗口一样的时间,在 a 时刻的时候生成的键 keyA,然后过期时间是一秒。然后在 b 时刻,生成的键也是 keyA(在同一秒内),但是由于网络或者其他原因,b 时刻的命令在一秒之后才发送到 redis server。由于过期时间是一秒,此时旧的 keyA 已经过期,那么 b 时刻就会创建一个新的键。
此时,需要考虑另外一个问题,如果超过限制,以上代码会如何表现。
假设,一秒钟内只允许请求100次。那么第101次,也会去 redis 中执行 incr 命令,往后的请求都会执行。其实这些命令的执行时没有意义的,因为第 101 次时,这一秒的请求已经到限制了,所以我们需要另外一个存储来记录以上数据。
我选用 AtomicLong 来记录已经到限的窗口。分析一下是否可行。
- AtomicLong 属于 java.util.concurrent.atomic 包,采用 CAS 与 volatile 来保证数据的线程安全。
- 上述需求,我们只需要在单机上记录 flag 即可,不需要考虑分布式情况。
论述可行,以下展示代码。
private final AtomicLong flag = new AtomicLong();
/**
* 系统全局流量限制
*/
public void systemFlowLimit() {
// 判断 flag 是否与当前秒相同
if (flag.get() != System.currentTimeMillis() / 1000) {
// 由于 flag.get 到 flag.set 之间的所有操作组合之后 不具备原子性,所以会有 小于 线程数 的线程会进入到这里面。
// 意思是,当 第一个 线程将 flag 设置为 当前秒级 时间戳之后, 会有一部分线程已经执行完 flag.get 的判断逻辑
// 此时,部分线程会继续 redis 操作与 日志操作
Long count = systemLimitService.getSystemMessageCountAtomic();
if (count >= systemProperties.getFlowLimit()) {
// 超过之后会将flag 设置为当前秒
flag.set(System.currentTimeMillis() / 1000);
LOGGER.warn("system flow now is out of system flow limit,at:{}", System.currentTimeMillis() / 1000);
throw new BusinessException(...);
}
} else {
throw new BusinessException(...);
}
}
总结
以上整理了使用 redis 做限流的一些方法,经常使用的算法便是滑动窗口,所以花了较大笔墨解释滑动窗口的实现。
当然,我们还可以使用 lua 脚本来操作 redis 以实现限流与其他 redis 操作的配合。
我经常遇到的一个场景就是,往 redis 队列中写数据需要进行限流,当流量达到之后需要删除部分 redis 队列中的内容。此时,使用 lua 脚本来做可以很优雅的保持多个 redis 操作的原子性,也可以减少网络情况的开销。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。