
实战|防重复提交6种方案:从按钮禁用到AOP全自动防护
重复提交是开发中最容易遇到的技术坑之一。用户连续点击按钮、网络延迟导致的重试、恶意请求重放,都可能造成订单重复生成、支付重复扣款、数据库重复记录等严重问题。作为Java开发者,我们需要构建一套从前端到后端的完整防御体系——前端防误操作,后端防攻击,分布式环境下还要解决集群部署的一致性问题。今天就带大家系统梳理6种防重复提交方案,从简单的按钮禁用到底层的AOP全自动防护,附完整代码实现和踩坑指南。
前端防重复提交:第一道防线
前端方案不能替代后端校验,但能有效减少用户误操作,提升体验。这层防御就像给大门装了把手,虽然挡不住专业窃贼,但能拦住大部分无意的碰撞。
按钮禁用:最简单直接的用户体验优化
用户点击提交按钮后,如果没有任何反馈,很可能会再次点击。按钮禁用就是在点击后立即将按钮置为不可用状态,直到请求完成或失败。
实现原理:通过JavaScript监听按钮点击事件,在请求发送前禁用按钮并修改文案(如“提交中…”),请求完成(成功/失败)后恢复按钮状态。
核心代码片段:
html
<button id="submitBtn" onclick="submitForm()">提交订单</button>
<script>
function submitForm() {
const btn = document.getElementById("submitBtn");
// 防止重复点击
if (btn.disabled) return;
btn.disabled = true;
btn.innerText = "提交中...";
// 发送请求
fetch("/api/order/submit", {
method: "POST",
body: JSON.stringify({ /* 订单数据 */ })
}).then(res => {
if (res.ok) {
alert("提交成功!");
} else {
alert("提交失败,请重试");
}
}).catch(err => {
alert("网络异常,请稍后重试");
}).finally(() => {
// 无论成功失败,恢复按钮状态(根据业务可调整)
btn.disabled = false;
btn.innerText = "提交订单";
});
}
</script>
适用场景:所有表单提交场景,尤其是用户交互频繁的页面(如订单提交、评论发布)。
优缺点:✅ 优点:实现简单,用户体验好,能有效防止手滑连续点击。❌ 缺点:可被轻易绕过(如F12修改disabled属性、直接调用submitForm函数),安全性极低。
部署注意事项:必须配合后端校验,不能单独作为防重提交方案;按钮文案需清晰提示状态(如“提交中…”而非直接变灰无提示)。
防抖函数:控制请求触发频率
当用户快速点击按钮时,防抖函数可以确保在指定时间内只执行一次请求。列如设置1秒防抖,用户连续点击5次,只会在最后一次点击后1秒执行请求。
实现原理:通过定时器延迟执行请求,每次触发时清除上一个定时器,重新计时。
核心代码片段:
javascript
// 防抖函数实现
function debounce(func, wait) {
let timeout = null;
return function() {
const context = this;
const args = arguments;
// 清除上一次定时器
clearTimeout(timeout);
// 重新设置定时器
timeout = setTimeout(() => {
func.apply(context, args);
}, wait);
};
}
// 使用示例:1秒内连续点击只执行一次
const submitOrder = debounce(function() {
fetch("/api/order/submit", { method: "POST" });
}, 1000);
// 按钮绑定防抖后的函数
document.getElementById("submitBtn").onclick = submitOrder;
适用场景:搜索框输入联想、高频点击按钮(如点赞、收藏)、表单实时保存。
优缺点:✅ 优点:减少无效请求,提升性能;实现简单,兼容性好。❌ 缺点:无法完全防止重复提交(如用户间隔超过防抖时间点击);同样可被绕过。
部署注意事项:防抖时间需根据业务调整(表单提交提议500-1000ms,搜索联想可缩短至300ms);需提示用户“操作处理中”,避免用户因无反馈重复操作。
请求拦截:阻止重复发送一样请求
通过拦截器记录未完成的请求,若一样请求再次发送则直接拦截。这里的“一样”一般指URL+参数完全一致。
实现原理:使用Axios拦截器,请求发送前生成唯一请求标识(如URL+参数MD5),存入Map;请求完成(成功/失败)后删除标识,若一样标识已存在则拦截请求。
核心代码片段:
javascript
import axios from 'axios';
// 存储 pending 请求的 Map
const pendingRequests = new Map();
// 请求拦截器
axios.interceptors.request.use(config => {
// 生成请求唯一标识(URL + 参数)
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;
},
error => {
// 异常情况也需删除标识
const config = error.config;
if (config) {
const requestKey = `${config.url}/${JSON.stringify(config.data || {})}`;
pendingRequests.delete(requestKey);
}
return Promise.reject(error);
}
);
适用场景:单页应用(SPA)中需要频繁发送请求的场景(如数据表格提交、多表单页)。
优缺点:
✅ 优点:能拦截同一页面的重复请求,无需修改业务代码。
❌ 缺点:无法识别不同页面的一样请求(如两个标签页提交同一订单);参数变化时标识变化,可能失效(如时间戳参数)。
部署注意事项:请求标识生成规则需根据业务调整(如排除无关参数timestamp);需处理请求超时、网络异常等边缘情况,避免标识残留导致后续请求被拦截。
后端防重复提交:核心安全防线
前端方案就像“防盗窗”,能挡住大多数意外,但挡不住专业“窃贼”。后端方案才是最后的“防盗门”,必须做到无法绕过、绝对可靠。
Token机制:经典的表单防重方案
Token机制是最经典的后端防重复提交方案,通过服务端生成唯一Token,前端提交时携带,验证通过后立即失效,确保同一请求只能提交一次。
实现原理:
- 用户访问表单页时,服务端生成唯一Token(如UUID),存入Session/Redis,返回给前端;
- 前端提交表单时携带Token;
- 后端验证Token是否存在且有效,通过后立即删除Token,防止二次使用。
核心代码片段:1. 生成Token(Controller层):
java
@GetMapping("/order/form")
public String getOrderForm(HttpSession session) {
// 生成唯一Token
String token = UUID.randomUUID().toString();
// 存入Session,有效期30分钟
session.setAttribute("ORDER_SUBMIT_TOKEN", token);
session.setMaxInactiveInterval(1800);
// 返回表单页,前端通过隐藏域携带Token
return "orderForm";
}
2. 验证Token(Service层):
java
public boolean validateAndRemoveToken(HttpSession session, String clientToken) {
if (clientToken == null || clientToken.isEmpty()) {
return false; // Token不存在
}
String serverToken = (String) session.getAttribute("ORDER_SUBMIT_TOKEN");
if (serverToken == null || !serverToken.equals(clientToken)) {
return false; // Token不匹配
}
// 验证通过,立即删除Token
session.removeAttribute("ORDER_SUBMIT_TOKEN");
return true;
}
3. 前端表单携带Token:
html
<form action="/api/order/submit" method="post">
<input type="hidden" name="token" value="${ORDER_SUBMIT_TOKEN}">
<!-- 其他表单项 -->
<button type="submit">提交订单</button>
</form>
适用场景:传统表单提交(如订单、支付)、需要防止CSRF攻击的场景。
优缺点:
✅ 优点:安全性高,能有效防止重复提交;可结合Session验证用户身份,防止CSRF。
❌ 缺点:依赖Session(分布式环境需Session共享,如Redis);前后端交互复杂(需先获取Token)。
部署注意事项:分布式环境下需使用Redis存储Token(而非本地Session);Token需设置合理过期时间(如30分钟,避免长期占用内存);验证通过后必须立即删除Token,防止重复使用。
AOP+Redis:分布式环境下的无侵入方案
在微服务或集群部署场景下,AOP+Redis方案通过自定义注解和Redis分布式锁,实现无侵入的防重复提交,是企业级应用的首选方案。
实现原理:
- 自定义防重复提交注解(如@NoRepeatSubmit),标记需要防重的方法;
- 通过AOP切面拦截被注解的方法,生成唯一业务标识(用户ID+接口URI+参数摘要);
- 使用Redis的SETNX命令尝试加锁,成功则执行业务,失败则抛出重复提交异常;
- 业务执行完成后释放锁(或设置自动过期)。
核心代码片段:1. 自定义注解:
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
int lockTime() default 5; // 默认锁定时间5秒
}
2. AOP切面实现:
java
@Aspect
@Component
@Slf4j
public class NoRepeatSubmitAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(noRepeatSubmit)")
public Object around(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) throws Throwable {
// 获取当前请求上下文
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
// 1. 生成业务唯一标识(用户ID + URI + 参数摘要)
String userId = getCurrentUserId(request); // 从Token或Session获取用户ID
String uri = request.getRequestURI();
String params = buildParamsDigest(joinPoint.getArgs()); // 参数MD5摘要
String lockKey = String.format("repeat:submit:%s:%s:%s", userId, uri, params);
// 2. Redis尝试加锁(SETNX + EXPIRE,5秒过期)
Boolean locked = redisTemplate.opsForValue().setIfAbsent(
lockKey, "1", noRepeatSubmit.lockTime(), TimeUnit.SECONDS
);
if (Boolean.TRUE.equals(locked)) {
try {
// 加锁成功,执行业务方法
return joinPoint.proceed();
} finally {
// 可选:业务完成后立即释放锁(根据业务决定,避免锁未释放导致阻塞)
// redisTemplate.delete(lockKey);
}
} else {
// 加锁失败,抛出重复提交异常
log.warn("重复提交拦截:userId={}, uri={}, params={}", userId, uri, params);
throw new BusinessException("操作过于频繁,请稍后再试");
}
}
// 构建参数摘要(MD5避免长参数占用Redis空间)
private String buildParamsDigest(Object[] args) {
if (args == null || args.length == 0) {
return "";
}
try {
return DigestUtils.md5DigestAsHex(new ObjectMapper().writeValueAsBytes(args));
} catch (JsonProcessingException e) {
return "";
}
}
// 获取当前用户ID(示例实现,需根据项目认证方式调整)
private String getCurrentUserId(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader("X-User-Id"))
.orElse("anonymous"); // 未登录用户使用anonymous
}
}
3. 使用方式(Controller层):
java
@RestController
@RequestMapping("/api/order")
public class OrderController {
@PostMapping("/submit")
@NoRepeatSubmit(lockTime = 10) // 锁定10秒
public Result submitOrder(@RequestBody OrderDTO orderDTO) {
// 订单提交业务逻辑
return Result.success(orderService.submit(orderDTO));
}
}
适用场景:微服务、分布式集群、高并发接口(如秒杀、支付回调)。
优缺点:
✅ 优点:无侵入(注解方式);支持分布式;灵活控制锁定时间;可自定义业务标识规则。
❌ 缺点:依赖Redis;键名设计不当可能导致误拦截(如参数未包含关键业务字段)。
部署注意事项:
- 键名设计必须唯一:包含用户ID(区分不同用户)、URI(区分接口)、参数摘要(区分不同请求);
- 锁定时间需合理设置:短于业务平均执行时间(避免业务未完成锁已释放),但也不宜过长(避免死锁导致长期阻塞);
- 避免误删锁:若手动释放锁,需确保只有加锁人能删除(可在Value中存储请求ID,删除时校验)。
拦截器+Redis:全局统一防重方案
拦截器方案将防重复提交逻辑聚焦在拦截器中,通过配置URL规则实现全局控制,适合需要统一管理防重策略的场景。
实现原理:
- 自定义拦截器,实现HandlerInterceptor接口;
- 预处理阶段(preHandle)判断请求是否需要防重(如配置的URL patterns);
- 生成防重标识(类似AOP方案),通过Redis加锁;
- 拦截重复请求,正常请求则放行。
核心代码片段:1. 拦截器实现:
java
@Component
public class RepeatSubmitInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate redisTemplate;
// 配置需要防重的URL(支持通配符)
private static final List<String> ANTI_REPEAT_URLS = Arrays.asList(
"/api/order/submit/ **",
"/api/pay/ **"
);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 判断当前URL是否需要防重
String uri = request.getRequestURI();
if (!isNeedAntiRepeat(uri)) {
return true; // 无需防重,直接放行
}
// 2. 生成防重标识(用户ID + URI + 参数摘要)
String userId = getCurrentUserId(request);
String params = buildParamsDigest(request);
String lockKey = String.format("repeat:submit:interceptor:%s:%s:%s", userId, uri, params);
// 3. Redis加锁(锁定时间5秒)
Boolean locked = redisTemplate.opsForValue().setIfAbsent(
lockKey, "1", 5, TimeUnit.SECONDS
);
if (Boolean.TRUE.equals(locked)) {
// 加锁成功,放行
return true;
} else {
// 重复提交,返回错误响应
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(Result.fail("请勿重复提交")));
return false;
}
}
// 判断URL是否需要防重
private boolean isNeedAntiRepeat(String uri) {
return ANTI_REPEAT_URLS.stream().anyMatch(pattern ->
new AntPathMatcher().match(pattern, uri)
);
}
// 构建请求参数摘要(GET取queryString,POST取body)
private String buildParamsDigest(HttpServletRequest request) {
try {
if ("GET".equalsIgnoreCase(request.getMethod())) {
return DigestUtils.md5DigestAsHex(request.getQueryString() == null ? "" : request.getQueryString().getBytes());
} else {
// POST请求读取body(需配合ContentCachingRequestWrapper,避免流只能读一次)
ContentCachingRequestWrapper wrapper = (ContentCachingRequestWrapper) request;
return DigestUtils.md5DigestAsHex(wrapper.getContentAsByteArray());
}
} catch (Exception e) {
return "";
}
}
// 获取当前用户ID(同上)
private String getCurrentUserId(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader("X-User-Id")).orElse("anonymous");
}
}
2. 注册拦截器(WebMvcConfig):
java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private RepeatSubmitInterceptor repeatSubmitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(repeatSubmitInterceptor)
.addPathPatterns("/ **") // 拦截所有请求
.excludePathPatterns("/api/login", "/api/register"); // 排除无需防重的接口
}
// 解决POST请求body只能读一次问题
@Bean
public FilterRegistrationBean<ContentCachingFilter> contentCachingFilter() {
FilterRegistrationBean<ContentCachingFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new ContentCachingFilter());
registrationBean.addUrlPatterns("/api/*"); // 对API请求启用
return registrationBean;
}
}
适用场景:需要全局统一配置防重策略的系统(如网关层、中台服务)。
优缺点:
✅ 优点:聚焦管理,无需逐个方法加注解;可灵活配置URL规则,适配不同业务。
❌ 缺点:参数处理复杂(POST请求body需缓存);不支持方法级别的个性化配置(如不同接口不同锁定时间)。
部署注意事项:必须配置ContentCachingFilter缓存POST请求body(否则流读取一次后拦截器无法获取参数);URL匹配规则需准确(避免误拦截或漏拦截);与AOP方案二选一,避免重复拦截。
分布式环境进阶:Redis+Lua与ZooKeeper方案
在高并发分布式场景下,基础Redis方案可能存在原子性问题(如SETNX和EXPIRE非原子操作),需要更可靠的分布式锁实现。
Redis+Lua脚本:保证加锁原子性
Redis的SETNX和EXPIRE命令分开执行时,若SETNX成功后服务宕机,EXPIRE未执行,会导致锁永久有效(死锁)。通过Lua脚本可将两个命令合并为原子操作。
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
// 加载Lua脚本
private static final DefaultRedisScript<Long> LOCK_SCRIPT;
static {
LOCK_SCRIPT = new DefaultRedisScript<>();
LOCK_SCRIPT.setScriptText("if redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2], 'NX') then return 1 else return 0 end");
LOCK_SCRIPT.setResultType(Long.class);
}
// 调用脚本加锁
public boolean tryLockWithLua(String key, String value, long expireSeconds) {
Long result = redisTemplate.execute(
LOCK_SCRIPT,
Collections.singletonList(key),
value, String.valueOf(expireSeconds)
);
return result != null && result == 1;
}
优化方案:使用Redisson客户端,内置分布式锁实现,支持可重入锁、看门狗自动续期(避免业务执行超时锁释放):
java
@Autowired
private RedissonClient redissonClient;
public Object executeWithRedissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
RLock lock = redissonClient.getLock(lockKey);
boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS); // 等待3秒,30秒自动释放
if (locked) {
try {
return joinPoint.proceed();
} finally {
lock.unlock();
}
} else {
throw new BusinessException("系统繁忙,请稍后再试");
}
}
ZooKeeper分布式锁:强一致性方案
ZooKeeper基于临时有序节点实现分布式锁,具有强一致性(CP模型),适合对数据一致性要求极高的场景(如金融交易)。
实现原理:
- 客户端在ZooKeeper的/locks节点下创建临时有序子节点(如/locks/order-000000001);
- 客户端获取/locks下所有子节点,判断自己的节点是否为最小序号;
- 若是最小节点,则获取锁;若不是,则监听前一个节点的删除事件;
- 释放锁:客户端断开连接(如服务宕机),临时节点自动删除,后续节点监听触发,竞争锁。
Curator客户端实现:
java
@Autowired
private CuratorFramework curatorFramework;
private static final String LOCK_PATH = "/repeat_submit/order";
public Object executeWithZkLock(ProceedingJoinPoint joinPoint) throws Throwable {
InterProcessMutex lock = new InterProcessMutex(curatorFramework, LOCK_PATH);
try {
// 尝试获取锁,最多等待3秒,获取后锁有效期30秒
if (lock.acquire(3, TimeUnit.SECONDS)) {
return joinPoint.proceed();
} else {
throw new BusinessException("系统繁忙,请稍后再试");
}
} finally {
if (lock.isAcquiredInThisProcess()) {
lock.release(); // 释放锁
}
}
}
Redis vs ZooKeeper:
维度Redis分布式锁ZooKeeper分布式锁一致性模型最终一致性(AP)强一致性(CP)性能高(单机万级QPS)中(依赖ZooKeeper集群性能)可用性高(主从切换自动恢复)中(leader选举期间不可用)适用场景高并发、一致性要求不严格金融交易、数据强一致性场景
方案对比与最佳实践
为了协助大家快速选择合适的方案,整理了所有方案的核心指标对比:
|
方案 |
推荐程度 |
适用场景 |
优点 |
缺点 |
|
前端按钮禁用 |
⚠️ 辅助 |
所有表单 |
实现简单,用户体验好 |
可被绕过,安全性低 |
|
前端防抖 |
⚠️ 辅助 |
高频点击场景(点赞、搜索) |
减少无效请求,提升性能 |
无法完全防止重复提交 |
|
前端请求拦截 |
⚠️ 辅助 |
SPA单页应用 |
拦截同一页面重复请求 |
无法跨页面拦截,参数变化时失效 |
|
后端Token机制 |
✅ 推荐 |
传统表单、CSRF防护 |
安全性高,防CSRF |
依赖Session,分布式需共享 |
|
后端AOP+Redis |
✅✅ 强烈推荐 |
微服务、分布式集群 |
无侵入,支持分布式,灵活配置 |
依赖Redis,键名设计复杂 |
|
后端拦截器+Redis |
✅ 推荐 |
全局统一配置场景 |
聚焦管理,URL规则灵活 |
参数处理复杂,不支持方法级个性化配置 |
|
Redis+Lua |
✅✅ 强烈推荐 |
高并发分布式场景 |
原子性操作,防止死锁 |
需编写Lua脚本,维护成本略高 |
|
ZooKeeper锁 |
✅ 可选 |
金融交易、强一致性场景 |
强一致性,自动释放锁 |
性能较低,依赖ZooKeeper集群 |
最佳实践提议
- 前后端结合:前端防误操作(按钮禁用+防抖)+ 后端最终防护(AOP+Redis/Lua),双重保障。
- 分场景选型: 普通表单:Token机制 + 前端禁用; 微服务接口:AOP+Redis+Lua; 金融交易:ZooKeeper锁 + 数据库唯一索引;
- 关键参数设计:防重标识必须包含用户ID(区分用户)、业务ID(如订单号)、接口标识(URI),避免误拦截。
- 兜底方案:数据库唯一索引(如订单号唯一约束),防止极端情况(如所有锁失效)下的数据重复。
总结
防重复提交是系统稳定性的基础保障,从简单的按钮禁用到复杂的分布式锁,技术方案的选择需结合业务场景、并发量和一致性要求。记住:前端是体验,后端是底线,只有多层次防御才能真正做到万无一失。
技术标签:#分布式锁 #Java并发 #Redis实战 #AOP编程 #防重复提交 #分布式系统 #Lua脚本 #SpringBoot
感谢关注【AI码力】,获取更多技术秘籍!















- 最新
- 最热
只看作者