☰
Spring Boot接口防抖:AOP+Redis注解式防重复提交方案
2026/9/29 15:24:21 网站建设 项目流程

做后端开发这些年,几乎每个项目都会碰到一类“玄学bug”:用户明明只点了一下提交,数据库里却凭空多出两条一模一样的订单;消息队列消费端收到同一个事件,业务数据被处理了两遍;压测不过几次,库存莫名变成了负数。排查到最后往往是同一个根因——同一个请求在极短时间窗口内被执行了多次。Spring Boot接口防抖,就是专门解决这类问题的通用手段,核心价值在于用极小的成本守住数据一致性的第一道防线。这篇文章我会从场景分析、方案选型到完整可落地的注解+AOP+Redis实现,把接口防抖的关键细节和坑一次讲透,适合正在做Java后端、被重复提交问题折磨过、想在项目里快速接入防抖能力的开发者。

我在实际项目里做过不下三次防抖方案,第一次用前端按钮置灰,第二次用数据库唯一索引兜底,最后才沉淀出一套相对通用的注解式方案。这篇就按我自己的踩坑顺序来写,不绕弯子,直接上干货。

1. 接口防抖到底防的是什么

1.1 从一次重复提交事故说起

先讲一个真实发生过的场景。一个讲座预约系统上线第一天,运营反馈后台数据异常:同一个学生账号,在同一秒内预约了同一个场次两次。查日志发现前端确实只提交了一次,但网络组件在超时后自动重发了请求,加上服务端处理请求的线程稍微慢了一点点,两个请求几乎同时进入业务代码,等到了查询剩余名额的环节,两边读到的都是“还剩1个名额”,于是都做了扣减,名额库存就变成了-1。

这种问题不是个例。用户手抖双击提交按钮、网关层重试策略、RPC框架自带的重试机制、消息队列的at-least-once投递语义,任何一个环节都可能让同一个请求重复到达服务端。在做微服务改造之后,这个问题被进一步放大:原本单机内的同步锁还能兜住一部分,一旦服务多实例部署,本地锁就完全失效了。

接口防抖要做的事情很纯粹:在接口入口处识别出“同一用户在短时间内重复请求同一操作”,只放行第一个,把后续重复请求直接挡回去。不需要去改业务代码,不需要在每张表里加唯一索引,用一个通用机制覆盖所有需要防重的接口。

1.2 防抖与幂等的边界

很多人容易把防抖和幂等混为一谈,这俩确实是两个层面的事情,但很多人把它们混为一谈,我在这里帮大家理顺。

防抖是“拦住重复的请求”,在一个时间窗口内,同一个操作最多执行一次。它的判断发生在请求进入业务逻辑之前,用的是时间窗口+唯一标识,比如5秒内同一个用户同一个操作只放行一次。防抖的本质是“挡”,拦截后直接返回失败或提示,不产生副作用。

幂等是“重复执行也没关系”,接口支持被多次调用,结果一致,不产生重复数据。幂等的实现通常靠业务侧,比如订单号唯一索引、版本号乐观锁、状态机流转限制等。幂等的本质是“容错”,重复请求进来后执行一次或执行多次,最终效果都一样。

两者是互补关系。防抖可以挡掉大部分重复请求,但不能保证一定能挡住所有——比如防抖窗口过期后刚好又来了一个重试请求,就只能靠幂等兜底。反过来,只做幂等不做防抖,虽然数据不会错,但每次重复请求都会消耗数据库资源、放大性能压力,而且用户会看到“提交中…提交中…”卡半天。所以成熟的项目是两层都上:入口层做防抖,业务层做幂等,重复请求先被防抖拦掉,漏网的交给幂等处理。

1.3 数据一致性如何在入口层被保护

数据一致性出问题,本质上是因为“检查-操作”两个步骤之间有时间差。典型的竞态条件:

  1. 请求A查询库存,得到剩余1
  2. 请求B查询库存,得到剩余1
  3. 请求A扣减库存,变为0
  4. 请求B也扣减库存,变为-1

防抖做的事情,就是在第1步之前先抢到一把“临时锁”。抢到锁的请求才能继续执行,没抢到的直接打回。这样一来,同一时间窗口内只有第一个请求能走到业务逻辑,“检查-操作”的竞态窗口从“多个请求重叠执行”缩小到了“单一请求执行”,数据一致性在入口层就被保护住了。

