京东自营 618 + 国补 iPhone 历史最低价          领 618 红包,最高25618元

Spring Boot 接口防刷安全策略详解

在应对接口被刷的问题时,业界已经形成了多种行之有效的策略,每种策略都有其独特的原理和适用场景。下面将详细介绍几种常见的接口防刷策略。

基于时间窗口的请求计数

基于时间窗口的请求计数是一种较为直观和简单的接口防刷策略。其原理是在一个固定的时间窗口内,对某个接口的请求次数进行统计 。例如,设定一个时间窗口为 1 分钟,规定在这 1 分钟内,某个接口最多允许被访问 100 次。当有请求到达时,系统会检查当前时间窗口内该接口的请求次数是否已经达到设定的限制。如果未达到限制,则允许请求通过并将请求次数加 1;如果已经达到或超过限制,后续的请求将被拒绝,并返回相应的错误提示,比如 “请求过于频繁,请稍后再试”。这种策略实现起来相对简单,通过一个计数器和一个时间标记就可以完成基本的逻辑。它的优点是易于理解和实现,能够在一定程度上防止接口被简单的暴力刷。不过,它也存在一些局限性,例如在时间窗口的边界处可能会出现突发流量的问题,因为它只关注固定时间窗口内的总请求数,无法平滑地处理流量变化。

令牌桶算法

令牌桶算法是一种广泛应用的流量控制算法,在接口防刷中也发挥着重要作用。其核心机制是系统以固定的速率向一个令牌桶中生成令牌 ,每个令牌代表一次请求的许可。当有请求到达接口时,首先需要从令牌桶中获取一个令牌,如果令牌桶中有足够的令牌,请求可以正常执行,同时令牌桶中的令牌数量减 1;如果令牌桶为空,即没有可用的令牌,请求将被拒绝或者等待,直到有新的令牌生成。例如,假设令牌桶的容量为 100 个令牌,系统每秒向令牌桶中生成 10 个令牌。如果一个请求在某一时刻到达,此时令牌桶中有 50 个令牌,那么该请求可以获取一个令牌并继续执行,令牌桶中剩余 49 个令牌。令牌桶算法的优势在于它能够较好地处理突发流量,因为令牌桶可以预先存储一定数量的令牌,当突发请求到来时,只要令牌桶中有足够的令牌,就可以允许这些请求快速通过,而不会像基于时间窗口的请求计数那样严格限制在固定时间内的请求次数。这使得它在应对一些具有突发特性的业务场景,如电商的秒杀活动、新闻网站的热点新闻发布时的高并发请求,表现得更加灵活和有效。

漏桶算法

漏桶算法也是一种经典的流量整形和速率限制算法,常用于接口防刷场景。它的原理可以形象地类比为一个底部有小孔的水桶 。请求就像水一样流入漏桶,而漏桶以固定的速率将水(请求)从底部的小孔流出,即系统以固定的速率处理请求。如果流入漏桶的水(请求)速度过快,超过了漏桶的流出速率,那么漏桶就会被填满,后续流入的水(请求)就会溢出,也就是被丢弃。例如,设定漏桶的容量为 50 个请求,流出速率为每秒 5 个请求。当请求以每秒 8 个请求的速度流入时,漏桶会逐渐被填满,多余的请求将被丢弃,以保证系统处理请求的速率始终维持在每秒 5 个左右。漏桶算法的主要优点是能够非常平滑地处理流量,确保系统以稳定的速率处理请求,不会因为突发流量而导致系统负载过高。但它的缺点是对突发流量的处理能力相对较弱,因为即使在短时间内有大量请求到达,它也只能按照固定的速率处理,可能会导致部分请求被丢弃,在一些对实时性要求较高的场景下,可能不太适用。

Spring Boot 接口防刷实现步骤

环境准备

首先,搭建一个 Spring Boot 项目。在pom.xml文件中添加必要的依赖,这里我们需要添加 Spring Boot 对 Redis 支持的依赖以及相关的 Web 依赖,以便后续实现接口和利用 Redis 进行请求计数。假设我们使用的是 Maven 构建项目,添加如下依赖:

