接到这个题目时,我脑子里首先跳出来的是上周刚处理过的一个线上问题。一个用户报名接口,参与者在弱网环境下连续点了十几次“提交”,前端按钮也确实做了 loading 禁用,但所有请求早就排队发出去了,后端整整插入了 6 条重复报名记录。排查到最后发现,前端防抖在“请求已发出但响应还没回来”这个阶段根本兜不住,真正能兜底的,只有后端接口自己做防抖。
这篇文章就针对 Spring Boot 后端接口防抖这个话题,把“注解 + Redis”这套完整实现拆开讲清楚,包括为什么需要后端防抖、方案怎么选型、注解和 AOP 切面怎么写、Redis key 怎么设计、上线后哪些坑最容易踩。内容是我自己在项目中沉淀下来的实操总结,不是贴官方文档,适合正在为重复提交、接口抖动、调用方重试而头疼的后端开发者参考。
1. 后端接口为什么需要防抖:一次线上事故给我的教训
1.1 事故经过:重复点击如何打爆一个接口
那次事故其实很典型。活动报名接口本来只是一个普通的 insert,单次请求耗时不到 100ms,平时毫无压力。但用户在 App 端遇到“转圈”时本能地会连续点击,每个点击动作在前端都触发了 axios 请求,按钮 disabled 只拦截了后续点击,前面的请求已经走在路上了。突然几十个相同请求同时打到后端,报名记录被反复插入,数据库连接池瞬间被打满,整个服务跟着雪崩。
这类问题有几个共同特征:
- 请求在时间上高度集中,往往是毫秒到秒级的并发。
- 请求的业务语义完全相同,都是“同一个用户对同一个活动报名”。
- 服务端如果只做常规参数校验,根本发现不了这是重复请求。
所以后端接口防抖的核心诉求就一句话:在极短的时间窗口内,识别出“同一个业务动作”的重复请求,只放行第一个,其余直接拒绝。它和性能优化无关,和并发正确性有关。
1.2 防抖和节流、幂等、限流不是一回事
很多团队把防抖和限流、幂等混着用,经常出问题。我先用一张表把边界理清楚:
| 概念 | 作用维度 | 典型手段 | 适用场景 |
|---|---|---|---|
| 防抖 | 同一个业务请求在窗口内只放行一次 | 注解 + Redis setnx | 重复点击、重复提交 |
| 节流 | 控制单位时间内的请求频率 | 令牌桶、滑动窗口 | 接口限频、短信防刷 |
| 限流 | 总量/速率超过阈值直接丢弃 | Sentinel、Guava RateLimiter | 高并发保护、防止拖垮下游 |
| 幂等 | 同一个请求无论执行多少次,结果一致 | 唯一索引、状态机、幂等表 | 支付回调、MQ 重复消费 |
| 分布式锁 | 同一时刻只允许一个线程执行关键操作 | Redis 锁、ZooKeeper | 并发互斥、分布式定时任务 |
防抖判断的是“请求是否重复”,限流判断的是“请求是否过多”,幂等保证的是“重复执行也不会出错”。防止重复提交最常见的组合是:接口外面加防抖挡住明显重复,接口里面用幂等机制兜底极端并发。只做防抖不做幂等,遇到请求刚好绕过窗口期的情况依然会重复;只做幂等不做防抖,数据库的重复写压力并不会减少。
1.3 哪些接口最应该加防抖
不是所有接口都需要加,我只给写操作和回调类接口加,读接口基本不考虑。具体来说,下面这几类优先级最高:
- 创建类接口,比如报名、下单、创建订单、申请提现,这类接口一旦重复执行,数据直接翻倍。
- 支付回调、退款回调,很多第三方回调会重试多次,虽然回调里有流水号,但处理逻辑写得不严谨就容易重复入账。
- 批量导入、批量操作,一次导入几万条数据,用户重复触发一次,数据库可能直接锁死。
- 对外提供的 API 写接口,调用方不可控,经常有人写定时任务重复调你的接口。
判断标准很简单:这个接口如果被连续调用两次,第二次会不会产生脏数据或者额外成本?会,就应该加防抖。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:为什么是“注解 + AOP + Redis”
2.1 我考虑过的几种备选方案
这里我花了不少时间对比,先把核心认识说透。当初我面前摆着几个备选方案:
方案一:本地内存防抖
用 ConcurrentHashMap 存每个接口最后一次请求时间,请求过来时判断当前时间是否在窗口内。实现非常简单,10 行代码就能写完,不需要引入任何中间件。缺点也很明显:应用部署多个实例时内存不共享,A 实例拦截了,B 实例照样放行;应用重启后状态清零。只适合单体单机的小项目,或者对防抖要求不高的内部系统。
方案二:数据库唯一索引防重
给业务表加唯一索引,重复插入直接抛 DuplicateKeyException。这个方案对数据的兜底最硬,但在业务上不一定总能找到能建唯一索引的字段,而且每次都靠数据库报错来拦截,压力全给到数据库,用户收到的也不是友好的提示。
方案三:拦截器 + Redis
通过拦截器拿到请求 URL 和用户信息,拼 key 后判断 Redis 里有没有。它能覆盖写请求,但粒度只能到 URL,同一个 URL 可能对应多种业务参数,比如“提交订单”和“提交退款”如果共用同一个接口路径,就很难精确控制。也没办法按业务主键做防抖。
方案四:注解 + AOP + Redis
把防抖逻辑收敛到一个切面里,在需要防抖的接口方法上加一个注解,通过注解参数指定防抖 key 的维度。既能拿到方法参数、方法名、业务主键,又对业务代码侵入极小,是几种方案里最灵活、最干净的一种。
三种方案对比下来,结论很清楚:
| 对比项 | 本地内存 | 数据库唯一索引 | 拦截器+Redis | 注解+AOP+Redis |
|---|---|---|---|---|
| 实现复杂度 | 最低 | 低 | 中 | 稍高 |
| 精度 | 只能到 URL/用户 | 只能到唯一字段 | 只能到 URL | 可到方法+参数+用户 |
| 多实例支持 | 不支持 | 支持但压力大 | 支持 | 支持 |
| 业务侵入 | 需加代码 | 需建表/改表 | 低 | 最低 |
| 可复用性 | 低 | 低 | 中 | 高 |
2.2 为什么最终选 Redis 做支撑
确定了“注解 + AOP”这套形态后,存储层选 Redis 基本没有悬念。Redis 的 setnx 语义和防抖天然匹配:多个请求同时写同一个 key,只有第一个能写成功,其他全部失败,而这正是防抖需要的原子性。
很多项目里其实已经把 Redis 当成基础设施了,不需要额外引入重量级组件。用 Redis 还有一个好处是天然支持多实例部署,校验逻辑在各节点间是共享的,不会出现服务 A 拦了、服务 B 又放行的情况。
要注意一个关键点:防抖操作的原子性必须靠 Redis 单命令保证。很多人会先执行 setnx,再执行 expire,两条命令分开写,一旦第一步成功、第二步异常,这个 key 就永远不会过期,后面所有相同请求都会被永久拦截。正确做法是直接用 set 命令的 NX + EX 能力,在 Spring Data Redis 里对应的方法是 setIfAbsent(key, value, timeout, timeUnit),一条命令搞定。
2.3 使用效果预览:业务代码长什么样
我理想中的接入方式是这样的:业务开发人员不需要理解 Redis 命令、分布式锁、防抖窗口这些底层细节,只需要在方法上加一个注解并告诉它按哪个业务键防抖。
java复制@NoRepeatSubmit(
keySpel = "#req.userId + ':' + #req.activityId",
interval = 3000,
message = "提交太频繁,请稍后再试"
)
public Result createOrder(OrderCreateRequest req) {
// 真正的业务逻辑
return orderService.create(req);
}
接口的第一道防线在切面统一处理,业务代码本身变得非常干净。就算后面想调整防抖窗口时间、拦截提示语,也不需要改业务方法,改注解参数就行。这也是 AOP 方案最舒服的地方。
3. 完整落地代码:从注解定义到 AOP 切面
3.1 准备工作:引入依赖和 Redis 配置
如果你还没有引入 Redis 和 AOP,先加这两个起步依赖。Spring Boot 2.x 和 3.x 都适用:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
Redis 连接配置在 application.yml 里正常配置即可。我这边用的是 Spring Boot 2.7 + 默认的 Lettuce 连接池,没有引入额外客户端包。
yaml复制spring:
redis:
host: 10.0.0.12
port: 6379
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 50
max-idle: 20
min-idle: 5
配置文件里的 password 要放到环境变量,不能直接写死在仓库里,这个大家应该深有体会。实际落地时还要确认你的 Redis 版本支持 NX + EX 的原子操作,一般 2.6 以上都支持,现在的云 Redis 都是 4.x、5.x 起,完全没问题。
3.2 定义防抖注解:字段设计要有弹性
先定义一个 @NoRepeatSubmit 注解,核心字段包括时间窗口、时间单位、SpEL 表达式、兜底提示信息。这里设计字段时一定要考虑两点:一是同一套注解以后可能要支持不同场景(有的接口需要 1 秒防抖,有的需要 5 秒,还有的按用户维度、按订单号维度);二是如果搞成固定参数,以后想扩展就只能加新注解,维护成本会很高。
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoRepeatSubmit {
/**
* 固定业务 key,优先级最低。
* 适用场景:同一个接口只有一种防抖维度。
*/
String key() default "";
/**
* SpEL 表达式,用于指定参与防抖的业务键。
* 比如 "#req.activityId + ':' + #req.userId"。
* SpEL 的优先级最高。
*/
String keySpel() default "";
/**
* 防抖时间窗口,默认 3 秒。
*/
long interval() default 3000L;
/**
* 时间单位,默认毫秒。
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* 被拦截时抛出的异常提示。
*/
String message() default "请求正在处理中,请勿重复提交";
}
我把 key 和 keySpel 分开放的原因后面会讲到,核心是给不同代码习惯的团队留出选择空间。message 默认值要写得像一个正常人话,不要让用户看到“服务器繁忙”这种误导性文本。
3.3 AOP 切面:核心防抖逻辑
这是整个方案的重头戏。切面做的事情其实只有四步:根据注解和请求参数拼出防抖 key,用 Redis 的 setIfAbsent 尝试占位,占位成功就放行业务逻辑,占位失败就抛出异常拦截。
java复制@Aspect
@Component
public class NoRepeatSubmitAspect {
private static final Logger log = LoggerFactory.getLogger(NoRepeatSubmitAspect.class);
private static final String KEY_PREFIX = "app:debounce:";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final StringRedisTemplate redisTemplate;
private final SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
private final DefaultParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();
public NoRepeatSubmitAspect(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Around("@annotation(noRepeatSubmit)")
public Object around(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) throws Throwable {
String key = buildKey(joinPoint, noRepeatSubmit);
String requestId = UUID.randomUUID().toString();
long windowMillis = noRepeatSubmit.timeUnit().toMillis(noRepeatSubmit.interval());
Boolean acquired = null;
try {
acquired = redisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + key, requestId, windowMillis, TimeUnit.MILLISECONDS);
} catch (Exception e) {
log.error("[NoRepeatSubmit] Redis 操作异常,按放行处理,key={}", key, e);
}
if (Boolean.FALSE.equals(acquired)) {
log.warn("[NoRepeatSubmit] 拦截重复请求,key={}", key);
throw new RepeatSubmitException(noRepeatSubmit.message());
}
log.info("[NoRepeatSubmit] 请求放行,key={}, requestId={}", key, requestId);
return joinPoint.proceed();
}
}
RepeatSubmitException 是我自定义的异常,可以配合全局异常处理器统一返回错误码。这里有两个细节是实际写代码时容易忽略的:
第一,setIfAbsent 返回的 null 也要处理。网络抖动时 Redis 客户端可能返回异常,也可能返回 null,如果直接 if (!acquired) 会 NPE。我用 Boolean.FALSE.equals(acquired) 这个写法,只有明确返回 false 才拦截,返回 null 或者异常都按放行处理。
第二,业务执行完不要手动删除 key。防抖窗口的意义就是在这个时间段内不再让相同请求进来,如果每次执行完就删除,那两个请求间隔 100ms 就依然会同时进来,防抖形同虚设。让 key 靠过期时间自然消失就够了,这也是我没有在 proceed() 之后调用 delete 的原因。
3.4 防抖 key 的生成规则
key 是整个防抖方案最容易写崩的地方。我把 key 的生成逻辑拆成三级优先级:
- 优先使用
keySpel指定的业务字段,比如订单号、活动 ID、用户 ID。 - 其次使用
key指定的固定 key。 - 都没有指定时,使用方法名 + 全参数 JSON 的 MD5 指纹。
实际代码如下:
java复制private String buildKey(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
StringBuilder keyBuilder = new StringBuilder();
keyBuilder.append(method.getDeclaringClass().getName())
.append("#")
.append(method.getName());
if (StringUtils.hasText(noRepeatSubmit.keySpel())) {
keyBuilder.append(":")
.append(parseSpel(noRepeatSubmit.keySpel(), method, joinPoint.getArgs()));
} else if (StringUtils.hasText(noRepeatSubmit.key())) {
keyBuilder.append(":").append(noRepeatSubmit.key());
} else {
keyBuilder.append(":").append(buildParamFingerprint(joinPoint.getArgs()));
}
return md5(keyBuilder.toString());
}
private String parseSpel(String expression, Method method, Object[] args) {
StandardEvaluationContext context = new StandardEvaluationContext();
String[] paramNames = nameDiscoverer.getParameterNames(method);
if (paramNames == null) {
throw new IllegalArgumentException("@NoRepeatSubmit keySpel 解析失败:无法获取参数名");
}
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
Expression parsed = spelExpressionParser.parseExpression(expression);
Object value = parsed.getValue(context);
return value == null ? "null" : value.toString();
}
private String buildParamFingerprint(Object[] args) {
StringBuilder raw = new StringBuilder();
for (Object arg : args) {
if (arg == null) {
raw.append("null;");
} else if (arg instanceof HttpServletRequest
|| arg instanceof HttpServletResponse
|| arg instanceof MultipartFile
|| arg instanceof HttpSession) {
// 这些对象与请求上下文绑定,不能参与指纹
raw.append("skip;");
} else {
try {
raw.append(OBJECT_MAPPER.writeValueAsString(arg)).append(";");
} catch (JsonProcessingException e) {
raw.append("unserializable_").append(arg.getClass().getName()).append(";");
}
}
}
return md5(raw.toString());
}
所有 key 最后都做了一次 MD5,避免业务字段里带空格、冒号、超长字符串导致 Redis key 难排查。Redis 本身要求 key 别太长,一个带长 JSON 的参数摘要如果不做 MD5,key 可能几百个字符,既占内存又难看清。
3.5 没有 Redis 的降级方案:本地 Caffeine 也能顶一下
如果你所在的项目确实还没有 Redis,又想在短时间内解决重复点击问题,可以考虑用 Caffeine 做一个进程内的短窗口缓存,效果类似 Redis 版本,但不支持多实例。
java复制@Component
public class LocalDebounceHelper {
private final Cache<String, Boolean> cache = Caffeine.newBuilder()
.expireAfterWrite(3, TimeUnit.SECONDS)
.maximumSize(10000)
.build();
public boolean tryAcquire(String key) {
return cache.get(key, k -> Boolean.TRUE) == null;
}
}
Caffeine 的内部结构保证同一时间只有一个线程能写入同一个 key,实现思路和 Redis 的 setnx 类似。但这个方案我只建议在两三种场景使用:一是应用只有单实例,二是对防抖一致性要求不高,三是在过渡期临时顶着用。一旦服务多实例部署,它就无法保证跨节点的请求是否重复了。
4. 防抖细节决定成败:key 维度、窗口期、响应策略
4.1 key 设计不当会让防抖完全失效
key 设计是防抖里的核心中的核心,比 AOP 切面本身更容易出错。最常见的错误是全参数参与指纹计算,结果请求体里带了一个每次都在变的 traceId、时间戳或者签名串,导致同一业务动作每次生成的 key 都不同,防抖直接失效。
举个真实例子。之前有个同事在请求体里放了一个“前端提交序号”,每次点击都自增。默认全参数指纹模式下,同一用户连点三次,三个 key 完全不同,Redis 占位三次全都成功。这不是 Redis 方案有问题,而是 key 的语义定义错了。
正确的做法是用业务上能标识“同一动作”的字段组合来生成 key。页面每次刷新属于新的一次会话,真正要防止的是用户在同一个会话内对同一个目标重复操作。所以:
- 防重复报名:
userId + activityId - 防重复退款:
userId + refundOrderNo - 防重复转账:
userId + sourceAccountNo + targetAccountNo + amount
这些都是和业务强相关的字段,适合通过注解的 keySpel 指定,而不要默认自动生成全参数指纹。
4.2 防抖窗口期到底设多少合适
窗口期太短容易漏掉肉眼可见的重复请求,太长又可能误伤用户正常操作。比如一个用户提交后 5 秒内想修改参数再提交,结果被拦截提示“请求太频繁”,体验就很差。
我自己的经验分两档:
- 单纯防止误点击、连点,窗口期 1 秒到 3 秒足够。
- 防重复下单、重复报名这类业务动作,建议 3 秒到 5 秒。
有一个更重要的参数你要记住:窗口期必须大于接口单次执行的最长时间。如果接口正常需要执行 4 秒,但你防抖窗口只设了 2 秒,第一个请求还在事务里,key 已经过期了,第二个相同请求又进来,防抖就形同虚设。这就是为什么默认窗口要给 3 秒,而不是更短的 500ms 的原因。
4.3 被拦截的请求应该如何响应
我见过有团队把重复请求直接返回 200,前端一脸迷茫,不知道到底提交成功没有。更合理的做法是像下面这样区分:
- 如果接口是对内的,直接抛出异常,由全局异常处理器统一包装。
- 如果接口是对外的 HTTP API,建议返回一个明确错误码,不要返回 200 + 业务成功。
- HTTP 状态码可以考虑 429 Too Many Requests 或 409 Conflict,但国内很多公司前端不处理非 200 状态码,所以我更常用的是
200 + code的方式,让前端根据业务码判断。
抛异常还有一个额外好处:能防止调用方在收到 429 后继续重试。很多 HTTP 客户端默认对 5xx 做重试,如果你的响应是 500,客户端的自动重试机制会再次打进来,反而加重了问题。明确告诉调用方“这是重复请求,不需要重试”,反而更安全。
4.4 Redis 故障时怎么处理:fail-open 还是 fail-close
这一点我在方案评审时被问过很多次。Redis 抖动导致 Redis 操作抛异常时,如果把异常直接抛出去,接口全挂,业务方会反过来骂你。但如果静默放行,防抖等于暂时失效,重复请求又可能涌入。
两个策略里我推荐默认 fail-open:捕获 Redis 异常后打 error 日志,本次请求放行。理由是防抖是“锦上添花”的优化,不是数据安全的最终兜底。如果业务重要性很高,重复会导致资金损失,那就需要 fail-close,宁可让用户稍后再试也不能重复扣款。具体用什么策略,可以直接做成配置项,上线前按接口重要程度决定。
5. 实战中容易踩的坑:问题排查速查
5.1 高频问题清单
我把实际运行中遇到的、以及帮别人 review 代码时发现的问题整理成一张表,你可以当作 check list 来用。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 注解加了但切面不生效 | 方法内部自调用,比如 this.submit() | 保证调用方注入的是 Spring 代理对象 |
| Redis 里 key 一直不消失 | setnx 和 expire 分两条命令执行,expire 没执行 | 用 setIfAbsent 的原子方法 |
| 重复请求仍然全部进入 | key 粒度错,请求体含随机参数导致每次都不同 | 用 keySpel 精确指定业务字段 |
| 用户 A 的请求把用户 B 拦了 | key 没有包含用户维度 | key 中拼上 userId |
| Redis 挂了导致接口全挂 | Redis 异常没有捕获 | 显式 catch,按策略降级 |
| 请求执行完马上再点又成功 | 窗口期太短或手动删除了 key | 不删 key,拉长窗口期 |
| 拦截后调用方继续重试 | 返回状态码不正确 | 返回明确业务错误码并提示不要重试 |
5.2 深挖一个高频坑:Spring AOP 自调用问题
这是所有注解类方案都逃不掉的问题。假设 Service 里有一个方法 A,方法 A 内部调用了同一个类里加了 @NoRepeatSubmit 的方法 B,方法 B 上的切面不会生效。原因是 Spring AOP 基于代理,外部调用走的是代理对象,内部 this.method() 调用走的是普通对象,没有经过代理增强。
AOP 切面不生效的排查顺序我建议这样:先确认类是否被 Spring 管理,再看类上有没有引发 CGLIB 代理的问题(比如 private 方法、final 类),最后确认调用方是不是把对象注入进来而不是 new 出来的。多数“加了注解没效果”的问题,最后都定位到自调用或手动 new 对象这两个原因上。
5.3 不同环境共用 Redis 导致请求互相干扰
在开发阶段,如果你只有一套公共 Redis,不同分支的应用同时连它,防抖 key 会互相覆盖。比如我在 dev 环境测活动接口,测试环境也在跑同一套代码,两边同时对一个活动提交请求,其中一个就会被另一个环境拦截。这个问题很容易被忽略,因为本地测试时根本不觉得。
解决方式我在 key 前缀里已留了口子:app:debounce: 后面建议再拼环境名。更好的做法是配置里加一个 spring.profiles.active 或者自定义的应用前缀,组合进 key:
java复制private static final String KEY_PREFIX = "debounce:" + activeProfile + ":";
这样 dev、test、prod 三个环境共用同一个 Redis 实例时,互相之间完全隔离。
5.4 切面里抛异常后事务是否回滚
很多用注解防抖的人还会在同方法上加 @Transactional,比如“
