前后端如何防重复提交?6种方案,从按钮禁用到AOP全自动防护

前后端如何防重复提交?6种方案,从按钮禁用到AOP全自动防护

实战|防重复提交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,前端提交时携带,验证通过后立即失效,确保同一请求只能提交一次。

实现原理

  1. 用户访问表单页时,服务端生成唯一Token(如UUID),存入Session/Redis,返回给前端;
  2. 前端提交表单时携带Token;
  3. 后端验证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分布式锁,实现无侵入的防重复提交,是企业级应用的首选方案。

实现原理

  1. 自定义防重复提交注解(如@NoRepeatSubmit),标记需要防重的方法;
  2. 通过AOP切面拦截被注解的方法,生成唯一业务标识(用户ID+接口URI+参数摘要);
  3. 使用Redis的SETNX命令尝试加锁,成功则执行业务,失败则抛出重复提交异常;
  4. 业务执行完成后释放锁(或设置自动过期)。

核心代码片段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规则实现全局控制,适合需要统一管理防重策略的场景。

实现原理

  1. 自定义拦截器,实现HandlerInterceptor接口;
  2. 预处理阶段(preHandle)判断请求是否需要防重(如配置的URL patterns);
  3. 生成防重标识(类似AOP方案),通过Redis加锁;
  4. 拦截重复请求,正常请求则放行。

核心代码片段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模型),适合对数据一致性要求极高的场景(如金融交易)。

实现原理

  1. 客户端在ZooKeeper的/locks节点下创建临时有序子节点(如/locks/order-000000001);
  2. 客户端获取/locks下所有子节点,判断自己的节点是否为最小序号;
  3. 若是最小节点,则获取锁;若不是,则监听前一个节点的删除事件;
  4. 释放锁:客户端断开连接(如服务宕机),临时节点自动删除,后续节点监听触发,竞争锁。

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集群

最佳实践提议

  1. 前后端结合:前端防误操作(按钮禁用+防抖)+ 后端最终防护(AOP+Redis/Lua),双重保障。
  2. 分场景选型: 普通表单:Token机制 + 前端禁用; 微服务接口:AOP+Redis+Lua; 金融交易:ZooKeeper锁 + 数据库唯一索引;
  3. 关键参数设计:防重标识必须包含用户ID(区分用户)、业务ID(如订单号)、接口标识(URI),避免误拦截。
  4. 兜底方案:数据库唯一索引(如订单号唯一约束),防止极端情况(如所有锁失效)下的数据重复。

总结

防重复提交是系统稳定性的基础保障,从简单的按钮禁用到复杂的分布式锁,技术方案的选择需结合业务场景、并发量和一致性要求。记住:前端是体验,后端是底线,只有多层次防御才能真正做到万无一失。

技术标签:#分布式锁 #Java并发 #Redis实战 #AOP编程 #防重复提交 #分布式系统 #Lua脚本 #SpringBoot


感谢关注【AI码力】,获取更多技术秘籍!

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 共1条

请登录后发表评论