<dependencies>

   <!-- Spring Boot Starter Data Redis -->

   <dependency>

       <groupId>org.springframework.boot</groupId>

       <artifactId>spring-boot-starter-data-redis</artifactId>

   </dependency>

   <!-- Spring Boot Starter Web -->

   <dependency>

       <groupId>org.springframework.boot</groupId>

       <artifactId>spring-boot-starter-web</artifactId>

   </dependency>

</dependencies>

添加完依赖后,在application.yml文件中配置 Redis 服务器的相关信息,例如:

spring:

 redis:

   host: 127.0.0.1

   port: 6379

   password:

这样就完成了 Spring Boot 项目与 Redis 的集成准备,后续可以通过 Redis 来存储和管理接口请求的相关数据。

自定义注解

接下来,编写一个自定义注解,用于标识哪些接口需要进行防刷处理。创建一个名为AccessLimit的注解类,代码如下:

import java.lang.annotation.ElementType;

import java.lang.annotation.Retention;

import java.lang.annotation.RetentionPolicy;

import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)

@Target(ElementType.METHOD)

public @interface AccessLimit {

   // 限制的时间窗口,单位为秒

   long seconds();

   // 在时间窗口内允许的最大请求次数

   long maxCount();

   // 是否需要登录,默认为true,即需要登录才能访问

   boolean needLogin() default true;

}

在这个注解中,seconds参数表示限制的时间窗口,例如设置为 60,则表示 60 秒内;maxCount参数表示在这个时间窗口内允许的最大请求次数;needLogin参数用于判断该接口是否需要用户登录后才能访问 ,如果设置为false,则未登录用户也可以访问,但会受到请求次数限制。通过在接口方法上添加这个注解,并设置相应的参数,就可以对该接口进行个性化的防刷配置。

实现拦截器

实现一个拦截器,用于在请求到达接口方法之前进行拦截,并根据自定义注解的配置和 Redis 中的请求计数来判断是否放行请求。创建一个名为AccessLimitInterceptor的类,实现HandlerInterceptor接口,代码如下:

import com.alibaba.fastjson.JSON;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.data.redis.core.RedisTemplate;

import org.springframework.stereotype.Component;

import org.springframework.web.method.HandlerMethod;

import org.springframework.web.servlet.HandlerInterceptor;

import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;

import javax.servlet.http.HttpServletResponse;

import java.io.OutputStream;

import java.util.concurrent.TimeUnit;

@Component

public class AccessLimitInterceptor implements HandlerInterceptor {

   @Autowired

   private RedisTemplate<String, Object> redisTemplate;

   @Override

   public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

       // 判断请求是否是方法请求

       if (!(handler instanceof HandlerMethod)) {

           return true;

       }

       HandlerMethod hm = (HandlerMethod) handler;

       // 获取方法上的AccessLimit注解

       AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);

       if (accessLimit == null) {

           return true;

       }

       long seconds = accessLimit.seconds();

       long maxCount = accessLimit.maxCount();

       boolean needLogin = accessLimit.needLogin();

       String key = request.getRequestURI();

       // 如果需要登录,这里可以获取用户标识并拼接到key中,例如获取用户ID

       if (needLogin) {

           // 假设这里通过session获取用户ID,实际应用中需要根据具体的认证方式获取

           Object userId = request.getSession().getAttribute("userId");

           if (userId != null) {

               key += ":" + userId;

           } else {

               // 如果未登录,返回提示信息

               render(response, "请先登录");

               return false;

           }

       }

       // 从Redis中获取当前接口的请求次数

       Object count = redisTemplate.opsForValue().get(key);

       if (count == null) {

           // 首次访问,设置请求次数为1,并设置过期时间为seconds秒

           redisTemplate.opsForValue().set(key, 1, seconds, TimeUnit.SECONDS);

       } else {

           long currentCount = (long) count;

           if (currentCount >= maxCount) {

               // 超出访问次数,返回提示信息

               render(response, "请求过于频繁,请稍后再试");

               return false;

           } else {

               // 未超出访问次数,请求次数加1

               redisTemplate.opsForValue().increment(key);

           }

       }

       return true;

   }

   @Override

   public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

   }

   @Override

   public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

   }

   private void render(HttpServletResponse response, String msg) throws Exception {

       response.setContentType("application/json;charset=UTF-8");

       OutputStream out = response.getOutputStream();

       String str = JSON.toJSONString("{\\"code\\": -1, \\"msg\\": \\"" + msg + "\\"}");

       out.write(str.getBytes("UTF-8"));

       out.flush();

       out.close();

   }

}

