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

简介: 前端防抖先出手,后端加锁不能少。令牌机制来帮忙,唯一约束最可靠。根据场景选方案,系统稳定没烦恼。用户手抖不可怕,我有妙招来护驾!

大家好,我是小悟。

一、什么是接口防抖

想象一下这个场景:用户小张在提交订单时,因为网络延迟,他以为没点中那个“提交”按钮,于是疯狂连击了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机制 安全性高,前端可控 需要两次请求,增加交互 表单提交,需要严格防重

四、防抖策略选择指南

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


  1. 根据系统架构选择
  • 单机应用 → 本地缓存最香
  • 分布式集群 → Redis是王道
  • 微服务 → 考虑分布式锁服务


  1. 实用小贴士
// 最佳实践:组合拳!
   @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. 前端也要防:前后端双重防护才是王道

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


程序员防抖口诀

前端防抖先出手,后端加锁不能少。令牌机制来帮忙,唯一约束最可靠。根据场景选方案,系统稳定没烦恼。用户手抖不可怕,我有妙招来护驾!

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


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


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

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

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

相关文章
|
23天前
|
安全 Java 程序员
SpringBoot参数配置:一场“我说了算”的奇幻之旅
SpringBoot应用就像一个超级智能的变形金刚,而参数配置就是你手里的万能遥控器!你想让它变成温柔的Hello Kitty模式?调参数!
122 4
|
11天前
|
缓存 供应链 架构师
数据架构是什么?一文讲清数据架构和技术架构的区别
本文系统解析企业数字化核心框架——“4A架构”(业务、数据、应用、技术架构),阐明其严格递进的逻辑链:业务架构定方向(做什么)、数据架构转语言(数据化表达)、应用架构落功能(系统实现)、技术架构保运行(稳定支撑)。破除“重技术轻业务”误区,助企业构建贴合实际、可演进的数字化架构体系。
数据架构是什么?一文讲清数据架构和技术架构的区别
|
29天前
|
存储 人工智能 关系型数据库
OpenClaw怎么可能没痛点?用RDS插件来释放OpenClaw全部潜力
OpenClaw插件是深度介入Agent生命周期的扩展机制,提供24个钩子,支持自动注入知识、持久化记忆等被动式干预。相比Skill/Tool,插件可主动在关键节点(如对话开始/结束)执行逻辑,适用于RAG增强、云化记忆等高级场景。
815 56
OpenClaw怎么可能没痛点?用RDS插件来释放OpenClaw全部潜力
|
20天前
|
人工智能 Linux API
OpenClaw 阿里云秒级部署保姆级教程:从0到1搭建7×24小时AI助手
2026年3月,OpenClaw(原Clawdbot)凭借其轻量化架构、丰富技能生态与大模型适配能力,成为个人与小型团队搭建AI助手的首选方案。阿里云提供专属应用镜像与一键部署能力,可实现“秒级上线”,搭配百炼Coding Plan免费大模型API,无需本地算力即可拥有7×24小时在线的AI智能体。本文提供从服务器选购、端口放行、一键部署、模型配置到本地MacOS/Linux/Windows11联动的全流程保姆级教程,所有命令可直接复制执行,无冗余步骤,零基础也能一次成功。
378 11
|
16天前
|
人工智能 自然语言处理 安全
保姆级零门槛|阿里云部署OpenClaw+智谱GLM-5大模型配置,新手10分钟上手(含避坑指南)
2026年,AI智能体技术迎来爆发式迭代,OpenClaw(曾用名Clawdbot、Moltbot)作为轻量化开源AI自动化助手,凭借“自然语言驱动、多工具协同、零编程门槛”的核心优势,成为个人与轻量团队解锁自动化办公、代码开发、多场景任务处理的首选工具。它无需复杂操作,仅需输入口语化指令,就能自动完成文档整理、网页抓取、代码生成、定时任务、跨平台同步等重复性工作,堪称“7×24小时不下线的私人AI助理”,彻底解放双手、提升效率。
1044 4
|
24天前
|
人工智能 自然语言处理 API
零基础必看:阿里云轻量服务器部署OpenClaw(Clawdbot)完整教程+百炼Coding Plan API配置避坑指南
在AI智能体技术深度落地的2026年,OpenClaw(原Clawdbot,曾用名Moltbot)凭借大模型+技能插件的组合模式,打破了传统AI仅能语言交互的局限,成为个人办公提效、企业轻量协作的核心工具。这款开源AI智能体框架的核心价值的在于“连接大模型大脑与设备执行能力”,不仅能理解自然语言指令,更能直接在云服务器上执行文件管理、日程安排、跨平台自动化等实际任务,真正实现了从“被动问答”到“主动执行”的跨越。其隐私优先的核心理念,让所有数据在用户自己的服务器上处理,永不上传第三方平台,既保证了数据安全,又实现了自主可控,深受对数据敏感的个人和轻量团队青睐。
577 8
|
29天前
|
人工智能 安全 前端开发
阿里开源 Team 版 OpenClaw,5分钟完成本地安装
HiClaw 是 OpenClaw 的升级版,通过引入 Manager Agent 架构和分布式设计,解决了 OpenClaw 在安全性、多任务协作、移动端体验、记忆管理等方面的核心痛点。
1796 60
阿里开源 Team 版 OpenClaw,5分钟完成本地安装
|
18天前
|
消息中间件 缓存 NoSQL
秒杀系统高并发核心优化与落地全指南
本文系统阐述秒杀系统架构设计:剖析瞬时高并发、库存超卖等核心痛点,提出漏斗过滤、读写分离、强一致性等设计原则;详解前端、Nginx、网关、业务、缓存、消息队列及数据库七层优化方案;并给出Redis预扣减+异步落库等生产级解决方案与完整代码实现。
235 3
|
2天前
|
人工智能 缓存 安全
本地跑 Gemma 4 替代 Claude Code?M4 Max 实测告诉你为什么行不通
谷歌 Gemma 4 本地部署对接 Claude Code 的完整踩坑实录���性能分析,M4 Max 128GB 实测数据揭示云端大模型与本地推理的真实差距。
266 4