Java防重提交流程如何统一

wen java案例 33

本文目录导读:

Java防重提交流程如何统一

  1. 核心设计思路
  2. 关键技术选型
  3. 具体实现步骤(代码示例)
  4. 统一异常处理
  5. 关键问题与优化

Java 中实现统一的防重提交(防止重复请求)的核心思想是:利用请求的唯一标识 (如用户ID+接口+参数生成的MD5,或者前端生成的唯一请求ID),在短时间内(如 1-3 秒)对此标识进行判重加锁

为了实现 统一(即对业务代码侵入小,通用性强),通常采用 注解 + AOP(Aspect Oriented Programming,面向切面编程) 的方式,并结合 Redis(内存数据库) 作为高性能的锁存储。

以下是构建统一防重提交流程的完整方案:

核心设计思路

  1. 定义注解:创建一个 @RepeatSubmit 注解,用于标记需要防重的方法。
  2. 切面处理:通过 AOP 拦截被注解标记的方法,在方法执行前进行校验。
  3. 生成锁Key:结合请求上下文(Token、IP、URL、参数)生成唯一的锁ID。
  4. 执行加锁:使用 Redis 的 SET NX EX 命令(原子操作)尝试加锁。
  5. 请求放行:如果加锁成功(返回OK),则执行业务逻辑;如果加锁失败(已存在),则直接返回“请求频繁”或“重复提交”的异常/提示。

关键技术选型

  • 存储介质Redis(最佳实践),因为它支持分布式、原子操作、自动过期。
  • 锁类型Redis 的分布式锁(基于 SETNX 命令)。
  • 前端配合(可选但推荐):前端在请求头或参数中携带 requestIdUUID,通用唯一识别码),后端以此为主键,可以更精准地防止网络重试导致的数据重复

具体实现步骤(代码示例)

引入依赖(Maven/Gradle)

<!-- Spring Boot AOP -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Spring Boot Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

定义注解 @RepeatSubmit

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
    /**
     * 防重锁的时间间隔(秒)
     * 在此时间内,相同标识的请求将被拦截
     */
    int interval() default 3;
    /**
     * 提示信息
     */
    String message() default "操作频率过快,请稍后再试!";
    /**
     * 锁的粒度模式
     * PARAM: 基于请求参数(适合写操作)
     * TOKEN: 基于用户Token + 请求路径(适合通用场景)
     * GLOBAL: 全局锁,只基于请求路径
     */
    LockMode mode() default LockMode.TOKEN;
    enum LockMode {
        PARAM, TOKEN, GLOBAL
    }
}

核心 AOP 切面实现

这是防重提交流程的心脏,负责拦截、加锁、校验。

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
@Slf4j
@Aspect
@Component
public class RepeatSubmitAspect {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Around("@annotation(noRepeatSubmit)")
    public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit noRepeatSubmit) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        // 1. 获取注解配置
        int interval = noRepeatSubmit.interval();
        String message = noRepeatSubmit.message();
        RepeatSubmit.LockMode mode = noRepeatSubmit.mode();
        // 2. 生成唯一的锁 Key
        String lockKey = generateLockKey(joinPoint, method, mode, noRepeatSubmit);
        // 3. 尝试加锁(Redis SET NX EX 原子操作)
        //    key 不存在则设置,并设置过期时间;返回 true 表示加锁成功
        boolean locked = Boolean.TRUE.equals(
                redisTemplate.opsForValue().setIfAbsent(lockKey, "1", interval, TimeUnit.SECONDS)
        );
        if (!locked) {
            // 4. 加锁失败,说明重复提交
            log.warn("检测到重复提交请求, key: {}", lockKey);
            // 这里可以抛出异常,或者返回统一结果
            throw new RuntimeException(message);
            // 或者:return Result.error(message);
        }
        // 5. 加锁成功,执行业务逻辑
        try {
            return joinPoint.proceed();
        } catch (Exception e) {
            // 业务执行异常,需要释放锁吗?通常不需要,等待自动过期即可,防止锁误删
            throw e;
        }
        // 注意:这里故意不手动释放锁
        // 因为防重锁的生命周期就是 interval 秒,到期自动释放,确保在这段时间内不重复提交。
    }
    /**
     * 生成锁 Key 的策略
     */
    private String generateLockKey(ProceedingJoinPoint joinPoint, Method method, RepeatSubmit.LockMode mode, RepeatSubmit annotation) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        StringBuilder key = new StringBuilder("repeat:submit:");
        // 1. 固定前缀:请求路径
        key.append(request.getRequestURI());
        switch (mode) {
            case TOKEN:
                // 最常见:基于用户身份(从请求头或JWT中获取UserID/Token) + 请求路径
                String userId = request.getHeader("Authorization"); // 假设Token放在Authorization头
                // 实际开发中应解析Token获取userId,或直接使用sessionId
                key.append(":").append(userId != null ? userId : request.getSession().getId());
                break;
            case PARAM:
                // 最严格:基于请求参数(将参数JSON序列化作为key的一部分)
                // 注意:参数顺序可能不同,建议对参数Map排序后转为Json
                Object[] args = joinPoint.getArgs();
                // 生产环境建议使用更复杂的序列化(如取参数的HashCode)
                key.append(":").append(Arrays.deepHashCode(args));
                break;
            case GLOBAL:
                // 全局锁,只基于URI,不区分用户(极少使用,如秒杀前端限流)
                // 不添加额外后缀
                break;
        }
        return key.toString();
    }
}