有人可能会问:只防住几秒内的重复请求,如果用户过了几秒再点一次,不是还能重复提交吗?确实是这样。防抖解决的是“短时间内手抖/超时重试/重复投递”这类异常重复,不是解决“用户有意重复下单”的问题。后者要靠幂等、风控、业务规则去约束,不能指望防抖一个机制全包圆。

2. 方案对比:别急着写代码,先选型

2.1 前端拦截靠不住,但要做

最容易想到的方案是前端控制:用户点击提交按钮后立即置灰,等接口响应后再恢复。这个方案能防住90%的用户手抖,开发成本最低,几乎为零。

但前端拦截有明显的漏洞:用户强制刷新页面后按钮恢复可点;请求超时后用户自然重试;绕过前端直接调接口的客户端、脚本、爬虫,按钮置灰根本管不到。还有一个更隐蔽的场景——同一用户在两个浏览器标签页同时打开同一个页面,两边按钮都亮了,各点一次就是两个请求。

我的观点是:前端按钮置灰照做,这是用户体验层面的优化,但它不能作为数据一致性的保障手段。一旦涉及资金、库存、订单这类敏感数据,后端必须有一套自己的防重机制。

2.2 数据库唯一索引:兜底之王

数据库唯一约束是防重最可靠的手段。比如订单表加上唯一索引(user_id, event_id),重复插入时数据库会因为违反唯一索引而报错,第二个请求自然失败。

这个方案的优点是绝对可靠,没有任何中间件依赖,数据库是最终的事实来源。但缺点是明显的:

  • 需要为每个防重场景设计唯一索引,代码侵入性强
  • 重复请求不是被“挡”回去的,而是走完业务流程后在插入那一步才报错,浪费了大量计算资源
  • 唯一索引上的冲突异常需要额外处理,业务代码会多出一堆DuplicateKeyException的判断
  • 不能覆盖没有落库的操作,比如调用第三方接口、发送消息等

所以唯一索引一般作为最后的兜底,而不是主要防线。它解决的是“防抖没拦住”之后的最终一致性,而不是“防抖”本身。

2.3 Redis原子操作与分布式锁

真正适合做接口防抖的,是一个分布式环境通用的轻量机制:Redis的SETNX/INCR原子操作。

Redis提供了SET key value NX EX seconds这条命令,语义是“如果key不存在则设置值并设置过期时间,如果key已存在则不做任何操作”,整个判断和设置是一步完成的,天然是原子操作,不会出现先查再设这种竞态窗口。放到接口防抖的场景里就是:以“用户标识+接口标识+业务参数”为key,请求进来先执行一次SETNX,返回成功说明是第一次请求,放行;返回失败说明窗口内已有请求,直接拦截。

这套方案的优势:

  • 原子操作,不需要额外加锁
  • Redis是分布式组件,多实例部署依然有效
  • 性能极高,单次SETNX命令耗时通常不到1ms
  • 带过期时间,key自动清理,不用手动删
  • 代码可以做成通用注解,接入成本极低

如果业务对锁的要求更高,比如需要可重入、需要自动续期,可以考虑Redisson的分布式锁。但对接口防抖来说,Redisson偏重了,SETNX方案更简洁,足够解决90%的问题。

2.4 防抖方案选型速查表

方案适用场景优点缺点推荐度
前端按钮置灰提升用户体验零成本可绕过,不保证一致性必须做,辅助用
数据库唯一索引高强度兜底绝对可靠侵入强、浪费资源兜底用
本地锁(synchronized/ReentrantLock)单机部署轻量简单集群失效、不可通用不推荐
Redis SETNX分布式通用防抖原子、高性能、易复用依赖Redis、需要设计key首选
Redisson分布式锁复杂锁需求可重入、可续期偏重,防抖场景用不上按需选择

我自己在项目里的组合拳是:前端按钮置灰降低用户误触概率 + Redis SETNX注解做入口防抖 + 核心业务表唯一索引兜底。三层各管一段,重复提交事故基本绝迹。

3. 实战落地:注解+AOP+Redis,30分钟接进项目

3.1 前置准备:依赖与Redis配置

进入正题。我用的是Spring Boot 3.x版本,先引入AOP和Redis的starter依赖。这里多说一句,如果你项目里还在用Spring Boot 2.x,依赖坐标不变,只是Redis连接配置类的写法有些差异,核心逻辑不受影响。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

Redis连接配置,常规的本地开发配置即可:

