拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot接口防抖:AOP+Redis注解式防重复提交方案

Spring Boot接口防抖:AOP+Redis注解式防重复提交方案 做后端开发这些年几乎每个项目都会碰到一类“玄学bug”用户明明只点了一下提交数据库里却凭空多出两条一模一样的订单消息队列消费端收到同一个事件业务数据被处理了两遍压测不过几次库存莫名变成了负数。排查到最后往往是同一个根因——同一个请求在极短时间窗口内被执行了多次。Spring Boot接口防抖就是专门解决这类问题的通用手段核心价值在于用极小的成本守住数据一致性的第一道防线。这篇文章我会从场景分析、方案选型到完整可落地的注解AOPRedis实现把接口防抖的关键细节和坑一次讲透适合正在做Java后端、被重复提交问题折磨过、想在项目里快速接入防抖能力的开发者。我在实际项目里做过不下三次防抖方案第一次用前端按钮置灰第二次用数据库唯一索引兜底最后才沉淀出一套相对通用的注解式方案。这篇就按我自己的踩坑顺序来写不绕弯子直接上干货。1. 接口防抖到底防的是什么1.1 从一次重复提交事故说起先讲一个真实发生过的场景。一个讲座预约系统上线第一天运营反馈后台数据异常同一个学生账号在同一秒内预约了同一个场次两次。查日志发现前端确实只提交了一次但网络组件在超时后自动重发了请求加上服务端处理请求的线程稍微慢了一点点两个请求几乎同时进入业务代码等到了查询剩余名额的环节两边读到的都是“还剩1个名额”于是都做了扣减名额库存就变成了-1。这种问题不是个例。用户手抖双击提交按钮、网关层重试策略、RPC框架自带的重试机制、消息队列的at-least-once投递语义任何一个环节都可能让同一个请求重复到达服务端。在做微服务改造之后这个问题被进一步放大原本单机内的同步锁还能兜住一部分一旦服务多实例部署本地锁就完全失效了。接口防抖要做的事情很纯粹在接口入口处识别出“同一用户在短时间内重复请求同一操作”只放行第一个把后续重复请求直接挡回去。不需要去改业务代码不需要在每张表里加唯一索引用一个通用机制覆盖所有需要防重的接口。1.2 防抖与幂等的边界很多人容易把防抖和幂等混为一谈这俩确实是两个层面的事情但很多人把它们混为一谈我在这里帮大家理顺。防抖是“拦住重复的请求”在一个时间窗口内同一个操作最多执行一次。它的判断发生在请求进入业务逻辑之前用的是时间窗口唯一标识比如5秒内同一个用户同一个操作只放行一次。防抖的本质是“挡”拦截后直接返回失败或提示不产生副作用。幂等是“重复执行也没关系”接口支持被多次调用结果一致不产生重复数据。幂等的实现通常靠业务侧比如订单号唯一索引、版本号乐观锁、状态机流转限制等。幂等的本质是“容错”重复请求进来后执行一次或执行多次最终效果都一样。两者是互补关系。防抖可以挡掉大部分重复请求但不能保证一定能挡住所有——比如防抖窗口过期后刚好又来了一个重试请求就只能靠幂等兜底。反过来只做幂等不做防抖虽然数据不会错但每次重复请求都会消耗数据库资源、放大性能压力而且用户会看到“提交中…提交中…”卡半天。所以成熟的项目是两层都上入口层做防抖业务层做幂等重复请求先被防抖拦掉漏网的交给幂等处理。1.3 数据一致性如何在入口层被保护数据一致性出问题本质上是因为“检查-操作”两个步骤之间有时间差。典型的竞态条件请求A查询库存得到剩余1请求B查询库存得到剩余1请求A扣减库存变为0请求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. 实战落地注解AOPRedis30分钟接进项目3.1 前置准备依赖与Redis配置进入正题。我用的是Spring Boot 3.x版本先引入AOP和Redis的starter依赖。这里多说一句如果你项目里还在用Spring Boot 2.x依赖坐标不变只是Redis连接配置类的写法有些差异核心逻辑不受影响。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependencyRedis连接配置常规的本地开发配置即可spring.data.redis.host127.0.0.1 spring.data.redis.port6379 spring.data.redis.password spring.data.redis.database0 spring.data.redis.timeout2000ms要确保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 ResultT { private int code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static ResultVoid error(int code, String message) { ResultVoid 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 groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration parameterstrue/parameters /configuration /plugin业务抛异常时手动删除key是我二开这个方案时候加的关键逻辑。原因很简单如果业务执行失败了比如库存不足、参数校验不通过用户收到的反馈是“失败”他大概率会重试一次。如果key还在TTL有效期内重试进来会被误拦用户就会莫名其妙看到“请勿重复提交”但实际上他上一次提交根本没有成功。删除key能保证失败后重试不被拦截这是提升体验很重要的一环。但如果业务执行成功了不要删key。让它等到TTL自然过期即可防止用户在“成功结果”还没返回时再次点击产生第二个成功请求。3.4 参数计算超时时间与key规范超时时间是最需要根据业务场景调的一个参数。设太小起不到防抖作用设太大会误伤正常操作。我常用的估算方法先统计目标接口正常响应时间的TP99值把防抖窗口设置为TP99的三到五倍。假设讲座预约接口正常响应时间P99是300ms那防抖窗口设1到1.5秒基本够用但考虑用户点击到网络重发的间隔设置3秒是比较稳妥的折中。也有一类接口响应时间本身就长比如批量导入接口可能要几秒钟。这种接口如果防抖窗口只有3秒第一个请求还没执行完TTL就过期了后一个重复请求就能趁虚而入。有两个改进思路把窗口调大覆盖最坏执行时间在业务方法内部“续期”延长key的TTL但这样会给业务代码增加侵入性对大多数场景我建议窗口直接设为3-5秒够用了。剩下的边界场景交给数据库唯一索引兜底。key的命名规范也值得认真设计。我惯用的格式是nrs:接口标识:业务标识nrs是防抖模块的固定前缀方便排查时一键从Redis里捞数据接口标识能定位到具体的方法比如OrderController.submitOrder业务标识是细粒度维度一般是用户ID订单号/活动ID/场次ID的组合看一个实际使用示例PostMapping(/reserve) public ResultVoid reserve(RequestBody ReserveRequest request) { // 业务代码 } // 标注防抖注解后的样子 PostMapping(/reserve) NoRepeatSubmit(prefix lecture:reserve, key #request.userId : #request.lectureId, timeout 3) public ResultVoid 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规则后我都会先用自动化脚本模拟多用户并发压测确认拦截比例符合预期再上生产。防抖是给业务加分的东西别让它变成误伤的来源。如果项目后续要扩展可以考虑在注解上增加一个“白名单模式”开关部分接口只记录重复日志不拦截作为恶意刷接口的行为分析依据。这个扩展方向需要时可以做但眼下这套方案已经够日常项目稳稳跑很久了。
返回列表