SpringBoot接口防抖大作战,拒绝“手抖”重复提交!

大家好,我是小悟。

一、什么是接口防抖?(又名:救救那个手抖的程序员)

想象一下这个场景:用户小张在提交订单时,因为网络延迟,他以为没点中那个"提交"按钮,于是疯狂连击了10次!结果...10个一模一样的订单诞生了!

接口防抖 就像是给按钮加上了一层"冷静期"------"兄弟,你点太快了,先冷静3秒再说!"

防止重复提交 则是更严格的保安大哥------"同样的身份证(请求)只能进一次,想蒙混过关?没门!"

下面我来教你在SpringBoot中布下天罗地网,拦截这些"手抖攻击"!


二、实战方案大集合

方案1:前端防抖 + 后端令牌锁(双保险)

前端防抖代码(JavaScript版):

ini 复制代码
// 给按钮加个"冷静debuff"
let isSubmitting = false;

function submitOrder() {
    if (isSubmitting) {
        alert("客官您点得太快了,喝口茶歇歇~");
        return;
    }
    
    isSubmitting = true;
    // 提交请求...
    
    // 3秒后才能再次点击
    setTimeout(() => {
        isSubmitting = false;
    }, 3000);
}

后端令牌锁实现:

步骤1:创建防抖注解

less 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
    /**
     * 防抖时间(秒),默认3秒
     */
    int lockTime() default 3;
    
    /**
     * 锁的key,支持SpEL表达式
     */
    String key() default "";
    
    /**
     * 提示信息
     */
    String message() default "请勿重复提交";
}

步骤2:实现AOP切面

less 复制代码
@Aspect
@Component
@Slf4j
public class DuplicateSubmitAspect {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private HttpServletRequest request;
    
    @Pointcut("@annotation(preventDuplicateSubmit)")
    public void pointcut(PreventDuplicateSubmit preventDuplicateSubmit) {
    }
    
    @Around("pointcut(preventDuplicateSubmit)")
    public Object around(ProceedingJoinPoint joinPoint, 
                        PreventDuplicateSubmit preventDuplicateSubmit) throws Throwable {
        
        // 1. 构造锁的key
        String lockKey = buildLockKey(joinPoint, preventDuplicateSubmit);
        
        // 2. 尝试加锁(setnx操作)
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, "LOCKED", 
                           preventDuplicateSubmit.lockTime(), TimeUnit.SECONDS);
        
        if (Boolean.TRUE.equals(success)) {
            // 加锁成功,执行方法
            try {
                return joinPoint.proceed();
            } finally {
                // 可以根据业务决定是否立即删除锁
                // redisTemplate.delete(lockKey);
            }
        } else {
            // 加锁失败,说明重复提交了
            throw new RuntimeException(preventDuplicateSubmit.message());
        }
    }
    
    private String buildLockKey(ProceedingJoinPoint joinPoint, 
                               PreventDuplicateSubmit annotation) {
        StringBuilder keyBuilder = new StringBuilder("SUBMIT:LOCK:");
        
        // 如果有自定义key
        if (StringUtils.isNotBlank(annotation.key())) {
            keyBuilder.append(parseKey(joinPoint, annotation.key()));
        } else {
            // 默认使用:方法名 + 用户ID + 参数hash
            keyBuilder.append(joinPoint.getSignature().toShortString());
            
            // 加上用户ID(如果有登录)
            String userId = getCurrentUserId();
            if (userId != null) {
                keyBuilder.append(":").append(userId);
            }
            
            // 加上参数摘要
            Object[] args = joinPoint.getArgs();
            if (args.length > 0) {
                String argsHash = DigestUtils.md5DigestAsHex(
                    Arrays.deepToString(args).getBytes()
                ).substring(0, 8);
                keyBuilder.append(":").append(argsHash);
            }
        }
        
        return keyBuilder.toString();
    }
    
    private String getCurrentUserId() {
        // 从Token或Session中获取用户ID
        // 这里简化处理
        return (String) request.getSession().getAttribute("userId");
    }
}

步骤3:使用示例

less 复制代码
@RestController
@RequestMapping("/order")
public class OrderController {
    
    @PostMapping("/create")
    @PreventDuplicateSubmit(lockTime = 5, message = "订单正在处理中,请勿重复提交")
    public ApiResult createOrder(@RequestBody OrderDTO orderDTO) {
        // 业务逻辑
        orderService.create(orderDTO);
        return ApiResult.success("下单成功");
    }
    
