SpringBoot 接口防刷的5种方案,太强了!

在当今的互联网环境中,接口防刷已成为保障系统安全与稳定运行的关键环节。恶意的高频请求如同隐藏在暗处的“杀手”,不仅会大量消耗服务器宝贵的资源,还可能引发数据异常,严重时甚至会导致整个系统瘫痪,给业务带来不可估量的损失。

本文将深入剖析在 Spring Boot 框架下实现接口防刷的 5 种技术方案,帮助你根据实际需求选择最合适的方法,为系统安全保驾护航。

1. 基于注解的访问频率限制

基于注解的访问频率限制是一种广受欢迎的接口防刷方案,它通过自定义注解和 AOP 切面的巧妙结合,实现了对接口访问频率的有效控制。这种方法简单易用,实现成本低,就像给接口穿上了一层轻便的“防护衣”。

实现步骤

1.1 创建限流注解

首先,我们需要创建一个自定义的限流注解,用于标记需要进行访问频率限制的接口方法。以下是具体的代码实现:

在这个注解中,我们可以通过 time 属性设置限制的时间段,通过 count 属性设置在该时间段内允许的最大请求次数,通过 key 属性指定限流的键,支持使用 SpEL 表达式进行灵活配置,message 属性则用于在请求被限定时给用户提供提示信息。

1.2 实现限流切面

接下来,我们需要实现一个 AOP 切面,用于处理限流逻辑。以下是具体的代码实现:

