一、什么是重复提交?
重复提交指用户在短时间内多次发送相同请求到服务端,导致数据被多次处理的现象。常见场景包括:
- 用户快速点击两次提交按钮
- 网络延迟导致客户端重复发送请求
- 浏览器刷新/后退后重新提交表单
危害:
- 数据库产生重复数据(如重复订单)
- 业务逻辑异常(如积分被多次扣除)
- 服务器资源浪费,影响系统性能
二、重复提交的常见原因
1. 用户操作层面
- 多次点击提交按钮:网络卡顿、信号差时,尤其是前端未做防抖处理时
- 浏览器行为:刷新、回退后重新提交
- 弱网环境重试:客户端自动重发未收到响应的请求
2. 系统设计层面
- 非幂等接口设计:相同请求多次执行结果不一致
- 缺乏并发控制:未对并发请求做同步处理
三、解决方案分类
1. 前端控制(辅助方案)
// 提交后禁用按钮
document.getElementById("submitBtn").disabled = true;
// 使用防抖函数(500ms内只执行一次)
let isSubmitting = false;
function submitForm() {
if (isSubmitting) return;
isSubmitting = true;
// 提交逻辑...
}
适用场景:简单表单提交
缺点:可通过禁用JS或直接调用API绕过
2. Token令牌机制(推荐)
实现步骤:
- 生成Token:服务端生成唯一Token存入Session/Redis
- 传递Token:将Token放入表单隐藏域或请求头
- 验证Token:服务端校验Token是否存在且匹配
- 销毁Token:处理成功后立即移除Token
// Spring Boot示例
@GetMapping("/form")
public String showForm(Model model, HttpSession session) {
String token = UUID.randomUUID().toString();
session.setAttribute("formToken", token);
model.addAttribute("token", token);
return "form-page";
}
@PostMapping("/submit")
public ResponseEntity<?> submitForm(@RequestParam String token, HttpSession session) {
String serverToken = (String) session.getAttribute("formToken");
if (serverToken == null || !serverToken.equals(token)) {
return ResponseEntity.badRequest().body("重复提交!");
}
session.removeAttribute("formToken");
// 处理业务逻辑...
return ResponseEntity.ok("提交成功");
}
优点:安全性高,可防御恶意重复提交
缺点:需维护Token状态,分布式环境下需用Redis
3. 基于AOP+注解的防重提交(生产推荐)
3.1 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
int lockTime() default 5; // 锁定时间(秒)
String key() default ""; // 自定义业务键
}
3.2 AOP切面实现
@Aspect
@Component
public class RepeatSubmitAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Around("@annotation(noRepeatSubmit)")
public Object around(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String key = buildKey(request, noRepeatSubmit);
if (redisTemplate.hasKey(key)) {
throw new RuntimeException("请勿重复提交");
}
redisTemplate.opsForValue().set(key, "", noRepeatSubmit.lockTime(), TimeUnit.SECONDS);
return pjp.proceed();
}
private String buildKey(HttpServletRequest request, NoRepeatSubmit annotation) {
return String.format("submit:lock:%s:%s",
annotation.key(),
request.getSession().getId() + ":" + request.getRequestURI());
}
}
3.3 使用示例
@PostMapping("/order")
@NoRepeatSubmit(lockTime = 10, key = "createOrder")
public ApiResult createOrder(@RequestBody OrderDTO dto) {
// 创建订单逻辑...
}
技术栈:Spring AOP + Redis
优点:
- 通过注解灵活控制接口防重
- 支持分布式环境
- 可扩展自定义防重策略
4. 数据库层防护
4.1 唯一索引
ALTER TABLE orders ADD UNIQUE KEY uk_order_no (order_no);
4.2 乐观锁
// 更新时校验版本号
UPDATE account
SET balance = balance - 100, version = version + 1
WHERE id = 123 AND version = 1;
适用场景:
- 核心业务数据防重(如订单号)
- 高并发扣减类操作
四、方案选型建议
方案 | 适用场景 | 优点 | 缺点 |
---|---|---|---|
前端控制 | 简单表单 | 实现简单 | 安全性低 |
Token机制 | 传统Web表单 | 安全性较好 | 需维护Session/Redis |
AOP+Redis | 分布式系统接口 | 灵活、可扩展性强 | 增加Redis依赖 |
数据库约束 | 核心数据强一致性要求 | 数据层最终保障 | 增加数据库压力 |
五、幂等性设计最佳实践
- HTTP方法规范:
- GET:天然幂等
- PUT/DELETE:应实现幂等
- POST:需业务层保证
- 幂等Token设计:
// 生成全局唯一业务ID
String bizId = IdWorker.get32UUID();
- 请求签名验证:
String sign = DigestUtils.md5Hex(requestParam + timestamp + secretKey);
六、测试与验证
使用JMeter模拟并发提交:
<!-- JMeter测试计划示例 -->
<ThreadGroup>
<LoopController loops="5" />
<HTTPSampler>
<POST>/api/submit</POST>
<Arguments>
<Argument name="data" value="test_data" />
</Arguments>
</HTTPSampler>
</ThreadGroup>
预期结果:多个请求中仅第一个成功,后续返回防重提示
总结
防止重复提交需从前端、服务端到数据库多层防护。对于Java技术栈,推荐组合使用:
- 前端防抖 + Token机制 处理基础场景
- AOP+Redis注解 实现分布式接口防护
- 数据库唯一约束 作为最终保障
根据实际业务场景选择合适的组合方案,可有效提升系统健壮性。示例代码已验证,可直接应用于生产环境。