在这个拦截器中,preHandle方法是核心逻辑所在。首先判断请求是否是方法请求,然后获取方法上的AccessLimit注解。如果注解存在,根据注解的参数设置时间窗口、最大请求次数和是否需要登录等信息。接着根据请求的 URI 和用户信息(如果需要登录)生成一个唯一的 key,用于在 Redis 中存储和获取请求次数。如果是首次访问,在 Redis 中设置请求次数为 1 并设置过期时间;如果不是首次访问,获取当前请求次数并判断是否超过最大限制,若超过则返回提示信息,否则请求次数加 1。postHandleafterCompletion方法目前暂时为空,可根据实际需求添加后续处理逻辑,例如记录日志等。render方法用于向客户端返回错误提示信息。

注册拦截器

为了使拦截器生效,需要在 Spring 的配置类中进行注册。创建一个配置类,例如WebConfig,实现WebMvcConfigurer接口,并重写addInterceptors方法,代码如下:

import org.springframework.beans.factory.annotation.Autowired;

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 WebConfig implements WebMvcConfigurer {

   @Autowired

   private AccessLimitInterceptor accessLimitInterceptor;

   @Override

   public void addInterceptors(InterceptorRegistry registry) {

       registry.addInterceptor(accessLimitInterceptor)

              .addPathPatterns("/\*\*") // 拦截所有路径

              .excludePathPatterns("/login", "/register"); // 排除登录和注册接口,可根据实际情况添加更多排除路径

   }

}

在这个配置类中,通过addInterceptor方法将AccessLimitInterceptor拦截器添加到 Spring 的拦截器链中。addPathPatterns("/**")表示拦截所有的请求路径,即对所有接口进行防刷检查;excludePathPatterns("/login", "/register")表示排除登录和注册接口,因为这两个接口通常需要允许用户频繁访问,不进行防刷限制,可根据实际业务需求添加更多需要排除的路径。

在 Controller 中使用注解

最后,在需要防刷的 Controller 接口方法上添加自定义的AccessLimit注解,并设置相应的参数。例如,有一个用户信息查询接口,代码如下:

import org.springframework.web.bind.annotation.GetMapping;

import org.springframework.web.bind.annotation.RequestMapping;

import org.springframework.web.bind.annotation.RestController;

@RestController

@RequestMapping("/user")

public class UserController {

   @GetMapping("/info")

   @AccessLimit(seconds = 60, maxCount = 10, needLogin = true)

   public String getUserInfo() {

       // 这里是获取用户信息的业务逻辑

       return "用户信息";

   }

}

在这个例子中,@AccessLimit(seconds = 60, maxCount = 10, needLogin = true)表示该接口在 60 秒内最多允许同一个用户(通过用户 ID 区分)访问 10 次,并且需要用户登录后才能访问。如果用户在 60 秒内访问该接口超过 10 次,将返回 “请求过于频繁,请稍后再试” 的提示信息;如果未登录访问该接口,将返回 “请先登录” 的提示信息。通过这种方式,就可以很方便地对不同的接口进行个性化的防刷配置 。

示例代码解析

注解类代码

import java.lang.annotation.ElementType;

import java.lang.annotation.Retention;