@Aspect
@Component
@Slf4j
publicclass RateLimitAspect {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Around("@annotation(rateLimit)")
    public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
        // 获取请求的方法名
        String methodName = pjp.getSignature().getName();
        // 获取请求的类名
        String className = pjp.getTarget().getClass().getName();
        
        // 组合限流 key
        String limitKey = getLimitKey(pjp, rateLimit, methodName, className);
        
        // 获取限流参数
        int time = rateLimit.time();
        int count = rateLimit.count();
        
        // 执行限流逻辑
        boolean limited = isLimited(limitKey, time, count);
        if (limited) {
            thrownew RuntimeException(rateLimit.message());
        }
        
        // 执行目标方法
        return pjp.proceed();
    }
    
    private String getLimitKey(ProceedingJoinPoint pjp, RateLimit rateLimit, String methodName, String className) {
        // 获取用户自定义的 key
        String key = rateLimit.key();
        
        if (StringUtils.hasText(key)) {
            // 支持 SpEL 表达式解析
            StandardEvaluationContext context = new StandardEvaluationContext();
            MethodSignature signature = (MethodSignature) pjp.getSignature();
            String[] parameterNames = signature.getParameterNames();
            Object[] args = pjp.getArgs();
            
            for (int i = 0; i < parameterNames.length; i++) {
                context.setVariable(parameterNames[i], args[i]);
            }
            
            ExpressionParser parser = new SpelExpressionParser();
            Expression expression = parser.parseExpression(key);
            key = expression.getValue(context, String.class);
        } else {
            // 默认使用类名+方法名+IP 地址作为 key
            HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
            String ip = getIpAddress(request);
            key = ip + ":" + className + ":" + methodName;
        }
        
        return"rate_limit:" + key;
    }
    
    private boolean isLimited(String key, int time, int count) {
        // 使用 Redis 的计数器实现限流
        try {
            Long currentCount = redisTemplate.opsForValue().increment(key, 1);
            
            // 如果是第一次访问,设置过期时间
            if (currentCount == 1) {
                redisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
            
            return currentCount > count;
        } catch (Exception e) {
            log.error("限流异常", e);
            returnfalse;
        }
    }
    
    private String getIpAddress(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_CLIENT_IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_X_FORWARDED_FOR");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

在这个切面中,我们使用 @Around 注解拦截所有标记了 @RateLimit 注解的方法。在方法执行前,我们会根据注解中的参数计算出限流的键,并使用 Redis 的计数器来记录请求次数。如果请求次数超过了限制,我们会抛出一个异常,提示用户操作过于频繁。

1.3 使用示例

以下是一个使用 @RateLimit 注解的示例:

在这个示例中,我们在 getUser 方法上使用了 @RateLimit 注解,设置了 60 秒内最多允许 3 次请求。在 updateUser 方法上,我们使用了 SpEL 表达式来动态生成限流的键,实现了更加灵活的限流策略。

优缺点分析

优点
  • 实现简单,上手容易:该方案的实现逻辑清晰,代码量较少,即使是初学者也能快速掌握。在单机情况下,甚至可以去掉 Redis 换成本地缓存实现,进一步降低了实现难度。

  • 注解式使用,对业务代码无侵入:通过自定义注解的方式,我们可以将限流逻辑与业务逻辑分离,对业务代码的改动非常小,不会影响原有的业务功能。

  • 可以精确控制接口粒度:我们可以针对不同的接口方法设置不同的限流参数,实现对接口访问频率的精确控制。

  • 支持灵活的限流策略配置:通过 SpEL 表达式,我们可以根据请求的参数、用户信息等动态生成限流的键,实现更加灵活的限流策略。

缺点
  • 限流逻辑相对简单,无法应对复杂场景:该方案的限流逻辑主要基于固定的时间窗口和请求次数,对于一些复杂的场景,如突发流量、动态限流等,可能无法满足需求。

  • 缺少预警机制:当请求次数接近或达到限制时,系统无法及时发出预警,可能会导致用户体验下降。

2. 令牌桶算法实现限流

令牌桶算法是一种更加灵活的限流算法,它就像一个装有令牌的桶,系统会以固定的速率向桶中添加令牌,每个请求需要从桶中获取一个令牌才能被处理。这种算法可以允许突发流量,同时又能限制长期的平均流量,为系统提供了更加灵活的流量控制能力。

实现步骤

2.1 引入依赖

Google 提供的 Guava 库中包含了令牌桶的实现,我们可以通过以下依赖将其引入项目:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.1-jre</version>
</dependency>
2.2 创建令牌桶限流器

接下来,我们需要创建一个令牌桶限流器,用于管理不同接口的令牌桶。以下是具体的代码实现:

@Component
publicclass RateLimiter {
    // 使用 ConcurrentHashMap 存储不同接口的令牌桶
    privatefinal ConcurrentHashMap<String, com.google.common.util.concurrent.RateLimiter> rateLimiterMap = new ConcurrentHashMap<>();
    
    /**
     * 获取特定接口的令牌桶,不存在则创建
     * @param key 限流键
     * @param permitsPerSecond 每秒允许的请求量
     * @return 令牌桶实例
     */
    public com.google.common.util.concurrent.RateLimiter getRateLimiter(String key, double permitsPerSecond) {
        return rateLimiterMap.computeIfAbsent(key, 
            k -> com.google.common.util.concurrent.RateLimiter.create(permitsPerSecond));
    }
    
    /**
     * 尝试获取令牌
     * @param key 限流键
     * @param permitsPerSecond 每秒允许的请求量
     * @param timeout 超时时间
     * @param unit 时间单位
     * @return 是否获取成功
     */
    public boolean tryAcquire(String key, double permitsPerSecond, long timeout, TimeUnit unit) {
        com.google.common.util.concurrent.RateLimiter rateLimiter = getRateLimiter(key, permitsPerSecond);
        return rateLimiter.tryAcquire(1, timeout, unit);
    }
}

在这个限流器中,我们使用 ConcurrentHashMap 来存储不同接口的令牌桶,确保线程安全。通过 getRateLimiter 方法,我们可以根据限流键获取对应的令牌桶,如果令牌桶不存在,则会自动创建一个新的令牌桶。通过 tryAcquire 方法,我们可以尝试从令牌桶中获取一个令牌,如果在指定的超时时间内获取成功,则返回 true,否则返回 false

2.3 创建拦截器

为了实现对接口的限流,我们需要创建一个拦截器,在请求进入接口之前进行令牌的获取操作。以下是具体的代码实现:

在这个拦截器中,我们首先判断请求的 URI 是否以 /api/ 开头,如果是,则进行限流处理。然后,我们获取请求的 IP 地址和 URI,组合成限流键。接着,我们尝试从令牌桶中获取一个令牌,如果获取失败,则返回一个限流响应,提示用户请求过于频繁。

2.4 配置拦截器

最后,我们需要将拦截器配置到 Spring Boot 应用中,使其生效。以下是具体的代码实现:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Autowired
    private TokenBucketInterceptor tokenBucketInterceptor;
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(tokenBucketInterceptor)
                .addPathPatterns("/**");
    }
}

在这个配置类中,我们使用 WebMvcConfigurer 接口的 addInterceptors 方法将拦截器添加到拦截器链中,并设置拦截所有的请求。

优缺点分析

优点
  • 支持突发流量,不会完全拒绝短时高峰:令牌桶算法允许在短时间内有较高的请求速率,只要桶中有足够的令牌。当突发流量到来时,系统可以快速处理这些请求,而不会像固定窗口算法那样直接拒绝请求。

  • 平滑的限流效果,用户体验更好:由于令牌桶算法是以固定的速率向桶中添加令牌,因此可以实现平滑的限流效果,避免了请求的突然中断,提高了用户体验。

  • 可以配置不同接口的不同限流策略:通过使用不同的限流键,我们可以为不同的接口配置不同的令牌桶,实现对不同接口的差异化限流。

  • 无需额外的存储设施:令牌桶算法的实现不需要额外的存储设施,只需要在内存中维护一个令牌桶即可,降低了系统的复杂度和成本。

缺点
  • 只适用于单机部署,分布式环境需要额外改造:该方案的令牌桶是在内存中维护的,因此只适用于单机部署的环境。在分布式环境中,需要将令牌桶的状态存储到共享的存储设施中,如 Redis,才能实现分布式限流。

  • 重启应用后状态丢失:由于令牌桶的状态是在内存中维护的,因此当应用重启后,令牌桶的状态会丢失,需要重新初始化。

  • 无法精确控制时间窗口内的请求总量:令牌桶算法只能控制请求的平均速率,无法精确控制在某个时间窗口内的请求总量。在某些对请求总量有严格限制的场景下,可能无法满足需求。

