重复提交是开发中最容易遇到的技术坑之一。用户连续点击按钮、网络延迟导致的重试、恶意请求重放,都可能造成订单重复生成、支付重复扣款、数据库重复记录等严重问题。作为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 请求的 Mapconst 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@Slf4jpublic 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
@Componentpublic 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
@Configurationpublic 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 -- 加锁失败endJava调用示例:
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
@Autowiredprivate 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
@Autowiredprivate 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码力】,获取更多技术秘籍!
转载请注明来自海坡下载,本文标题:《前后端分离项目如何进行安全性防护(前后端如何防重复提交6种方案)》
京公网安备11000000000001号
京ICP备11000001号
还没有评论,来说两句吧...