import java.lang.annotation.RetentionPolicy;

import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)

@Target(ElementType.METHOD)

public @interface AccessLimit {

   // 限制的时间窗口,单位为秒

   long seconds();

   // 在时间窗口内允许的最大请求次数

   long maxCount();

   // 是否需要登录,默认为true,即需要登录才能访问

   boolean needLogin() default true;

}

@Retention(RetentionPolicy.RUNTIME):这表明该注解在运行时仍然有效,可以通过反射获取。这是非常关键的,因为我们在拦截器中需要通过反射来读取方法上的注解信息,从而判断该方法是否需要进行接口防刷处理以及获取相应的配置参数。如果没有这个设置,在运行时就无法获取到注解,整个接口防刷机制也就无法正常工作。

@Target(ElementType.METHOD):表示这个注解只能作用在方法上。这符合我们的需求,因为我们是对具体的接口方法进行防刷限制,而不是对类或者其他元素进行限制。

long seconds():定义了一个方法来获取限制的时间窗口,单位是秒。在实际使用中,开发人员可以根据接口的业务需求,在注解中设置不同的时间窗口值。例如,对于一些频繁调用但对性能要求不高的接口,可以设置较短的时间窗口,如 10 秒;对于一些关键且不希望被频繁访问的接口,可以设置较长的时间窗口,如 60 秒甚至更长。

long maxCount():用于获取在指定时间窗口内允许的最大请求次数。这个值同样需要根据接口的实际情况进行设置。比如,对于一些公开的查询接口,可能允许在 1 分钟内访问 50 次;而对于一些涉及到敏感操作的接口,如支付接口,可能只允许在 1 分钟内访问 10 次。

boolean needLogin() default true:判断该接口是否需要用户登录后才能访问,默认值为true。这在实际业务中非常实用,对于一些需要用户身份验证的接口,只有登录用户的请求才会被纳入防刷统计和限制范围;而对于一些公开的接口,可以将其设置为false,但仍然会对请求次数进行限制,以防止恶意刷接口行为。

拦截器代码

import com.alibaba.fastjson.JSON;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.data.redis.core.RedisTemplate;

import org.springframework.stereotype.Component;

import org.springframework.web.method.HandlerMethod;

import org.springframework.web.servlet.HandlerInterceptor;

import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;

import javax.servlet.http.HttpServletResponse;

import java.io.OutputStream;

import java.util.concurrent.TimeUnit;

@Component

public class AccessLimitInterceptor implements HandlerInterceptor {

   @Autowired

   private RedisTemplate<String, Object> redisTemplate;

   @Override

   public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

       // 判断请求是否是方法请求

       if (!(handler instanceof HandlerMethod)) {

           return true;

       }

       HandlerMethod hm = (HandlerMethod) handler;

       // 获取方法上的AccessLimit注解

       AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);

       if (accessLimit == null) {

           return true;

       }

       long seconds = accessLimit.seconds();

       long maxCount = accessLimit.maxCount();

       boolean needLogin = accessLimit.needLogin();

       String key = request.getRequestURI();

       // 如果需要登录,这里可以获取用户标识并拼接到key中,例如获取用户ID

       if (needLogin) {

           // 假设这里通过session获取用户ID,实际应用中需要根据具体的认证方式获取

           Object userId = request.getSession().getAttribute("userId");

           if (userId != null) {

               key += ":" + userId;

           } else {

               // 如果未登录,返回提示信息

               render(response, "请先登录");

               return false;

           }

       }

       // 从Redis中获取当前接口的请求次数

       Object count = redisTemplate.opsForValue().get(key);

       if (count == null) {

           // 首次访问,设置请求次数为1,并设置过期时间为seconds秒

           redisTemplate.opsForValue().set(key, 1, seconds, TimeUnit.SECONDS);

       } else {

           long currentCount = (long) count;

           if (currentCount >= maxCount) {

               // 超出访问次数,返回提示信息

               render(response, "请求过于频繁,请稍后再试");

               return false;

           } else {

               // 未超出访问次数,请求次数加1

               redisTemplate.opsForValue().increment(key);

           }

       }

       return true;

   }

   @Override

   public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

   }

   @Override

   public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

   }

   private void render(HttpServletResponse response, String msg) throws Exception {

       response.setContentType("application/json;charset=UTF-8");

       OutputStream out = response.getOutputStream();

       String str = JSON.toJSONString("{\\"code\\": -1, \\"msg\\": \\"" + msg + "\\"}");

       out.write(str.getBytes("UTF-8"));

       out.flush();

       out.close();

   }

}