    @PostMapping("/pay")
    @PreventDuplicateSubmit(
        key = "'PAY:' + #orderNo + ':' + T(com.example.util.UserUtil).getCurrentUserId()",
        lockTime = 10,
        message = "支付请求已提交,请勿重复操作"
    )
    public ApiResult payOrder(String orderNo) {
        // 支付逻辑
        return ApiResult.success("支付成功");
    }
}

方案2:数据库唯一约束(最硬核的方案)

有时候,最简单的最有效!

less 复制代码
@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    // 业务唯一号:时间戳 + 用户ID + 随机数
    @Column(name = "order_no", unique = true, nullable = false)
    private String orderNo;
    
    // 或者使用请求ID作为防重
    @Column(name = "request_id", unique = true)
    private String requestId;
    
    // ...其他字段
}

@Service
@Slf4j
public class OrderService {
    
    @Transactional(rollbackFor = Exception.class)
    public void createOrder(OrderDTO dto) {
        // 生成唯一请求ID(前端传递或后端生成)
        String requestId = dto.getRequestId();
        if (StringUtils.isBlank(requestId)) {
            requestId = UUID.randomUUID().toString();
        }
        
        // 检查是否已处理过该请求
        if (orderRepository.existsByRequestId(requestId)) {
            log.warn("重复请求被拦截:{}", requestId);
            throw new BusinessException("订单已提交,请勿重复操作");
        }
        
        // 创建订单
        Order order = new Order();
        order.setRequestId(requestId);
        order.setOrderNo(generateOrderNo());
        // ...设置其他字段
        
        try {
            orderRepository.save(order);
        } catch (DataIntegrityViolationException e) {
            // 捕获唯一约束异常
            throw new BusinessException("订单已存在,请勿重复提交");
        }
    }
}

方案3:本地Guava缓存(轻量级方案)

适合单机部署,简单快捷!

typescript 复制代码
@Component
public class LocalDuplicateChecker {
    
    // Guava缓存,3秒自动过期
    private final Cache<String, Boolean> submitCache = CacheBuilder.newBuilder()
            .expireAfterWrite(3, TimeUnit.SECONDS)
            .maximumSize(10000)
            .build();
    
    /**
     * 检查是否重复提交
     * @param key 请求唯一标识
     * @return true=重复提交, false=首次提交
     */
    public boolean isDuplicate(String key) {
        try {
            // 如果key不存在,则放入缓存并返回null
            // 如果key存在,则返回缓存的值
            return submitCache.get(key, () -> {
                // 这个lambda只在key不存在时执行
                return false;
            });
        } catch (ExecutionException e) {
            return true;
        }
    }
    
    /**
     * 手动放入缓存(用于防止并发时多次通过检查)
     */
    public void markAsSubmitted(String key) {
        submitCache.put(key, true);
    }
}

// 使用方式
@RestController
public class ApiController {
    
    @Autowired
    private LocalDuplicateChecker duplicateChecker;
    
    @PostMapping("/api/submit")
    public ApiResult submitData(@RequestBody SubmitData data, 
                               HttpServletRequest request) {
        
        // 构造唯一key:IP + 用户ID + 数据摘要
        String clientIp = request.getRemoteAddr();
        String userId = getCurrentUserId();
        String dataHash = DigestUtils.md5DigestAsHex(
            JSON.toJSONString(data).getBytes()
        ).substring(0, 8);
        
        String lockKey = String.format("SUBMIT:%s:%s:%s", 
                                      clientIp, userId, dataHash);
        
        if (duplicateChecker.isDuplicate(lockKey)) {
            return ApiResult.error("请勿重复提交");
        }
        
        // 标记为已提交
        duplicateChecker.markAsSubmitted(lockKey);
        
        // 执行业务逻辑
        return processData(data);
    }
}

方案4:Token令牌机制(最经典的方案)

这个方案就像发门票,一张票只能进一个人!

步骤1:生成Token

kotlin 复制代码
@RestController
public class TokenController {
    
    @GetMapping("/api/getToken")
    public ApiResult getToken() {
        String token = UUID.randomUUID().toString();
        
        // 存入Redis,有效期5分钟
        redisTemplate.opsForValue().set(
            "SUBMIT_TOKEN:" + token, 
            "VALID", 
            5, TimeUnit.MINUTES
        );
        
        return ApiResult.success(token);
    }
}

步骤2:验证Token

less 复制代码
@Aspect
@Component
public class TokenCheckAspect {
    
    @Pointcut("@annotation(needTokenCheck)")
    public void pointcut(NeedTokenCheck needTokenCheck) {
    }
    
