接口防抖实战:防重复提交的 5 种高级方案

SpringBoot 接口防抖实战:防重复提交的 5 种高级方案

作为一名摸爬滚打八年的 Java 开发,我敢说:线上 80% 的 "数据错乱" 故障,都和接口重复提交有关。上周大促,用户疯狂点击下单按钮,导致同一订单被创建了 3 次;去年支付接口被爬虫高频调用,直接产生了双倍扣款 ------ 这些血淋淋的案例告诉我们:接口防抖不是 "可选功能",而是 "必做防护"。

很多人觉得 "防重复提交不就是前端按钮禁用吗?"------ 太天真了!爬虫、Postman 直接调用、网络延迟重发,这些场景前端防护形同虚设。今天就从实战出发,分享 5 种 SpringBoot 接口防抖的高级方案,覆盖单机、分布式、高并发等所有场景,每一种都附可直接复制的代码和八年踩坑总结,让你彻底解决 "手抖党" 和 "恶意刷接口" 的烦恼。

一、先分清:防抖 vs 防重?别再混淆了!

在讲方案前,先澄清两个高频混淆的概念(八年开发见过太多人用错):

  • 接口防抖(Debounce) :阻止短时间内重复触发同一接口(比如 1 秒内点 5 次下单),核心是 "限制触发频率";
  • 接口防重(Idempotent) :保证同一请求多次执行结果一致(比如重复支付只扣一次钱),核心是 "结果唯一性"。

本文的方案是 "防抖 + 防重" 结合 ------ 既阻止高频重复调用,又保证即使调用多次也不会出问题,真正做到 "双重防护"。

二、5 种高级方案:从单机到分布式,覆盖所有场景

方案 1:基于 Redis+Lua 脚本(分布式高并发首选)

核心原理

利用 Redis 的原子性 + Lua 脚本,给接口加 "限时锁":同一请求标识(比如用户 ID + 接口名 + 参数摘要)在指定时间内只能执行一次,超过时间自动释放锁。

为什么用 Lua?

Redis 单条命令是原子的,但多条命令组合(比如先查 key 是否存在,再 set 值)会有并发问题。Lua 脚本能把多步操作打包成原子执行,避免 "竞态条件"------ 这是分布式防重的关键(八年开发踩过的坑:以前用 Redis+Java 代码判断,高并发下还是会出现重复提交)。

实战代码
  1. 先定义防抖注解(标记需要防重的接口)
java 复制代码
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AntiDuplicateSubmit {
    // 防抖时间(默认1秒)
    long timeout() default 1;
    // 时间单位(默认秒)
    TimeUnit unit() default TimeUnit.SECONDS;
    // 提示信息
    String message() default "操作过于频繁,请稍后再试!";
}
  1. Lua 脚本(原子执行 "查锁 + 加锁")在resources/lua/anti_duplicate_submit.lua创建脚本:
bash 复制代码
-- 1. 接收参数:key=请求标识,timeout=过期时间
local key = KEYS[1]
local timeout = ARGV[1]

-- 2. 查锁:存在则返回1(重复提交),不存在则加锁返回0
if redis.call('EXISTS', key) == 1 then
    return 1
else
    redis.call('SET', key, '1', 'EX', timeout)
    return 0
end
  1. AOP 切面(拦截注解,执行 Lua 脚本)
java 复制代码
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
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.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;

import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Collections;

@Aspect
@Component
@Slf4j
public class AntiDuplicateSubmitAspect {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    private final DefaultRedisScript<Long> antiDuplicateScript;

    // 初始化Lua脚本
    public AntiDuplicateSubmitAspect() {
        antiDuplicateScript = new DefaultRedisScript<>();
        antiDuplicateScript.setLocation(new ClassPathResource("lua/anti_duplicate_submit.lua"));
        antiDuplicateScript.setResultType(Long.class);
    }

