前言

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

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

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


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

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

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

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

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

1. 按钮禁用

提交后立即禁用按钮,防止用户连续点击。

<button id="submitBtn" onclick="submitForm()">提交</button>
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)

function debounce(func, wait) {
  let timeout;
  return function () {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      func.apply(this, arguments);
    }, wait);
  };
}

// 使用示例
const submitForm = debounce(function() {
  // 提交逻辑
}, 1000);

3. 请求拦截

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. 核心代码实现

// 生成 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. 前端表单

<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. 自定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
    int lockTime() default 5; // 锁定时间(秒)
}

2. AOP 切面实现

@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. 使用方式

@PostMapping("/order/submit")
@NoRepeatSubmit(lockTime = 10)
public Result submitOrder(@RequestBody OrderDTO order) {
    // 业务逻辑
    return Result.success();
}

优点:无侵入、支持分布式、灵活控制锁定时间。


方案三:拦截器 + Token/Redis(增强版)

将 Token 验证逻辑封装在拦截器中,避免每个方法手动校验。

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 脚本保证原子性:

-- 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 中调用:

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% 的开发者都选错了》

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]