前言
日常开发中,我们总会遇到重复提交的问题。用户点击按钮过快,连续提交同一表单,或者接口响应慢时反复重试等。
这些情况都可能导致重复提交。不仅影响用户体验,更会带来数据错误、业务混乱等严重问题。
这篇文章,我们来系统的梳理 前后端防止重复提交的几种主流方案。
一、为什么需要防重复提交?
用户在提交表单时,由于网络延迟、页面无反馈等原因,很容易连续点击多次提交按钮。如果后端不做控制,就可能导致:
- 订单重复生成
- 支付重复扣款
- 抽奖机会被多次消耗
- 数据库插入重复记录
核心原则 :前端可防误操作,但后端才是最终防线。
二、前端防重复提交(第一道防线)
前端方案不能替代后端校验,但能提升用户体验。
1. 按钮禁用
提交后立即禁用按钮,防止用户连续点击。
html
<button id="submitBtn" onclick="submitForm()">提交</button>
javascript
function submitForm() {
const btn = document.getElementById("submitBtn");
if (btn.disabled) return;
btn.disabled = true;
btn.innerText = "提交中...";
// 发送请求
fetch("/order/submit", {
method: "POST",
body: new FormData(document.getElementById("orderForm"))
}).then(res => {
// 成功处理
}).finally(() => {
btn.disabled = false;
btn.innerText = "提交";
});
}
2. 按钮防抖(Debounce)
javascript
function debounce(func, wait) {
let timeout;
return function () {
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(this, arguments);
}, wait);
};
}
// 使用示例
const submitForm = debounce(function() {
// 提交逻辑
}, 1000);
3. 请求拦截
js
const pendingRequests = new Map();
axios.interceptors.request.use(config => {
const requestKey = `${config.url}/${JSON.stringify(config.data)}`;
if (pendingRequests.has(requestKey)) {
return Promise.reject(new Error('请勿重复提交'));
}
pendingRequests.set(requestKey, true);
return config;
});
axios.interceptors.response.use(response => {
const requestKey = `${response.config.url}/${JSON.stringify(response.config.data)}`;
pendingRequests.delete(requestKey);
return response;
});
前端方案优点:用户体验好,防止手滑。
缺点:可被绕过(如F12修改JS、Postman重放请求)。
三、后端防重复提交(核心防线)
方案一:Token 机制
这是最经典的方案,常用于表单提交、支付等场景。
1. 流程说明
- 用户访问表单页时,服务端生成一个唯一 Token,存入 Session(或 Redis),并返回给前端。
- 前端提交表单时携带该 Token。
- 后端验证 Token 是否匹配且存在,验证通过后立即删除 Token,防止二次使用。
2. 核心代码实现
java
// 生成 Token
public String generateToken(HttpServletRequest request) {
String token = UUID.randomUUID().toString();
request.getSession().setAttribute("FORM_TOKEN", token);
return token;
}
// 验证 Token
public boolean validateToken(HttpServletRequest request) {
String clientToken = request.getParameter("token");
if (clientToken == null) return false;
String serverToken = (String) request.getSession().getAttribute("FORM_TOKEN");
if (serverToken == null || !serverToken.equals(clientToken)) {
return false;
}
// ⚠️ 验证通过后必须删除,防止重复使用
request.getSession().removeAttribute("FORM_TOKEN");
return true;
}
3. 前端表单
html
<form action="/order/submit" method="post">
<input type="hidden" name="token" value="${token}">
<!-- 其他表单项 -->
<button type="submit">提交订单</button>
</form>
优点:简单、安全、可防止CSRF(配合SameSite等)。
缺点:依赖 Session,分布式环境下需 Session 共享或使用 Redis 存储 Token。
方案二:基于 AOP + Redis 的防重复提交(推荐)
适用于微服务、集群部署环境,利用 Redis 实现分布式锁。
1. 自定义注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
int lockTime() default 5; // 锁定时间(秒)
}
2. AOP 切面实现
java
@Aspect
@Component
@Slf4j
public class NoRepeatSubmitAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Around("@annotation(noRepeatSubmit)")
public Object around(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String key = buildKey(request);
int lockTime = noRepeatSubmit.lockTime();
// 尝试加锁(SETNX + EXPIRE)
Boolean locked = redisTemplate.opsForValue().setIfAbsent(key, "1", lockTime, TimeUnit.SECONDS);
if (!locked) {
throw new RuntimeException("操作过于频繁,请稍后再试");
}
try {
return pjp.proceed(); // 执行业务方法
} finally {
// 可选:执行完成后立即释放锁(根据业务决定)
// redisTemplate.delete(key);
}
}
private String buildKey(HttpServletRequest request) {
String userId = getCurrentUserId(request); // 从Token或Session获取
String uri = request.getRequestURI();
String params = request.getQueryString() != null ? request.getQueryString() : "";
return "repeat:submit:" + userId + ":" + uri + ":" + DigestUtils.md5Hex(params);
}
private String getCurrentUserId(HttpServletRequest request) {
// 示例:从Header或Session中获取用户ID
return Optional.ofNullable(request.getHeader("X-User-Id"))
.orElse("anonymous");
}
}
3. 使用方式
java
@PostMapping("/order/submit")
@NoRepeatSubmit(lockTime = 10)
public Result submitOrder(@RequestBody OrderDTO order) {
// 业务逻辑
return Result.success();
}
优点:无侵入、支持分布式、灵活控制锁定时间。
注意:
- 键名设计要唯一,建议包含用户ID、URI、参数摘要。
- 避免误删锁(可用 Lua 脚本保证原子性删除)。
方案三:拦截器 + Token/Redis(增强版)
将 Token 验证逻辑封装在拦截器中,避免每个方法手动校验。
java
public class RepeatSubmitInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod)) return true;
HandlerMethod hm = (HandlerMethod) handler;
if (hm.getMethodAnnotation(NoRepeatSubmit.class) == null) return true;
if (isRepeatSubmit(request)) {
throw new RuntimeException("请勿重复提交");
}
return true;
}
private boolean isRepeatSubmit(HttpServletRequest request) {
String key = "submit:" + getSessionId(request) + ":" + request.getRequestURI();
Boolean exists = redisTemplate.hasKey(key);
if (exists) return true;
// 设置锁,过期时间5秒
redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.SECONDS);
return false;
}
}
注册拦截器即可全局生效。
四、分布式环境下的优化:Redis + Lua 脚本防误删
在高并发场景下,直接 SETIFABSENT + EXPIRE
不是原子操作,可能出问题。
推荐使用 Lua 脚本保证原子性:
lua
-- SET key value EX seconds NX
if redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2], 'NX') then
return 1
else
return 0
end
Java 中调用:
java
String script = "if redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2], 'NX') then return 1 else return 0 end";
Boolean result = (Boolean) redisTemplate.execute(
new DefaultRedisScript<>(script, Boolean.class),
Arrays.asList(key), value, String.valueOf(expireTime)
);
更高阶:可结合 Redisson 实现可重入锁、看门狗机制,防止业务执行超时锁被提前释放。
总结
方案 | 是否推荐 | 适用场景 | 优点 | 缺点 |
---|---|---|---|---|
前端禁用按钮 | ⚠️ 辅助 | 所有表单 | 用户体验好 | 可被绕过 |
Session Token | ✅ 推荐 | 单机/Session共享 | 安全、经典 | 分布式需共享Session |
AOP + Redis | ✅✅ 强烈推荐 | 分布式、微服务 | 无侵入、灵活 | 依赖Redis |
拦截器 + Redis | ✅ 推荐 | 统一控制 | 集中管理 | 灵活性略低 |
数据库唯一索引 | ✅ 辅助 | 有唯一约束的场景 | 简单 | 无法阻止请求进入 |
最佳实践建议
- 前后端结合:前端防"误点",后端防"重放"。
- Token 机制优先:适合表单类操作,防止CSRF。
- 分布式用 Redis:AOP + 注解方式最优雅。
- 合理设置过期时间:5~10秒足够,避免影响正常操作。
- 日志记录:对重复提交行为打日志,便于排查。
- 友好提示:不要返回500,应提示"请勿重复提交"。
- 注意幂等性:防重不等于幂等,复杂业务还需设计幂等接口。
防重复提交是保障系统稳定性和数据一致性的基础能力。我们不能依赖用户的不手滑,而应通过技术手段构建多层防御体系。
根据你的业务场景选择合适的方案,必要时组合使用,才能真正做到万无一失。
希望这篇文章对你有帮助!如果有问题或不对的地方,欢迎在评论区留言讨论~
本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期精彩
《Java8 都出这么多年了,Optional 还是没人用?到底卡在哪了?》
《90%的人不知道!Spring官方早已不推荐@Autowired?这3种注入方式你用对了吗?》