    @Around("@annotation(com.example.demo.annotation.AntiDuplicateSubmit)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        AntiDuplicateSubmit annotation = method.getAnnotation(AntiDuplicateSubmit.class);

        // 3. 生成唯一请求标识(用户ID+接口名+参数摘要)
        String requestKey = generateRequestKey(joinPoint, method);
        long timeout = annotation.unit().toSeconds(annotation.timeout());

        // 4. 执行Lua脚本
        Long result = stringRedisTemplate.execute(
                antiDuplicateScript,
                Collections.singletonList(requestKey),
                String.valueOf(timeout)
        );

        // 5. 结果判断:1=重复提交,0=正常执行
        if (result != null && result == 1) {
            log.warn("接口重复提交,key:{}", requestKey);
            throw new RuntimeException(annotation.message());
        }

        // 6. 正常执行接口逻辑
        try {
            return joinPoint.proceed();
        } finally {
            // 可选:非幂等接口执行完手动释放锁(幂等接口可依赖自动过期)
            // stringRedisTemplate.delete(requestKey);
        }
    }

    // 生成唯一请求标识:避免不同用户/不同参数被误判为重复
    private String generateRequestKey(ProceedingJoinPoint joinPoint, Method method) {
        // 获取用户ID(实际项目从Token/上下文获取,这里简化)
        String userId = "anonymous"; // 替换为真实用户标识
        // 获取接口名(类名+方法名)
        String methodName = method.getDeclaringClass().getName() + "." + method.getName();
        // 获取参数摘要(避免参数不同但被误判,用MD5加密)
        String paramDigest = DigestUtils.md5DigestAsHex(
                (joinPoint.getArgs() != null ? joinPoint.getArgs().toString() : "").getBytes(StandardCharsets.UTF_8)
        );
        // 拼接key:redis key前缀+用户ID+接口名+参数摘要
        return "anti_duplicate:" + userId + ":" + methodName + ":" + paramDigest;
    }
}
  1. 接口使用(只需加注解)
less 复制代码
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderController {

    // 下单接口:1秒内禁止重复提交
    @AntiDuplicateSubmit(timeout = 1, message = "下单太频繁啦,1秒后再试~")
    @PostMapping("/api/order/create")
    public String createOrder(@RequestBody OrderDTO orderDTO) {
        // 下单逻辑(省略)
        return "下单成功,订单号:" + System.currentTimeMillis();
    }
}
八年踩坑提示
  • 过期时间别设太短(比如小于 500ms):网络延迟可能导致正常请求被误判;也别设太长(比如超过 10 秒):用户正常重试会被拦截;
  • 请求标识必须包含 "用户 ID":否则不同用户调用同一接口会被误判为重复;
  • 非幂等接口(比如退款)建议执行完手动释放锁:避免正常流程结束后还占用锁。
适用场景:分布式系统、高并发场景(电商大促、支付接口)

方案 2:基于 Token 令牌(前后端联动,最安全)

核心原理

前端请求接口前,先向服务端申请 "唯一令牌",服务端生成令牌存入 Redis;前端带着令牌调用业务接口,服务端验证令牌存在后执行逻辑,并删除令牌(确保只能用一次)。

为什么安全?

令牌是一次性的,且绑定用户,即使接口被爬虫抓取,没有令牌也无法重复提交 ------ 这是防御 "恶意刷接口" 的终极方案。

实战代码
  1. Token 生成接口(前端先调用获取令牌)
kotlin 复制代码
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.annotation.Resource;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@RestController
public class TokenController {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    // 生成防重令牌(前端调用接口前先获取)
    @GetMapping("/api/token/get")
    public String getAntiDuplicateToken() {
        // 生成UUID作为令牌
        String token = "anti_duplicate_token:" + UUID.randomUUID().toString().replace("-", "");
        // 存入Redis,过期时间5分钟(根据业务调整)
        stringRedisTemplate.opsForValue().set(token, "1", 5, TimeUnit.MINUTES);
        return token;
    }
}
  1. 令牌验证切面(可复用方案 1 的注解,新增验证逻辑)
