目录
了解Redis
Redis(Remote Dictionary Server)是一个开源的高性能键值对存储数据库。它支持多种数据结构,包括字符串(String)、哈希(Hash)、列表(List)、集合(Set)、有序集合(Sorted Set)等。Redis的特点包括:
-
内存存储:Redis将数据存储在内存中,因此读写速度非常快,适用于对性能有较高要求的场景。
-
持久化:Redis支持持久化将内存中的数据保存到硬盘上,以便在服务器重启后能够恢复数据。
-
数据结构多样:Redis不仅仅支持简单的键值对存储,还支持丰富的数据结构,例如列表、集合、有序集合等,使其具备更多的功能和用途。
-
高并发:Redis是单线程模型,通过使用异步I/O和非阻塞I/O来支持高并发。
-
多语言支持:Redis支持多种编程语言的客户端,如Java、Python、C#等,便于开发人员在不同平台上使用。
-
发布/订阅:Redis支持发布/订阅模式,允许客户端订阅一个或多个频道并接收对应频道的消息。
-
事务支持:Redis支持事务,可以在一个事务中执行多个命令,并保证这些命令的原子性。
由于Redis具有高性能、灵活的数据结构和丰富的功能,它被广泛用于缓存、消息队列、计数器、实时排行榜、会话管理等多种应用场景。
需求&为什么需要接口限流
需求:针对相同IP,60s的接口请求次数不能超过10000次
接口限流是为了保护系统和服务,防止因为过多的请求而导致系统过载、性能下降甚至崩溃。以下是进行接口限流的几个主要原因:
-
防止恶意攻击:接口限流可以防止恶意用户或者攻击者通过大量的请求来攻击系统,保护系统的稳定性和安全性。
-
保护系统资源:对于一些计算密集型或者资源消耗较大的接口,限制请求的频率可以避免服务器资源被过度消耗,保障其他正常请求的处理。
-
避免雪崩效应:当某个服务不可用或者响应时间过长时,如果没有限流措施,大量请求可能会涌入后端,导致更多的请求失败,产生雪崩效应。
-
提升系统性能:限流可以控制并发请求数,避免过多的请求导致服务器负载过高,从而提升系统的整体性能和响应速度。
-
提供公平资源分配:通过限流,可以实现对不同用户或者不同服务请求的公平分配,避免某些请求占用过多资源而影响其他请求。
综上所述,进行接口限流是保护系统和提升性能的重要手段,对于高并发的系统尤为重要。通过合理设置限流策略,可以有效地平衡资源利用和系统稳定性,提供更好的用户体验。
实现方案
方案一:固定时间段
思路:
当用户在第一次访问该接口时,向Redis中设置一个包含了用户IP和接口方法名的key,value的值初始化为1(表示第一次访问当前接口),同时设置该key的过期时间(60秒),只要此Redis的key没有过期,每次访问都将value的值自增1次,用户每次访问接口前,先从Redis中拿到当前接口访问次数,如果发现访问次数大于规定的次数(超过10000次),则向用户返回接口访问失败的标识。
实现:
(一)拦截器
1、添加Redis依赖:首先在pom.xml
文件中添加Spring Data Redis依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2、 配置Redis连接信息:在application.properties
或application.yml
中配置Redis的连接信息,包括主机、端口、密码等。
3、创建限流拦截器:在项目中创建一个限流拦截器,用于对用户IP进行接口限流。拦截器可以实现HandlerInterceptor
接口,并重写preHandle
方法进行限流逻辑。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.concurrent.TimeUnit;
public class RateLimitInterceptor implements HandlerInterceptor {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String ipAddress = getIpAddress(request);
String uri = request.getRequestURI().replace("/","_");
String key = "apiVisits:" + uri + ":" + ipAddress;
// 判断是否已经达到限流次数
String value = redisTemplate.opsForValue().get(key);
// key 不存在,则是第一次请求设置过期时间
if(StringUtils.isBlank(value)){
redisTemplate.opsForValue().increment(key, 1);
redisTemplate.expire(key, time, TimeUnit.SECONDS);
return true;
}
if (value != null && Integer.parseInt(value) > 10) {
response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS);
return false;
}
// 未达到限流次数,自增
redisTemplate.opsForValue().increment(key, 1);
return true;
}
private String getIpAddress(HttpServletRequest request) {
// 从请求头或代理头中获取真实IP地址
String ipAddress = request.getHeader("X-Forwarded-For");
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("WL-Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getRemoteAddr();
}
return ipAddress;
}
}
4、注册拦截器:在配置类中注册自定义的限流拦截器。
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private RateLimitInterceptor rateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor).addPathPatterns("/**");
}
}
(二)AOP
以注解+切面的方式实现,将需要进行限流的API加上注解即可
1、创建注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentLimiting {
/**
* 缓存key
*/
String key() default "apiVisits:";
/**
* 限流时间,单位秒
*/
int time() default 5;
/**
* 限流次数
*/
int count() default 10;
}
2、创建AOP切面
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class CurrentLimitingAspect {
private final RedisTemplate redisTemplate;
/**
* 带有注解的方法之前执行
*/
@SuppressWarnings("unchecked")
@Before("@annotation(currentLimiting)")
public void doBefore(JoinPoint point, CurrentLimiting currentLimiting) throws Throwable {
int time = currentLimiting.time();
int count = currentLimiting.count();
// 将接口方法和用户IP构建Redis的key
String key = getCurrentLimitingKey(currentLimiting.key(), point);
// 判断是否已经达到限流次数
String value = redisTemplate.opsForValue().get(key);
if (value != null && Integer.parseInt(value) > count) {
log.error("接口限流,key:{},count:{},currentCount:{}", key, count, value);
throw new RuntimeException("访问过于频繁,请稍后再试!");
}
// 未达到限流次数,自增
redisTemplate.opsForValue().increment(key, 1);
// key 不存在,则是第一次请求设置过期时间
if(StringUtils.isBlank(value)){
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
}
/**
* 组装 redis 的 key
*/
private String getCurrentLimitingKey(String prefixKey,JoinPoint point) {
StringBuilder sb = new StringBuilder(prefixKey);
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();
return sb.append("_").append( targetClass.getName() )
.append("_").append(method.getName()).toString();
}
}
缺陷:
当在10:00访问接口,这个时候向Reids写入一条数据访问次数为1,在10:59的时候突然访问了9999次,然后redis过期,在11:00访问了9999次,这样出现的问题就是在10:59到11:00之间访问了9999+9999次。故以固定时间段的方式进行限流可能会不起作用,会存在Reids过期的临界点内造成大量的用户访问。
方案二:滑动窗口
思路:
由于方案一的时间是固定的,我们可以把固定的时间段改成动态的,也就是在用户每次访问接口时,记录当前用户访问的时间点(时间戳),并计算前一分钟内用户访问该接口的总次数。如果总次数大于限流次数,则不允许用户访问该接口。这样就能保证在任意时刻用户的访问次数不会超过10000次。
实现:
1、创建注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentLimiting {
/**
* 缓存key
*/
String key() default "apiVisits:";
/**
* 限流时间,单位秒
*/
int time() default 5;
/**
* 限流次数
*/
int count() default 10;
}
2、创建AOP切面
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class CurrentLimitingAspect {
private final RedisTemplate redisTemplate;
/**
* 带有注解的方法之前执行
*/
@SuppressWarnings("unchecked")
@Before("@annotation(currentLimiting)")
public void doBefore(JoinPoint point, CurrentLimiting currentLimiting) throws Throwable {
int time = currentLimiting.time();
int count = currentLimiting.count();
// 将接口方法和用户IP构建Redis的key
String key = getCurrentLimitingKey(currentLimiting.key(), point);
// 使用Zset的 score 设置成用户访问接口的时间戳
ZSetOperations zSetOperations = redisTemplate.opsForZSet();
// 当前时间戳
long currentTime = System.currentTimeMillis();
zSetOperations.add(key, currentTime, currentTime);
// 设置过期时间防止key不消失
redisTemplate.expire(key, time, TimeUnit.SECONDS);
// 移除 time 秒之前的访问记录,动态时间段
zSetOperations.removeRangeByScore(key, 0, currentTime - time * 1000);
// 获得当前时间窗口内的访问记录数
Long currentCount = zSetOperations.zCard(key);
// 限流判断
if (currentCount > count) {
log.error("接口限流,key:{},count:{},currentCount:{}", key, count, currentCount);
throw new RuntimeException("访问过于频繁,请稍后再试!");
}
}
/**
* 组装 redis 的 key
*/
private String getCurrentLimitingKey(String prefixKey,JoinPoint point) {
StringBuilder sb = new StringBuilder(prefixKey);
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();
return sb.append("_").append( targetClass.getName() )
.append("_").append(method.getName()).toString();
}
}