一、引言
在高并发的分布式系统中,如何有效地进行请求限流并防止重复提交,一直是开发者们面临的挑战。尤其是当需要同时针对多个维度(如IP、用户ID、用户名等)进行限流时,现有的一些解决方案往往难以满足灵活性和可维护性的要求。本篇文章将对Redis的多规则限流方案进行重构,探讨如何通过更符合单一职责原则的设计,提升代码的可读性与易用性,并为大家提供一套更加高效、实用的限流解决方案。
二、简介
本文详细讲解了如何利用Redis的Zset结构实现多规则限流,并包含在线演示、源码解析及讲解。针对之前版本在实际项目中的局限性(如防重复提交和限流配置不符合单一职责原则、RateLimiter仅能针对单一前缀key进行限流等问题),本文对代码进行了重构,以更简洁、合理的方式解决这些问题,为开发者提供更优的限流策略。 原文链接 : Redis如何多规则限流和防重复提交?
三、回顾代码
我们之前编写过的注解有 RateLimiter、RateRule , 我们对其进行了lua脚本编写,以及AOP切面编程,已达到以下效果 :
// 通过注解针对 ip 1分钟内访问10次 , 1小时内访问 20 次 , 1天内访问 60 次
@RateLimiter(
rules = {
@RateRule(count = 10, time = 1, timeUnit = TimeUnit.SECONDS),
@RateRule(count = 20, time = 1, timeUnit = TimeUnit.HOURS),
@RateRule(count = 60, time = 1, timeUnit = TimeUnit.DAYS)
},
preventDuplicate = true
)
针对以上点,我们对原有代码进行修改
四、重构限流以及防重复提交
项目最终结构
1. 重构注解
新增 RateLimiters 包含 RateLimiter[] , 这样可以方便针对 ip、用户名、局部方法,多个种类多规则限流。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RateLimiters {
/**
* 多个限流规则
*/
RateLimiter[] rateLimiters();
}
删除原本的 RateLimiter 与防重复提交相关的属性 preventDuplicate 、preventDuplicateRule , 将防重复提交单独剥离出来。
@Target({ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RateLimiter {
/**
* 限流类型 ( 默认全局 )
*/
LimitTypeEnum limitTypeEnum() default LimitTypeEnum.GLOBAL;
/**
* 对应限流规则
*/
RateRule[] rateRules();
/**
* 在限流后,确定是否将请求加入黑名单 ( 用户 和 ip 都将进入黑名单 )
*/
boolean addToBlacklist() default false;
/**
* 如果配置了 RateLimiters 中 message,以 RateLimiters 为准
*/
ResultCode message() default ResultCode.REQUEST_RATE_LIMIT;
}
而 RateRule 不进行过多修改,只修改了对应属性名。
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RateRule {
/**
* 限流次数
*/
long limit() default 10;
/**
* 限流时间
*/
long timeDuration() default 60;
/**
* 限流时间单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
2. 针对重构后的 RateLimiters 进行切面编程
大致讲解一下 :
-
核心逻辑:boBefore 方法
- 权限检查:首先检查当前用户是否是管理员,如果是,则跳过限流逻辑。
- 生成限流 Key:根据限流类型(IP、用户ID、用户名等),生成一个唯一的限流 Key。
- 执行限流脚本:通过 Redis 执行限流脚本,判断请求是否超过限制。
- 限流结果处理:如果限流成功(达到限制次数),则可能将用户或 IP 加入黑名单,并抛出限流异常。
-
类的主要字段
RedisUtil redisUtil
: 处理与 Redis 的常见操作。RedisTemplate<String, Object> redisTemplate
: 用于执行 Redis 的操作,比如执行限流脚本。RedisScript<Boolean> limitScript
: 存储一个限流的 Lua 脚本。Snowflake snowflake
: 用于生成唯一的 ID(通常用来标识某次请求)。
-
辅助方法
generateLimiterKey
: 根据不同的限流模式(IP、用户ID、用户名等)生成限流 Key。getRules
: 获取限流规则,将每个限流规则的限制次数和时间转换为 Redis 可用的参数。addToRedisBlackList
: 将用户或 IP 添加到 Redis 黑名单中,以阻止未来的访问。
@Aspect
@Order(20)
@Component
@Slf4j
@RequiredArgsConstructor
public class RateLimitersAspect {
private final RedisUtil redisUtil;
private final RedisTemplate<String, Object> redisTemplate;
private final RedisScript<Boolean> limitScript;
private final Snowflake snowflake;
@Before(value = "@annotation(rateLimiters)")
public void boBefore(JoinPoint joinPoint, RateLimiters rateLimiters) {
if (SecurityUtil.isAdmin()) {
return;
}
RateLimiter[] limiters = rateLimiters.rateLimiters();
for (RateLimiter limiter : limiters) {
// 1. 生成限流key
String limitKey = generateLimiterKey(joinPoint, limiter);
// 2. 执行脚本返回是否限流成功 (传入key,唯一标识,当前时间 )
Boolean execute = redisTemplate.execute(limitScript, List.of(
limitKey, snowflake.nextIdStr(), String.valueOf(System.currentTimeMillis())
), getRules(limiter));
// 3. 判断是否限流
if (Boolean.TRUE.equals(execute)) {
// 3.1 是否加入黑名单
if (limiter.addToBlacklist()) {
this.addToRedisBlackList();
}
// 3.2 抛出限流错误
throw new ServiceException(ResultCode.REQUEST_RATE_LIMIT);
}
}
}
/**
* 生成限流key
*/
private String generateLimiterKey(JoinPoint joinPoint, RateLimiter limiter) {
StringBuilder key = new StringBuilder(RedisKeyConstants.RATE_LIMIT_CACHE_PREFIX);
switch (limiter.limitTypeEnum()) {
case IP -> {
// 1. IP 模式
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
key.append(IpUtil.getIpAddr(request)).append(":");
}
case USER_ID -> {
// 2. userId 模式
Long userId = SecurityUtil.getUserId();
if (userId != null) {
key.append(userId).append(":");
} else {
log.error("RateLimitersAspect.generateLimiterKey() => 无法获取到Id");
throw new ServiceException(ResultCode.AUTH_TOKEN_INVALID);
}
}
case USER_NAME -> {
// 3. userId 模式
String username = SecurityUtil.getUsername();
if (StringUtils.hasText(username)) {
key.append(username).append(":");
} else {
log.error("RateLimitersAspect.generateLimiterKey() => 无法获取到用户名");
throw new ServiceException(ResultCode.AUTH_TOKEN_INVALID);
}
}
default -> {
// 4. 默认全局模式
}
}
// 拼接 role:类名:方法名
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Class<?> targetClass = method.getDeclaringClass();
key.append(targetClass.getSimpleName()).append(":").append(method.getName());
return key.toString();
}
/**
* 获取规则集合
*/
private Object[] getRules(RateLimiter limiter) {
RateRule[] rateRules = limiter.rateRules();
// 1. 创建返回对象 ( i * 2 === i << 1)
Object[] result = new Object[rateRules.length * 2];
// 2. 遍历规则返回
for (int i = 0; i < rateRules.length; i++) {
result[i * 2] = rateRules[i].limit();
result[i * 2 + 1] = rateRules[i].timeUnit().toMillis(rateRules[i].timeDuration());
}
// 3. 返回结果
return result;
}
/**
* 添加到 redis 黑名单操作
*/
private void addToRedisBlackList() {
// http 请求不会为空
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
String ipAddr = IpUtil.getIpAddr(request);
Long userId = SecurityUtil.getUserId();
// 3.1.1 用户Id 和 ip 不为空则存入缓存
if (userId != null) {
redisUtil.addToCacheSet(RedisKeyConstants.BLACKLIST_USER_ID_CACHE_PREFIX, userId);
}
redisUtil.addToCacheSet(RedisKeyConstants.BLACKLIST_IP_CACHE_PREFIX, ipAddr);
}
}
实际使用
@RateLimiters(rateLimiters = {
@RateLimiter(limitTypeEnum = LimitTypeEnum.USER_ID,
rateRules = {@RateRule(
limit = 60,
timeDuration = 1,
timeUnit = TimeUnit.DAYS)})
})
3. 编写工具类调用多规则限流
逻辑与以上相同 , 改工具类是为了方便在项目中调用,而不是使用注解的方式调用。
@Component
@RequiredArgsConstructor
public class RateLimiterUtil {
private final RedisTemplate<String, Object> redisTemplate;
private final RedisScript<Boolean> limitScript;
private final Snowflake snowflake;
/**
* @param rateLimitRules rateLimitRules 为单个
* @return 是否限流
*/
public boolean rateLimiterSingle(RateLimitRule... rateLimitRules) {
for (RateLimitRule rateLimitRule : rateLimitRules) {
// Lua 脚本中的 keys
List<String> keys = List.of(
rateLimitRule.getRedisKey(),
snowflake.nextIdStr(),
String.valueOf(System.currentTimeMillis())
);
// Lua 脚本中的 args
List<Object> args = List.of(
rateLimitRule.getLimit(),
rateLimitRule.getTimeUnit().toMillis(rateLimitRule.getTimeDuration())
);
// 执行脚本
Boolean isLimited = redisTemplate.execute(limitScript, keys, args);
if (Boolean.TRUE.equals(isLimited)) {
return true;
}
}
return false;
}
/**
* @param rateLimitRules rateLimitRules 是一组
* @return 是否限流
*/
public boolean rateLimiterGroup(String redisKey, RateLimitRule... rateLimitRules) {
// Lua 脚本中的 keys
List<String> keys = List.of(
redisKey,
snowflake.nextIdStr(),
String.valueOf(System.currentTimeMillis())
);
// Lua 脚本中的 args
Object[] args = new Object[rateLimitRules.length * 2];
for (int i = 0; i < rateLimitRules.length; i++) {
args[i * 2] = rateLimitRules[i].getLimit();
args[i * 2 + 1] = rateLimitRules[i].getTimeUnit().toMillis(rateLimitRules[i].getTimeDuration());
}
// 执行脚本
return Boolean.TRUE.equals(redisTemplate.execute(limitScript, keys, args));
}
/**
* 规则类:定义限流规则
*/
@Data
@Builder
public static class RateLimitRule {
/**
* Redis key
*/
private String redisKey;
/**
* 访问限制次数
*/
private Integer limit;
/**
* 限制时间
*/
private long timeDuration;
/**
* 时间单位
*/
private TimeUnit timeUnit;
}
}
4. 编写分离出来的 Redis 防重复提交
4.1 Redis 防重复提交注解
编写 PreventDuplicateSubmit 注解 , PreventDuplicateSubmit 中的属性在注释中有具体描述
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface PreventDuplicateSubmit {
DuplicateTypeEnum type() default DuplicateTypeEnum.IP;
/**
* 是否为全局
* 默认 : ip:classSimpleName:methodName
* 全局 : ip
*/
boolean global() default false;
/**
* 防重提交锁过期时间(秒)
* <p>
* 默认1秒内不允许重复提交
*/
int expire() default 1;
}
4.2 编写Redis防重复提交的切面方法
主要步骤
-
生成锁的 Key:首先,通过
generateResubmitLockKey
方法生成一个唯一的 Redis 锁 Key,这个 Key 用于标识某个请求。 -
获取分布式锁:使用
redissonClient.getLock(prefixKey)
获取一个基于 Redis 的分布式锁对象RLock
。 -
尝试获取锁:
lock.tryLock(0, preventDuplicateSubmit.expire(), TimeUnit.SECONDS)
尝试立即获取锁,如果获取失败,锁的有效期为preventDuplicateSubmit.expire()
秒。- 如果获取锁失败,说明有相同的请求正在执行,进入下一步。
-
处理重复提交:
- 如果提交类型是
DuplicateTypeEnum.ARGS
,抛出REQUEST_OTHER_OPERATION
错误,提示用户该操作正在处理中。 - 否则,抛出
REQUEST_MORE_ERROR
错误,提示用户重复请求。
- 如果提交类型是
-
执行目标方法:如果获取锁成功,使用
joinPoint.proceed()
继续执行目标方法(即实际业务逻辑)。
具体AOP代码
@Aspect
@Order(0)
@Component
@Slf4j
@RequiredArgsConstructor
public class PreventDuplicateSubmitAspect {
private final RedissonClient redissonClient;
@Around(value = "@annotation(preventDuplicateSubmit)")
public Object preventDuplicateSubmit(ProceedingJoinPoint joinPoint, PreventDuplicateSubmit preventDuplicateSubmit) throws Throwable {
String prefixKey = generateResubmitLockKey(joinPoint, preventDuplicateSubmit);
RLock lock = redissonClient.getLock(prefixKey);
if (!lock.tryLock(0, preventDuplicateSubmit.expire(), TimeUnit.SECONDS)) {
if (preventDuplicateSubmit.type().equals(DuplicateTypeEnum.ARGS)) {
throw new ServiceException(ResultCode.REQUEST_OTHER_OPERATION);
} else {
throw new ServiceException(ResultCode.REQUEST_MORE_ERROR);
}
}
return joinPoint.proceed();
}
/**
* 生成防重复提交的键
*
* @param joinPoint 切入点,表示当前拦截的方法
* @param preventDuplicateSubmit 防重复提交注解
* @return 防重复提交的 Redis 键
*/
private String generateResubmitLockKey(ProceedingJoinPoint joinPoint, PreventDuplicateSubmit preventDuplicateSubmit) {
String prefix = "";
Object[] args = joinPoint.getArgs();
// 获取防重复提交的类型
DuplicateTypeEnum type = preventDuplicateSubmit.type();
// 根据类型生成键的前缀
switch (type) {
case USER_ID -> {
// 用户ID模式
Long userId = SecurityUtil.getUserId();
if (userId == null) {
log.error("PreventDuplicateSubmitAspect.generateResubmitLockKey -> USER_ID 未解析到用户Id");
} else {
prefix = userId.toString();
}
}
case USER_NAME -> {
// 用户名模式
String username = SecurityUtil.getUsername();
if (username == null) {
log.error("PreventDuplicateSubmitAspect.generateResubmitLockKey -> USER_NAME 未解析出用户名");
} else {
prefix = username;
}
}
case ARGS ->
// 参数模式 : TODO 后续修改为 Spring 表达式语言(SP EL)
// 注意:使用参数生成键时,应防止生成过大的键
prefix = Arrays.deepToString(args);
default -> {
// 默认模式为IP模式
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
prefix = IpUtil.getIpAddr(request);
}
}
// 如果不是全局限制,进一步添加类名和方法名作为前缀
if (!preventDuplicateSubmit.global()) {
// 获取目标方法所属的类的名称
String className = joinPoint.getTarget().getClass().getSimpleName();
// 获取方法的名称
String methodName = joinPoint.getSignature().getName();
// 组合前缀
prefix = prefix + ":" + className + ":" + methodName;
}
// 返回完整的 Redis 键
return RedisKeyConstants.PREVENT_DUPLICATE_SUBMIT_PREFIX + prefix;
}
}
五、重构完成,让我们在项目中使用一下
当我们快速点击的时候,弹出防重复提交操作。