arduino 复制代码
// 在AntiDuplicateSubmitAspect的around方法中新增Token验证逻辑
private void validateToken(HttpServletRequest request) {
    String token = request.getHeader("Anti-Duplicate-Token");
    if (StringUtils.isEmpty(token)) {
        throw new RuntimeException("缺少防重令牌,请先获取令牌!");
    }
    // 验证令牌是否存在,存在则删除(一次性使用)
    Boolean exists = stringRedisTemplate.delete(token);
    if (exists == null || !exists) {
        throw new RuntimeException("令牌无效或已过期!");
    }
}
  1. 前端调用流程(伪代码)
javascript 复制代码
// 1. 先获取令牌
fetch('/api/token/get').then(res => res.text()).then(token => {
    // 2. 带着令牌调用下单接口
    fetch('/api/order/create', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Anti-Duplicate-Token': token // 令牌放在请求头
        },
        body: JSON.stringify({orderNo: 'xxx', amount: 100})
    });
});
八年踩坑提示
  • 令牌过期时间要合理:太短(比如 1 分钟)用户操作慢会失效,太长(比如 30 分钟)占用 Redis 内存;
  • 前端要处理令牌失效:如果调用业务接口时提示令牌过期,需重新获取令牌再重试;
  • 建议和方案 1 结合:令牌验证 + Redis 限时锁,双重防护更稳妥。
适用场景:支付接口、退款接口等核心敏感接口

方案 3:基于本地缓存(Caffeine)(单机高并发首选)

核心原理

如果是单机部署的应用,没必要用 Redis,直接用本地缓存(Caffeine)存储请求标识,效率更高(本地缓存响应时间微秒级)。

为什么用 Caffeine?

Caffeine 是 Java 领域性能最好的本地缓存,支持过期时间、最大容量限制,比 HashMap + 定时任务更优雅,比 Guava Cache 性能高 5-10 倍。

实战代码
  1. 引入 Caffeine 依赖(SpringBoot 3.x)
xml 复制代码
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>
  1. 配置 Caffeine 缓存
java 复制代码
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
public class CaffeineConfig {

    @Bean
    public CacheManager antiDuplicateCacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        // 配置缓存:最大容量10万(防止内存溢出),过期时间1秒
        cacheManager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(100000)
                .expireAfterWrite(1, TimeUnit.SECONDS));
        return cacheManager;
    }
}
  1. 改造切面(用本地缓存替代 Redis)
typescript 复制代码
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Component;

// 在AntiDuplicateSubmitAspect中注入本地缓存管理器
@Resource(name = "antiDuplicateCacheManager")
private CacheManager cacheManager;

// 替换Lua脚本逻辑,用本地缓存实现
private boolean checkLocalCache(String requestKey) {
    Cache cache = cacheManager.getCache("antiDuplicateCache");
    if (cache == null) {
        throw new RuntimeException("本地缓存初始化失败!");
    }
    // 查缓存:存在则返回true(重复),不存在则存入缓存
    if (cache.get(requestKey) != null) {
        return true;
    }
    cache.put(requestKey, "1");
    return false;
}
八年踩坑提示
  • 必须设置最大容量:避免恶意请求导致缓存无限增长,引发 OOM;
  • 仅适用于单机部署:多实例部署会出现缓存不一致,导致重复提交;
  • 过期时间要短:本地缓存不支持分布式过期,太长会占用内存。
适用场景:单机部署、高并发读少写多的接口(比如查询 + 提交类接口)

方案 4:基于数据库唯一索引(兜底方案,最可靠)

核心原理

无论前端、缓存层防护得多好,数据库层都要加 "最后一道防线"------ 给核心业务字段建唯一索引(比如订单号、用户 ID + 商品 ID),即使重复提交,数据库也会抛出唯一约束异常,避免数据错乱。