3. 分布式限流(Redis + Lua 脚本)

在分布式系统中,单机限流方案往往难以满足需求,因为不同的实例之间无法共享限流状态。利用 Redis 和 Lua 脚本可以实现高效的分布式限流,确保系统在分布式环境下的安全性和稳定性。

实现步骤

3.1 定义 Lua 脚本

首先,我们需要定义一个 Redis 限流的 Lua 脚本,用于实现限流逻辑。以下是具体的脚本内容:

在这个脚本中,我们首先获取限流键、限流窗口、限流阈值和当前时间戳。然后,我们移除过期的请求记录,统计当前窗口内的请求数。如果请求数超过了阈值,我们返回 0 表示拒绝请求。否则,我们添加当前请求记录,并设置过期时间,最后返回当前窗口剩余可用请求数。

3.2 创建 Redis 限流服务

接下来,我们需要创建一个 Redis 限流服务,用于执行 Lua 脚本并处理限流逻辑。以下是具体的代码实现:

@Service
@Slf4j
publicclass RedisRateLimiterService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private DefaultRedisScript<Long> rateLimiterScript;
    
    @PostConstruct
    public void init() {
        // 加载 Lua 脚本
        rateLimiterScript = new DefaultRedisScript<>();
        rateLimiterScript.setLocation(new ClassPathResource("scripts/rate_limiter.lua"));
        rateLimiterScript.setResultType(Long.class);
    }
    
    /**
     * 尝试获取访问权限
     * @param key 限流键
     * @param window 时间窗口(秒)
     * @param threshold 阈值
     * @return 剩余可用请求数,-1 表示被限流
     */
    public long isAllowed(String key, int window, int threshold) {
        try {
            // 执行 lua 脚本
            List<String> keys = Collections.singletonList(key);
            Long remainingCount = redisTemplate.execute(
                rateLimiterScript, 
                keys, 
                String.valueOf(window), 
                String.valueOf(threshold),
                String.valueOf(System.currentTimeMillis())
            );
            
            return remainingCount == null ? -1 : remainingCount;
        } catch (Exception e) {
            log.error("Redis rate limiter error", e);
            // 发生异常时放行请求
            return threshold;
        }
    }
}

在这个服务中,我们使用 StringRedisTemplate 来执行 Lua 脚本。在 init 方法中,我们加载 Lua 脚本并设置返回值类型。在 isAllowed 方法中,我们执行 Lua 脚本并根据返回值判断是否允许访问。如果返回值为 -1 表示被限流,否则返回剩余可用请求数。

3.3 创建分布式限流注解

为了方便使用,我们可以创建一个分布式限流注解,用于标记需要进行分布式限流的接口方法。以下是具体的代码实现:

在这个注解中,我们可以通过 prefix 属性设置限流键的前缀,通过 window 属性设置时间窗口,通过 threshold 属性设置时间窗口内允许的最大请求数,通过 mode 属性设置限流模式。

3.4 实现分布式限流切面

接下来,我们需要实现一个 AOP 切面,用于处理分布式限流逻辑。以下是具体的代码实现:

@Aspect
@Component
@Slf4j
publicclass DistributedRateLimitAspect {
    
    @Autowired
    private RedisRateLimiterService rateLimiterService;
    
    @Autowired(required = false)
    private HttpServletRequest request;
    
    @Around("@annotation(rateLimit)")
    public Object around(ProceedingJoinPoint pjp, DistributedRateLimit rateLimit) throws Throwable {
        String key = generateKey(pjp, rateLimit);
        
        long remainingCount = rateLimiterService.isAllowed(
            key, 
            rateLimit.window(), 
            rateLimit.threshold()
        );
        
        if (remainingCount < 0) {
            thrownew RuntimeException("接口访问过于频繁,请稍后再试");
        }
        
        // 执行目标方法
        return pjp.proceed();
    }
    
    private String generateKey(ProceedingJoinPoint pjp, DistributedRateLimit rateLimit) {
        String methodName = pjp.getSignature().getName();
        String className = pjp.getTarget().getClass().getName();
        StringBuilder key = new StringBuilder(rateLimit.prefix());
        
        key.append(className).append(".").append(methodName);
        
        // 根据限流模式添加不同的后缀
        switch (rateLimit.mode()) {
            case"ip":
                // 按 IP 限流
                key.append(":").append(getIpAddress());
                break;
            case"user":
                // 按用户限流
                Object userId = getUserId();
                key.append(":").append(userId != null ? userId : "anonymous");
                break;
            case"all":
                // 接口总体限流,不添加后缀
                break;
            default:
                key.append(":").append(getIpAddress());
                break;
        }
        
        return key.toString();
    }
    
    private String getIpAddress() {
        // IP 获取方法同上
        if (request == null) {
            return"unknown";
        }
        // 获取 IP 的代码同上一个示例
        return"127.0.0.1"; // 简化处理
    }
    
    // 获取当前用户 ID,根据实际认证系统实现
    private Object getUserId() {
        // 这里简化处理,实际中应从认证信息中获取
        // 例如:SecurityContextHolder.getContext().getAuthentication().getPrincipal()
        returnnull;
    }
}

