1.问题分析
在某些场景下,需要对接口的访问进行限制,以防止恶意攻击或过度频繁的请求对系统造成压力。通过控制 IP 访问次数,可以有效地保护系统的稳定性和安全性。
2.解决方案
第一步在 yaml 文件中添加如下配置:
yaml
spring.redis.host: 172.xx.xx.xx
spring.redis.port: 6379
spring.redis.database: 1
spring.redis.password: 1234
以上配置用于连接到 Redis 服务器。 封装注解
封装一个注解@RequestLimit,使用方式如下:
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestLimit {
// 限制时间 单位:秒(默认值:一分钟)
long period() default 60;
// 允许请求的次数(默认值:20 次)
long count() default 20;
}
该注解用于标记需要限制 IP 访问次数的接口,并可以设置限制时间和允许请求的次数。
主体逻辑切面实现
使用 AOP 的切面来配合注解实现限流逻辑,代码如下:
@Aspect
@Component
public class RequestLimitAspect {
@Autowired
StringRedisTemplate redisTemplate;
private static final Logger logger = LoggerFactory.getLogger(RequestLimitAspect.class);
private static final String blackListKey = "ai:black_list";
// 切点
@Pointcut("@annotation(requestLimit)")
public void controllerAspect(RequestLimit requestLimit) { }
@Around("controllerAspect(requestLimit)")
public Object doAround(ProceedingJoinPoint joinPoint, RequestLimit requestLimit) throws Throwable {
SseEmitter emitter = new SseEmitter(0L);
// 获取当前请求 request 对象
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
throw new IllegalStateException("在上下文中没有请求的属性,可能是非 web 程序访问。");
}
HttpServletRequest request = attributes.getRequest();
long period = requestLimit.period();
long limitCount = requestLimit.count();
// 解析出来真实请求的 ip,防止多重代理 ip 攻击
String ip = RequestUtil.getIpAdrress(request);
String uri = request.getRequestURI();
String key = "ai:req_limit_" + uri + ":" + ip;
// 检查用户 IP 是否在黑名单中
BoundSetOperations<String, String> blackListOperations = redisTemplate.boundSetOps(blackListKey);
if (blackListOperations.isMember(ip)) {
logger.error("接口拦截:真实 IP 为{}, 已经在黑名单中", ip);
// 这里被我 aop 环绕的接口返回值是 sse 类型,所以此处我也需要使用 sse 形式返回。根据你接口返回值来
SseEmitter emitter = new SseEmitter(0L);
emitter.send(SseEmitter.event().name(AppConsts.EVENT_ERROR).data("您的请求过于频繁,请于 5 分钟后再次访问"));
emitter.complete();
return emitter;
}
ZSetOperations zSetOperations = redisTemplate.opsForZSet();
// 添加当前时间戳
long currentMs = System.currentTimeMillis();
// zSetOperations.add(key, currentMs, currentMs);
zSetOperations.add(key, String.valueOf(currentMs), currentMs);
// 设置用户的过期时间
redisTemplate.expire(key, period, TimeUnit.SECONDS);
// 删除当前窗口之外的值(如果时间窗口是 60 秒,那么在 60 秒内的同一 IP 请求会被计数,超过 60 秒的请求就不应该再被计数了,因为它们已经滑出时间窗口了)
Long aLong = zSetOperations.removeRangeByScore(key, 0, currentMs - period * 1000);
// 检查所有可用计数
Long count = zSetOperations.zCard(key);
if (count > limitCount) {
logger.error("接口拦截:{} 请求超过限制频率【{}次/{}s】, 真实 IP 为{}", uri, limitCount, period, ip);
// 将用户 IP 添加到黑名单并设置过期时间为 5 分钟(300 秒)
blackListOperations.add(ip);
redisTemplate.expire(blackListKey, 300, TimeUnit.SECONDS);
SseEmitter emitter = new SseEmitter(0L);
emitter.send(SseEmitter.event().name(AppConsts.EVENT_ERROR).data("系统繁忙,请稍后重试"));
emitter.complete();
return emitter;
}
// 如果条件不成立,将继续执行 controller 层的方法
return joinPoint.proceed();
}
}
在该切面中,通过获取请求的 IP 和 URI 生成键,检查 IP 是否在黑名单中。如果不在黑名单中,则向 Redis 的 ZSet 中添加当前时间戳,并设置过期时间。然后删除当前窗口之外的值,并检查可用计数。如果计数超过限制次数,则将 IP 添加到黑名单中,并返回相应的提示信息;否则,继续执行 controller 层的方法。
添加注解到接口
在想要限流的接口上添加@RequestLimit注解,并设置相应的属性值。
@RequestLimit(count = 30)
public Result knowledgeConverseEvents(QAAppDto dto, FacadeBase FacadeBase) {
return appIntelligentAS.knowledgeConverseStream(dto);
}`
3.优缺点
优点:
- 利用 Redis 的高并发和内存单线程优势,能够快速处理大量的请求,提高系统的性能。
- 通过注解的方式,可以方便地将限流逻辑应用到需要的接口上,具有较好的灵活性和可扩展性。
- 能够有效地防止恶意攻击和过度频繁的请求,保护系统的稳定性和安全性。
缺点:
- 依赖 Redis 服务器,如果 Redis 服务器出现故障,可能会影响限流功能的正常运行。
- 对于分布式系统,需要确保 Redis 服务器的高可用性和数据一致性。