防刷接口必备!SpringBoot 整合 Redis 实现 IP 限流!
大家好,我是谦!本篇介绍的是 SpringBoot 项目使用 Redis 对用户 IP 进行接口限流。
一、思路
接口限流核心价值在于提升系统稳定性,防御恶意高频访问(短时海量请求冲击)。
例如:某接口需限制 1 分钟内请求不超过 1000 次,应如何设计实现方案?
下面讲两种思路,如果想看代码可直接翻到后面的代码部分。
1.1 固定时间段(旧思路)
1.1.1 思路描述
该方案的思路是:使用Redis记录固定时间段内某用户IP访问某接口的次数,其中:
- Redis的key:用户IP + 接口方法名
- Redis的value:当前接口访问次数
当用户首次访问接口时,在 Redis 中创建包含用户 IP 与接口标识的组合键,初始值设为 1(标识首次访问),并同步设置 60 秒过期时间。
之后,只要这个key还未过期,用户每次访问该接口都会导致value自增1次。
用户每次访问接口前,先从Redis中拿到当前接口访问次数,如果发现访问次数大于规定的次数(如超过1000次),则向用户返回接口访问失败的标识。
1.1.2 思路缺陷
该方案的缺点在于,限流时间段是固定的。
比如要求某接口在1分钟内请求次数不超过1000次,观察以下流程:
1.2 滑动窗口(新思路)
1.2.1 思路描述
为规避方案1键过期引发的瞬时流量突增风险,策略调整为动态时段限流:设定接口10秒内仅允许5次访问。用户每次访问时记录时间戳,计算前10秒内该接口总访问次数。若超限则拒绝访问,确保任意时刻访问量≤1000次。示例如图:用户0:19访问时,前10秒内累计5次,故允许本次请求。
假设用户0:20时间点访问接口,经检查其前10秒内访问次数为6次(超出限流次数5次),则不允许本次访问。
1.2.2 Redis部分的实现
1)选用何种 Redis 数据结构
首先是需要确定使用哪个Redis数据结构。用户每次访问时,需要用一个key记录用户访问的时间点,而且还需要利用这些时间点进行范围检查。
2)为何选择 zSet 数据结构
为了能够实现范围检查,可以考虑使用Redis中的zSet有序集合。
添加一个zSet元素的命令如下:
ZADD [key] [score] [member]
它有一个关键的属性score,通过它可以记录当前member的优先级。
因此将 score 设为用户访问时间戳,便于基于 score 进行范围检索。key 需记录用户 IP 与接口标识,而 member 值可自由定义(通常存储访问时间点),故亦可设为时间戳。
3)zSet 如何进行范围检查(检查前几秒的访问次数)
思路是,把特定时间间隔之前的member都删掉,留下的member就是时间间隔之内的总访问次数。然后统计当前key中的member有多少个即可。
① 把特定时间间隔之前的member都删掉。
zSet有如下命令,用于删除score范围在[min~max]之间的member:
Zremrangebyscore [key] [min] [max]
假设限流时间设置为5秒,当前用户访问接口时,获取当前系统时间戳为currentTimeMill,那么删除的score范围可以设置为:
min = 0
max = currentTimeMill - 5 * 1000
相当于把5秒之前的所有member都删除了,只留下前5秒内的key。
② 统计特定key中已存在的member有多少个。
zSet有如下命令,用于统计某个key的member总数:
ZCARD [key]
统计的key的member总数,就是当前接口已经访问的次数。如果该数目大于限流次数,则说明当前的访问应被限流。
二、代码实现
主要是使用注解 + AOP的形式实现。
2.1 固定时间段思路
使用了lua脚本
2.1.1 限流注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimiter {
/**
* 限流时间,单位秒
*/
int time() default 5;
/**
* 限流次数
*/
int count() default 10;
}
2.1.2 定义lua脚本
在resources/lua下新建limit.lua:
-- 获取redis键
local key = KEYS[1]
-- 获取第一个参数(次数)
local count = tonumber(ARGV[1])
-- 获取第二个参数(时间)
local time = tonumber(ARGV[2])
-- 获取当前流量
local current = redis.call('get', key);
-- 如果current值存在,且值大于规定的次数,则拒绝放行(直接返回当前流量)
if current and tonumber(current) > count then
return tonumber(current)
end
-- 如果值小于规定次数,或值不存在,则允许放行,当前流量数+1 (值不存在情况下,可以自增变为1)
current = redis.call('incr', key);
-- 如果是第一次进来,那么开始设置键的过期时间。
if tonumber(current) == 1 then
redis.call('expire', key, time);
end
-- 返回当前流量
return tonumber(current)
2.1.3 注入Lua执行脚本
关键代码是limitScript()方法
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
// 使用Jackson2JsonRedisSerialize 替换默认序列化(默认采用的是JDK序列化)
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
redisTemplate.setKeySerializer(jackson2JsonRedisSerializer);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashKeySerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
return redisTemplate;
}
/**
* 解析lua脚本的bean
*/
@Bean("limitScript")
public DefaultRedisScript<Long> limitScript() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/limit.lua")));
redisScript.setResultType(Long.class);
return redisScript;
}
}
2.1.3 定义Aop切面类
@Slf4j
@Aspect
@Component
public class RateLimiterAspect {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private RedisScript<Long> 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.type(), point);
List<String> keys = Collections.singletonList(combineKey);
try {
Long number = (Long) redisTemplate.execute(limitScript, keys, count, time);
// 当前流量number已超过限制,则抛出异常
if (number == null || number.intValue() > count) {
throw new RuntimeException("访问过于频繁,请稍后再试");
}
log.info("[limit] 限制请求数'{}',当前请求数'{}',缓存key'{}'", count, number.intValue(), combineKey);
} catch (Exception ex) {
ex.printStackTrace();
throw new RuntimeException("服务器限流异常,请稍候再试");
}
}
/**
* 把用户IP和接口方法名拼接成 redis 的 key
* @param point 切入点
* @return 组合key
*/
private String getCombineKey(JoinPoint point) {
StringBuilder sb = new StringBuilder("rate_limit:");
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
sb.append( Utils.getIpAddress(request) );
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
Class<?> targetClass = method.getDeclaringClass();
// keyPrefix + "-" + class + "-" + method
return sb.append("-").append( targetClass.getName() )
.append("-").append(method.getName()).toString();
}
}
2.2 滑动窗口思路
2.2.1 限流注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimiter {
/**
* 限流时间,单位秒
*/
int time() default 5;
/**
* 限流次数
*/
int count() default 10;
}
2.2.2 定义Aop切面类
@Slf4j
@Aspect
@Component
public class RateLimiterAspect {
@Autowired
private RedisTemplate redisTemplate;
/**
* 实现限流(新思路)
* @param point
* @param rateLimiter
* @throws Throwable
*/
@SuppressWarnings("unchecked")
@Before("@annotation(rateLimiter)")
public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
// 在 {time} 秒内仅允许访问 {count} 次。
int time = rateLimiter.time();
int count = rateLimiter.count();
// 根据用户IP(可选)和接口方法,构造key
String combineKey = getCombineKey(rateLimiter.type(), point);
// 限流逻辑实现
ZSetOperations zSetOperations = redisTemplate.opsForZSet();
// 记录本次访问的时间结点
long currentMs = System.currentTimeMillis();
zSetOperations.add(combineKey, currentMs, currentMs);
// 这一步是为了防止member一直存在于内存中
redisTemplate.expire(combineKey, time, TimeUnit.SECONDS);
// 移除{time}秒之前的访问记录(滑动窗口思想)
zSetOperations.removeRangeByScore(combineKey, 0, currentMs - time * 1000);
// 获得当前窗口内的访问记录数
Long currCount = zSetOperations.zCard(combineKey);
// 限流判断
if (currCount > count) {
log.error("[limit] 限制请求数'{}',当前请求数'{}',缓存key'{}'", count, currCount, combineKey);
throw new RuntimeException("访问过于频繁,请稍后再试!");
}
}
/**
* 把用户IP和接口方法名拼接成 redis 的 key
* @param point 切入点
* @return 组合key
*/
private String getCombineKey(JoinPoint point) {
StringBuilder sb = new StringBuilder("rate_limit:");
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
sb.append( Utils.getIpAddress(request) );
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
Class<?> targetClass = method.getDeclaringClass();
// keyPrefix + "-" + class + "-" + method
return sb.append("-").append( targetClass.getName() )
.append("-").append(method.getName()).toString();
}
}
本篇分享到此就结束啦!大家下篇见!
点赞关注不迷路,分享了解小技术!走起!