前后端防重复提交的 6 种落地实现:从按钮禁用到 AOP 全自动防护

前言

日常开发中,我们总会遇到重复提交的问题。用户点击按钮过快,连续提交同一表单,或者接口响应慢时反复重试等。

这些情况都可能导致重复提交。不仅影响用户体验,更会带来数据错误、业务混乱等严重问题。

这篇文章,我们来系统的梳理 前后端防止重复提交的几种主流方案


一、为什么需要防重复提交?

用户在提交表单时,由于网络延迟、页面无反馈等原因,很容易连续点击多次提交按钮。如果后端不做控制,就可能导致:

  • 订单重复生成
  • 支付重复扣款
  • 抽奖机会被多次消耗
  • 数据库插入重复记录

核心原则 :前端可防误操作,但后端才是最终防线


二、前端防重复提交(第一道防线)

前端方案不能替代后端校验,但能提升用户体验。

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. 流程说明

  1. 用户访问表单页时,服务端生成一个唯一 Token,存入 Session(或 Redis),并返回给前端。
  2. 前端提交表单时携带该 Token。
  3. 后端验证 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 ✅ 推荐 统一控制 集中管理 灵活性略低
数据库唯一索引 ✅ 辅助 有唯一约束的场景 简单 无法阻止请求进入

最佳实践建议

  1. 前后端结合:前端防"误点",后端防"重放"。
  2. Token 机制优先:适合表单类操作,防止CSRF。
  3. 分布式用 Redis:AOP + 注解方式最优雅。
  4. 合理设置过期时间:5~10秒足够,避免影响正常操作。
  5. 日志记录:对重复提交行为打日志,便于排查。
  6. 友好提示:不要返回500,应提示"请勿重复提交"。
  7. 注意幂等性:防重不等于幂等,复杂业务还需设计幂等接口。

防重复提交是保障系统稳定性和数据一致性的基础能力。我们不能依赖用户的不手滑,而应通过技术手段构建多层防御体系。

根据你的业务场景选择合适的方案,必要时组合使用,才能真正做到万无一失。

希望这篇文章对你有帮助!如果有问题或不对的地方,欢迎在评论区留言讨论~

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《Java8 都出这么多年了,Optional 还是没人用?到底卡在哪了?》

《90%的人不知道!Spring官方早已不推荐@Autowired?这3种注入方式你用对了吗?》

《别再写 TypeScript enum了!新枚举方式让 bundle 瞬间小20%》

《Vue3 的 ref 和 reactive 到底用哪个?90% 的开发者都选错了》

相关推荐
lllsure3 小时前
Java Stream API
java·开发语言
chirrupy_hamal3 小时前
IO 流篇
java
程序新视界3 小时前
MySQL的OR条件查询不走索引及解决方案
数据库·后端·mysql
Le1Yu3 小时前
2025-10-6学习笔记
java·笔记·学习
EnCi Zheng3 小时前
JJWT 依赖包完全指南-从入门到精通
java·spring boot
这周也會开心3 小时前
Tomcat本地部署SpringBoot项目
java·spring boot·tomcat
Pr Young4 小时前
MVCC 多版本并发控制
数据库·后端·mysql
IT_陈寒4 小时前
Java并发编程避坑指南:7个常见陷阱与性能提升30%的解决方案
前端·人工智能·后端
Brookty4 小时前
【算法】二分查找(一)朴素二分
java·学习·算法·leetcode·二分查找