Redis与自定义注解实现重复方式

好的,这是一个非常经典且实用的技术方案。结合 Redis 与自定义注解来实现防重复提交,可以有效地将业务逻辑与防重逻辑解耦,使代码更加清晰和易于维护。

图片[1]_Redis与自定义注解实现重复方式_知途无界

下面我将为您详细阐述其实现原理、步骤和代码示例。

核心思想

  1. 自定义注解​:定义一个注解(如 @PreventDuplicateSubmit),用它来标记那些需要防止重复提交的方法(通常是 Controller 中的接口方法)。
  2. AOP 切面​:创建一个 Aspect(切面),在目标方法执行前(@Before)或环绕(@Around)拦截被 @PreventDuplicateSubmit 标记的注解。
  3. Redis 作为媒介​:
    • 生成唯一 Key​:在切面中,根据一定的规则(如用户ID + 请求参数 + 接口路径)生成一个唯一的标识请求的 Key。
    • 检查与占位​:在执行目标方法前,尝试将这个 Key 存入 Redis,并设置过期时间(例如 5-10 秒)。如果这个 Key 已经存在(即 setIfAbsent 返回 false),则说明是重复请求,直接抛出异常或返回错误提示。
    • 执行业务与清理​:如果 Key 设置成功,则执行原方法。方法执行完毕后(无论成功失败),删除这个 Key,以便用户下次正常操作。(注意:这里使用 try...finally 确保删除操作一定会执行)。

实现步骤与代码示例

步骤 1:引入依赖

确保你的项目中包含了 Spring Boot AOP 和 Redis 的依赖。

<!-- 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>

步骤 2:定义自定义注解

import java.lang.annotation.*;

/**
 * 防止重复提交的注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PreventDuplicateSubmit {

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

    /**
     * 提示消息,当重复提交时返回给前端的信息
     */
    String message() default "请勿重复提交,请稍后再试";
}

步骤 3:创建 AOP 切面

这是最核心的部分。我们使用 @Around 环绕通知,以便在方法执行前后都能进行控制。

import com.example.demo.annotation.PreventDuplicateSubmit;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.concurrent.TimeUnit;

@Aspect
@Component
public class PreventDuplicateSubmitAspect {

    private static final Logger logger = LoggerFactory.getLogger(PreventDuplicateSubmitAspect.class);

    private final StringRedisTemplate stringRedisTemplate;

    // 构造器注入
    public PreventDuplicateSubmitAspect(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 环绕通知,处理重复提交逻辑
     */
    @Around("@annotation(preventDuplicateSubmit)")
    public Object around(ProceedingJoinPoint joinPoint, PreventDuplicateSubmit preventDuplicateSubmit) throws Throwable {
        // 1. 获取 HttpServletRequest
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes == null) {
            // 如果不是Web请求,直接放行
            return joinPoint.proceed();
        }
        HttpServletRequest request = attributes.getRequest();

        // 2. 生成唯一的Redis Key
        String key = generateKey(joinPoint, request);
        Integer expireTime = preventDuplicateSubmit.expireTime();
        String message = preventDuplicateSubmit.message();

        // 3. 尝试在Redis中设置这个Key
        Boolean isSuccess = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", expireTime, TimeUnit.SECONDS);

        // 4. 判断是否设置成功
        if (Boolean.TRUE.equals(isSuccess)) {
            logger.info("--- 请求未被重复提交,Key: {}, 开始执行业务 ---", key);
            try {
                // 设置成功,执行业务方法
                return joinPoint.proceed();
            } finally {
                // 【重要】无论业务执行成功与否,都尝试删除Key,释放锁。
                // 使用delete而非expire=0,是为了避免误删其他可能生成的相同key(尽管概率极低)。
                // 注意:在高并发下,第一个请求执行完删除了key,第二个请求可能已经通过了setIfAbsent的检查,导致并发问题。
                // 更严谨的做法是使用Lua脚本保证原子性,或者接受短暂的锁失效窗口期(通常几秒内可接受)。
                stringRedisTemplate.delete(key);
                logger.info("--- 业务执行完毕,Key: {} 已从Redis中删除 ---", key);
            }
        } else {
            // 设置失败,说明Key已存在,是重复请求
            logger.warn("--- 检测到重复提交,Key: {} ---", key);
            // 抛出业务异常,由全局异常处理器捕获并返回给前端
            throw new RuntimeException(message);
        }
    }

