⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7298635806475386916 「翼飞」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

简介

市面上很多介绍redis如何实现限流的,但是大部分都有一个缺点,就是只能实现单一的限流,比如1分钟访问1次或者60分钟访问10次这种,但是如果想一个接口两种规则都需要满足呢,我们的项目又是分布式项目,应该如何解决,下面就介绍一下redis实现分布式多规则限流的方式。

思考

  1. 如何一分钟只能发送一次验证码,一小时只能发送10次验证码等等多种规则的限流
  2. 如何防止接口被恶意打击(短时间内大量请求)
  3. 如何限制接口规定时间内访问次数

解决方法

记录某IP访问次数

使用 String结构 记录固定时间段内某用户IP访问某接口的次数

  • RedisKey = prefix : className : methodName
  • RedisVlue = 访问次数

拦截请求:

  1. 初次访问时设置 「[RedisKey] [RedisValue=1] [规定的过期时间]」
  2. 获取 RedisValue 是否超过规定次数,超过则拦截,未超过则对 RedisKey 进行加1

分析: 规则是每分钟访问 1000 次

  1. 考虑并发问题
  • 假设目前 RedisKey => RedisValue 为 999
  • 目前大量请求进行到第一步( 获取Redis请求次数 ),那么所有线程都获取到了值为999,进行判断都未超过限定次数则不拦截,导致实际次数超过 1000 次
  • 「解决办法:」 保证方法执行原子性(加锁、lua)
  1. 考虑在临界值进行访问
  • 思考下图

图片

代码实现: 比较简单,参考:https://gitee.com/y_project/RuoYi-Vue/blob/master/ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/RateLimiterAspect.java``。

Zset解决临界值问题

使用 Zset 进行存储,解决临界值访问问题

图片

网上几乎都有实现,这里就不过多介绍

实现多规则限流

先确定最终需要的效果

  • 能实现多种限流规则
  • 能实现防重复提交

通过以上要求设计注解(先想象出最终实现效果)

@RateLimiter(
rules = {
// 60秒内只能访问10次
@RateRule(count = 10, time = 60, timeUnit = TimeUnit.SECONDS),
// 120秒内只能访问20次
@RateRule(count = 20, time = 120, timeUnit = TimeUnit.SECONDS)

},
// 防重复提交 (5秒钟只能访问1次)
preventDuplicate = true
)

编写注解(RateLimiter,RateRule)

编写 RateLimiter 注解。

/**
* @Description: 请求接口限制
* @Author: yiFei
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface RateLimiter {

/**
* 限流key
*/
String key() default RedisKeyConstants.RATE_LIMIT_CACHE_PREFIX;

/**
* 限流类型 ( 默认 Ip 模式 )
*/
LimitTypeEnum limitType() default LimitTypeEnum.IP;

/**
* 错误提示
*/
ResultCode message() default ResultCode.REQUEST_MORE_ERROR;

/**
* 限流规则 (规则不可变,可多规则)
*/
RateRule[] rules() default {};

/**
* 防重复提交值
*/
boolean preventDuplicate() default false;

/**
* 防重复提交默认值
*/
RateRule preventDuplicateRule() default @RateRule(count = 1, time = 5);
}

编写RateRule注解

