☰
Spring Boot接口防重实战:从单机内存到Redis分布式防抖
2026/10/17 4:52:42 网站建设 项目流程

写完之后容易重复提交,这是所有做业务系统的人都会碰到的糟心事。用户连点两次按钮、前端超时重试、RPC调用方做补偿性调用,同一个请求在后端被处理了两遍,轻则生成两条重复订单,重则库存扣两次、积分发双份。Spring Boot 下接口防抖(防重复提交)不是一个新话题,但网上很多方案要么太简陋(比如前端按钮置灰,根本防不住绕过浏览器的请求),要么太重(引入消息队列做幂等,杀鸡用牛刀)。这篇文章我把自己在项目里实际用过、验证过的几种后端方案整理出来,从最简单的单机内存方案到分布式场景下的 Redis 实现,把思路、代码、坑都讲清楚,适合正在做 Web 后端、需要快速给接口加上防重能力的开发者参考。

1. 防抖的核心逻辑与方案选型思路

1.1 先搞清楚到底要防什么

接口防抖,准确说应该叫“接口幂等保护”,核心目标只有一个:同一个请求在短时间内被提交多次,只有第一次真正生效,后续的重复请求直接拒绝或者返回上一次的结果。这里要区分两个概念,一个是“防抖”(Debounce),一个是“节流”(Throttle)。防抖是在一段时间内只认第一次请求,后续的都拦掉;节流是控制请求频率,比如每秒最多放行 N 次。防重复提交用的是防抖思路,同一把业务钥匙(比如订单号、用户ID加接口路径)在时间窗口内只允许一个请求通过。

我见过不少人一上来就想着用 Spring AOP 加自定义注解,这确实是主流方案,但做之前必须先想清楚三个问题。第一,重复请求怎么识别?前端传 token 也好,后端根据参数生成签名也罢,总得有一个唯一标识来判定两个请求是不是同一个。第二,这个标识要存哪里?单机部署放内存就行,多实例部署就得上 Redis,不然每个实例的内存各自为政,拦不住跨节点的重复请求。第三,时间窗口设多长?太短防不住慢请求(比如三秒超时的接口,窗口只有一秒,第一批还没处理完第二批就进来了),太长又会误伤正常的连续请求(比如用户在不同页面触发的合法操作)。

这三个问题想清楚了,方案选型就水到渠成。

1.2 常见方案对比:从请求方防到处理方

按防抖的拦截位置划分,市面上常见的做法大致分三类。

第一类是前端方案,按钮 loading 置灰、提交后禁用表单。优点是零后端成本,缺点也很致命——绕过浏览器就能轻松伪造请求,而且前后端分离后,移动端、第三方调用的场景根本管不住。这类方案只能作为辅助,不能作为防线。

第二类是网关层方案,在 Nginx 或者 Spring Cloud Gateway 层面根据 IP 加 URL 做限流防重。优点是能挡掉一部分异常流量,但粒度太粗,同一个 IP 下不同用户的不同请求会被误伤,而且网关层拿不到业务参数,做不到精确的“同一业务操作”判定。

第三类是应用层方案,也就是 Spring Boot 项目里通过拦截器、过滤器、AOP 在接口调用前后做幂等控制。这是目前最灵活也最常用的一类,可以根据 token、参数指纹、用户维度自定义唯一键,配合 Redis 做分布式控制。这篇文章的重点就放在这一类,下面每个方案都会给出可以直接落地的代码。

方案层级实现方式防重复精度分布式支持工程成本
前端按钮置灰低天然支持极低
网关IP+URL限流中支持中
应用层拦截器+Redis高支持低
应用层AOP+自定义注解最高支持中

一个成熟系统往往会把前端置灰作为第一道闸,应用层防重作为最后的兜底,两层配合才能做到体验和安全兼顾。

2. 单机场景下的接口防抖:本地缓存方案详解

2.1 手写一个基于 ConcurrentHashMap 的防抖组件