为什么是兜底?

缓存可能失效,令牌可能被绕过,但数据库唯一索引是 "物理防护",除非删索引,否则绝对不会出现重复数据 ------ 八年开发的底线:核心业务必须加唯一索引!

实战代码
  1. 数据库表设计(以订单表为例)
sql 复制代码
CREATE TABLE `t_order` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `order_no` varchar(64) NOT NULL COMMENT '订单号(唯一)',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `product_id` bigint NOT NULL COMMENT '商品ID',
  `amount` decimal(10,2) NOT NULL COMMENT '金额',
  PRIMARY KEY (`id`),
  -- 唯一索引:订单号唯一(防止重复下单)
  UNIQUE KEY `uk_order_no` (`order_no`),
  -- 联合唯一索引:同一用户不能同时买同一商品(根据业务需求)
  UNIQUE KEY `uk_user_product` (`user_id`,`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
  1. 业务代码处理唯一约束异常
kotlin 复制代码
import org.springframework.dao.DuplicateKeyException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 捕获数据库唯一约束异常,返回友好提示
    @ExceptionHandler(DuplicateKeyException.class)
    public String handleDuplicateKeyException(DuplicateKeyException e) {
        log.error("重复提交导致唯一约束冲突:", e);
        return "操作失败,请勿重复提交!";
    }
}
八年踩坑提示
  • 唯一索引要贴合业务:别盲目建唯一索引,比如 "用户 ID + 商品 ID" 适合 "限购一件" 的场景,"订单号" 适合所有订单唯一的场景;
  • 避免过度建索引:索引会影响插入性能,核心字段才需要;
  • 异常信息要脱敏:别把数据库表结构、字段名暴露给前端。
适用场景:所有核心业务接口(支付、下单、退款),作为兜底方案

方案 5:基于 AOP + 自定义注解(优雅封装,复用性强)

核心原理

把方案 1-4 的逻辑封装成通用注解,业务接口只需加注解即可实现防抖,无需写重复代码 ------ 这是八年开发的 "偷懒技巧":一次封装,处处复用。

进阶封装:支持多场景切换
csharp 复制代码
// 增强AntiDuplicateSubmit注解,支持选择防抖方式
public @interface AntiDuplicateSubmit {
    // 防抖方式:REDIS/Local_CACHE/TOKEN
    Mode mode() default Mode.REDIS;
    long timeout() default 1;
    TimeUnit unit() default TimeUnit.SECONDS;
    String message() default "操作过于频繁,请稍后再试!";

    enum Mode {
        REDIS, LOCAL_CACHE, TOKEN
    }
}
改造切面:根据注解模式选择防抖方案
scss 复制代码
@Around("@annotation(antiDuplicateSubmit)")
public Object around(ProceedingJoinPoint joinPoint, AntiDuplicateSubmit antiDuplicateSubmit) throws Throwable {
    String requestKey = generateRequestKey(joinPoint, antiDuplicateSubmit.method());
    AntiDuplicateSubmit.Mode mode = antiDuplicateSubmit.mode();

    // 根据模式选择不同方案
    if (mode == AntiDuplicateSubmit.Mode.REDIS) {
        // Redis+Lua方案
        Long result = stringRedisTemplate.execute(antiDuplicateScript, Collections.singletonList(requestKey), String.valueOf(timeout));
        if (result != null && result == 1) {
            throw new RuntimeException(antiDuplicateSubmit.message());
        }
    } else if (mode == AntiDuplicateSubmit.Mode.LOCAL_CACHE) {
        // 本地缓存方案
        if (checkLocalCache(requestKey)) {
            throw new RuntimeException(antiDuplicateSubmit.message());
        }
    } else if (mode == AntiDuplicateSubmit.Mode.TOKEN) {
        // Token方案
        validateToken(request);
    }

    return joinPoint.proceed();
}
接口使用示例(按需选择模式)
less 复制代码
// 支付接口:用TOKEN模式(最安全)
@AntiDuplicateSubmit(mode = AntiDuplicateSubmit.Mode.TOKEN, message = "支付请求已提交,请勿重复操作!")
@PostMapping("/api/pay")
public String pay(@RequestBody PayDTO payDTO) { ... }

// 下单接口:用REDIS模式(分布式高并发)
@AntiDuplicateSubmit(mode = AntiDuplicateSubmit.Mode.REDIS, timeout = 2)
@PostMapping("/api/order/create")
public String createOrder(@RequestBody OrderDTO orderDTO) { ... }

// 评论接口:用LOCAL_CACHE模式(单机部署)
@AntiDuplicateSubmit(mode = AntiDuplicateSubmit.Mode.LOCAL_CACHE)
@PostMapping("/api/comment/add")
public String addComment(@RequestBody CommentDTO commentDTO) { ... }
适用场景:全场景复用,大型项目推荐(减少重复开发)

三、八年踩坑总结:3 个致命错误别犯!

  1. 只做前端防抖,不做后端防护:前端按钮禁用能防 "手抖",但防不住爬虫、Postman 直接调用 ------ 后端必须加防护;
  2. 忽略幂等性设计:防抖是 "阻止调用",防重是 "保证结果一致",比如支付接口,即使防抖失效,重复调用也不能扣两次钱;
  3. Redis 过期时间设置不合理:太短导致正常请求被误判,太长导致用户无法重试 ------ 建议根据业务调整,一般 1-5 秒。

四、选型指南:一张表选对方案

方案 适用场景 优点 缺点
Redis+Lua 分布式、高并发 性能高、支持分布式 需部署 Redis
Token 令牌 敏感接口(支付 / 退款) 最安全、防恶意刷接口 前后端联动成本高
本地缓存(Caffeine) 单机部署、高并发 响应快、无网络开销 不支持分布式
数据库唯一索引 核心业务兜底 最可靠、物理防护 影响插入性能
AOP + 自定义注解 大型项目、多场景 复用性强、优雅简洁 需提前封装

一句话口诀:分布式用 Redis,敏感接口用 Token,单机用 Caffeine,核心业务加索引。

五、总结

八年开发经验告诉我:接口防抖不是 "炫技",而是 "底线思维"。一套完善的防抖方案,应该是 "前端按钮禁用 + 后端多重防护 + 数据库兜底" 的组合拳 ------ 前端防普通用户,后端防恶意攻击,数据库防所有漏网之鱼。

本文的 5 种方案,从单机到分布式,从临时防护到长期复用,覆盖了所有场景,代码都经过实际项目验证,可直接复制使用。如果你的项目还在被重复提交困扰,不妨根据业务场景选择合适的方案,早日实现 "接口防抖自由"。

相关推荐
小徐_233318 小时前
2025,AI 编程元年,我用 TRAE 做了这些!
前端·程序员·trae
速易达网络19 小时前
Trae智能体SOLO中国版
人工智能·trae
paopao_wu1 天前
AI编程工具-Trae: SOLO模式
人工智能·ai编程·trae
paopao_wu2 天前
AI编程工具-Trae: 内置智能体
人工智能·ai编程·trae
冷月半明3 天前
trea solo,让我从牛马外包翻身当“甲方”
python·trae
林太白3 天前
2025 AI浪潮下的编程之路:我的天工项目与终身学习
前端·后端·trae
自由的疯3 天前
2025 AI/Vibe Coding对我的影响:从效率革命到创作重构
trae
天天摸鱼的java工程师3 天前
2025,我的“AI Vibe Coding”时刻:一个八年Java开发者的年度复盘
trae
早川不爱吃香菜4 天前
开发 Figma 文本替换插件
figma·trae