欢迎光临
我们一直在努力

SpringBoot AOP 实现接口防抖功能

前言

在高并发场景下,接口防抖是一个必备的系统保护机制。它能够有效防止用户频繁点击、网络抖动导致的重复请求,保护系统资源的同时提升用户体验。本文将基于 Spring AOP + 自定义注解,手把手教你实现一套完整的接口防抖方案。

核心概念辨析:防抖 vs 幂等性

在深入代码之前,我们先理清一个常见的概念混淆:

维度接口防抖重复提交防护(幂等性)
核心目标 解决时间密集型问题 解决业务幂等性问题
识别依据 IP + 方法签名 + 时间窗口 业务唯一标识(订单ID、流水号)
存储方式 内存缓存、Redis(TTL) 数据库唯一索引、Redis分布式锁
有效时间 短暂(秒/分钟) 持久化(或业务周期)
业务影响 临时限制,可重试 绝对唯一,不可重试
典型技术 AOP + ConcurrentHashMap 分布式锁 + 数据库事务

防抖场景:用户疯狂点击"发送验证码"按钮、前端网络超时自动重试
幂等场景:支付后页面卡顿刷新导致重复扣款、订单创建后网络中断重新提交

实现方案设计

技术架构

用户请求

Controller 方法(标注 @RateLimit 注解)

AOP 切面拦截

生成唯一标识(IP + 方法签名 + 业务参数)

检查缓存中是否存在最近请求

存在 & 时间窗口内 → 拒绝请求,返回防抖提示

不存在 / 超出时间窗口 → 放行请求,更新缓存时间戳

执行业务逻辑