preHandle方法是整个拦截器的核心逻辑所在,它在请求被处理之前执行。首先判断请求是否是方法请求,如果不是(比如是静态资源请求等),则直接放行,返回true。这是因为我们只需要对接口方法进行防刷处理,对于其他类型的请求不需要进行额外的限制。

接着通过HandlerMethod获取方法上的AccessLimit注解。如果方法上没有该注解,也直接放行,返回true。这使得我们可以灵活地选择哪些接口方法需要进行防刷处理,而不需要对所有接口都进行统一的限制。

获取注解中的配置参数,包括时间窗口seconds、最大请求次数maxCount和是否需要登录needLogin。然后根据请求的 URI 生成一个唯一的key,用于在 Redis 中存储和获取请求次数。

如果needLogintrue,则尝试从session中获取用户 ID,并将其拼接到key中。这是为了区分不同用户对同一接口的请求次数,确保每个用户的请求都能被独立统计和限制。如果获取不到用户 ID,说明用户未登录,调用render方法返回 “请先登录” 的提示信息,并返回false,阻止请求继续执行。

从 Redis 中获取当前接口的请求次数。如果是首次访问(countnull),则在 Redis 中设置请求次数为 1,并设置过期时间为seconds秒。这是利用 Redis 的过期机制,实现时间窗口的功能,过期后请求次数统计将重新开始。

如果不是首次访问,获取当前请求次数currentCount,并判断是否超过最大限制maxCount。若超过,则调用render方法返回 “请求过于频繁,请稍后再试” 的提示信息,并返回false;否则,请求次数加 1,继续放行请求。

postHandle方法在请求处理之后,视图渲染之前执行,目前为空,可根据实际需求添加逻辑,例如在请求处理成功后记录一些日志信息,或者对返回的数据进行一些预处理等。

afterCompletion方法在整个请求处理完毕后执行,同样目前为空,可用于资源清理等操作。比如,如果在请求处理过程中创建了一些临时资源,在这个方法中可以进行释放。

render方法用于向客户端返回错误提示信息。它设置响应的内容类型为application/json;charset=UTF-8,并将错误信息封装成 JSON 格式写入响应流中,然后关闭流。这样客户端就可以接收到统一格式的错误提示,方便进行处理和展示。

配置类代码

import org.springframework.beans.factory.annotation.Autowired;

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 WebConfig implements WebMvcConfigurer {

   @Autowired

   private AccessLimitInterceptor accessLimitInterceptor;

   @Override

   public void addInterceptors(InterceptorRegistry registry) {

       registry.addInterceptor(accessLimitInterceptor)

              .addPathPatterns("/\*\*") // 拦截所有路径

              .excludePathPatterns("/login", "/register"); // 排除登录和注册接口,可根据实际情况添加更多排除路径

   }

}

@Configuration注解表明这是一个配置类,用于配置 Spring 的相关组件和功能。它会被 Spring 容器扫描并加载,其中的配置信息会生效。

通过@Autowired注入AccessLimitInterceptor拦截器实例。这是 Spring 的依赖注入机制,确保在配置类中可以使用已经创建好的拦截器实例。

addInterceptors方法是WebMvcConfigurer接口中的一个方法,用于注册拦截器。在这个方法中,调用registry.addInterceptor(accessLimitInterceptor)AccessLimitInterceptor拦截器添加到 Spring 的拦截器链中。