@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface RateRule {

/**
* 限流次数
*/
long count() default 10;

/**
* 限流时间
*/
long time() default 60;

/**
* 限流时间单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;

}

拦截注解 RateLimiter

  • 确定redis存储方式

    • RedisKey = prefix : className : methodName
    • RedisScore = 时间戳
    • RedisValue = 任意分布式不重复的值即可
  • 编写生成 RedisKey 的方法

/**
* 通过 rateLimiter 和 joinPoint 拼接 prefix : ip / userId : classSimpleName - methodName
*
* @param rateLimiter 提供 prefix
* @param joinPoint 提供 classSimpleName : methodName
* @return
*/
public String getCombineKey(RateLimiter rateLimiter, JoinPoint joinPoint) {
StringBuffer key = new StringBuffer(rateLimiter.key());
// 不同限流类型使用不同的前缀
switch (rateLimiter.limitType()) {
// XXX 可以新增通过参数指定参数进行限流
case IP:
key.append(IpUtil.getIpAddr(((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest())).append(":");
break;
case USER_ID:
SysUserDetails user = SecurityUtil.getUser();
if (!ObjectUtils.isEmpty(user)) key.append(user.getUserId()).append(":");
break;
case GLOBAL:
break;
}
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Class<?> targetClass = method.getDeclaringClass();
key.append(targetClass.getSimpleName()).append("-").append(method.getName());
return key.toString();
}

编写lua脚本

编写lua脚本 (两种将时间添加到Redis的方法)。

Zset的UUID value值

UUID(可用其他有相同的特性的值)为Zset中的value值

  • 参数介绍

    • KEYS[1] = prefix : ? : className : methodName
    • KEYS[2] = 唯一ID
    • KEYS[3] = 当前时间
    • ARGV = [次数,单位时间,次数,单位时间, 次数, 单位时间 …]
  • 由java传入分布式不重复的 value 值

-- 1. 获取参数
local key = KEYS[1]
local uuid = KEYS[2]
local currentTime = tonumber(KEYS[3])
-- 2. 以数组最大值为 ttl 最大值
local expireTime = -1;
-- 3. 遍历数组查看是否超过限流规则
for i = 1, #ARGV, 2 do
local rateRuleCount = tonumber(ARGV[i])
local rateRuleTime = tonumber(ARGV[i + 1])
-- 3.1 判断在单位时间内访问次数
local count = redis.call('ZCOUNT', key, currentTime - rateRuleTime, currentTime)
-- 3.2 判断是否超过规定次数
if tonumber(count) >= rateRuleCount then
return true
end
-- 3.3 判断元素最大值,设置为最终过期时间
if rateRuleTime > expireTime then
expireTime = rateRuleTime
end
end
-- 4. redis 中添加当前时间
redis.call('ZADD', key, currentTime, uuid)
-- 5. 更新缓存过期时间
redis.call('PEXPIRE', key, expireTime)
-- 6. 删除最大时间限度之前的数据,防止数据过多
redis.call('ZREMRANGEBYSCORE', key, 0, currentTime - expireTime)
return false

根据时间戳作为Zset中的value值

  • 参数介绍

    • KEYS[1] = prefix : ? : className : methodName
    • KEYS[2] = 当前时间
    • ARGV = [次数,单位时间,次数,单位时间, 次数, 单位时间 …]
  • 根据时间进行生成value值,考虑同一毫秒添加相同时间值问题

    • 以下为第二种实现方式,在并发高的情况下效率低,value是通过时间戳进行添加,但是访问量大的话会使得一直在调用 redis.call(‘ZADD’, key, currentTime, currentTime),但是在不冲突value的情况下,会比生成 UUID 好
-- 1. 获取参数
local key = KEYS[1]
local currentTime = KEYS[2]
-- 2. 以数组最大值为 ttl 最大值
local expireTime = -1;
-- 3. 遍历数组查看是否越界
for i = 1, #ARGV, 2 do
local rateRuleCount = tonumber(ARGV[i])
local rateRuleTime = tonumber(ARGV[i + 1])
-- 3.1 判断在单位时间内访问次数
local count = redis.call('ZCOUNT', key, currentTime - rateRuleTime, currentTime)
-- 3.2 判断是否超过规定次数
if tonumber(count) >= rateRuleCount then
return true
end
-- 3.3 判断元素最大值,设置为最终过期时间
if rateRuleTime > expireTime then
expireTime = rateRuleTime
end
end
-- 4. 更新缓存过期时间
redis.call('PEXPIRE', key, expireTime)
-- 5. 删除最大时间限度之前的数据,防止数据过多
redis.call('ZREMRANGEBYSCORE', key, 0, currentTime - expireTime)
-- 6. redis 中添加当前时间 ( 解决多个线程在同一毫秒添加相同 value 导致 Redis 漏记的问题 )
-- 6.1 maxRetries 最大重试次数 retries 重试次数
local maxRetries = 5
local retries = 0
while true do
local result = redis.call('ZADD', key, currentTime, currentTime)
if result == 1 then
-- 6.2 添加成功则跳出循环
break
else
-- 6.3 未添加成功则 value + 1 再次进行尝试
retries = retries + 1
if retries >= maxRetries then
-- 6.4 超过最大尝试次数 采用添加随机数策略
local random_value = math.random(1, 1000)
currentTime = currentTime + random_value
else
currentTime = currentTime + 1
end
end
end

return false

编写 AOP 拦截

@Autowired
private RedisTemplate<String, Object> redisTemplate;

@Autowired
private RedisScript<Boolean> limitScript;

/**
* 限流
* XXX 对限流要求比较高,可以使用在 Redis中对规则进行存储校验 或者使用中间件
*
* @param joinPoint joinPoint
* @param rateLimiter 限流注解
*/
@Before(value = "@annotation(rateLimiter)")
public void boBefore(JoinPoint joinPoint, RateLimiter rateLimiter) {
// 1. 生成 key
String key = getCombineKey(rateLimiter, joinPoint);
try {
// 2. 执行脚本返回是否限流
Boolean flag = redisTemplate.execute(limitScript,
ListUtil.of(key, String.valueOf(System.currentTimeMillis())),
(Object[]) getRules(rateLimiter));
// 3. 判断是否限流
if (Boolean.TRUE.equals(flag)) {
log.error("ip: '{}' 拦截到一个请求 RedisKey: '{}'",
IpUtil.getIpAddr(((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest()),
key);
throw new ServiceException(rateLimiter.message());
}
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
e.printStackTrace();
}
}

/**
* 获取规则
*
* @param rateLimiter 获取其中规则信息
* @return
*/
private Long[] getRules(RateLimiter rateLimiter) {
int capacity = rateLimiter.rules().length << 1;
// 1. 构建 args
Long[] args = new Long[rateLimiter.preventDuplicate() ? capacity + 2 : capacity];
// 3. 记录数组元素
int index = 0;
// 2. 判断是否需要添加防重复提交到redis进行校验
if (rateLimiter.preventDuplicate()) {
RateRule preventRateRule = rateLimiter.preventDuplicateRule();
args[index++] = preventRateRule.count();
args[index++] = preventRateRule.timeUnit().toMillis(preventRateRule.time());
}
RateRule[] rules = rateLimiter.rules();
for (RateRule rule : rules) {
args[index++] = rule.count();
args[index++] = rule.timeUnit().toMillis(rule.time());
}
return args;
}

以上,欢迎大家提出意见。

文章目录
  1. 1. 简介
  2. 2. 思考
  3. 3. 解决方法
    1. 3.0.1. 记录某IP访问次数
    2. 3.0.2. Zset解决临界值问题
  • 4. 实现多规则限流
    1. 4.0.1. 先确定最终需要的效果
    2. 4.0.2. 编写注解(RateLimiter,RateRule)
    3. 4.0.3. 拦截注解 RateLimiter
  • 5. 编写lua脚本
    1. 5.0.1. Zset的UUID value值
    2. 5.0.2. 根据时间戳作为Zset中的value值
    3. 5.0.3. 编写 AOP 拦截