SpringBoot实现接口防重复提交的最佳实践

 更新时间:2026年07月29日 08:36:01   作者:(farerboy)  
在前端网络抖动、用户手抖连击,或者微服务重试机制下,同一个接口可能在极短时间内被多次调用,本文将深入讲解如何利用Redis+AOP+自定义注解实现接口防重复提交,希望对大家有所帮助

在前端网络抖动、用户手抖连击,或者微服务重试机制下,同一个接口可能在极短时间内被多次调用。如果接口不是幂等的(如支付扣款、创建订单),就会导致数据错乱。本文将深入讲解如何利用 Redis + AOP + 自定义注解 构建一套优雅的防重复提交方案。

一、 什么是接口幂等性?

幂等性 (Idempotency) 是指任意多次执行所产生的影响均与一次执行的影响相同。

在 Web 开发中,常见的非幂等场景包括:

  • 用户提交订单时连点两次“支付”,导致扣款两次。
  • 前端网络超时重试,导致插入两条相同的记录。
  • 消息队列(MQ)消费者重试,导致同一条消息被处理多次。

为了保证系统的数据一致性,我们需要对关键接口进行防重复提交处理。

二、 常见解决方案对比

方案原理优点缺点适用场景
数据库唯一索引利用唯一约束拦截重复插入最底层,兜底保障仅适用于插入场景,无法拦截所有业务逻辑所有涉及唯一数据的场景
Token 机制提交前请求 Token,提交时校验并删除通用性强,逻辑清晰需要前端配合,多了一步交互表单提交、关键操作
分布式锁基于请求参数或用户 ID 加锁实现简单,无需前端改代码锁粒度控制复杂,容易误伤正常请求简单防重,非强一致性要求
状态机控制通过记录状态流转(如订单状态)业务层面防重侵入业务代码订单、审批流

本文重点实现:Token 机制 + AOP + Redis,这是目前企业中最通用、体验最好的方案。

三、 核心思路:Token 机制

流程如下:

  1. 用户进入页面或点击提交前,前端调用 /api/token 获取一个唯一的 Token。
  2. 后端将 Token 存入 Redis,设置过期时间(如 5 分钟)。
  3. 前端在提交业务数据时,将 Token 放在 Header 中。
  4. 后端通过 AOP 拦截请求,检查 Redis 中是否存在该 Token:
    • 存在:删除 Token,放行请求。
    • 不存在:说明是重复请求或 Token 已失效,拒绝请求。

为什么先存后删? 利用 Redis 的 del 操作是原子性的,结合 setnx (Set if Not Exists) 可以保证只有一个请求能成功拿到 Token。

四、 实战编码

1. 定义自定义注解

我们希望防重逻辑对业务代码无侵入,只需加一个注解即可。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoRepeatSubmit {

    /**
     * 锁过期时间,默认 5 秒
     */
    long expireTime() default 5;

    /**
     * 提示信息
     */
    String message() default "请勿重复提交";
}

2. Redis 工具类封装

我们需要利用 Redis 的原子操作。这里假设你已经集成了 RedisTemplate

@Component
public class RedisUtil {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String PREFIX = "no_repeat_submit:";