.addPathPatterns("/**")表示拦截所有的请求路径,即对所有接口进行防刷检查。这是一个通用的配置,确保没有接口被遗漏。

.excludePathPatterns("/login", "/register")表示排除登录和注册接口。因为登录和注册接口通常需要允许用户频繁访问,不进行防刷限制。在实际应用中,可以根据业务需求添加更多需要排除的路径,比如一些公开的接口、健康检查接口等。

Controller 代码

import org.springframework.web.bind.annotation.GetMapping;

import org.springframework.web.bind.annotation.RequestMapping;

import org.springframework.web.bind.annotation.RestController;

@RestController

@RequestMapping("/user")

public class UserController {

   @GetMapping("/info")

   @AccessLimit(seconds = 60, maxCount = 10, needLogin = true)

   public String getUserInfo() {

       // 这里是获取用户信息的业务逻辑

       return "用户信息";

   }

}

@RestController注解表示这是一个 RESTful 风格的控制器,它会将方法返回的对象直接转换为 JSON 格式并返回给客户端。这在前后端分离的开发模式中非常常用,方便前端接收和处理数据。

@RequestMapping("/user")定义了该控制器的基础路径为/user,即所有该控制器下的接口路径都将以/user开头。

@GetMapping("/info")表示这是一个处理 HTTP GET 请求的方法,路径为/info,完整的请求路径为/user/info

@AccessLimit(seconds = 60, maxCount = 10, needLogin = true)是我们自定义的接口防刷注解,用于对该接口进行防刷配置。这里设置在 60 秒内最多允许同一个用户(通过用户 ID 区分)访问 10 次,并且需要用户登录后才能访问。如果用户在 60 秒内访问该接口超过 10 次,将返回 “请求过于频繁,请稍后再试” 的提示信息;如果未登录访问该接口,将返回 “请先登录” 的提示信息。在实际业务中,根据接口的重要性和使用频率,可以灵活调整这些配置参数。例如,对于一些对安全性要求较高的用户敏感信息查询接口,可以将maxCount设置得更小,seconds设置得更长;对于一些普通的查询接口,可以适当放宽限制。

优化与拓展

分布式环境下的防刷

在分布式系统中,由于存在多个应用实例,基于单机的接口防刷策略往往无法满足需求。此时,使用分布式缓存(如 Redis Cluster)来实现接口防刷成为一种有效的解决方案。Redis Cluster 是 Redis 的分布式部署方案,它通过将数据分布在多个节点上,实现了高可用和高扩展性。

在分布式环境下,利用 Redis Cluster 实现接口防刷的原理与单机版类似,但需要注意的是,所有的请求计数和状态管理都需要在分布式缓存中进行。例如,在之前的实现中,我们使用 Redis 来存储接口的请求次数。在分布式环境中,同样可以使用 Redis Cluster 来存储这些信息,并且所有的应用实例都从这个分布式缓存中读取和更新请求次数。

假设我们有一个分布式系统,包含多个 Spring Boot 应用实例,每个实例都需要对某个接口进行防刷处理。首先,确保所有应用实例都能正确连接到 Redis Cluster。在application.yml文件中,可以配置 Redis Cluster 的节点信息:

spring:

 redis:

   cluster:

     nodes: 192.168.1.100:7000,192.168.1.101:7001,192.168.1.102:7002

在拦截器中,获取和更新请求次数的逻辑与单机版类似,但需要注意的是,由于多个实例可能同时访问和更新 Redis 中的数据,需要考虑并发问题。Redis 提供了一些原子操作,如INCR(增加指定 key 的值)和SETNX(只有在 key 不存在时,才设置 key 的值),可以利用这些原子操作来确保数据的一致性。例如,在拦截器中获取请求次数并判断是否超过限制的代码可以修改为:

import org.springframework.data.redis.core.RedisTemplate;

import org.springframework.stereotype.Component;

import org.springframework.web.method.HandlerMethod;