spring.data.redis.host=127.0.0.1 spring.data.redis.port=6379 spring.data.redis.password= spring.data.redis.database=0 spring.data.redis.timeout=2000ms

要确保RedisTemplate或StringRedisTemplate能被注入。我这里选StringRedisTemplate,因为防抖的key和value都是简单的字符串,没必要走序列化器,能少踩不少坑。

3.2 自定义注解与统一返回

先定义一个注解,用来标记哪些方法需要防抖:

import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.concurrent.TimeUnit; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface NoRepeatSubmit { /** * 防抖key前缀,默认取“类名+方法名” */ String prefix() default ""; /** * SpEL表达式,从请求参数中提取业务唯一标识 * 比如 "#user.id"、"#dto.orderNo"、"#id" */ String key() default ""; /** * 防抖时间窗口,默认3秒 */ long timeout() default 3; /** * 时间单位,默认秒 */ TimeUnit unit() default TimeUnit.SECONDS; }

这里有一个设计考量:key字段用了SpEL表达式而非直接写死字符串。原因是防抖的粒度要精确到“某个用户对某个业务对象的某个操作”,如果所有用户共用一个key,第一个用户请求后其他用户全部被误拦。比如讲座预约场景,key应该是学生ID+场次ID,而不是笼统的“预约接口”。

为了方便业务侧感知“被拦截了”,还要定义统一的返回结果类和异常:

public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 200; r.message = "success"; r.data = data; return r; } public static Result<Void> error(int code, String message) { Result<Void> r = new Result<>(); r.code = code; r.message = message; return r; } // 省略getter/setter }
public class RepeatSubmitException extends RuntimeException { public RepeatSubmitException(String message) { super(message); } }

这里我特意把重复提交的code定为429,和HTTP的Too Many Requests对齐。这样前端和后端都能直观地理解这个状态的含义,排查日志时也容易分辨。

3.3 AOP切面与SpEL动态key