在这个切面中,我们使用 @Around 注解拦截所有标记了 @DistributedRateLimit 注解的方法。在方法执行前,我们会根据注解中的参数生成限流键,并调用 RedisRateLimiterService 的 isAllowed 方法判断是否允许访问。如果不允许访问,我们会抛出一个异常,提示用户接口访问过于频繁。

3.5 使用示例

以下是一个使用 @DistributedRateLimit 注解的示例:

@RestController
@RequestMapping("/api")
publicclass PaymentController {
    
    @DistributedRateLimit(prefix = "pay:", window = 3600, threshold = 5, mode = "user")
    @PostMapping("/payment")
    public Result createPayment(@RequestBody PaymentRequest paymentRequest) {
        // 创建支付业务逻辑
        return paymentService.createPayment(paymentRequest);
    }
    
    @DistributedRateLimit(window = 60, threshold = 30, mode = "ip")
    @GetMapping("/products")
    public List<Product> getProducts() {
        // 查询产品列表
        return productService.findAll();
    }
    
    @DistributedRateLimit(window = 1, threshold = 100, mode = "all")
    @GetMapping("/hot/resource")
    public Resource getHotResource() {
        // 获取热门资源
        return resourceService.getHotResource();
    }
}

在这个示例中,我们在 createPayment 方法上使用了 @DistributedRateLimit 注解,设置了 3600 秒内每个用户最多允许 5 次请求。在 getProducts 方法上,我们设置了 60 秒内每个 IP 最多允许 30 次请求。在 getHotResource 方法上,我们设置了 1 秒内整个接口最多允许 100 次请求。

优缺点分析

优点
  • 适用于分布式系统,多实例间共享限流状态:通过使用 Redis 作为共享存储,不同的实例可以共享限流状态,确保系统在分布式环境下的一致性和稳定性。

  • 支持多种限流模式:可以根据 IP、用户、接口总量等不同的维度进行限流,满足不同场景的需求。

  • 基于滑动窗口,计数更精确:Lua 脚本使用滑动窗口算法来统计请求数,相比固定窗口算法,计数更加精确,可以有效避免误判。

  • 使用 Lua 脚本保证原子性,避免竞态条件:Lua 脚本在 Redis 中是原子执行的,确保了限流逻辑的原子性,避免了多个实例同时修改限流状态时可能出现的竞态条件。

缺点
  • 强依赖 Redis:该方案的实现依赖于 Redis,如果 Redis 出现故障,可能会影响系统的正常运行。

  • 实现复杂度较高:需要编写 Lua 脚本并进行 Redis 操作,实现复杂度相对较高,对开发人员的技术要求也较高。

4. 集成 Sentinel 实现接口防刷

阿里巴巴开源的 Sentinel 是一个强大的流量控制组件,它就像一个智能的“交通警察”,可以对系统的流量进行实时监控和控制,提供了丰富的限流、熔断、系统保护等功能。

实现步骤

4.1 添加依赖

首先,我们需要在项目中添加 Sentinel 的依赖:

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
    <version>2021.0.4.0</version>
</dependency>
4.2 配置 Sentinel

在 application.properties 中添加 Sentinel 的配置:

# Sentinel 控制台地址
spring.cloud.sentinel.transport.dashboard=localhost:8080
# 取消 Sentinel 控制台懒加载
spring.cloud.sentinel.eager=true
# 应用名称
spring.application.name=my-application

在这个配置中,我们设置了 Sentinel 控制台的地址,取消了控制台的懒加载,并指定了应用的名称。

4.3 创建 Sentinel 配置

接下来,我们需要创建一个 Sentinel 配置类,用于定义流控规则。以下是具体的代码实现:

在这个配置类中,我们创建了一个 SentinelResourceAspect 实例,并在 init 方法中定义了流控规则。对于 /api/user 接口,我们设置了每秒允许 10 个请求的限流规则。对于 /api/order 接口,我们设置了每秒允许 5 个请求的限流规则,并开启了预热模式,预热期为 10 秒。

4.4 创建 URL 资源解析器

为了让 Sentinel 能够正确识别请求的资源,我们需要创建一个 URL 资源解析器。以下是具体的代码实现:

@Component
publicclass UrlCleaner implements RequestOriginParser {
    
    @Override
    public String parseOrigin(HttpServletRequest request) {
        // 获取请求的 URL 路径
        String path = request.getRequestURI();
        
        // 可以添加更复杂的解析逻辑,例如:
        // 1. 去除路径变量:/api/user/123 -> /api/user/{id}
        // 2. 添加请求方法前缀:GET:/api/user
        
        return path;
    }
}