import org.springframework.web.servlet.HandlerInterceptor;

import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;

import javax.servlet.http.HttpServletResponse;

import java.io.OutputStream;

import java.util.concurrent.TimeUnit;

@Component

public class DistributedAccessLimitInterceptor implements HandlerInterceptor {

   private static final String REDIS\_KEY\_PREFIX = "distributed\_access\_limit:";

   private final RedisTemplate<String, Object> redisTemplate;

   public DistributedAccessLimitInterceptor(RedisTemplate<String, Object> redisTemplate) {

       this.redisTemplate = redisTemplate;

   }

   @Override

   public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

       if (!(handler instanceof HandlerMethod)) {

           return true;

       }

       HandlerMethod hm = (HandlerMethod) handler;

       // 获取方法上的AccessLimit注解

       // 假设AccessLimit注解的定义与之前相同

       AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);

       if (accessLimit == null) {

           return true;

       }

       long seconds = accessLimit.seconds();

       long maxCount = accessLimit.maxCount();

       boolean needLogin = accessLimit.needLogin();

       String key = generateRedisKey(request, needLogin);

       // 使用Redis的原子操作INCR获取当前请求次数

       Long currentCount = redisTemplate.opsForValue().increment(key);

       if (currentCount == 1) {

           // 如果是首次访问,设置过期时间

           redisTemplate.expire(key, seconds, TimeUnit.SECONDS);

       }

       if (currentCount > maxCount) {

           // 超出访问次数,返回提示信息

           render(response, "请求过于频繁,请稍后再试");

           return false;

       }

       return true;

   }

   @Override

   public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

   }

   @Override

   public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

   }

   private void render(HttpServletResponse response, String msg) throws Exception {

       response.setContentType("application/json;charset=UTF-8");

       OutputStream out = response.getOutputStream();

       String str = "{\\"code\\": -1, \\"msg\\": \\"" + msg + "\\"}";

       out.write(str.getBytes("UTF-8"));

       out.flush();

       out.close();

   }

   private String generateRedisKey(HttpServletRequest request, boolean needLogin) {

       StringBuilder keyBuilder = new StringBuilder(REDIS\_KEY\_PREFIX);

       keyBuilder.append(request.getRequestURI());

       if (needLogin) {

           // 假设这里通过session获取用户ID,实际应用中需要根据具体的认证方式获取

           Object userId = request.getSession().getAttribute("userId");

           if (userId != null) {

               keyBuilder.append(":").append(userId);

           } else {

               throw new RuntimeException("用户未登录");

           }

       }

       return keyBuilder.toString();

   }

}

在这个实现中,generateRedisKey方法根据请求的 URI 和用户信息(如果需要登录)生成一个唯一的 Redis key。在preHandle方法中,使用redisTemplate.opsForValue().increment(key)来原子性地增加请求次数,并在首次访问时设置过期时间。通过这种方式,即使在分布式环境下,也能准确地统计和限制接口的请求次数,从而实现有效的接口防刷。

结合令牌桶算法优化

前面介绍的基于时间窗口的请求计数方式虽然简单易懂,但在处理突发流量时存在一定的局限性。为了更灵活地控制接口请求速率,引入 Guava 库中的令牌桶算法是一种很好的优化方案。Guava 是 Google 开源的 Java 核心库,其中的RateLimiter类提供了令牌桶算法的实现。

首先,在pom.xml文件中添加 Guava 库的依赖:

<dependency>

   <groupId>com.google.guava</groupId>

   <artifactId>guava</artifactId>

   <version>31.1-jre</version>

</dependency>

然后,创建一个令牌桶管理器类,用于管理令牌桶的生成和获取:

import com.google.common.util.concurrent.RateLimiter;

import org.springframework.stereotype.Component;

import java.util.concurrent.ConcurrentHashMap;

import java.util.concurrent.ConcurrentMap;

@Component

public class TokenBucketManager {

