文章目录
- Redis多规则限流和防重复提交
-
- 记录访问次数
- 解决临界值访问问题
- 实现多规则限流
-
- 先确定最终需要的效果
- 编写注解(RateLimiter,RateRule)
- 拦截注解 RateLimiter
- 编写lua脚本
-
- UUID
- 时间戳
- 编写 AOP 拦截
- 总结
Redis多规则限流和防重复提交
市面上很多介绍redis如何实现限流的,但是大部分都有一个缺点,就是只能实现单一的限流,比如1分钟访问1次或者60分钟访问10次这种,但是如果想一个接口两种规则都需要满足呢,我们的项目又是分布式项目,应该如何解决,下面就介绍一下redis实现分布式多规则限流的方式。
- 如何一分钟只能发送一次验证码,一小时只能发送10次验证码等等多种规则的限流?
- 如何防止接口被恶意打击(短时间内大量请求)?
- 如何限制接口规定时间内访问次数?
记录访问次数
使用 String
结构,记录固定时间段内某用户IP
访问某接口的次数
RedisKey = prefix : className : methodName
RedisVlue = 访问次数
拦截请求:
- 初次访问时设置
[RedisKey] [RedisValue=1] [规定的过期时间]
- 获取
RedisValue
是否超过规定次数,超过则拦截,未超过则对RedisKey
进行加1
分析: 规则是每分钟访问 1000 次
- 考虑并发问题
- 假设目前
RedisKey => RedisValue 为 999
- 目前大量请求进行到第一步( 获取
Redis
请求次数 ),那么所有线程都获取到了值为999,进行判断都未超过限定次数则不拦截,导致实际次数超过 1000 次 - 解决办法: 保证方法执行原子性(加锁、lua)
- 假设目前
- 考虑在临界值进行访问
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IL4u0jaY-1721999915731)(https://i-blog.csdnimg.cn/direct/1eacf46030b6471e91ce43d1e5eae900.png)]
java
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import com.ruoyi.common.annotation.RateLimiter;
import com.ruoyi.common.enums.LimitType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.ip.IpUtils;
/**
* 限流处理
*/
@Aspect
@Component
public class RateLimiterAspect
{
private static final Logger log = LoggerFactory.getLogger(RateLimiterAspect.class);
private RedisTemplate<Object, Object> redisTemplate;
private RedisScript<Long> limitScript;
@Autowired
public void setRedisTemplate1(RedisTemplate<Object, Object> redisTemplate)
{
this.redisTemplate = redisTemplate;
}
@Autowired
public void setLimitScript(RedisScript<Long> limitScript)
{
this.limitScript = limitScript;
}
@Before("@annotation(rateLimiter)")
public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable
{
int time = rateLimiter.time();
int count = rateLimiter.count();
String combineKey = getCombineKey(rateLimiter, point);
List<Object> keys = Collections.singletonList(combineKey);
try
{
Long number = redisTemplate.execute(limitScript, keys, count, time);
if (StringUtils.isNull(number) || number.intValue() > count)
{
throw new ServiceException("访问过于频繁,请稍候再试");
}
log.info("限制请求'{}',当前请求'{}',缓存key'{}'", count, number.intValue(), combineKey);
}
catch (ServiceException e)
{
throw e;
}
catch (Exception e)
{
throw new RuntimeException("服务器限流异常,请稍候再试");
}
}
public String getCombineKey(RateLimiter rateLimiter, JoinPoint point)
{
StringBuffer stringBuffer = new StringBuffer(rateLimiter.key());
if (rateLimiter.limitType() == LimitType.IP)
{
stringBuffer.append(IpUtils.getIpAddr()).append("-");
}
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
Class<?> targetClass = method.getDeclaringClass();
stringBuffer.append(targetClass.getName()).append("-").append(method.getName());
return stringBuffer.toString();
}
}
解决临界值访问问题
使用 Zset
进行存储,解决临界值访问问题。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-frPzmAdy-1721999915734)(https://i-blog.csdnimg.cn/direct/1ce98a4076d343f69e7ed880eff292bf.png)]
实现多规则限流
先确定最终需要的效果
- 能实现多种限流规则
- 能实现防重复提交
通过以上要求设计注解(先想象出最终实现效果)
java
@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 注解
java
/**
* @Description: 请求接口限制
*/
@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 注解
java
@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
的方法
java
/**
* 通过 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脚本
两种将时间添加到Redis的方法。
UUID
UUID(可用其他有相同的特性的值)为Zset中的value值
- 参数介绍
KEYS[1] = prefix : ? : className : methodName
KEYS[2] = 唯一ID
KEYS[3] = 当前时间
ARGV = [次数,单位时间,次数,单位时间, 次数, 单位时间 ...]
- 由
java
传入分布式不重复的value
值
lua
-- 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
好
- 以下为第二种实现方式,在并发高的情况下效率低,
lua
-- 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 拦截
java
@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;
}
总结
为了实现多规则限流和防止重复提交,我们可以采用Redis作为后端存储,结合Lua脚本来确保原子性和准确性。下面是基于你的需求和提供的示例代码的详细总结:
设计目标
- 多规则限流:实现多种不同时间范围内的访问频率控制。
- 防止重复提交:在一定时间内限制同一请求的重复提交。
方案概述
- 使用
String
结构记录访问次数:适用于简单限流,但容易遇到并发问题。 - 使用
ZSet
结构:解决并发问题,并支持多规则限流。 - 编写Lua脚本:用于高效地处理多规则限流逻辑。
- AOP拦截器:在Spring框架中使用AspectJ进行请求拦截。
限流规则
- 规则定义 :通过自定义注解
@RateLimiter
和@RateRule
来指定限流规则和防重复提交的策略。 - 规则应用:通过AspectJ切面编程在方法执行前进行检查。
Redis键值设计
- 键 :
prefix : ? : className : methodName
- 分值:时间戳
- 成员:唯一标识符(如UUID)或时间戳
Lua脚本实现
-
使用UUID:解决并发问题的同时避免数据冗余。
-
使用时间戳:简化脚本实现,但需要解决同一毫秒内多个请求的问题。
-
多规则限流 :通过使用
ZSet
结构和Lua脚本实现。 -
防重复提交:通过在规则中添加额外的限流规则实现。
-
并发安全:Lua脚本确保了限流逻辑的原子性。
-
性能优化:通过预先计算过期时间,减少不必要的Redis命令调用。
这样,你可以有效地在分布式系统中实现多规则限流和防止重复提交。