好的,这是一个非常经典且实用的技术方案。结合 Redis 与自定义注解来实现防重复提交,可以有效地将业务逻辑与防重逻辑解耦,使代码更加清晰和易于维护。
![图片[1]_Redis与自定义注解实现重复方式_知途无界](https://zhituwujie.com/wp-content/uploads/2025/12/d2b5ca33bd20251212101154.png)
下面我将为您详细阐述其实现原理、步骤和代码示例。
核心思想
- 自定义注解:定义一个注解(如
@PreventDuplicateSubmit),用它来标记那些需要防止重复提交的方法(通常是 Controller 中的接口方法)。 - AOP 切面:创建一个 Aspect(切面),在目标方法执行前(
@Before)或环绕(@Around)拦截被@PreventDuplicateSubmit标记的注解。 - 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("服务器内部错误");
}
}
方案优化与注意事项
- 原子性操作:上述方案中,
setIfAbsent和delete是两个操作,存在极小的可能性在delete之前锁已自动过期,导致误删其他请求的锁。更严格的做法是使用 Lua 脚本将“判断是否存在并设置”和“删除”操作变为原子操作。但对于防重复提交这种场景,短暂的锁失效窗口期(几秒钟)通常是可接受的。 - 锁的粒度:Key 的设计决定了锁的粒度。粒度太粗(如只用用户ID)会影响同用户其他操作的并发性;粒度太细会增加Redis的负担和Key冲突概率。
- 适用场景:此方案主要适用于幂等性要求高的写操作(如创建订单、支付、提交表单)。对于读操作或天然幂等的操作(如查询、更新状态为固定值),无需使用。
- 用户体验:在前端配合“按钮置灰”或“Loading状态”,可以进一步提升用户体验,减少无效请求到达后端。
通过以上步骤,您就可以成功地利用 Redis 和自定义注解实现一个功能强大且灵活的重复提交防护系统。
© 版权声明
文中内容均来源于公开资料,受限于信息的时效性和复杂性,可能存在误差或遗漏。我们已尽力确保内容的准确性,但对于因信息变更或错误导致的任何后果,本站不承担任何责任。如需引用本文内容,请注明出处并尊重原作者的版权。
THE END

























暂无评论内容