一,概述
日常开发中会有一个常见的需求,需要限制接口在单位时间内的访问次数,比如说某个免费的接口限制单个IP一分钟内只能访问5次。该怎么实现呢,通常大家都会想到用redis,确实通过redis可以实现这个功能,下面实现一下。
二,常见错误
固定时间窗口
有人设计了一个在每分钟内只允许访问1000次的限流方案,如下图01:00s-02:00s之间只允许访问1000次。这种设计的问题在于,请求可能在01:59s-02:00s之间被请求1000次,02:00s-02:01s之间被请求了1000次,这种情况下01:59s-02:01s间隔0.02s之间被请求2000次,很显然这种设计是错误的。
三, 实现
1,基于滑动时间窗口
在指定的时间窗口内次数是累积的,超过阈值,都会限制。
2,流程如下
3,代码实现
前提:pom文件引入redis,Spring AOP等
(1)添加注解RequestLimit
package com.xxx.demo.aspect;
import java.lang.annotation.*;
/**
* 接口访问频率注解,默认一分钟只能访问10次
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestLimit {
// 限制时间 单位:秒(默认值:一分钟)
long period() default 60;
// 允许请求的次数(默认值:10次)
long count() default 10;
}
(2)添加切面实现注解的限制访问逻辑
package com.xxx.demo.aspect;
import com.xgd.demo.commons.ErrorCode;
import com.xgd.demo.handler.BusinessException;
import com.xgd.demo.util.IpUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.log4j.Log4j2;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.concurrent.TimeUnit;
/**
* @date 2024/11/8 上午8:43
*/
@Aspect
@Component
@Log4j2
public class RequestLimitAspect {
@Autowired
RedisTemplate redisTemplate;
@Pointcut("@annotation(requestLimit)")
public void controllerAspect(RequestLimit requestLimit) {}
@Around("controllerAspect(requestLimit)")
public Object doAround(ProceedingJoinPoint joinPoint, RequestLimit requestLimit) throws Throwable {
// 从注解中获取限制次数和窗口时间
long period = requestLimit.period();
long limitCount = requestLimit.count();
// 请求
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
assert attributes != null;
HttpServletRequest request = attributes.getRequest();
String ip = IpUtil.getIpFromRequest(request);
String uri = request.getRequestURI();
//设置客户端访问的key
String key = "req_limit_".concat(uri).concat(ip);
ZSetOperations zSetOperations = redisTemplate.opsForZSet();
// 添加当前时间戳,分数为当前时间戳
long currentMs = System.currentTimeMillis();
zSetOperations.add(key, currentMs, currentMs);
// 设置窗口时间作为过期时间
redisTemplate.expire(key, period, TimeUnit.SECONDS);
// 移除掉不在窗口里的数据
zSetOperations.removeRangeByScore(key, 0, currentMs - period * 1000);
// 查询窗口内已经访问过的次数
Long count = zSetOperations.zCard(key);
if (count > limitCount) {
log.error("接口拦截:{} 请求超过限制频率【{}次/{}s】,IP为{}", uri, limitCount, period, ip);
throw new BusinessException(ErrorCode.REQUEST_LIMITED.getCode(), ErrorCode.REQUEST_LIMITED.getMessage());
}
// 继续执行请求
return joinPoint.proceed();
}
}
上面里面请求被拦截,是抛出了一个自定义的业务异常,大家可以根据自己的情况自己定义。
(3)同时附上上面中引用到自定义工具类
package com.xxx.demo.util;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Objects;
/**
* @date 2024/11/8 上午9:06
*/
public class IpUtil {
private static final String X_FORWARDED_FOR_HEADER = "X-Forwarded-For";
private static final String X_REAL_IP_HEADER = "X-Real-IP";
/**
* 从请求中获取IP
*
* @return IP;当获取不到时,返回null
*/
public static String getIpFromRequest(HttpServletRequest request ) {
return getRealIp(request);
}
/**
* 获取请求的真实IP,优先级从高到低为:<br/>
* 1.从请求头X-Forwarded-For中获取ip,并且只获取第一个ip(从左到右) <br/>
* 2.从请求头X-Real-IP中获取ip <br/>
* 3.使用{@link HttpServletRequest#getRemoteAddr()}方法获取ip
*
* @param request 请求对象,必须不能为null
* @return ip
*/
private static String getRealIp(HttpServletRequest request) {
Objects.requireNonNull(request, "request must be not null");
String ip = request.getHeader(X_FORWARDED_FOR_HEADER);
if (ip != null && !ip.isBlank()) {
int delimiterIndex = ip.indexOf(',');
if (delimiterIndex != -1) {
// 如果存在多个ip,则取第一个ip
ip = ip.substring(0, delimiterIndex);
}
return ip;
}
ip = request.getHeader(X_REAL_IP_HEADER);
if (ip != null && !ip.isBlank()) {
return ip;
} else {
return request.getRemoteAddr();
}
}
}
(4)使用注解
这里限制为10秒内只允许访问3次,超过就抛出异常
(5)访问测试
前3次访问,接口正常访问
后面的访问,返回自定义异常的结果
如果对你有帮助,记得点赞关注哟!