    /**
     * 尝试获取锁 (对应 Redis SETNX)
     * @param key Key
     * @param expireTime 过期时间 (秒)
     * @return 如果 key 不存在则设置成功返回 true,否则返回 false
     */
    public boolean tryLock(String key, long expireTime) {
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(PREFIX + key, "1", expireTime, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    /**
     * 删除锁 (验证通过后删除)
     * 注意:Token 机制通常是获取到就删除,或者验证通过就删除。
     * 如果是分布式锁模式,则不需要这步,依靠过期时间自动释放。
     * 本方案采用 Token 模式:请求来时检查是否有,有则删除并放行。
     */
    public boolean releaseLock(String key) {
        // 这里为了简化,直接删除。实际 Token 机制中,tryLock 返回 true 即代表获取成功。
        // 如果是“检查并删除”的原子操作,需要 Lua 脚本。
        // 但 Token 模式通常是:前端拿 token -> Redis set token -> 提交带 token -> 后端 check and delete token
        // 下面 AOP 中我们将演示“检查并删除”的逻辑
        return Boolean.TRUE.equals(redisTemplate.delete(PREFIX + key));
    }
}

3. 编写 AOP 切面

这是最核心的部分。

@Slf4j
@Aspect
@Component
public class NoRepeatSubmitAspect {

    @Autowired
    private RedisUtil redisUtil;

    @Pointcut("@annotation(com.example.annotation.NoRepeatSubmit)")
    public void pointcut() {}

    @Around("pointcut()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        
        // 1. 获取注解信息
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        NoRepeatSubmit annotation = method.getAnnotation(NoRepeatSubmit.class);
        
        // 2. 生成 Key (策略:User ID + 请求 URI + 参数 Hash)
        // 这样防止不同接口之间冲突,也防止同一接口不同参数冲突
        HttpServletRequest request = getRequest();
        String userId = getCurrentUserId(); // 假设从 SecurityContext 获取
        String uri = request.getRequestURI();
        String paramsHash = getParamsHash(joinPoint.getArgs());
        
        String lockKey = userId + ":" + uri + ":" + paramsHash;

        // 3. 检查并删除 Token (原子操作)
        // 注意:RedisTemplate 默认没有“检查并删除”的原子方法,建议使用 Lua 脚本
        // 这里为了演示清晰,使用简单的 exists + delete,实际高并发推荐 Lua
        if (redisUtil.releaseLock(lockKey)) {
            log.info("重复提交校验通过: key={}", lockKey);
            return joinPoint.proceed();
        } else {
            log.warn("重复提交拦截: key={}", lockKey);
            throw new BizException(400, annotation.message());
        }
    }
    
    // ... 辅助方法:getRequest(), getCurrentUserId(), getParamsHash()
    // getParamsHash 可以通过计算 args 的 MD5 或 JSON HashCode 实现
}

修正:Token 模式 vs 分布式锁模式

  • Token 模式:前端先请求 /token,后端 redis.set(token, 1)。提交时,后端 redis.del(token)。如果删除成功(返回 1),说明是第一次提交;如果删除失败(返回 0),说明是重复提交。这种方式更精准,不会误杀并发请求。
  • AOP 锁模式:如上文 AOP 代码所示,直接拦截。如果两个请求几乎同时到达,del 操作可能产生竞争。为了严谨,AOP 防重提交通常采用 Redis Lua 脚本 保证 “Get and Delete” 的原子性,或者使用 Token 模式

4. 进阶:基于 Lua 脚本的原子删除

为了在 AOP 中安全地实现“检查并删除”,我们使用 Lua 脚本。

-- Lua script: if key exists, delete it and return 1, else return 0
if redis.call("exists", KEYS[1]) == 1 then
    return redis.call("del", KEYS[1])
else
    return 0
end

在 Java 中封装:

public boolean checkAndDelete(String key) {
    String script = "if redis.call('exists', KEYS[1]) == 1 then return redis.call('del', KEYS[1]) else return 0 end";
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
    Long result = redisTemplate.execute(redisScript, Collections.singletonList(PREFIX + key));
    return result != null && result > 0;
}

将 AOP 中的判断逻辑替换为 if (redisUtil.checkAndDelete(lockKey)) 即可。

五、 方案落地总结

  1. 前端配合:如果是 Token 模式,需要前端在进入页面时请求 Token 并缓存在变量中,提交时携带。
  2. Key 的生成:Key 必须包含用户标识,防止 A 用户提交的 Token 被 B 用户复用。
  3. 过期时间:一定要设置过期时间,防止 Redis 内存泄漏。
  4. 粒度控制:如果希望“同一用户同一接口不管参数如何都不能并发”,Key 中可以去掉参数 Hash;如果希望“不同参数可以并发”,则必须带上参数 Hash。

六、 常见坑点

  • 误杀正常请求:如果 AOP 中使用简单的 lock(key) (如 setnx + expire),当请求处理时间超过过期时间,锁自动释放,第二个请求就会进来。这适合短时间防抖,但不适合 Token 模式。Token 模式必须是 “一次性的”
  • 事务问题:如果方法内有 @Transactional,AOP 的执行顺序很重要。确保防重校验在事务开启之前执行(默认 AOP 顺序符合)。
  • 分布式环境:必须使用 Redis 或 ZooKeeper 等共享存储,不能使用 ConcurrentHashMap 本地锁。

七、 总结

通过 自定义注解 + AOP + Redis,我们将防重复提交的逻辑从业务代码中剥离,实现了开箱即用的效果。业务开发人员只需在 Controller 方法上加一行 @NoRepeatSubmit,即可轻松解决并发提交导致的数据脏乱问题,极大地提升了系统的健壮性。

到此这篇关于SpringBoot实现接口防重复提交的最佳实践的文章就介绍到这了,更多相关SpringBoot接口防重复提交内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • Java的Socket网络编程基础知识入门教程

    Java的Socket网络编程基础知识入门教程

    这篇文章主要介绍了Java的Socket网络编程基础知识入门教程,包括基于TCP/IP和UDP协议的简单实例程序讲解,需要的朋友可以参考下
    2016-01-01
  • Spring Boot应用事件监听示例详解

    Spring Boot应用事件监听示例详解

    这篇文章主要给大家介绍了关于Spring Boot应用事件监听的相关资料,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2018-12-12
  • java如何实现判断文件的真实类型

    java如何实现判断文件的真实类型

    本篇文章主要介绍了java如何实现判断文件的真实类型,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-08-08
  • springboot 1.5.2 集成kafka的简单例子

    springboot 1.5.2 集成kafka的简单例子

    本篇文章主要介绍了springboot 1.5.2 集成kafka的简单例子 ,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-11-11
  • SpringBoot简单实现定时器过程

    SpringBoot简单实现定时器过程

    这篇文章主要介绍了SpringBoot简单实现定时器过程,对于Java后端来说肯定实现定时功能肯定是使用到Spring封装好的定时调度Scheduled
    2023-04-04
  • 详解如何在SpringBoot控制器中处理用户数据

    详解如何在SpringBoot控制器中处理用户数据

    在 Spring Boot 应用开发中,控制器(Controller)扮演着至关重要的角色,它负责接收用户请求、处理数据并返回响应,本文将深入浅出地讲解如何在 Spring Boot 控制器中处理用户数据,包括获取请求参数、处理表单提交、返回 JSON 数据等常见场景,需要的朋友可以参考下
    2025-05-05
  • idea中如何找到VM options选项

    idea中如何找到VM options选项

    本文介绍了在IDEA中通过EditConfiguration添加和修改JVM参数的方法,并详细解释了各常用参数的作用,如堆内存、线程栈、代大小等,对性能优化和调试具有参考价值
    2025-10-10
  • Java中减少if-else的设计模式和优化技巧

    Java中减少if-else的设计模式和优化技巧

    “过于依赖 if-else 不仅会让代码变得臃肿不堪,还会使维护成本大大增加,其实,if-else 虽然是最基础的条件分支,但它并不是万能的解决方案,今天,我将带大家了解一些取代 if-else 的设计模式和优化技巧,需要的朋友可以参考下
    2025-04-04
  • 微信支付java版本之Native付款

    微信支付java版本之Native付款

    这篇文章主要为大家详细介绍了微信支付java版本之Native付款,感兴趣的小伙伴们可以参考一下
    2016-08-08
  • Java线程同步、同步方法实例详解

    Java线程同步、同步方法实例详解

    本篇文章主要通过实例介绍了Java线程:线程的同步-同步方法,需要的朋友可以参考下
    2017-04-04

最新评论