在这个解析器中,我们简单地返回请求的 URL 路径,你可以根据实际需求添加更复杂的解析逻辑。

4.5 创建全局异常处理器

当请求被 Sentinel 限流时,会抛出 BlockException 异常,我们需要创建一个全局异常处理器来处理这个异常。以下是具体的代码实现:

@RestControllerAdvice
publicclass SentinelExceptionHandler {
    
    @ExceptionHandler(BlockException.class)
    public Result handleBlockException(BlockException e) {
        String message = "请求过于频繁,请稍后再试";
        if (e instanceof FlowException) {
            message = "接口限流:" + message;
        } elseif (e instanceof DegradeException) {
            message = "服务降级:系统繁忙,请稍后再试";
        } elseif (e instanceof ParamFlowException) {
            message = "热点参数限流:请求过于频繁";
        } elseif (e instanceof SystemBlockException) {
            message = "系统保护:系统资源不足";
        } elseif (e instanceof AuthorityException) {
            message = "授权控制:没有访问权限";
        }
        
        return Result.error(429, message);
    }
}

在这个异常处理器中,我们根据不同的 BlockException 类型返回不同的错误信息,提示用户请求被限流的原因。

4.6 使用 @SentinelResource 注解

为了让 Sentinel 能够对接口进行限流,我们需要在接口方法上使用 @SentinelResource 注解。以下是具体的代码实现:

@RestController
@RequestMapping("/api")
publicclass UserController {
    
    // 使用资源名定义限流资源
    @SentinelResource(value = "getUserById", 
                      blockHandler = "getUserBlockHandler",
                      fallback = "getUserFallback")
    @GetMapping("/user/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.getUser(id);
    }
    
    // 限流处理方法
    public User getUserBlockHandler(Long id, BlockException e) {
        log.warn("Get user request blocked: {}", id, e);
        thrownew RuntimeException("请求频率过高,请稍后再试");
    }
    
    // 异常回退方法
    public User getUserFallback(Long id, Throwable t) {
        log.error("Get user failed: {}", id, t);
        User fallbackUser = new User();
        fallbackUser.setId(id);
        fallbackUser.setName("Unknown");
        return fallbackUser;
    }
}

在这个示例中,我们在 getUser 方法上使用了 @SentinelResource 注解,指定了资源名、限流处理方法和异常回退方法。当请求被限流时,会调用 getUserBlockHandler 方法进行处理。当方法执行过程中出现异常时,会调用 getUserFallback 方法进行回退。

4.7 更复杂的限流规则配置

除了基本的流控规则,Sentinel 还支持更复杂的限流规则配置,如基于 QPS + 调用关系的限流规则、基于并发线程数的限流规则、热点参数限流规则等。以下是具体的代码实现:

@Service
@Slf4j
publicclass SentinelRuleService {
    
    public void initComplexFlowRules() {
        List<FlowRule> rules = new ArrayList<>();
        
        // 基于 QPS + 调用关系的限流规则
        FlowRule apiRule = new FlowRule();
        apiRule.setResource("/api/data");
        apiRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
        apiRule.setCount(20);
        
        // 限制调用来源
        apiRule.setLimitApp("frontend"); // 只限制来自前端应用的调用
        
        // 流控策略:关联资源
        apiRule.setStrategy(RuleConstant.STRATEGY_RELATE);
        apiRule.setRefResource("/api/important"); // 当 important 接口 QPS 高时,限制 data 接口
        
        rules.add(apiRule);
        
        // 基于并发线程数的限流
        FlowRule threadRule = new FlowRule();
        threadRule.setResource("/api/heavy-task");
        threadRule.setGrade(RuleConstant.FLOW_GRADE_THREAD); // 基于线程数
        threadRule.setCount(5); // 最多 5 个线程同时处理
        rules.add(threadRule);
        
        // 加载规则
        FlowRuleManager.loadRules(rules);
    }
    
    public void initHotspotRules() {
        // 热点参数限流规则
        List<ParamFlowRule> rules = new ArrayList<>();
        
        ParamFlowRule rule = new ParamFlowRule("/api/product");
        // 对第 0 个参数(productId)进行限流
        rule.setParamIdx(0);
        rule.setCount(5);
        
        // 特例配置
        ParamFlowItem item1 = new ParamFlowItem();
        item1.setObject("1"); // productId = 1 的商品
        item1.setCount(10);  // 可以有更高的 QPS
        
        ParamFlowItem item2 = new ParamFlowItem();
        item2.setObject("2"); // productId = 2 的商品
        item2.setCount(2);   // 更严格的限制
        
        rule.setParamFlowItemList(Arrays.asList(item1, item2));
        
        rules.add(rule);
        ParamFlowRuleManager.loadRules(rules);
    }
}

在这个服务中,我们定义了基于 QPS + 调用关系的限流规则、基于并发线程数的限流规则和热点参数限流规则,并使用 FlowRuleManager 和 ParamFlowRuleManager 加载这些规则。

优缺点分析

