这个限流器的原理是使用 Redis 的incr
命令来累计次数,key 的过期时间作为时间滑动窗口来实现。比如限制每5
秒最多请求10
次,那么就将 key 的过期时间设置为5
秒,每次执行前对这个 key 自增,5
秒内的次数将累计到这一个 key 上,如果自增的结果没有超过10
次,代表没有被限流。5
秒过后 key 将被 Redis 清除,后续次数将重新累计。
这里大家需要了解下incr
使用的一些细节。incr
每次执行都是将 key 的值自增1
,并返回自增后的结果,比如对key=1执行incr
结果为2
;如果 key 不存在,将设置这个 key 值为1
,返回结果自然也是1
,并且这个 key 是没有过期时间的。
Redis 的incr
不能在自增的同时设置过期时间,这就意味着自增和设置过期时间要分两步做,在第一次incr
完成之后,紧接着使用expire
指令来给这个 key 设置过期时间。非原子方式会带来并发问题,如果incr
成功,而expire
失败将导致生成了一个永不过期的 key,次数一直累计到最大值,永远进入限流状态。这个问题我们可以用个兜底逻辑来解决,在incr
前获取这个 key 的过期时间,如果没有那就删掉。
看到这,有了解过 Redis lua 脚本的同学可能会提出,既然这么麻烦,**为何不用 lua 脚本自己实现一个自增且同时能够同时设置过期时间的功能?**这个思路很棒,代码量不大且 Redis 也是完全可以支持的。但是在大点的公司,运维可能会禁止开发使用 lua 这种扩展方式,Redis 只有一个主线程执行执行命令,如果脚本中的逻辑执行时间过长将导致后续指令排队等待,它们响应时间自然也会变长,这种不可控的风险运维肯定不愿意承担。当然如果公司允许,并且有其他手段可以控制这个风险,lua 实现还是非常可行的。
**为何不直接使用JDK实现而要借助中间件?**因为实现出来只能在当前进程有有效,集群情况下不能累计到一起。
下面是具体代码,可以直接使用,代码关键处有详细的注释:
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.lang.NonNull;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* 用Redis实现的限流器,用于限制方法或者接口请求频率。比如:限制接口每秒请求次数;某个用户请求接口的次数,属于滑动窗口算法。
* 核心方法是 {@link #acquire(RedisTemplate, String, long, long)}
*/
public abstract class RedisIncrLimiter {
/**
* 限制每秒次数,参考 {@link #acquire(RedisTemplate, String, long, long)}
*/
public static boolean acquireLimitPerSecond(@NonNull RedisTemplate<String, String> redisTemplate,
@NonNull String limiterKey, long maxTimes) {
return acquire(redisTemplate, limiterKey, 1, maxTimes);
}
/**
* 限制每分钟次数,参考 {@link #acquire(RedisTemplate, String, long, long)}
*/
public static boolean acquireLimitPerMinute(@NonNull RedisTemplate<String, String> redisTemplate,
@NonNull String limiterKey, long maxTimes) {
return acquire(redisTemplate, limiterKey, 60, maxTimes);
}
/**
* 限制每小时次数,参考 {@link #acquire(RedisTemplate, String, long, long)}
*/
public static boolean acquireLimitPerHour(@NonNull RedisTemplate<String, String> redisTemplate,
@NonNull String limiterKey, long maxTimes) {
return acquire(redisTemplate, limiterKey, 3600, maxTimes);
}
/**
* 限制每天次数,参考 {@link #acquire(RedisTemplate, String, long, long)}
*/
public static boolean acquireLimitPerDay(@NonNull RedisTemplate<String, String> redisTemplate,
@NonNull String limiterKey, long maxTimes) {
return acquire(redisTemplate, limiterKey, 86400, maxTimes);
}
/**
* 执行限流逻辑前,调用这个方法获取一个令牌,如果返回 true 代表没被限流,可以执行。比如:
* {@code
* // 限制每秒最多发10次消息
* if (RedisIncrLimiter.acquire(redisTemplate, "sendMessage", 1, 10)) {
* // 发消息
* } else {
* // 被限流后的操作
* }
* }
* 如果限流粒度是用户级,可以将用户的ID或者唯一身份标识加到限流Key中。
* 这个也是限流核心方法,利用 Redis incr 命令累计次数,KEY过期时间作为时间窗口实现。
* 相同的限流KEY、时间窗口和最大次数才会累计到一起,三个参数任一不一致会分开累计,
* 参考{@link #buildFinalLimiterKey(String, long, long)}
*
* @param redisTemplate redisTemplate
* @param limiterKey 限流Key(代表限流逻辑的字符串)
* @param timeWindowSecond 时间窗口
* @param maxTimes 时间窗口内最大次数
* @return true-没有被限流
*/
public static boolean acquire(@NonNull RedisTemplate<String, String> redisTemplate,
@NonNull String limiterKey, long timeWindowSecond, long maxTimes) {
limiterKey = buildFinalLimiterKey(limiterKey, timeWindowSecond, maxTimes);
/*
如果异常情况下产生了没有过期时间的KEY,将导致次数不断累积到最大值(被限流)而无法解除。
这个兜底操作就是为了避免这个问题,清除没有过期时间的KEY
*/
Long ttl = redisTemplate.getExpire(limiterKey);
if (ttl == null || ttl == -1L) {
redisTemplate.delete(limiterKey);
return true;
}
Long incr = redisTemplate.opsForValue().increment(limiterKey);
Objects.requireNonNull(incr);
// 在第一次请求的时候设置过期时间(时间窗口)
if (incr == 1L) {
redisTemplate.expire(limiterKey, timeWindowSecond, TimeUnit.SECONDS);
}
return incr <= maxTimes;
}
/**
* @param limiterKey 限流Key
* @param timeWindowSecond 时间窗口
* @param maxTimes 时间窗口内最大次数
* @return 构建最终的限流 Redis Key,格式为:限流Key:时间窗口:最多次数
*/
private static String buildFinalLimiterKey(String limiterKey, long timeWindowSecond, long maxTimes) {
return limiterKey + ":" + timeWindowSecond + ":" + maxTimes;
}
}
注解版使用起来比较方便,只需要在限流的方法上指定时间三个关键的参数就行,底层逻辑还是上面的代码。比如:
// 每5秒最多10次
@RedisIncrLimit(limiterKey = "test", timeWindowSecond = 5L, maxTimes = 10L)
public String test() {
return "ok";
}
RedisIncrLimit
只用来标记限流方法,接收限流参数。
import java.lang.annotation.*;
/**
* {@link RedisIncrLimiter} 注解版
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RedisIncrLimit {
/**
* @return 限流KEY
*/
String limiterKey();
/**
* @return 时间窗口
*/
long timeWindowSecond();
/**
* @return 时间窗口内最大次数
*/
long maxTimes();
}
下面切面逻辑doBefore()
会在加了RedisIncrLimit
注解的方法前执行,先判断是否被限流。
import javax.annotation.Resource;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class RedisLimiterAspect {
@Resource
private RedisTemplate<String, String> redisTemplate;
@Pointcut("@annotation(redisLimit)")
public void pointcut(RedisIncrLimit redisLimit) {
}
@Before("pointcut(redisLimit)")
public void doBefore(RedisIncrLimit redisLimit) {
if (!RedisIncrLimiter.acquire(
redisTemplate, redisLimit.limiterKey(), redisLimit.timeWindowSecond(), redisLimit.maxTimes())) {
throw new IllegalStateException("rate limit");
}
}
}
以上是Redis限流器的全部内容,微信号搜索【wybqbx】或者扫描二维码关注公众号,里面有更多的分享,欢迎大家交流提问