接口重复提交可能来自连续点击,也可能来自网络重试。本文代码演示短时间窗口内拒绝相同请求,需与前端防抖、并发锁和业务幂等区分。

三种机制的区别

机制处理什么不能单独保证什么
前端防抖事件停止一段时间后再执行无法阻止绕过页面的请求
时间窗口去重同一个请求 Key 在 TTL 内只接受一次TTL 后重试仍可能重复执行业务
并发锁同一时刻只有一个持有者进入释放后再次调用仍可执行
业务幂等同一业务操作重复请求仍得到一致的业务效果需要业务唯一键、结果记录或状态约束

防抖应用场景

  1. 用户输入类接口
    • 示例:搜索框自动补全
    • 处理方式:用户停止输入一段时间后才发请求
  2. 按钮点击类接口
    • 示例:提交订单按钮
    • 处理方式:用户点击后立即锁定按钮,防止重复提交
  3. 滚动加载类接口
    • 示例:列表滚动到底自动加载更多
    • 处理方式:延迟处理滚动事件,防止接口频繁调用

如何判断重复请求

判断是否为重复请求可依据以下条件:

  1. 时间间隔限制:设置允许的最小请求间隔;
  2. 请求参数对比:对关键参数(如 userId、orderNo)进行比对;
  3. 请求路径匹配:同一 URL 与参数组合可以认为是同一请求。

防抖方案设计

方案一:基于共享缓存实现防抖

使用带过期时间的 SET key value NX EX/PX 原子占位,在时间窗口内拒绝相同 Key。设置值与过期时间必须在同一个命令里完成;它不等于完整的业务幂等。参见 Redis SET。

缓存方案
缓存方案

方案二:基于分布式锁实现防抖

使用 Redisson 的分布式锁机制,实现多实例部署场景下的防重复请求控制。

分布式锁方案
分布式锁方案

具体实现

控制层代码示例

@PostMapping("/add")
@RequiresPermissions("add")
@Log(methodDesc = "添加用户")
public ResponseEntity<String> add(@RequestBody AddReq addReq) {
return userService.add(addReq);
}

请求参数类:AddReq

@Data
public class AddReq {
private String userName;
private String userPhone;
private List<Long> roleIdList;
}

注解与 Key 生成

注解定义

@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestKeyParam {}

Key 生成逻辑

比如文章提交的时候,是不可能将所有的文章内容也传递拼接到key中,我们只需要部分参数,通过解析参数或字段上的 @RequestKeyParam 注解,拼接生成唯一请求 Key:

public class RequestKeyGenerator {
public static String getLockKey(ProceedingJoinPoint joinPoint) {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
RequestLock requestLock = method.getAnnotation(RequestLock.class);
Object[] args = joinPoint.getArgs();
Parameter[] parameters = method.getParameters();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < parameters.length; i++) {
RequestKeyParam keyParam = parameters[i].getAnnotation(RequestKeyParam.class);
if (keyParam != null) {
sb.append(requestLock.delimiter()).append(args[i]);
}
}
if (StringUtils.isEmpty(sb.toString())) {
Annotation[][] paramAnns = method.getParameterAnnotations();
for (int i = 0; i < paramAnns.length; i++) {
Object arg = args[i];
for (Field field : arg.getClass().getDeclaredFields()) {
if (field.isAnnotationPresent(RequestKeyParam.class)) {
field.setAccessible(true);
sb.append(requestLock.delimiter()).append(ReflectionUtils.getField(field, arg));
}
}
}
}
return requestLock.prefix() + sb;
}
}

Redis 实现防抖

切面拦截器:RedisRequestLockAspect

@Aspect
@Configuration
@Order(2)
public class RedisRequestLockAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(com.summo.demo.config.requestlock.RequestLock)")
public Object interceptor(ProceedingJoinPoint joinPoint) {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
RequestLock requestLock = method.getAnnotation(RequestLock.class);
String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
Boolean success = redisTemplate.execute((RedisCallback<Boolean>) connection ->
connection.set(lockKey.getBytes(), new byte[0],
Expiration.from(requestLock.expire(), requestLock.timeUnit()),
RedisStringCommands.SetOption.SET_IF_ABSENT)
);
if (!Boolean.TRUE.equals(success)) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试");
}
try {
return joinPoint.proceed();
} catch (Throwable t) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系统异常");
}
}
}

SET_IF_ABSENT 对应 NX:仅当 Key 不存在时设置。请求 Key 还应包含用户或租户作用域,避免不同用户相互阻塞。

Redisson 分布式锁实现

Maven 依赖

<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.10.6</version>
</dependency>

配置类:RedissonConfig

@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword("xxxx")
.setDatabase(0)
.setConnectionPoolSize(10)
.setConnectionMinimumIdleSize(2);
return Redisson.create(config);
}
}

切面类:RedissonRequestLockAspect

@Aspect
@Configuration
@Order(2)
public class RedissonRequestLockAspect {
@Autowired
private RedissonClient redissonClient;
@Around("@annotation(com.summo.demo.config.requestlock.RequestLock)")
public Object interceptor(ProceedingJoinPoint joinPoint) {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
RequestLock requestLock = method.getAnnotation(RequestLock.class);
String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
RLock lock = redissonClient.getLock(lockKey);
boolean isLocked = false;
try {
isLocked = lock.tryLock(0, requestLock.expire(), requestLock.timeUnit());
if (!isLocked) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试");
}
// 已取得一次锁,不要再次 lock() 增加重入计数。
return joinPoint.proceed();
} catch (Throwable t) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系统异常");
} finally {
if (isLocked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}

这里的 Redisson 示例提供并发互斥,finally 解锁后下一次请求即可进入;不是整个 TTL 内都拒绝请求。显式租约到期后锁会释放,业务耗时超过租约时需要另行处理。

两种实现的适用范围

项目Redis 实现Redisson 实现
并发支持单次 SET 操作是原子的提供锁持有者与重入语义
实现复杂度中等稍高
依赖组件RedisTemplateRedissonClient
场景建议多实例共享时间窗口去重多实例共享并发互斥