优点
  • 功能全面:Sentinel 支持 QPS 限流、并发线程数限流、热点参数限流等多种限流方式,还提供了熔断、系统保护等功能,可以满足不同场景的需求。

  • 支持多种控制策略:可以选择直接拒绝、预热、排队等多种控制策略,根据实际情况灵活调整限流行为。

  • 提供控制台可视化管理:Sentinel 提供了可视化的控制台,可以实时监控系统的流量情况,方便进行规则的配置和管理。

  • 支持动态规则调整:可以通过控制台或 API 动态调整限流规则,无需重启应用,提高了系统的灵活性和可维护性。

  • 可与 Spring Cloud 体系无缝集成:Sentinel 可以与 Spring Cloud 体系无缝集成,方便在微服务架构中使用。

缺点
  • 学习曲线较陡峭:Sentinel 的功能丰富,配置复杂,对于初学者来说,学习成本较高。

  • 分布式场景下需要额外配置规则持久化:在分布式场景下,需要额外配置规则持久化,确保不同实例之间的规则一致性。

  • 引入了额外的依赖:集成 Sentinel 需要引入额外的依赖,增加了项目的复杂度和维护成本。

5. 验证码与行为分析防刷

对于某些敏感操作,如登录、注册、支付等,单纯的限流可能无法有效防止恶意请求。此时,可以结合验证码和行为分析来进一步增强系统的安全性,有效区分人类用户和自动化脚本。

实现步骤

5.1 图形验证码实现

首先,我们需要添加图形验证码的依赖:

<dependency>
    <groupId>com.github.whvcse</groupId>
    <artifactId>easy-captcha</artifactId>
    <version>1.6.2</version>
</dependency>
5.2 创建验证码服务

接下来,我们需要创建一个验证码服务,用于生成和验证验证码。以下是具体的代码实现:

@Service
publicclass CaptchaService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    privatestaticfinallong CAPTCHA_EXPIRE_TIME = 5 * 60; // 5 分钟
    
    /**
     * 生成验证码
     * @param request HTTP 请求
     * @param response HTTP 响应
     * @return 验证码 Base64 字符串
     */
    public String generateCaptcha(HttpServletRequest request, HttpServletResponse response) {
        // 生成验证码
        SpecCaptcha captcha = new SpecCaptcha(130, 48, 5);
        
        // 生成验证码 ID
        String captchaId = UUID.randomUUID().toString();
        
        // 将验证码存入 Redis
        redisTemplate.opsForValue().set(
            "captcha:" + captchaId, 
            captcha.text().toLowerCase(), 
            CAPTCHA_EXPIRE_TIME, 
            TimeUnit.SECONDS
        );
        
        // 设置 Cookie
        Cookie cookie = new Cookie("captchaId", captchaId);
        cookie.setMaxAge((int) CAPTCHA_EXPIRE_TIME);
        cookie.setPath("/");
        response.addCookie(cookie);
        
        // 返回 Base64 编码的验证码图片
        return captcha.toBase64();
    }
    
    /**
     * 验证验证码
     * @param request HTTP 请求
     * @param captchaCode 用户输入的验证码
     * @return 是否验证通过
     */
    public boolean validateCaptcha(HttpServletRequest request, String captchaCode) {
        // 从 Cookie 获取验证码 ID
        Cookie[] cookies = request.getCookies();
        String captchaId = null;
        
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("captchaId".equals(cookie.getName())) {
                    captchaId = cookie.getValue();
                    break;
                }
            }
        }
        
        if (captchaId == null) {
            returnfalse;
        }
        
        // 从 Redis 获取正确的验证码
        String key = "captcha:" + captchaId;
        String correctCode = redisTemplate.opsForValue().get(key);
        
        // 验证成功后删除验证码
        if (correctCode != null && correctCode.equals(captchaCode.toLowerCase())) {
            redisTemplate.delete(key);
            returntrue;
        }
        
        returnfalse;
    }
}

在这个服务中,我们使用 easy-captcha 库生成验证码,并将验证码存入 Redis 中。在验证验证码时,我们从 Cookie 中获取验证码 ID,然后从 Redis 中获取正确的验证码进行比对。

5.3 创建验证码控制器

为了方便前端获取验证码,我们需要创建一个验证码控制器。以下是具体的代码实现:

@RestController
@RequestMapping("/api/captcha")
public class CaptchaController {
    
    @Autowired
    private CaptchaService captchaService;
    
    @GetMapping
    public Map<String, String> getCaptcha(HttpServletRequest request, HttpServletResponse response) {
        String base64 = captchaService.generateCaptcha(request, response);
        return Map.of("captcha", base64);
    }
}

在这个控制器中,我们提供了一个 GET 请求接口,用于生成验证码并返回 Base64 编码的验证码图片。

5.4 创建验证码注解

为了标记需要进行验证码验证的接口方法,我们可以创建一个验证码注解。以下是具体的代码实现:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CaptchaRequired {
    String captchaParam() default "captchaCode";
}

在这个注解中,我们可以通过 captchaParam 属性指定验证码参数的名称。