如果你的项目是单实例部署,或者处于早期阶段还没引入 Redis,完全可以用一个简单的本地缓存组件实现接口防抖。原理不复杂:用ConcurrentHashMap保存请求唯一标识和它的过期时间,每次请求进来先检查 map 里有没有未过期的记录,有就判定为重复请求,没有就放入 map 并设置过期时间,相当于一个简化版的短时令牌。

直接上代码,组件分为两个部分,一个是防抖管理器,一个是注解加拦截器的使用入口。

@Component public class LocalIdempotentManager { // 存储请求签名 -> 过期时间戳(毫秒) private final Map<String, Long> cache = new ConcurrentHashMap<>(); // 定时清理过期 key,避免内存无限增长 @Scheduled(fixedDelay = 60000) public void evictExpired() { long now = System.currentTimeMillis(); cache.entrySet().removeIf(entry -> entry.getValue() < now); } /** * 尝试获取执行许可 * @param key 请求唯一标识 * @param expireMillis 防抖窗口时间(毫秒) * @return true=允许执行,false=重复请求 */ public boolean tryAcquire(String key, long expireMillis) { long now = System.currentTimeMillis(); Long expireAt = oldExpireAt(key); if (expireAt != null && expireAt > now) { return false; } // 重新设置过期时间 return tryUpdateExpire(key, now + expireMillis, expireAt); } private Long oldExpireAt(String key) { return cache.get(key); } private boolean tryUpdateExpire(String key, long newExpireAt, Long oldExpireAt) { Long currentExpireAt = cache.putIfAbsent(key, newExpireAt); if (currentExpireAt == null) { return true; } // 说明调用期间有其他线程已写入,判断是否未过期 return currentExpireAt < System.currentTimeMillis(); } }

这里有个细节值得专门说一下:为什么用ConcurrentHashMap而不是HashMap?因为接口防抖是典型的高并发读写场景,多个请求可能同时命中同一个 key,HashMap在多线程 put 时可能出现环形链表问题(JDK7)或者数据覆盖问题(JDK8),而ConcurrentHashMap的putIfAbsent是原子操作,能保证在并发下只有一个线程能抢到写入机会,这个原子性是这个方案的核心。

2.2 用拦截器把防抖能力无侵入接入接口

有了管理器,还得有一种方式让业务接口声明“我是防抖的”。我不建议在业务代码里手动调用tryAcquire,那样每个接口都要写一遍模板逻辑,很啰嗦。更优雅的做法是自定义一个@NoRepeatSubmit注解,再通过 Spring MVC 的HandlerInterceptor统一拦截。

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface NoRepeatSubmit { // 防抖时间窗口,默认3秒 long value() default 3000L; }
@Component public class RepeatSubmitInterceptor extends HandlerInterceptorAdapter { @Autowired private LocalIdempotentManager idempotentManager; // 这里需要额外获取当前登录用户,实际项目可从上下文或请求头中解析 private String resolveUserId(HttpServletRequest request) { // 伪代码:从请求头或 Session 中获取用户标识 return request.getHeader("X-User-Id"); } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } NoRepeatSubmit annotation = handlerMethod.getMethodAnnotation(NoRepeatSubmit.class); if (annotation == null) { return true; } String userId = resolveUserId(request); // 防抖 key 尽量带上用户ID和请求路径,避免不同操作的请求被误判 String key = userId + ":" + request.getRequestURI() + ":" + buildParamsSignature(request); if (!idempotentManager.tryAcquire(key, annotation.value())) { response.setStatus(429); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":429,\"message\":\"重复提交,请稍后再试\"}"); return false; } return true; } private String buildParamsSignature(HttpServletRequest request) { Map<String, String[]> params = request.getParameterMap(); // 对参数名排序后拼接,避免参数顺序不同导致误判 StringJoiner joiner = new StringJoiner("&"); params.entrySet().stream() .sorted(Map.Entry.comparingByKey()) .forEach(entry -> joiner.add(entry.getKey() + "=" + String.join(",", entry.getValue()))); return DigestUtils.md5DigestAsHex(joiner.toString().getBytes()); } }

注册拦截器的时候要注意,记得排除掉那些不需要防抖的路径,比如静态资源、健康检查接口。这个方案最关键的设计细节在防抖 key 的构建上,我见过很多人只用了 requestURI,结果用户连续调用两个不同参数的同路径接口被误拦,尤其查询接口最容易踩这个坑。把用户 ID、接口路径、参数签名三者拼接,既保证了同一用户同一操作才能被识别为重复,又避免了对正常参数变化的请求造成误伤。

2.3 单机方案的适用边界与内存隐患

这个方案代码量很小、部署简单、查询快,但有两个隐患是必须提前知道的。

第一个是内存占用。如果防抖窗口设置得很长(比如 10 秒)而并发量又大(每秒几百次请求),缓存里的 key 会积累得很快。虽然后台定时任务每分钟清理一次,但高峰期内存中仍然可能同时存在几万条记录,每条算上 key 字符串和 Long 对象,几十 KB 到几百 KB 的内存开销是跑不掉的。解决办法就是每秒钟清理一次过期 key,或者改用ScheduledThreadPoolExecutor做更细粒度的驱逐,本质上是拿 CPU 换内存。

第二个是单机限制。只要一上多实例部署,哪怕只有两个节点,这个方案就废了。用户请求打到 A 实例,A 实例的缓存里写了 key,但用户再点一次请求被负载均衡转发到 B 实例,B 实例的缓存里没有 key,请求就被放行了。这就是为什么但凡系统有扩容计划,最好一开始就直接上 Redis 方案——别问我怎么知道的,我就是从迁移的血泪史里总结出来的。

3. 分布式场景:Redis 防抖方案的完整落地

3.1 Redis 实现防抖的两种形态:SETNX 与 Lua 脚本

当系统上了多实例,或者容器编排平台让 Pod 数量随时变化时,就必须把防抖状态从内存挪到集中式存储里,Redis 是天然的选择。用 Redis 做防抖主要有两种实现形态。

第一种是基于 SETNX 的简单实现。利用setIfAbsent(key, value)的原子特性,key 不存在时才能写入成功,写成功就说明拿到了执行权,写失败说明已有相同请求在执行或被防抖拦截。配合setex设置过期时间,就完成了“窗口期内只允许一次”的效果。这种实现最简单,但存在一个老生常谈的坑——必须保证 setIfAbsent 和 setex 的原子性。以前有人用 SETNX 之后单独再调 EXPIRE,两个操作中间线程挂掉的话 key 就没有过期时间,会变成僵尸 key 一直占着防抖名额。现在 Spring Data Redis 提供的setIfAbsent(key, value, Duration)方法已经把两个操作在一个原子命令里完成了,所以直接用带过期时间的方法就好。

第二种是基于 Lua 脚本的实现。Redis 的 Lua 脚本是原子执行的,这是实现“检查加写入加设置过期时间”三步合一的最佳方式。我推荐用这种方式,因为它的扩展性更强,后续想加计数器、想记录请求次数、想同时做限流这种复合逻辑,都可以直接在脚本里完成,不用改 Java 代码。

@Component public class RedisIdempotentManager { @Autowired private StringRedisTemplate redisTemplate; private static final DefaultRedisScript<Boolean> TRY_ACQUIRE_SCRIPT; static { TRY_ACQUIRE_SCRIPT = new DefaultRedisScript<>(); TRY_ACQUIRE_SCRIPT.setResultType(Boolean.class); TRY_ACQUIRE_SCRIPT.setScriptText( "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " + " redis.call('expire', KEYS[1], ARGV[2]) " + " return true " + "else " + " return false " + "end" ); } public boolean tryAcquire(String key, long expireSeconds) { return Boolean.TRUE.equals(redisTemplate.execute( TRY_ACQUIRE_SCRIPT, List.of(key), String.valueOf(System.currentTimeMillis()), String.valueOf(expireSeconds) )); } }

在 Spring 容器里定义DefaultRedisScript的时候要注意,脚本对象最好是静态单例,不要每次执行都 new 一个,因为脚本对象要和 Redis 通信完成脚本缓存,反复创建会影响性能和内存。另外resultType要指定成 Boolean 而不是 Long,这样框架才能正确把 Redis 返回的 1/0 转成 true/false。

3.2 自定义注解 + Spring AOP 实现声明式防抖

Redis 管理器准备好了之后,接入方式我强烈建议用 Spring AOP + 自定义注解,而不是拦截器。原因有两点:拦截器能拿到的信息只有 HttpServletRequest,对于非 HTTP 入口(比如 MQ 消费者、内部 RPC)无能为力;而 AOP 可以直接作用在 Service 层方法上,不管上层是 Controller、RPC 还是消息监听器,都能统一保护。而且 AOP 能很方便地处理业务方法抛异常后的状态回滚,配合防抖解锁更灵活。

先定义注解,支持指定防抖 key 的 SpEL 表达式:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface NoRepeatSubmit { // 防抖窗口时间,默认3秒 long value() default 3000L; // 防抖唯一标识,支持SpEL表达式,如 #orderId、#request.userId String key() default ""; }

再写切面类:

@Aspect @Component public class NoRepeatSubmitAspect { @Autowired private RedisIdempotentManager idempotentManager; @Around("@annotation(noRepeatSubmit)") public Object around(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) throws Throwable { // 解析防抖 key String key = buildKey(joinPoint, noRepeatSubmit); long expireMillis = noRepeatSubmit.value(); if (!idempotentManager.tryAcquire(key, expireMillis / 1000)) { throw new RepeatSubmitException("重复提交,请勿频繁操作"); } try { return joinPoint.proceed(); } catch (Throwable ex) { // 业务异常时释放防抖锁,避免短期无法重试 idempotentManager.release(key); throw ex; } } private String buildKey(ProceedingJoinPoint joinPoint, NoRepeatSubmit annotation) { if (annotation.key().isEmpty()) { // 默认基于类名+方法名+参数toString的hash String target = joinPoint.getTarget().getClass().getName(); String method = joinPoint.getSignature().getName(); String params = Arrays.deepToString(joinPoint.getArgs()); return target + ":" + method + ":" + DigestUtils.md5DigestAsHex(params.getBytes()); } // 解析SpEL表达式,这里使用Spring的ExpressionEvaluator工具 return parseSpel(annotation.key(), joinPoint); } }

使用的时候只需要在需要防抖的接口上加注解:

@RestController public class OrderController { @PostMapping("/order/create") @NoRepeatSubmit(value = 5000, key = "#userId + '-create-order'") public Result createOrder(@RequestHeader Long userId, @RequestBody OrderRequest request) { // 创建订单业务逻辑 return Result.ok(); } }

这里有两个关键设计是实战中磨出来的。第一个是“业务异常时释放防抖锁”。很多教程代码只做 AOP 环绕,忘了 catch 异常这一步,结果接口因为参数校验失败抛了个异常,防抖窗口却依然把请求锁死,用户第一次提交没成功,第二次提交又被提示“重复操作”,体验极差。正确做法是业务代码自己抛出的已知异常(比如参数错误、库存不足)不应该继续占用防抖窗口,只有成功执行的请求才真正占用窗口。第二种情况是业务方法执行耗时超过防抖窗口,这个属于设计问题,后面第 4 节专门分析。

3.3 幂等 Token 模式:把防抖做成主动校验

第三种方案和前两种的防御思路完全不同,它把防抖从“后端被动拦截”变成了“业务主动校验”。流程分两步:第一步,客户端在提交前先向后端的一个接口申请一个一次性 token,后端把这个 token 以 key 的形式存到 Redis,value 可以是用户 ID 或业务类型;第二步,真正的业务提交接口要求客户端在请求头或请求体里带上这个 token,后端在执行业务前先删除这个 token,删掉成功就继续执行业务,删掉失败(token 不存在)就认定是重复提交。

// 申请 token @GetMapping("/token/apply") public String applyToken(@RequestHeader Long userId) { String token = UUID.randomUUID().toString().replace("-", ""); String key = "idempotent:token:" + userId + ":" + token; redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(10)); return token; }
// 提交业务时校验 @PostMapping("/order/create") public Result createOrder(@RequestHeader Long userId, @RequestHeader String idempotentToken, @RequestBody OrderRequest request) { String key = "idempotent:token:" + userId + ":" + idempotentToken; Boolean removed = redisTemplate.delete(key); if (Boolean.FALSE.equals(removed)) { return Result.error("重复提交或token已过期"); } // 执行业务逻辑 return Result.ok(); }

这个方案最大的优势在于它是“预约制”的,token 由业务方主动控制,灵活性极高:可以把 token 和某个业务行为强绑定(比如一个 token 只能用于一个订单),也可以在分布式事务里和本地消息表配合使用。而且因为 token 是一次性的,天然解决了第 2 节方案里“时间窗口”不好设定的问题——你不需要预估业务最慢执行多久,token 的生命周期可以按需设得长一些,反正一个 token 销毁后就不能再用。

当然它也有代价,客户端必须配合改造(先申请 token,再携带 token 提交),对于已经上线运行的老接口,改造面比注解方案大。适合数据正确性要求高、愿意为防重付出一定改造成本的核心场景,比如下单、支付、资金操作,而不适合日志上报、统计这类低价值接口。

4. 防抖实践中的常见坑与排查技巧

4.1 场景一:锁的过期时间比业务耗时长?窗口设置必须动态评估

这是一个防抖实现中特别容易出问题的点。我见过一个支付回调接口,防抖窗口设了 3 秒,但业务里要调用第三方支付平台确认,还要更新多个表的余额,平时只要几百毫秒,高峰期一排队就超过 3 秒了。结果第一个请求还在处理中,第二个请求发过来时防抖 key 已经过期,被放行进来了,两个线程同时操作账户余额,出现了重复入账。

这类问题有两种解法。第一种是设置一个足够长的防抖窗口,比业务最坏情况下的耗时长出一点,但窗口太长又会带来新的问题——用户第一次请求失败后想重试,得干等窗口期结束,体验很差。第二种才是更优雅的解法:用“执行中”状态的锁代替“时间窗口”的锁。点击提交前写入 key,执行业务,业务结束后主动删除 key。这样锁的生命周期等于业务的实际执行时长,既不会出现过期被并发穿透,也不会让用户等待不必要的窗口期。代价是释放锁的逻辑要保证一定执行,所以 AOP 的环绕通知必须放在finally块里释放。

场景防抖窗口方案执行锁方案
接口耗时稳定且短推荐,简单可靠可用
接口耗时波动大不推荐,窗口难设推荐
失败后快速重试需等窗口立即释放,可重试
异常兜底自动过期需主动释放+兜底过期

实际项目里我通常把两者结合:用“执行锁”保证并发唯一性,再加一个兜底过期时间(比如 10 秒)防止业务线程崩溃没释放锁导致死锁。

4.2 场景二:参数签名漏了关键项,导致误判或漏判

参数签名是防抖 key 的核心组成部分,做法不对会产生两类问题。一类是误判:两个不同业务请求参数只差一个非关键字段(比如备注),防抖 key 却把它排除了(有时候是漏写,有时候是故意忽略),结果第二个请求被当作重复请求拦掉,或者参数太粗放导致同用户不同请求被一把锁锁住。另一类是漏判:顺序不同的参数被拼出了不同的签名,比如a=1&b=2和b=2&a=1,同一个请求被识别成了两次,防抖失效。

解决方法是“字段归一化”三步走:第一步,把参数 map 里的 key 按字典序排序;第二步,对数组参数用固定分隔符拼接,不依赖原始顺序;第三步,对 JSON 字符串参数,用 Jackson ObjectMapper 序列化成规范形式(比如键按自然顺序输出)。我在代码里已经体现了前两步,第三步再补充一下:接口如果用的是@RequestBody接收对象,最好直接对对象做ObjectMapper.writeValueAsString而不是对HttpServletRequest的参数 map 处理,因为后者的内容通常不包含请求体。

4.3 场景三:Redis key 的过期与业务事务的原子性冲突

防抖和事务叠加时有一个很隐蔽的坑:假设你在 Service 方法上标了@Transactional,AOP 切面在这个方法前后执行。方法内部扣了库存、写了订单,一切正常,提交事务成功后才返回,但 AOP 的环绕通知是在事务提交之前执行的。如果业务方法抛了个异常,事务回滚了,但你的防抖释放逻辑写在 catch 里,它把防抖 key 删除了,表面上看起来没问题,但如果是“业务成功了但事务提交失败”的场景(比如事务提交时数据库连接断了,抛异常),那 AOP 会释放防抖锁,可事务实际没提交成功,用户重试时又有可能重复插入。虽然这种概率极低,但在金融级系统里必须考虑。

解决方案是把防抖锁的释放时机放到事务真正提交之后,也就是用TransactionSynchronizationManager.registerSynchronization注册一个 afterCommit 回调,在这个回调里释放 key。Spring Boot 下更简洁的做法是使用@TransactionalEventListener监听TransactionPhase.AFTER_COMMIT事件。这个细节普通教程很少提到,但原料充足的项目里确实有人踩过。

4.4 常见问题速查表

问题现象排查步骤根本原因解决方案
同参数请求被拦截但业务确实不同检查防抖 key 生成逻辑,看是否漏了请求体参数参数签名不完整改用对象序列化拼接请求体参数
重复请求没被拦住检查是否多实例 + 用了本地缓存单机方案不支持分布式切换到 Redis 方案
用户连续操作被误伤检查防抖 key 是否包含了用户ID不同用户的请求共用了同一个 key在 key 中加入用户标识
接口首次失败后立即重试却被拒检查 AOP 是否捕获了业务异常释放锁防抖窗口仍在生效在 catch 中主动释放防抖锁
防抖 key 积累越来越多检查 Redis 里是否还有未清理的 key执行锁方案缺少兜底过期时间设置合理的过期时间,增加兜底驱逐任务

4.5 验收防抖功能时的压测小技巧

最后分享一下我在测试环境验证防抖功能时用的一个方法。直接用自动化工具以每秒 100 次的速率并发请求同一个接口,看非第一次请求是否全部返回“重复提交”的提示。注意这时候要保证防抖 key 的生成是稳定的,否则并发下 key 稍有偏差就测不出效果。如果发现压测中有少部分请求漏过去了,先别急着怀疑防抖代码,很有可能是参数签名里有时间戳或随机串。我之前排查过一个案例,前端在请求体里带了一个毫秒级时间戳字段,导致同一个操作的两次请求因时间戳差异被识别成了不同请求,防抖形同虚设。把时间戳字段从签名逻辑里排除掉就好。

另外,线上环境建议把防抖拦截的命中次数、防抖 key 的分布情况作为监控指标收集起来。如果某个 key 的拦截量异常高,可能是某个客户端在疯狂重试,也可能是防抖 key 设计过粗导致大量正常请求被误拦。日志里打印防抖 key 能帮你很快定位这类问题。

我自己在项目里反复调整后的体会是:防抖方案没有一套通用的完美答案,必须根据接口的并发特征、业务容错要求、部署形态去权衡。如果只是为了快速解决用户连点问题,单机内存方案加前端按钮置灰已经足够;如果系统已经上了多实例或者对数据正确性有硬性要求,直接上 Redis 加 AOP 方案,一步到位;如果做的是资金核心链路,幂等 Token 模式才是真正的保险。最后再分享一个小经验:不管用哪种方案,防抖拦截一定要返回明确的提示信息(比如 429 加“重复提交”文案),千万别返回一个干巴巴的 500 错误,否则用户根本不知道发生了什么,尤其移动端收到 500 会直接弹“网络异常”,那种体验比防抖本身更让人崩溃。

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

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

立即咨询