重复提交怎么拦截?8种实战方案详解(附代码与避坑指南)
📖 目录导读
- 为什么必须拦截重复提交? – 真实案例与损失分析
- 前端拦截方案 – 按钮防抖、置灰、Token机制
- 后端拦截方案 – 幂等表、Redis锁、数据库唯一索引
- 分布式场景下的终极方案 – 基于Redisson的分布式锁
- 高并发下的陷阱 – 锁过期、ABA问题、降级策略
- Q&A常见问题 – 用户反复刷新/断网重连怎么办?
- 实战代码一键复用 – 拦截器+注解+全局配置
为什么必须拦截重复提交?
真实场景还原
某电商平台用户支付时因网络延迟连点3次“立即付款”,结果:

- 系统创建了3笔相同订单
- 库存扣减3次,导致超卖
- 用户收到3条扣费短信,引发客诉
核心风险:
- 数据不一致(订单、库存、余额)
- 系统资源浪费(重复调用外部接口)
- 业务逻辑错乱(如优惠券被重复使用)
行业共识:
重复提交是系统“慢性毒药”,99%的线上Bug源于未做拦截。
前端拦截方案(第一道防线)
1 按钮防抖(Debounce)
// Vue3 组合式API
const submit = debounce(async () => {
await doSubmit()
}, 300) // 300ms内重复点击只执行最后一次
2 按钮置灰(最基础)
<button :disabled="isSubmitting" @click="handleSubmit">
{{ isSubmitting ? '提交中...' : '提交' }}
</button>
⚠️ 前端方案的致命缺陷
- 用户可绕过(F12控制台直接调用接口)
- 断网重连后,请求被浏览器重放
- 多窗口/多设备操作完全无效
前端拦截仅用于提升体验,不能取代后端防御。
后端拦截方案(核心防线)
1 基于Token的防重复(最优解)
流程:
- 服务端生成唯一token(UUID)
- 前端提交时携带token
- 后端用Redis检验:
- 存在:删除token并执行业务
- 不存在:返回“重复提交”
代码实现(SpringBoot):
@PostMapping("/pay")
public Result pay(@RequestParam String token) {
Boolean success = redisTemplate.delete(token);
if (!Boolean.TRUE.equals(success)) {
return Result.error("订单正在处理,请勿重复提交");
}
// 执行支付逻辑
}
2 幂等表(适用于订单/转账)
CREATE TABLE idempotent (
id BIGINT AUTO_INCREMENT,
biz_key VARCHAR(128) UNIQUE, -- 业务唯一键(如订单号+操作)
PRIMARY KEY (id)
);
原理:插入幂等表时利用唯一索引,重复插入报错。
适用场景:强一致性的金融、支付业务。
3 数据库乐观锁
UPDATE inventory
SET stock = stock - 1
WHERE id = #{id} AND stock > 0; -- 查是否实际修改行数
if (updateRows == 0) {
throw new DuplicateSubmitException();
}
分布式场景下的终极方案(Redisson)
为什么需要分布式锁?
- 单体Redis的
delete-then-execute在并发下可能被绕过 - 节点扩容后,锁必须全局生效
基于Redisson的防重复锁
RLock lock = redissonClient.getFairLock("order:lock:" + orderId);
try {
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
// 执行核心业务
} else {
throw new DuplicateSubmitException("请求正在处理");
}
} finally {
lock.unlock();
}
Redisson优势:
- 自动续期(看门狗机制)
- 公平锁防止饥饿
- 支持哨兵/集群模式
高并发下的避坑指南
❌ 常见错误1:Redis锁过期
场景:业务执行超过锁的过期时间,锁自动释放导致重复提交。
方案:设置合适的过期时间 + Redisson自动续期。
❌ 常见错误2:ABA问题
场景:A请求删除token后,B请求恰好插入一个相同token。
方案:使用Redis的SET NX EX原子命令,key为业务唯一标识。
✅ 降级策略:融断 + 限流
# Sentinel规则
resources:
submitOrder:
degrade:
count: 10
timeWindow: 60 # 60秒内失败10次则融断
Q&A常见问题
Q1:用户反复刷新页面怎么办?
- 前端:使用
history.replaceState()禁止后退刷新 - 后端:利用Token机制 + 前端请求携带上次的token
Q2:断网重连后请求被重放?
- 方案:为每个请求生成
requestId(前端生成) - 后端校验:同一requestId只执行一次
Q3:接口被恶意刷单?
- 出口:接入WAF(Web应用防火墙)
- 入口:限制同一IP/设备调用的频率
实战代码一键复用
自定义注解 + AOP(推荐)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String key() default ""; // SpEL表达式(如 #order.orderId)
long expire() default 10; // 锁过期时间
}
AOP切面:
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) {
String uniqueKey = parseKey(idempotent, pjp.getArgs());
RLock lock = redissonClient.getLock("idem:" + uniqueKey);
boolean locked = lock.tryLock(0, idempotent.expire(), TimeUnit.SECONDS);
if (!locked) {
throw new DuplicateSubmitException();
}
return pjp.proceed();
}
使用示例:
@Idempotent(key = "#order.id")
public String submitOrder(Order order) {
// 业务逻辑
}
构建拦截重复提交的黄金法则
- 前端:按钮置灰 + 防抖(10%作用)
- 后端:Token机制 + 幂等表(80%作用)
- 分布式:Redisson分布式锁(99.9%作用)
- 监控:接入秒级告警(发现重复立即定位)
没有绝对的防重复,只有体系化的防御,建议组合使用2~3层方案,并做好日志记录,以便事后追溯。
最后一问:如果用户通过F12把前端disabled属性改掉怎么办?
答案:依赖后端防御!前端只是“提示器”,后端才是“守门员”。