    @Around("pointcut(needTokenCheck)")
    public Object checkToken(ProceedingJoinPoint joinPoint, 
                            NeedTokenCheck needTokenCheck) throws Throwable {
        
        HttpServletRequest request = ((ServletRequestAttributes) 
            RequestContextHolder.getRequestAttributes()).getRequest();
        
        String token = request.getHeader("X-Submit-Token");
        if (StringUtils.isBlank(token)) {
            throw new RuntimeException("提交令牌缺失");
        }
        
        String redisKey = "SUBMIT_TOKEN:" + token;
        String value = (String) redisTemplate.opsForValue().get(redisKey);
        
        if (!"VALID".equals(value)) {
            throw new RuntimeException("无效的提交令牌");
        }
        
        // 删除令牌(一次性使用)
        redisTemplate.delete(redisKey);
        
        return joinPoint.proceed();
    }
}

步骤3:前端配合

javascript 复制代码
// 提交前先获取令牌
async function submitWithToken(data) {
    // 1. 获取令牌
    const token = await fetch('/api/getToken').then(r => r.json());
    
    // 2. 携带令牌提交
    const result = await fetch('/api/submit', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-Submit-Token': token
        },
        body: JSON.stringify(data)
    });
    
    return result;
}

三、方案对比总结

方案 优点 缺点 适用场景
AOP + Redis锁 灵活可控,支持复杂规则 依赖Redis,增加系统复杂度 分布式系统,需要精细控制
数据库唯一约束 绝对可靠,永不漏网 对数据库有压力,需要设计唯一键 核心业务(如支付、订单)
本地缓存 性能极高,零延迟 仅限单机,集群无效 单体应用,高频但非核心接口
Token机制 安全性高,前端可控 需要两次请求,增加交互 表单提交,需要严格防重

四、防抖策略选择指南

  1. 根据业务重要性选择

    • 金融支付 → 数据库唯一约束 + Redis锁(双重保险)
    • 普通表单 → Token机制或AOP锁
    • 查询接口 → 本地缓存防抖
  2. 根据系统架构选择

    • 单机应用 → 本地缓存最香
    • 分布式集群 → Redis是王道
    • 微服务 → 考虑分布式锁服务
  3. 实用小贴士

    less 复制代码
    // 最佳实践:组合拳!
    @PostMapping("/important/submit")
    @PreventDuplicateSubmit(lockTime = 5)
    @Transactional(rollbackFor = Exception.class)
    public ApiResult importantSubmit(@RequestBody @Valid RequestDTO dto) {
        // 1. 检查请求ID是否重复
        checkRequestId(dto.getRequestId());
        
        // 2. 执行业务
        // 3. 数据库唯一约束兜底
        
        return ApiResult.success();
    }

五、最后

  1. 不要过度设计:简单的业务用简单的方案,杀鸡不要用牛刀
  2. 用户体验很重要:防抖提示要友好,别让用户一脸懵逼
  3. 监控不能少:记录被拦截的请求,分析用户行为
  4. 前端也要防:前后端双重防护才是王道

防抖的目的不是为难用户,而是保护系统和数据的安全。就像给你的接口穿上防弹衣,既能抵挡"手抖攻击",又能让正常请求畅通无阻!


程序员防抖口诀

前端防抖先出手,后端加锁不能少。

令牌机制来帮忙,唯一约束最可靠。

根据场景选方案,系统稳定没烦恼。

用户手抖不可怕,我有妙招来护驾!

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。

您的一键三连,是我更新的最大动力,谢谢

山水有相逢,来日皆可期,谢谢阅读,我们再会

我手中的金箍棒,上能通天,下能探海

相关推荐
编程大师哥2 小时前
Boost C++
java·c++·php
网安_秋刀鱼2 小时前
【java安全】shiro鉴权绕过
java·开发语言·安全
与遨游于天地2 小时前
Spring 的10个核心能力,对框架开发的启示
java·后端·spring
独自归家的兔3 小时前
通义千问3-VL-Plus - 界面交互(本地图片改进)
java·人工智能·交互
浔川python社3 小时前
《C++ 小程序编写系列》(第四部):实战:简易图书管理系统(类与对象篇)
java·开发语言·apache
楠枬3 小时前
OpenFeign
java·spring cloud·微服务
是席木木啊3 小时前
基于MinIO Java SDK实现ZIP文件上传的方案与实践
java·开发语言
shiwulou13 小时前
神经网络和深度学习 第三周:浅层神经网络(一)正向传播
后端
计算机毕设指导63 小时前
基于微信小程序的垃圾分类信息系统【源码文末联系】
java·spring boot·mysql·微信小程序·小程序·tomcat·maven