5.5 实现验证码拦截器

接下来,我们需要实现一个验证码拦截器,在请求进入接口之前进行验证码验证。以下是具体的代码实现:

@Component
publicclass CaptchaInterceptor implements HandlerInterceptor {
    
    @Autowired
    private CaptchaService captchaService;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!(handler instanceof HandlerMethod)) {
            returntrue;
        }
        
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        CaptchaRequired captchaRequired = handlerMethod.getMethodAnnotation(CaptchaRequired.class);
        
        if (captchaRequired == null) {
            returntrue;
        }
        
        // 获取验证码参数
        String captchaParam = captchaRequired.captchaParam();
        String captchaCode = request.getParameter(captchaParam);
        
        if (StringUtils.hasText(captchaCode)) {
            // 验证验证码
            boolean valid = captchaService.validateCaptcha(request, captchaCode);
            if (valid) {
                returntrue;
            }
        }
        
        // 验证失败
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpStatus.BAD_REQUEST.value());
        response.getWriter().write("{\"code\":400,\"message\":\"验证码错误或已过期\"}");
        returnfalse;
    }
}

在这个拦截器中,我们首先判断请求的处理方法是否标记了 @CaptchaRequired 注解。如果标记了,则获取验证码参数并调用 CaptchaService 的 validateCaptcha 方法进行验证。如果验证失败,则返回一个错误响应,提示用户验证码错误或已过期。

5.6 创建行为分析服务

除了验证码,我们还可以通过行为分析来检测可疑的机器行为。以下是具体的代码实现:

@Service
@Slf4j
publicclass BehaviorAnalysisService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * 检查是否是可疑的机器行为
     * @param request HTTP 请求
     * @return 是否可疑
     */
    public boolean isSuspicious(HttpServletRequest request) {
        // 1. 获取客户端信息
        String ip = getIpAddress(request);
        String userAgent = request.getHeader("User-Agent");
        String requestId = request.getSession().getId();
        
        // 2. 检查访问频率
        String freqKey = "behavior:freq:" + ip;
        Long count = redisTemplate.opsForValue().increment(freqKey, 1);
        redisTemplate.expire(freqKey, 1, TimeUnit.MINUTES);
        
        if (count != null && count > 30) {
            log.warn("访问频率异常: IP={}, count={}", ip, count);
            returntrue;
        }
        
        // 3. 检查 User-Agent
        if (userAgent == null || isBotuserAgent(userAgent)) {
            log.warn("可疑的 User-Agent: {}", userAgent);
            returntrue;
        }
        
        // 4. 检查请求时间模式
        String timeKey = "behavior:time:" + ip;
        long now = System.currentTimeMillis();
        String lastTimeStr = redisTemplate.opsForValue().get(timeKey);
        
        if (lastTimeStr != null) {
            long lastTime = Long.parseLong(lastTimeStr);
            long interval = now - lastTime;
            
            // 如果请求间隔非常均匀,可能是机器人
            if (isUniformInterval(ip, interval)) {
                log.warn("请求间隔异常均匀: IP={}, interval={}", ip, interval);
                returntrue;
            }
        }
        
        redisTemplate.opsForValue().set(timeKey, String.valueOf(now), 10, TimeUnit.MINUTES);
        
        // 更多高级检测逻辑...
        
        returnfalse;
    }
    
    /**
     * 检查是否是机器人 UA
     */
    private boolean isBotuserAgent(String userAgent) {
        String ua = userAgent.toLowerCase();
        return ua.contains("bot") || ua.contains("spider") || ua.contains("crawl") ||
               ua.isEmpty() || ua.length() < 40;
    }
    
    /**
     * 检查请求间隔是否异常均匀
     */
    private boolean isUniformInterval(String ip, long interval) {
        String key = "behavior:intervals:" + ip;
        
        // 获取最近的几个间隔
        List<String> intervalStrs = redisTemplate.opsForList().range(key, 0, 4);
        redisTemplate.opsForList().leftPush(key, String.valueOf(interval));
        redisTemplate.opsForList().trim(key, 0, 9);  // 只保留最近 10 个
        redisTemplate.expire(key, 10, TimeUnit.MINUTES);
        
        if (intervalStrs == null || intervalStrs.size() < 5) {
            returnfalse;
        }
        
        // 计算间隔的方差,方差小说明请求间隔很均匀
        List<Long> intervals = intervalStrs.stream()
                .map(Long::parseLong)
                .collect(Collectors.toList());
        
        double mean = intervals.stream().mapToLong(Long::longValue).average().orElse(0);
        double variance = intervals.stream()
                .mapToDouble(i -> Math.pow(i - mean, 2))
                .average()
                .orElse(0);
        
        return variance < 100;  // 方差阈值,需要根据实际情况调整
    }
    
    // getIpAddress 方法同上
}

在这个服务中,我们从多个维度对请求进行分析,包括访问频率、User-Agent 和请求时间模式。如果发现可疑的行为,我们会记录日志并返回 true

5.7 创建行为分析拦截器

