接到这个题目时,我脑子里首先跳出来的是上周刚处理过的一个线上问题。一个用户报名接口,参与者在弱网环境下连续点了十几次“提交”,前端按钮也确实做了 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 语义和防抖天然匹配:多个请求同时写同一个
