java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot接口防重复提交

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

作者:(farerboy)

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

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

一、 什么是接口幂等性?

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

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

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

二、 常见解决方案对比

方案原理优点缺点适用场景
数据库唯一索引利用唯一约束拦截重复插入最底层,兜底保障仅适用于插入场景,无法拦截所有业务逻辑所有涉及唯一数据的场景
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 分布式锁模式

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

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

您可能感兴趣的文章:
阅读全文