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 代码判断,高并发下还是会出现重复提交)。
实战代码
- 先定义防抖注解(标记需要防重的接口)
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 "操作过于频繁,请稍后再试!";
}
- 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
- 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;
}
}
- 接口使用(只需加注解)
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;前端带着令牌调用业务接口,服务端验证令牌存在后执行逻辑,并删除令牌(确保只能用一次)。
为什么安全?
令牌是一次性的,且绑定用户,即使接口被爬虫抓取,没有令牌也无法重复提交 ------ 这是防御 "恶意刷接口" 的终极方案。
实战代码
- 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 的注解,新增验证逻辑)
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("令牌无效或已过期!");
}
}
- 前端调用流程(伪代码)
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 倍。
实战代码
- 引入 Caffeine 依赖(SpringBoot 3.x)
xml
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
- 配置 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;
}
}
- 改造切面(用本地缓存替代 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),即使重复提交,数据库也会抛出唯一约束异常,避免数据错乱。
为什么是兜底?
缓存可能失效,令牌可能被绕过,但数据库唯一索引是 "物理防护",除非删索引,否则绝对不会出现重复数据 ------ 八年开发的底线:核心业务必须加唯一索引!
实战代码
- 数据库表设计(以订单表为例)
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='订单表';
- 业务代码处理唯一约束异常
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 个致命错误别犯!
- 只做前端防抖,不做后端防护:前端按钮禁用能防 "手抖",但防不住爬虫、Postman 直接调用 ------ 后端必须加防护;
- 忽略幂等性设计:防抖是 "阻止调用",防重是 "保证结果一致",比如支付接口,即使防抖失效,重复调用也不能扣两次钱;
- Redis 过期时间设置不合理:太短导致正常请求被误判,太长导致用户无法重试 ------ 建议根据业务调整,一般 1-5 秒。
四、选型指南:一张表选对方案
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Redis+Lua | 分布式、高并发 | 性能高、支持分布式 | 需部署 Redis |
| Token 令牌 | 敏感接口(支付 / 退款) | 最安全、防恶意刷接口 | 前后端联动成本高 |
| 本地缓存(Caffeine) | 单机部署、高并发 | 响应快、无网络开销 | 不支持分布式 |
| 数据库唯一索引 | 核心业务兜底 | 最可靠、物理防护 | 影响插入性能 |
| AOP + 自定义注解 | 大型项目、多场景 | 复用性强、优雅简洁 | 需提前封装 |
一句话口诀:分布式用 Redis,敏感接口用 Token,单机用 Caffeine,核心业务加索引。
五、总结
八年开发经验告诉我:接口防抖不是 "炫技",而是 "底线思维"。一套完善的防抖方案,应该是 "前端按钮禁用 + 后端多重防护 + 数据库兜底" 的组合拳 ------ 前端防普通用户,后端防恶意攻击,数据库防所有漏网之鱼。
本文的 5 种方案,从单机到分布式,从临时防护到长期复用,覆盖了所有场景,代码都经过实际项目验证,可直接复制使用。如果你的项目还在被重复提交困扰,不妨根据业务场景选择合适的方案,早日实现 "接口防抖自由"。