大家好,我是小悟。
一、什么是接口防抖?
想象一下这个场景:用户小张在提交订单时,因为网络延迟,他以为没点中那个“提交”按钮,于是疯狂连击了10次!结果…10个一模一样的订单诞生了!
接口防抖 就像是给按钮加上了一层“冷静期”——“兄弟,你点太快了,先冷静3秒再说!”
防止重复提交 则是更严格的保安大哥——“同样的身份证(请求)只能进一次,想蒙混过关?没门!”
下面我来教你在SpringBoot中布下天罗地网,拦截这些“手抖攻击”!
二、实战方案大集合
方案1:前端防抖 + 后端令牌锁(双保险)
前端防抖代码(JavaScript版):
// 给按钮加个“冷静debuff” let isSubmitting = false; function submitOrder() { if (isSubmitting) { alert("客官您点得太快了,喝口茶歇歇~"); return; } isSubmitting = true; // 提交请求... // 3秒后才能再次点击 setTimeout(() => { isSubmitting = false; }, 3000); }
后端令牌锁实现:
步骤1:创建防抖注解
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface PreventDuplicateSubmit { /** * 防抖时间(秒),默认3秒 */ int lockTime() default 3; /** * 锁的key,支持SpEL表达式 */ String key() default ""; /** * 提示信息 */ String message() default "请勿重复提交"; }
步骤2:实现AOP切面
@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:使用示例
@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:数据库唯一约束(最硬核的方案)
有时候,最简单的最有效!
@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缓存(轻量级方案)
适合单机部署,简单快捷!
@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
@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
@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:前端配合
// 提交前先获取令牌 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机制 | 安全性高,前端可控 | 需要两次请求,增加交互 | 表单提交,需要严格防重 |
四、防抖策略选择指南
- 根据业务重要性选择:
- 金融支付 → 数据库唯一约束 + Redis锁(双重保险)
- 普通表单 → Token机制或AOP锁
- 查询接口 → 本地缓存防抖
- 根据系统架构选择:
- 单机应用 → 本地缓存最香
- 分布式集群 → Redis是王道
- 微服务 → 考虑分布式锁服务
- 实用小贴士:
// 最佳实践:组合拳! @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(); }
五、最后
- 不要过度设计:简单的业务用简单的方案,杀鸡不要用牛刀
- 用户体验很重要:防抖提示要友好,别让用户一脸懵逼
- 监控不能少:记录被拦截的请求,分析用户行为
- 前端也要防:前后端双重防护才是王道
防抖的目的不是为难用户,而是保护系统和数据的安全。就像给你的接口穿上防弹衣,既能抵挡”手抖攻击”,又能让正常请求畅通无阻!
程序员防抖口诀:
前端防抖先出手,后端加锁不能少。令牌机制来帮忙,唯一约束最可靠。根据场景选方案,系统稳定没烦恼。用户手抖不可怕,我有妙招来护驾!
谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。
您的一键三连,是我更新的最大动力,谢谢
山水有相逢,来日皆可期,谢谢阅读,我们再会
我手中的金箍棒,上能通天,下能探海