使用示例

直接在 Controller 层方法上添加 @RepeatSubmit 注解即可。

@RestController
@RequestMapping("/order")
public class OrderController {
    @PostMapping("/create")
    @RepeatSubmit(interval = 3, message = "订单正在处理中,请勿重复提交!")
    public Result createOrder(@RequestBody OrderReq req) {
        // 业务逻辑(创建订单)
        return Result.success("下单成功");
    }
    @PostMapping("/pay")
    @RepeatSubmit(interval = 5, mode = RepeatSubmit.LockMode.PARAM, message = "支付请求正在处理,请勿重复操作")
    public Result pay(@RequestBody PayReq req) {
        // 支付逻辑
        return Result.success("支付成功");
    }
}

统一异常处理

当切面检测到重复提交并抛出 RuntimeException 时,需要 全局异常处理器 捕获并转化为友好的前端提示,避免直接返回500。

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(RuntimeException.class)
    public Result handleRepeatSubmitException(RuntimeException e) {
        // 判断是否是重复提交异常
        if (e.getMessage().contains("操作频率过快") || e.getMessage().contains("重复提交")) {
            return Result.error(429, e.getMessage()); // 429 Too Many Requests
        }
        return Result.error(500, "系统异常");
    }
}

关键问题与优化

锁的释放时机

  • Q:业务执行成功后,需要立即删除 Redis Key 吗?
  • A不建议,防重提交流程的业务含义是“间隔期内禁止重复操作”,如果立即删除,用户快速点击两次,第一次执行完后锁消失,第二次点击可能依然能打进来(假如业务处理耗时很短),正确的做法是利用 过期时间 做硬性隔离,过期前无论如何都不允许再次提交。

基于Token的场景

  • 在实际开发中,强烈建议前端在请求头中加入 X-Request-Id(UUID),后端 AOP 将 requestId 作为锁 Key,这是防重最强大的方式,因为即使是同一个用户两次完全相同的操作,只要 UUID 不同,也能确保不误拦截。

并发极高的场景(如秒杀)

  • interval 设置为1秒,QPS(每秒请求数)极高,SET NX 会成为瓶颈,可以考虑引入 Lua 脚本 进行更复杂的原子判断,或者使用 本地锁(Guava Cache + 布隆过滤器) 前置过滤,再用 Redis 兜底。
  • 如何统一:通过 自定义注解 + AOP + Redis
  • 核心流程
    1. 生成唯一 Key(URL + Token/UserID + 参数)。
    2. 尝试 Redis SET NX EX
    3. 成功:执行业务。
    4. 失败:抛出异常/返回提示。

这种方案对业务代码完全无侵入,只需在目标方法上加上 @RepeatSubmit 注解,即可完成统一的防重提交流程,支持分布式部署,且性能高效。

抱歉,评论功能暂时关闭!