本文目录导读:

Java 中实现统一的防重提交(防止重复请求)的核心思想是:利用请求的唯一标识 (如用户ID+接口+参数生成的MD5,或者前端生成的唯一请求ID),在短时间内(如 1-3 秒)对此标识进行判重与加锁。
为了实现 统一(即对业务代码侵入小,通用性强),通常采用 注解 + AOP(Aspect Oriented Programming,面向切面编程) 的方式,并结合 Redis(内存数据库) 作为高性能的锁存储。
以下是构建统一防重提交流程的完整方案:
核心设计思路
- 定义注解:创建一个
@RepeatSubmit注解,用于标记需要防重的方法。 - 切面处理:通过 AOP 拦截被注解标记的方法,在方法执行前进行校验。
- 生成锁Key:结合请求上下文(Token、IP、URL、参数)生成唯一的锁ID。
- 执行加锁:使用 Redis 的
SET NX EX命令(原子操作)尝试加锁。 - 请求放行:如果加锁成功(返回OK),则执行业务逻辑;如果加锁失败(已存在),则直接返回“请求频繁”或“重复提交”的异常/提示。
关键技术选型
- 存储介质:Redis(最佳实践),因为它支持分布式、原子操作、自动过期。
- 锁类型:Redis 的分布式锁(基于
SETNX命令)。 - 前端配合(可选但推荐):前端在请求头或参数中携带
requestId(UUID,通用唯一识别码),后端以此为主键,可以更精准地防止网络重试导致的数据重复。
具体实现步骤(代码示例)
引入依赖(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。
- 核心流程:
- 生成唯一 Key(URL + Token/UserID + 参数)。
- 尝试 Redis
SET NX EX。 - 成功:执行业务。
- 失败:抛出异常/返回提示。
这种方案对业务代码完全无侵入,只需在目标方法上加上 @RepeatSubmit 注解,即可完成统一的防重提交流程,支持分布式部署,且性能高效。