切面是整个方案的核心。逻辑并不复杂:请求进来时生成防抖key,执行SETNX,抢到锁就放行,没抢到就抛异常。我之前写这段代码时最费劲的是SpEL表达式解析,这里直接把完整代码贴出来:

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.core.DefaultParameterNameDiscoverer; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import java.lang.reflect.Method; @Aspect @Component public class NoRepeatSubmitAspect { private final StringRedisTemplate stringRedisTemplate; private final SpelExpressionParser spelParser = new SpelExpressionParser(); private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); public NoRepeatSubmitAspect(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } @Around("@annotation(noRepeatSubmit)") public Object around(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) throws Throwable { String key = buildKey(joinPoint, noRepeatSubmit); // SETNX原子操作,返回true说明第一次请求 Boolean firstRequest = stringRedisTemplate.opsForValue() .setIfAbsent(key, "1", noRepeatSubmit.timeout(), noRepeatSubmit.unit()); if (Boolean.TRUE.equals(firstRequest)) { try { return joinPoint.proceed(); } catch (Throwable throwable) { // 业务抛出异常,删除key,让用户有重试机会 stringRedisTemplate.delete(key); throw throwable; } } // 重复请求,直接拦截 throw new RepeatSubmitException("请求处理中,请勿重复提交"); } private String buildKey(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); String prefix = StringUtils.hasText(noRepeatSubmit.prefix()) ? noRepeatSubmit.prefix() : method.getDeclaringClass().getName() + "." + method.getName(); // 解析SpEL表达式,从参数中提取业务唯一标识 String keyPart = ""; if (StringUtils.hasText(noRepeatSubmit.key())) { Object[] args = joinPoint.getArgs(); String[] paramNames = parameterNameDiscoverer.getParameterNames(method); if (paramNames != null && args.length == paramNames.length) { EvaluationContext context = new StandardEvaluationContext(); for (int i = 0; i < args.length; i++) { context.setVariable(paramNames[i], args[i]); } Expression expression = spelParser.parseExpression(noRepeatSubmit.key()); Object value = expression.getValue(context); if (value != null) { keyPart = String.valueOf(value); } } } if (!StringUtils.hasText(keyPart)) { // 没有SpEL或解析失败时,退化为方法签名级别的防抖 keyPart = "DEFAULT"; } return "nrs:" + prefix + ":" + keyPart; } }

几个关键细节说明一下:

DefaultParameterNameDiscoverer用于获取方法参数名。Spring Boot默认编译参数是-parameters,如果你的项目没开这个参数,运行时获取到的参数名可能是arg0、arg1这类,SpEL表达式就会失效。解决方式是在pom.xml里配置:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <parameters>true</parameters> </configuration> </plugin>

业务抛异常时手动删除key,是我二开这个方案时候加的关键逻辑。原因很简单:如果业务执行失败了(比如库存不足、参数校验不通过),用户收到的反馈是“失败”,他大概率会重试一次。如果key还在TTL有效期内,重试进来会被误拦,用户就会莫名其妙看到“请勿重复提交”,但实际上他上一次提交根本没有成功。删除key能保证失败后重试不被拦截,这是提升体验很重要的一环。

但如果业务执行成功了,不要删key。让它等到TTL自然过期即可,防止用户在“成功结果”还没返回时再次点击,产生第二个成功请求。

3.4 参数计算:超时时间与key规范

超时时间是最需要根据业务场景调的一个参数。设太小起不到防抖作用,设太大会误伤正常操作。我常用的估算方法:先统计目标接口正常响应时间的TP99值,把防抖窗口设置为TP99的三到五倍。假设讲座预约接口正常响应时间P99是300ms,那防抖窗口设1到1.5秒基本够用,但考虑用户点击到网络重发的间隔,设置3秒是比较稳妥的折中。

也有一类接口响应时间本身就长,比如批量导入接口可能要几秒钟。这种接口如果防抖窗口只有3秒,第一个请求还没执行完,TTL就过期了,后一个重复请求就能趁虚而入。有两个改进思路:

  1. 把窗口调大,覆盖最坏执行时间
  2. 在业务方法内部“续期”,延长key的TTL,但这样会给业务代码增加侵入性

对大多数场景,我建议窗口直接设为3-5秒,够用了。剩下的边界场景交给数据库唯一索引兜底。

key的命名规范也值得认真设计。我惯用的格式是nrs:接口标识:业务标识:

  • nrs是防抖模块的固定前缀,方便排查时一键从Redis里捞数据
  • 接口标识能定位到具体的方法,比如OrderController.submitOrder
  • 业务标识是细粒度维度,一般是用户ID+订单号/活动ID/场次ID的组合

看一个实际使用示例:

@PostMapping("/reserve") public Result<Void> reserve(@RequestBody ReserveRequest request) { // 业务代码 } // 标注防抖注解后的样子 @PostMapping("/reserve") @NoRepeatSubmit(prefix = "lecture:reserve", key = "#request.userId + ':' + #request.lectureId", timeout = 3) public Result<Void> reserve(@RequestBody ReserveRequest request) { // 业务代码 }

这样标记后,同一个学生对同一场讲座,3秒内只能有一个预约请求能走到业务代码,第二个请求会立刻收到“请求处理中,请勿重复提交”。不同学生、不同场次之间互不影响,各抢各的锁,并行度完全不受影响。

4. 避坑实录:常见问题与排查技巧

4.1 Redis抖动时的降级策略

接入Redis防抖后,团队里最担心的一个问题就是Redis挂了怎么办。如果Redis不可用,setIfAbsent会抛连接异常,导致整个接口不可用。这个影响面太大了,必须做降级处理。

我采用的策略是:Redis异常时放行,降级为不做防抖,靠业务幂等兜底。因为防抖的目的是提升体验、降低重复概率,属于“优化”而非“必须”,在Redis不可用的极端场景下,保证接口可用性优先级更高。具体实现是在切面里catch掉Redis连接的异常:

@Around("@annotation(noRepeatSubmit)") public Object around(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) throws Throwable { try { String key = buildKey(joinPoint, noRepeatSubmit); Boolean firstRequest = stringRedisTemplate.opsForValue().setIfAbsent(...); // ... } catch (RedisConnectionException e) { // Redis不可用时降级,直接放行 log.warn("Redis不可用,接口防抖降级放行", e); return joinPoint.proceed(); } }

当然,如果项目对防抖有强诉求,即使Redis挂了也要挡住重复请求,那只能做本地缓存兜底或引入其他分布式组件。但以我的经验,Redis挂了的时候你大概率有更大的麻烦要处理,优先保可用性就对了。

4.2 集群部署时防抖还生效吗

这个问题被问过很多次,答案是非常确定:生效。防抖key存在Redis里,多个服务实例共享同一个Redis,无论请求被负载均衡打到哪一台机器,SETNX操作都落在同一个Redis key上,天然是全局互斥的,这本身就是Redis方案相比本地锁的最大优势。

如果有人实现了“先查询Redis再判断”的版本,那就要特别注意竞态问题——查完发现key不存在后,还没来得及SET,另一个请求也查到了同样的结果,两边都放行。这种写法必须改成一条原子命令,Spring Data Redis的setIfAbsent就是用SETNX实现的,直接用就行,别自己拼两步。

集群部署下还有一个隐蔽问题:服务重启或应用多副本共用同一个Redis环境时,key前缀要区分环境,比如nrs-dev:、nrs-prod:,防止测试环境的请求误拦生产环境。

4.3 用户隔离与参数维度

关于keb设计有一个高频调优场景:A用户操作后,B用户跟着报“重复提交”。这大概率是key设计漏了业务标识,所有用户共用了同一个锁,第一个请求占住后,后续所有请求都进不来。查出这个问题很快,但写出这样的芯片的教训通常来自上线后第一波用户反馈。

我的经验是:spEL表达式里,必须把key里至少带上“用户ID+业务对象ID”。用户ID保证不同的用户能并行访问,业务对象ID保证同一个用户对不同业务对象也能并行操作。比如讲座预约接口,光写#request.userId,同一个用户在3秒内预约了讲座A又预约讲座B,第二条会被误拦;加上#request.lectureId后就没这个问题了。这是设计key时最容易忽视的细节。

另外,参数对象如果是个大型DTO,不要在SpEL里直接拼整个对象toString。那会让key带上大量无关信息,权重不简洁、无法快速定位问题,而且不同的序列化顺序可能产生不同hashCode,导致防抖失效。正确的做法是取DTO中的关键字段来拼。

4.4 性能实测与优化建议

防抖对接口性能的影响可以小到忽略不计。我自己在4核8G的实例上压过,单次SETNX命令的平局在0.5ms左右,相比业务方法动不动几十毫秒的DB操作,基本可以忽略。Redis本身的性能也不是瓶颈,单节点Redis每秒能处理十万级SETNX请求,目标接口的QPS和它不在一个量级上。

不过有一个细节值得注意:如果防抖被用在一个极高频率的接口上,可以不经过统一异常就返回。我在拦截到重复请求时其实省略了一次异常栈打印的开销,直接抛出业务异常由全局异常处理器捕获,不会产生堆栈打印,所以整体开销控制得很好。

如果追求极致优化,还可以给防抖加一层本地缓存前置判断。比如用Caffeine做每台机器的本地窗口缓存,本地能拦住的请求根本不会打到Redis。但本地缓存又带了集群一致性问题,需要等TTL自然过期,复杂度上来了。对绝大多数项目来说,纯Redis方案足够,先不要过度设计。

4.5 关于异常处理里删除key的权衡

有个场景我提一下:业务逻辑执行时间特别长,比如一个接口要跑10秒,但防抖窗口只有3秒。第一个请求还在执行中,key已经过期了,第二个重复请求进来了,然后两个请求同时在业务里跑。之前我讲了两个思路——调大窗口、主动续期,但这两个都有代价。

调大窗口的问题是,用户正常操作可能在窗口内被误伤,比如他想连续提交两张不同的报名单,但key是按用户维度防抖,第二张就被拦了。所以与其把所有接口都调大窗口,不如聚焦在真正的热点接口上按需设计,或者让防抖窗口覆盖最坏执行时间即可。

主动续期的做法是在业务方法里动态刷新TTL,但会给业务代码增加侵入性,除非用事务模板或装饰器统一控制,否则我不太推荐。对大多数接口来说,3-5秒的窗口+幂等兜底已经是性价比最高的组合了。

最后一个总结性的心得,这个方案我一共用到了三个项目里,从讲座预约到商城的订单提交,测试阶段最怕的不是“没拦住重复”,而是“误拦了正常请求”。所以每次改key规则后我都会先用自动化脚本模拟多用户并发压测,确认拦截比例符合预期,再上生产。防抖是给业务加分的东西,别让它变成误伤的来源。

如果项目后续要扩展,可以考虑在注解上增加一个“白名单模式”开关,部分接口只记录重复日志不拦截,作为恶意刷接口的行为分析依据。这个扩展方向需要时可以做,但眼下这套方案,已经够日常项目稳稳跑很久了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询