    /**
     * 生成防重的Redis Key
     * 规则:prevent_duplicate_submit:用户标识:接口路径:参数摘要
     * 示例:prevent_duplicate_submit:user123:/api/order/create:paramDigestABC
     */
    private String generateKey(ProceedingJoinPoint joinPoint, HttpServletRequest request) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        String methodName = method.getName();

        // 获取用户标识(这里假设从Header中获取Token解析出的用户ID,或从Session中获取)
        // 为了通用性,我们先简化为一个固定值或从request中取IP
        String userIdentifier = request.getRemoteAddr(); // 简单示例,使用IP。实际应使用用户ID,更安全。
        // String userId = ... // 从Token中解析用户ID

        // 获取请求路径
        String requestURI = request.getRequestURI();

        // 获取方法参数的摘要(可选,用于区分同一接口不同参数的请求)
        // 这里简化,直接使用方法签名和参数toString()。生产环境建议使用MD5等哈希算法处理参数,避免过长Key和特殊字符。
        String argsStr = Arrays.toString(joinPoint.getArgs());
        String paramDigest = Integer.toHexString(argsStr.hashCode()); // 简单哈希

        // 拼接Key
        return String.format("prevent_duplicate_submit:%s:%s:%s:%s", userIdentifier, requestURI, methodName, paramDigest);
    }
}

关于 Key 生成的几点说明​:

  • 用户标识​:强烈建议使用用户唯一ID(如从JWT Token中解析),而不是IP地址。因为同一个局域网下IP可能相同,会导致误判。
  • 参数摘要​:如果不加参数,那么同一个用户快速点击同一按钮多次会被拦截,但如果带不同参数(比如表单提交了不同内容),也会被错误地拦截。加入参数摘要可以解决这个问题,但计算摘要需要性能开销。需要根据业务场景权衡。
  • 过期时间​:设置过短可能导致正常连续操作被拦截,设置过长则影响用户体验。一般建议 3-10 秒。

步骤 4:在 Controller 中使用注解

import com.example.demo.annotation.PreventDuplicateSubmit;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/order")
public class OrderController {

    @PostMapping("/create")
    @PreventDuplicateSubmit(expireTime = 10, message = "订单正在创建中,请勿重复点击")
    public ResponseEntity<String> createOrder(@RequestBody OrderCreateRequest request) {
        // 模拟创建订单的业务逻辑
        // orderService.create(request);
        System.out.println("创建订单业务逻辑执行...");
        return ResponseEntity.ok("订单创建成功");
    }

    // 另一个不需要防重的接口
    @GetMapping("/list")
    public ResponseEntity<String> getOrderList() {
        return ResponseEntity.ok("订单列表");
    }
}

步骤 5:配置全局异常处理(可选但推荐)

为了让前端收到统一的错误信息,可以配置一个全局异常处理器。

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<String> handleRuntimeException(RuntimeException e) {
        // 这里可以判断异常信息是否是重复提交导致的,也可以自定义一个特定的异常类
        if ("请勿重复提交,请稍后再试".equals(e.getMessage()) || e.getMessage().contains("订单正在创建中")) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
        // 其他运行时异常处理
        return ResponseEntity.internalServerError().body("服务器内部错误");
    }
}

方案优化与注意事项

  1. 原子性操作​:上述方案中,setIfAbsentdelete 是两个操作,存在极小的可能性在 delete 之前锁已自动过期,导致误删其他请求的锁。更严格的做法是使用 Lua 脚本将“判断是否存在并设置”和“删除”操作变为原子操作。但对于防重复提交这种场景,短暂的锁失效窗口期(几秒钟)通常是可接受的。
  2. 锁的粒度​:Key 的设计决定了锁的粒度。粒度太粗(如只用用户ID)会影响同用户其他操作的并发性;粒度太细会增加Redis的负担和Key冲突概率。
  3. 适用场景​:此方案主要适用于幂等性要求高的写操作(如创建订单、支付、提交表单)。对于读操作或天然幂等的操作(如查询、更新状态为固定值),无需使用。
  4. 用户体验​:在前端配合“按钮置灰”或“Loading状态”,可以进一步提升用户体验,减少无效请求到达后端。

通过以上步骤,您就可以成功地利用 Redis 和自定义注解实现一个功能强大且灵活的重复提交防护系统。

© 版权声明
THE END
喜欢就点个赞,支持一下吧!
点赞67 分享
评论 抢沙发
头像
欢迎您留下评论!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容