   private final ConcurrentMap<String, RateLimiter> rateLimiterMap = new ConcurrentHashMap<>();

   public RateLimiter getRateLimiter(String key, double permitsPerSecond) {

       return rateLimiterMap.computeIfAbsent(key, k -> RateLimiter.create(permitsPerSecond));

   }

}

在这个类中,rateLimiterMap用于存储不同接口(通过key区分)对应的RateLimiter实例。getRateLimiter方法根据key获取对应的RateLimiter,如果不存在则创建一个新的,permitsPerSecond参数表示每秒生成的令牌数量,即请求速率。

接下来,修改拦截器,使用令牌桶算法进行接口防刷:

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.stereotype.Component;

import org.springframework.web.method.HandlerMethod;

import org.springframework.web.servlet.HandlerInterceptor;

import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;

import javax.servlet.http.HttpServletResponse;

import java.io.OutputStream;

@Component

public class TokenBucketAccessLimitInterceptor implements HandlerInterceptor {

   @Autowired

   private TokenBucketManager tokenBucketManager;

   @Override

   public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

       if (!(handler instanceof HandlerMethod)) {

           return true;

       }

       HandlerMethod hm = (HandlerMethod) handler;

       // 假设这里定义了一个注解,用于设置令牌桶的参数

       TokenBucketLimit tokenBucketLimit = hm.getMethodAnnotation(TokenBucketLimit.class);

       if (tokenBucketLimit == null) {

           return true;

       }

       double permitsPerSecond = tokenBucketLimit.permitsPerSecond();

       String key = request.getRequestURI();

       // 根据接口路径获取对应的RateLimiter

       if (!tokenBucketManager.rateLimiterMap.containsKey(key)) {

           tokenBucketManager.getRateLimiter(key, permitsPerSecond);

       }

       RateLimiter rateLimiter = tokenBucketManager.getRateLimiter(key, permitsPerSecond);

       if (!rateLimiter.tryAcquire()) {

           // 获取令牌失败,请求被限制

           render(response, "请求过于频繁,请稍后再试");

           return false;

       }

       return true;

   }

   @Override

   public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

   }

   @Override

   public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

   }

   private void render(HttpServletResponse response, String msg) throws Exception {

       response.setContentType("application/json;charset=UTF-8");

       OutputStream out = response.getOutputStream();

       String str = "{\\"code\\": -1, \\"msg\\": \\"" + msg + "\\"}";

       out.write(str.getBytes("UTF-8"));

       out.flush();

       out.close();

   }

}

在这个拦截器中,首先获取方法上的TokenBucketLimit注解(假设已经定义),根据注解中的permitsPerSecond参数获取或创建对应的RateLimiter实例。然后调用rateLimiter.tryAcquire()方法尝试获取一个令牌,如果获取失败,说明请求频率过高,返回错误提示信息;如果获取成功,则允许请求继续执行。通过这种方式,利用令牌桶算法可以更平滑地处理接口请求,避免因突发流量导致的系统过载,同时也能更好地适应不同接口的业务需求,因为可以为每个接口单独设置令牌生成速率。

动态调整防刷策略

在实际应用中,接口的访问模式和业务需求可能会发生变化,因此需要一种机制来动态调整防刷策略,而不是在代码中硬编码固定的限制参数。通过配置中心可以实现这一目标,配置中心可以集中管理应用的配置信息,并支持动态更新,常见的配置中心有 Nacos、Apollo 等。这里以 Nacos 为例,介绍如何实现防刷策略的动态调整。

首先,在项目中引入 Nacos 客户端依赖。如果使用 Maven,在pom.xml文件中添加如下依赖:

<dependency>

   <groupId>com.alibaba.cloud</groupId>

   <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>

</dependency>

然后,在bootstrap.yml文件中配置 Nacos 的连接信息:

展开阅读全文

本文系作者在时代Java发表,未经许可,不得转载。

如有侵权,请联系nowjava@qq.com删除。

编辑于

关注时代Java

关注时代Java