核心组件

  • 自定义注解 @RateLimit:声明防抖规则
  • AOP 切面 RateLimitAspect:拦截并执行防抖逻辑
  • 异常处理器:统一处理防抖异常返回
  • 代码实现

    1. 自定义防抖注解

    package com.example.annotation;

    import java.lang.annotation.*;
    import java.util.concurrent.TimeUnit;

    /**
    * 接口防抖注解
    * 基于时间窗口限制同一标识的重复请求
    */

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface RateLimit {

    /**
    * 防抖标识的前缀,用于区分不同的防抖场景
    * 默认使用方法全限定名作为key的一部分
    */

    String prefix() default "";

    /**
    * 防抖时间窗口大小
    */

    long timeout() default 5;

    /**
    * 时间单位
    */

    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
    * 防抖提示信息
    */

    String message() default "操作过于频繁,请稍后再试";
    }

    设计要点:

    • prefix:区分不同业务场景,便于日志排查和缓存管理
    • timeout + timeUnit:灵活配置防抖时间窗口
    • message:自定义友好提示,提升用户体验

    2. AOP 切面实现

    package com.example.aspect;

    import com.example.annotation.RateLimit;
    import org.aspectj.lang.ProceedingJoinPoint;
    import org.aspectj.lang.annotation.Around;
    import org.aspectj.lang.annotation.Aspect;
    import org.aspectj.lang.reflect.MethodSignature;
    import org.springframework.stereotype.Component;
    import org.springframework.web.context.request.RequestContextHolder;
    import org.springframework.web.context.request.ServletRequestAttributes;

    import javax.servlet.http.HttpServletRequest;
    import java.lang.reflect.Method;
    import java.util.concurrent.ConcurrentHashMap;
    import java.util.concurrent.TimeUnit;

    /**
    * 接口防抖切面
    * 基于内存实现的简单防抖方案
    * 生产环境建议使用 Redis + Lua 脚本实现分布式防抖
    */

    @Aspect
    @Component
    public class RateLimitAspect {

    /**
    * 使用 ConcurrentHashMap 存储请求时间戳
    * key: 防抖唯一标识
    * value: 上一次请求的时间戳
    */

    private final ConcurrentHashMap<String, Long> requestCache = new ConcurrentHashMap<>();

    @Around("@annotation(rateLimit)")
    public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
    // 1. 获取请求信息
    HttpServletRequest request = getRequest();
    String methodSignature = getMethodSignature(joinPoint);

    // 2. 生成防抖唯一标识
    String limitKey = generateLimitKey(rateLimit, request, joinPoint);

    // 3. 获取当前时间戳
    long currentTime = System.currentTimeMillis();

    // 4. 检查上次请求时间
    Long lastRequestTime = requestCache.get(limitKey);

    if (lastRequestTime != null) {
    // 计算时间差(转换为指定单位)
    long timeDiff = currentTime lastRequestTime;
    long timeoutMillis = rateLimit.timeUnit().toMillis(rateLimit.timeout());

    // 如果在时间窗口内,抛出防抖异常
    if (timeDiff < timeoutMillis) {
    throw new RuntimeException(rateLimit.message());
    }
    }

    // 5. 更新缓存中的请求时间
    requestCache.put(limitKey, currentTime);

    // 6. 执行目标方法
    try {
    return joinPoint.proceed();
    } finally {
    // 可选:执行完成后延迟删除,避免内存泄漏
    // 在实际应用中可以设置定时清理过期数据
    }
    }

    /**
    * 生成防抖唯一标识
    * 规则:prefix + IP + 方法签名 + 业务参数(可选)
    */

    private String generateLimitKey(RateLimit rateLimit, HttpServletRequest request,
    ProceedingJoinPoint joinPoint) {
    StringBuilder keyBuilder = new StringBuilder();

    // 添加自定义前缀
    if (!rateLimit.prefix().isEmpty()) {
    keyBuilder.append(rateLimit.prefix()).append(":");
    }

    // 添加请求 IP(限制同一用户的重复请求)
    String ip = getClientIp(request);
    keyBuilder.append(ip).append(":");

    // 添加方法签名
    String methodSignature = getMethodSignature(joinPoint);
    keyBuilder.append(methodSignature).append(":");

    // 可选:添加业务参数(如用户ID、订单号等)
    Object[] args = joinPoint.getArgs();
    if (args != null && args.length > 0) {
    keyBuilder.append(generateParamKey(args));
    }

    return keyBuilder.toString();
    }

    /**
    * 从参数中生成防抖 key
    */

    private String generateParamKey(Object[] args) {
    if (args.length == 0) {
    return "";
    }
    return String.valueOf(args[0]);
    }

    /**
    * 获取方法签名
    */

    private String getMethodSignature(ProceedingJoinPoint joinPoint) {
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    return method.getDeclaringClass().getSimpleName() + "." + method.getName();
    }

    /**
    * 获取当前请求对象
    */

    private HttpServletRequest getRequest() {
    ServletRequestAttributes attributes =
    (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    return attributes != null ? attributes.getRequest() : null;
    }

    /**
    * 获取客户端真实 IP,考虑代理服务器情况
    */

    private String getClientIp(HttpServletRequest request) {
    if (request == null) {
    return "unknown";
    }

    String ip = request.getHeader("X-Forwarded-For");
    if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
    ip = request.getHeader("X-Real-IP");
    }
    if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
    ip = request.getRemoteAddr();
    }

    // 处理多个 IP 的情况,取第一个
    if (ip != null && ip.contains(",")) {
    ip = ip.split(",")[0].trim();
    }

    return ip != null ? ip : "unknown";
    }
    }

    核心技术点:

  • 线程安全:使用 ConcurrentHashMap 保证并发场景下的线程安全
  • IP 获取:考虑 Nginx 代理等场景,从请求头中获取真实 IP
  • 唯一标识生成:结合 IP、方法签名、业务参数,确保精准拦截
  • 异常处理:业务执行完毕后自动清理缓存,避免内存泄漏
  • 3. 使用示例

    package com.example.controller;

    import com.example.annotation.RateLimit;
    import org.springframework.web.bind.annotation.*;

    import java.util.concurrent.TimeUnit;

    @RestController
    @RequestMapping("/api")
    public class RateLimitDemo {

    /**
    * 场景1:简单的表单提交防抖
    * 5秒内同一IP不允许重复提交
    */

    @PostMapping("/submit")
    @RateLimit(timeout = 5, timeUnit = TimeUnit.SECONDS,
    message = "表单提交过于频繁,请5秒后再试")
    public String submitForm(@RequestParam String username,
    @RequestParam String email) {
    return "表单提交成功!";
    }

    /**
    * 场景2:订单创建防抖
    * 10秒内同一用户+同一商品不允许重复下单
    */

    @PostMapping("/order/create")
    @RateLimit(prefix = "order:create", timeout = 10,
    timeUnit = TimeUnit.SECONDS,
    message = "订单创建请求过于频繁,请稍后再试")
    public String createOrder(@RequestParam Long userId,
    @RequestParam Long productId) {
    return "订单创建成功!订单号:ORD" + System.currentTimeMillis();
    }

    /**
    * 场景3:敏感操作防抖(如密码重置)
    * 1分钟内同一IP只能请求一次
    */

    @PostMapping("/password/reset")
    @RateLimit(timeout = 1, timeUnit = TimeUnit.MINUTES,
    message = "密码重置请求已发送,请1分钟后再试")
    public String resetPassword(@RequestParam String email) {
    return "密码重置邮件已发送,请查收!";
    }

    /**
    * 场景4:短信验证码发送防抖
    * 60秒内同一手机号只能发送一次
    */

    @PostMapping("/sms/send")
    @RateLimit(prefix = "sms:send", timeout = 60,
    timeUnit = TimeUnit.SECONDS,
    message = "验证码发送过于频繁,请60秒后再试")
    public String sendSmsCode(@RequestParam String phoneNumber) {
    return "验证码已发送,有效期5分钟";
    }

    /**
    * 场景5:文件上传防抖
    * 30秒内同一IP+同一文件名不允许重复上传
    */

    @PostMapping("/file/upload")
    @RateLimit(timeout = 30, timeUnit = TimeUnit.SECONDS,
    message = "文件上传过于频繁,请稍后再试")
    public String uploadFile(@RequestParam String fileName) {
    return "文件上传成功!";
    }
    }

    4. 全局异常处理

    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.*;

    @ControllerAdvice
    class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    @ResponseBody
    public ResponseEntity<String> handleRateLimitException(RuntimeException e) {
    // 判断是否为防抖异常
    if (e.getMessage().contains("过于频繁") ||
    e.getMessage().contains("请稍后再试")) {
    return ResponseEntity
    .status(429) // Too Many Requests
    .body(e.getMessage());
    }
    return ResponseEntity.status(500).body("服务器内部错误");
    }
    }

    生产环境优化建议

    1. 分布式场景升级

    当前实现基于本地内存,适用于单体应用。生产环境建议升级为 Redis + Lua 脚本:

    // Redis Lua 脚本示例
    String luaScript =
    "if redis.call('exists', KEYS[1]) == 1 then " +
    " return 0 " +
    "else " +
    " redis.call('setex', KEYS[1], ARGV[1], ARGV[2]) " +
    " return 1 " +
    "end";

    // 在 AOP 中调用
    Long result = redisTemplate.execute(
    new DefaultRedisScript<>(luaScript, Long.class),
    Collections.singletonList(limitKey),
    String.valueOf(timeout),
    String.valueOf(currentTime)
    );

    优势:

    • 分布式环境统一管理防抖状态
    • Redis 自动过期,无需手动清理缓存
    • Lua 脚本保证原子性操作

    2. 参数 key 生成优化

    当前实现使用第一个参数生成 key,实际业务中可以使用 SpEL 表达式 动态提取:

    @RateLimit(prefix = "order", timeout = 10,
    key = "#userId + ':' + #productId")
    public String createOrder(@RequestParam Long userId,
    @RequestParam Long productId) {
    // …
    }

    3. 监控与告警

    • 记录被防抖拦截的请求日志
    • 统计高频拦截的 IP 和接口
    • 设置告警阈值,异常触发告警

    4. 白名单机制

    @RateLimit(prefix = "api", timeout = 5,
    whitelist = {"192.168.1.100", "10.0.0.50"})

    对可信 IP 或内部接口放开防抖限制。

    总结

    本文基于 Spring AOP + 自定义注解,实现了一套完整的接口防抖方案。核心要点:

  • 技术选型:AOP 切面拦截 + 自定义注解声明式配置
  • 防抖标识:结合 IP、方法签名、业务参数生成唯一 key
  • 线程安全:ConcurrentHashMap 保证并发安全
  • 生产升级:分布式环境建议使用 Redis + Lua 脚本
  • 接口防抖是系统保护的第一道防线,与幂等性设计配合使用,能够有效保障系统的稳定性和数据一致性。希望本文对你有所帮助!

    赞(0)
    未经允许不得转载:171主机测评 » SpringBoot AOP 实现接口防抖功能
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址