最后,我们需要创建一个行为分析拦截器,在请求进入接口之前进行行为分析。以下是具体的代码实现:

@Component
publicclass BehaviorAnalysisInterceptor implements HandlerInterceptor {
    
    @Autowired
    private BehaviorAnalysisService behaviorAnalysisService;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 对于需要保护的端点进行检查
        String path = request.getRequestURI();
        if (path.startsWith("/api/") && isPotentialRiskEndpoint(path)) {
            boolean suspicious = behaviorAnalysisService.isSuspicious(request);
            
            if (suspicious) {
                // 需要验证码或其他额外验证
                response.setContentType("application/json;charset=UTF-8");
                response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
                response.getWriter().write("{\"code\":429,\"message\":\"检测到异常访问,请进行验证\",\"needCaptcha\":true}");
                returnfalse;
            }
        }
        
        returntrue;
    }
    
    /**
     * 判断是否是高风险端点
     */
    private boolean isPotentialRiskEndpoint(String path) {
        return path.contains("/login") || 
               path.contains("/register") || 
               path.contains("/payment") || 
               path.contains("/order") ||
               path.contains("/password");
    }
}

在这个拦截器中,我们首先判断请求的 URI 是否以 /api/ 开头,并且是否是高风险端点。如果是,则调用 BehaviorAnalysisService 的 isSuspicious 方法进行行为分析。如果发现可疑行为,则返回一个错误响应,提示用户进行验证。

5.8 使用示例

以下是一个使用验证码和行为分析的示例:

@RestController
@RequestMapping("/api")
publicclass UserController {
    
    @CaptchaRequired
    @PostMapping("/login")
    public Result login(@RequestParam String username, 
                        @RequestParam String password,
                        @RequestParam String captchaCode) {
        // 登录逻辑
        return userService.login(username, password);
    }
    
    @CaptchaRequired
    @PostMapping("/register")
    public Result register(@RequestBody UserRegisterDTO registerDTO,
                          @RequestParam String captchaCode) {
        // 注册逻辑
        return userService.register(registerDTO);
    }
}

在这个示例中,我们在 login 和 register 方法上使用了 @CaptchaRequired 注解,要求用户输入验证码进行验证。

优缺点分析

优点
  • 能有效区分人类用户和自动化脚本:通过验证码和行为分析,可以准确地识别出自动化脚本的恶意请求,有效防止刷接口行为。

  • 对恶意用户有较强的阻止作用:验证码和行为分析的双重防护,大大增加了恶意用户的攻击成本,使其难以得逞。

  • 针对敏感操作提供额外安全层:对于登录、注册、支付等敏感操作,验证码和行为分析可以提供额外的安全保障,保护用户的隐私和财产安全。

  • 可以实现自适应安全策略:根据用户的行为特征和系统的安全状况,可以动态调整验证码和行为分析的策略,实现自适应的安全防护。

缺点
  • 增加了用户操作成本,可能影响用户体验:要求用户输入验证码会增加用户的操作步骤,降低用户体验。特别是对于一些频繁操作的用户,可能会感到厌烦。

  • 实现复杂,需要前后端配合:验证码和行为分析的实现涉及到前后端的多个环节,需要前后端密切配合,增加了开发和维护的难度。

  • 某些验证码可能被 OCR 技术破解:虽然现在的验证码技术不断发展,但仍然存在被 OCR 技术破解的风险,需要不断更新和改进验证码的生成和验证方式。

  • 行为分析可能产生误判:行为分析是基于一定的规则和算法进行的,可能会出现误判的情况,将正常用户的行为误判为可疑行为,影响用户的正常使用。

方案对比与选择

方案

实现难度

防刷效果

分布式支持

用户体验

适用场景

基于注解的访问频率限制

需配合 Redis

一般

一般接口,简单场景

令牌桶算法

中高

单机

允许突发流量的场景

分布式限流(Redis+Lua)

支持

一般

分布式系统,精确限流

Sentinel

中高

需额外配置

可配置

复杂系统,多维度防护

验证码与行为分析

支持

较差

敏感操作,关键业务

总结

接口防刷是一个系统性工程,需要综合考虑安全性、用户体验、性能开销和运维复杂度等多方面因素。本文介绍的 5 种方案各有优缺点,你可以根据实际需求灵活选择和组合。

无论采用哪种方案,接口防刷都应该遵循以下原则:

  • 最小影响原则:尽量不影响正常用户的体验,确保系统在安全的前提下能够高效运行。

  • 梯度防护原则:根据接口的重要程度和风险等级,采用不同强度的防护措施,实现精准防护。

  • 可监控原则:提供充分的监控和告警机制,及时发现和处理异常情况,确保系统的安全性和稳定性。

  • 灵活调整原则:支持动态调整防护参数和策略,根据系统的运行情况和安全需求,及时做出调整。

通过合理实施接口防刷策略,可以有效提高系统的安全性和稳定性,为用户提供更好的服务体验。希望本文能对你有所帮助,让你的系统在复杂的网络环境中更加安全可靠!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值