做后端这几年,接口重复提交是我见过引发线上事故最多的“小问题”。明明只是前端多点了一下,结果订单创建了两笔、库存扣了两次、优惠券发了两张,用户一脸无辜,运维半夜捞数据。尤其Spring Boot项目里,Post请求天然不具备幂等性,同一个请求在网络抖动或用户误操作下可能到达服务端多次。这篇文章我基于自己在多个项目里的踩坑经验,把接口防抖的几种主流方案从原理到代码一次性讲透:本地内存锁、Redis分布式锁、一次性Token令牌,以及它们各自适合什么场景、有什么坑、怎么组合才能做到既防得住事后投诉又不拖垮性能。
1. 重复提交到底怎么判定?先厘清防抖、幂等和限流的边界
1.1 一个订单提交场景的现场还原
先看一个最典型的案例:秒杀商品提交订单。用户在页面上点击“提交订单”,前端JS已经做了按钮置灰,但因为网络慢,用户等不及又刷新页面重新进入,再次提交;或者前端按钮置灰逻辑有bug,连点两次触发了两个POST请求。两个请求带着几乎相同的数据到达后端,后端各自开启了事务,分别查询库存、扣减库存、创建订单。在单体应用加数据库默认隔离级别下,两个事务几乎同时读到库存=1,然后都执行扣减,最后库存变成-1,订单生成了两笔。
这个问题的本质是:两个请求在时间窗口内被认为是“同一个操作”,而后端没有对这个“同一个操作”做任何拦截。防抖要做的事情,就是在这个窗口内,对第二次及后续的相同请求直接拒绝或返回“已存在”,不让它们进入业务逻辑。
1.2 “防抖”锁定的判定窗口:从请求特征到业务特征
怎么判断两个请求是不是“同一个操作”?这里有两个层面的特征。
第一层是请求特征,也就是技术层面的指纹。比如相同的接口路径、相同的请求参数、相同的用户标识。我见过很多项目用整个请求体做MD5,把MD5作为防抖的key。这种做法简单但容易误伤:请求体里如果带了时间戳、随机数、traceId这类每次都不一样的字段,指纹就会变,防抖完全失效;反之如果请求体非常大,计算MD5本身也有性能损耗。所以请求特征必须经过清洗,去掉每次请求都会变化的无意义字段。
第二层是业务特征,也就是用户操作的真实意图。例如“同一用户在同一秒内对同一个订单编号发起支付”“同一用户对同一个商品提交采购单”。业务特征比请求特征更准确,但需要后端接口本身具备明确的业务键。我一般建议,做防抖时优先使用业务特征作为key,比如用户ID+业务单号+操作类型,请求特征只能作为辅助。
1.3 为什么我建议先定业务键,再选技术方案
很多团队一上来就百度一个“注解+Redis”的防抖工具类,结果上线后各种漏防和误杀。根子就在于没想清楚用什么样的key去判定“同一个请求”。
举个例子,用户重复提交创建订单,接口参数里包含用户填写的收货地址、商品ID、数量。如果用整个参数体做key,用户第一次和第二次提交的参数可能因为前端带了不同的表单序列号而变化,导致防抖失效。如果只用用户ID做key,那这个用户所有创建订单的请求都会互相阻塞,用户连续买两件不同商品时,第二个请求会被误判为重复。正确做法是让前端在请求里带一个“业务请求ID”,每次进入页面时生成一个唯一值,在点提交时带上;或者后端根据用户ID和商品ID生成一个业务键,但需要允许用户在短时间内对同一商品下两单的场景有多特殊处理。
所以方案选型的第一步,永远是确定防抖key。我遇到的大多数“防抖方案没用”的反馈,最后排查下来都是key设计得不对。后面每个方案的代码示例里,我都会把key的设计单独拎出来说,因为这才是真正决定成败的地方。
2. 方案一:本地内存锁 + AOP 注解,单体应用最省事的防抖方式
2.1 自定义注解和切面:给接口加一个“正在处理”的标记
如果项目还在单实例部署阶段,也就是只有一个应用节点,没有负载均衡,那么最轻量的防抖方案就是利用JVM本地内存里的锁。实现方式可以非常优雅:自定义一个注解,在需要防抖的接口上加上,然后通过Spring AOP切面统一处理。
我给出一个实践过的核心代码。先定义注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface NoRepeatSubmit { /** * 防抖时间窗口,单位秒,默认2秒 * 窗口内相同key的重复请求会被拦截 */ int interval() default 2; }然后是切面逻辑。为了兼顾性能和简单,我通常使用ConcurrentHashMap作为锁的存储容器,key是防抖标识,value是过期时间戳。每次请求进来,判断key是否存在且未过期,如果存在说明窗口内已有相同请求,直接抛出重复提交异常;否则存入当前时间戳,放行。
@Component @Aspect public class NoRepeatSubmitAspect { private static final Map<String, Long> REPEATED_KEYS = new ConcurrentHashMap<>(); @Around("@annotation(noRepeatSubmit)") public Object around(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) throws Throwable { // 1. 生成防抖key String key = buildKey(joinPoint); long now = System.currentTimeMillis(); long windowStart = now - noRepeatSubmit.interval() * 1000L; Long lastTime = REPEATED_KEYS.putIfAbsent(key, now); if (lastTime != null && lastTime > windowStart) { // 窗口内已经处理过相同请求 throw new RuntimeException("请勿重复提交"); } // 如果已经存在但过期了,覆盖并放行 REPEATED_KEYS.replace(key, now); try { return joinPoint.proceed(); } catch (Throwable e) { // 业务异常不需要清空key,因为窗口内也不该重试 throw e; } } private String buildKey(ProceedingJoinPoint joinPoint) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); // 这里用类名+方法名+参数JSON作为基础key // 实际项目中建议替换为业务key:用户ID+业务单号等 String params = Arrays.toString(joinPoint.getArgs()); return method.getDeclaringClass().getName() + "." + method.getName() + ":" + params.hashCode(); } }这个版本我早期在内部管理系统里用过,接口并发量不大,效果明显。有一点必须说明:这里的putIfAbsent不是完全原子的“检查-设置”,因为replace在key存在但已过期时可能覆盖掉另一个并发请求刚写入的新值。所以这个简易版本只适用于并发极低的场景,或者配合synchronized分段锁使用。后面我会给出一个更严谨的写法。
2.2 细说锁的粒度与释放时机:小心同一个用户秒点两次
上面的简易版本有个大问题:它只在窗口期内拦截,但窗口期过了之后,同样的请求又可以提交了。如果第一次请求因为网络原因长时间未返回,用户又点了一次,而第一次请求实际在2秒后才完成,那么第二次请求进来时窗口已经过期,直接放行,又造成重复提交。
所以要控制“锁的释放时机”,不是简单看时间窗口,而是要看第一次请求是否已经处理完成。严谨的做法是:用一个ReentrantLock或Semaphore对同一个key做并发控制,第一个请求拿到锁,执行完业务逻辑后释放锁,第二个请求在锁等待期间发现锁被占用,就认为重复提交;如果第一个请求异常退出也要释放锁。
不过用JVM锁直接实现“执行完成才释放”有一个难题:不同线程之间怎么共享同一个key的锁对象?这里有一种常见方案,使用ConcurrentHashMap存储key对应的锁对象,但要注意锁对象不能永远留在Map里。可以借助WeakHashMap或定期清理。更多时候我会选用Caffeine这类本地缓存,设置短过期时间,缓存值是ReentrantLock,或者直接用RedissonClient的RLock(如果项目已经引入了Redisson)。
实际上,如果只是要防止“快速重复点击”,内存Map的时间窗口方案足够。如果要防止“第一个请求尚未结束就被重复提交”,必须使用锁而不是过期时间戳。我自己的建议是,除非接口是纯粹的读操作或极短事务,否则尽量选择锁方案,不要图省事用时间窗口。
2.3 这个方案的适用边界:单实例没问题,多实例立刻失效
本地锁最大的局限就是“本地”。一旦应用部署了多个实例,前面挂了Nginx做负载均衡,同一个用户的两次请求可能被分发到不同实例上,每个实例各有一份ConcurrentHashMap,互相不知道对方是否在处理同一个请求,防抖直接失效。
我见过一个项目,单体阶段用本地锁跑得好好的,后来为了高可用扩了第二个节点,上线第一周就出现了重复订单事故。排查的时候发现每个节点日志里都有“请勿重复提交”的拦截记录,但恰恰说明两个节点都放行了同一次操作。原因就是本地锁没有共享。
所以只有当你的项目满足以下条件时,本地锁方案才是一个合理选项:
- 应用以单实例方式部署,未来半年内没有多实例计划
- 接口并发量不高,防抖精度要求不严格(允许极少漏网)
- 不想引入Redis等额外中间件,希望零依赖实现
- 内部管理后台、低并发工具类接口
如果这些条件有任何一个不满足,直接看下一个方案。
3. 方案二:Redis 分布式锁,把“防抖状态”放进共享存储
3.1 从本地锁到分布式锁:问题只多了“共享”两个字
多实例场景下,最直接的思路就是把本地Map换成所有实例都能访问的共享存储。Redis是首选,因为它本身就有SETNX这样的原子命令,天然适合做分布式锁。防抖逻辑变成:请求进来时,尝试在Redis里写入一个key,设置过期时间,如果写入成功说明当前没有相同请求在处理,放行;如果写入失败说明key已存在,说明窗口内已有相同请求,拦截。
这里要特别强调,不要用“先GET判断是否存在,再SET”两步操作。因为在并发下,两个请求可能同时GET到key不存在,然后同时SET,造成都放行。正确做法是使用一条原子命令,比如Redis的SET key value EX seconds NX,它的含义是“只有key不存在时才设置,同时设置过期时间”,整个操作是原子的。这也是我推荐所有团队必须掌握的命令。
3.2 SETNX + 过期时间 + Lua 脚本:一个可以上生产的写法
我提供一个基于Spring Boot的完整方案。首先在pom里引入spring-boot-starter-data-redis,然后定义防抖注解和切面。核心在于切面里使用Redis的原子操作。
@Component @Aspect public class RedisNoRepeatSubmitAspect { private final StringRedisTemplate stringRedisTemplate; public RedisNoRepeatSubmitAspect(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } @Around("@annotation(redisNoRepeatSubmit)") public Object around(ProceedingJoinPoint joinPoint, RedisNoRepeatSubmit redisNoRepeatSubmit) throws Throwable { String key = buildKey(joinPoint); // 业务key,后续专门讲 String value = UUID.randomUUID().toString(); // 唯一客户端标识,用于安全删除 long expireSeconds = redisNoRepeatSubmit.interval(); // SET key value EX seconds NX,实现原子性“不存在才设置” Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS); if (Boolean.FALSE.equals(success)) { // key已存在,说明窗口期内已有相同请求 throw new RuntimeException("请求处理中,请勿重复提交"); } try { return joinPoint.proceed(); } finally { // 使用Lua脚本安全删除,防止误删其他请求的锁 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class); stringRedisTemplate.execute(redisScript, Collections.singletonList(key), value); } } }这段代码有几个设计细节值得展开。
第一,value用UUID生成,删除锁时用Lua脚本比较是否是自己写入的值。因为在极端情况下,第一个请求执行业务时间超过了锁的过期时间,锁自动过期,第二个相同请求又拿到了锁。如果第一个请求处理完执行DEL key,就会把第二个请求的锁删掉,导致第三个请求也能进入。用“值比较+原子删除”可以避免这种误删。
第二,finally里释放锁。这里释放的是“自己持有的锁”,不是简单调用delete。
第三,异常处理。我没有在业务异常时特殊处理锁,因为锁的过期时间足够短,就算业务异常,窗口期内拦截重复请求也是合理的。
3.3 锁 key 的设计:请求指纹、用户 ID 和业务单号怎么组合
分布式锁的可靠性不仅靠Redis命令,更靠key的设计。我在2.3里说过本地锁的key怎么定,分布式锁同样要面对这个问题。
我推荐一个通用模板:
lock:防抖:{接口标识}:{用户ID}:{业务请求ID}- 接口标识:可以用
Controller类名+方法名,或者自定义一个简短别名。不要直接拼接整个全限定名,太长占内存,可用短码映射。 - 用户ID:登录用户的主键,能限制锁的粒度,避免不同用户互相影响。
- 业务请求ID:对创建订单这类场景,前端在页面加载时生成UUID,提交时作为参数传入。这是最准确的“一次操作”标识。
如果一个接口没有业务请求ID参数,那就只能退而求其次,用请求参数中稳定字段的哈希值。比如订单提交接口,可以用用户ID+商品ID+提交时间戳(精确到分钟)。注意是“稳定字段”,不能让每次请求都会变的traceId混进去。
我见过一个线上事故,A团队用“类名+方法名+所有请求参数”做key,B团队前端为了调试在请求头里加了一个随机数,C团队后端把这个随机数序列化进了参数体,结果同一个业务操作每次生成的MD5都不同,防抖完全失效。最后加了“请求参数中排除指定字段”的逻辑,并把业务请求ID作为独立参数后,问题才解决。
3.4 实战中的三个坑:过期时间过短、重入问题、删锁误删
第一个坑,过期时间设置过短。业务高峰期,一笔订单提交事务可能要3秒以上,如果锁过期时间设置成2秒,第一个请求还没执行完,锁就自动过期了,第二个请求就会进入,重复问题依旧存在。所以过期时间必须大于接口处理时间的P99,我一般按接口压测数据的P99再乘以3,同时留出网络抖动余地。如果业务时间不可控,建议用Redisson的watch dog自动续期机制,而不是固定过期时间。
第二个坑,重入问题。如果防抖接口内部会调用另一个也加了防抖注解读同一个key的接口,第二次调用会因为key已存在而拦截自己,造成业务失败。解决方案有两种:一是尽量避免在不同层级的接口上重复使用同一个防抖key;二是用Redisson的RLock,它支持可重入,同一个线程可以重复获取锁。
第三个坑,删锁误删。前面代码里用了UUID+ Lua脚本。但有些团队的代码写的是:
if (success) { try { return proceed(); } finally { stringRedisTemplate.delete(key); } }这样在极端场景下会误删。比如业务执行超过锁过期时间,锁自动失效,另一个请求获得锁,此时第一个请求走finally执行delete,把第二个请求的锁删了,第三个请求趁虚而入。必须用“值匹配再删除”的Lua脚本,或者使用Redisson的unlock()由客户端判断锁holder。
4. 方案三:令牌机制,用一次性 Token 彻底堵住“先查后提交”的并发漏洞
4.1 为什么表单提交最适合令牌防抖:服务端预发,提交即作废
时间窗口锁适合拦截短时间内的重复请求,但有这么一类场景:用户先打开下单页面,页面从后端获取一个预下单Token,填完数据提交时带着Token。后端收到请求后校验Token存在且有效,然后立刻删除Token,再执行业务。第二次提交时,Token已经不存在了,直接被拒。
这个方案和分布式锁有什么本质区别?锁是“同一时刻只允许一个相同key的请求执行”,令牌是“每个操作只允许一次执行”。它的核心理念是“一次性凭证”,天然具备幂等性,不依赖时间窗口,即使第一次请求已经结束很久,第二次拿着同样的Token来提交,依然会被拒。
我特别喜欢把令牌机制用在创建订单、领取优惠券、提交申请单这类“一次操作只能成功一次”的场景。通常流程是:
- 用户进入表单页,后端生成一个唯一Token,存Redis并返回给前端。
- 前端提交时带上这个Token。
- 后端校验Token存在,存在则删除并放行;不存在则直接返回“重复提交”。
- 业务逻辑正常执行。
4.2 Redis 实现一次性令牌:SETNX 与 DEL 的原子性组合
用Redis实现令牌防抖,关键在“校验并删除”的原子性。如果先GET校验再DEL删除,在高并发下可能两个请求同时GET到相同的Token值,都通过了校验,然后都尝试DEL,最终两个请求都进入业务逻辑。这个漏洞相当隐蔽,我在早期就因此出过事故。
正确做法是使用SET命令维护Token集合,配合Lua脚本实现“原子消费”。下面给出两种实现思路。
思路一:预先生成Token并保存
后端生成Token时:
String token = UUID.randomUUID().toString(); stringRedisTemplate.opsForValue().set("token:" + token, "1", 30, TimeUnit.MINUTES);前端提交时,后端校验并消费:
-- 校验并删除一次性令牌 if redis.call('GET', KEYS[1]) then return redis.call('DEL', KEYS[1]) else return 0 end第二种思路是“用Token本身作为防抖key,在第一次提交时用SETNX写入并设过期时间”。如果SETNX返回成功,说明这个Token第一次被使用,放行;如果返回失败,说明已经用过了,拦截。这种方式不需要提前生成令牌,而是让前端生成一个唯一请求ID,后端在提交接口里用这个ID作为分布式锁key。本质上和3.2的分布式锁方案是同一种实现,只是把key的生成放到前端。
第一种思路适合“页面加载时就生成令牌”的灰度发布场景,第二种适合“前端不知道什么时候会提交”的场景。两者可以共存,但要注意别混淆。
4.3 不需要 Redis 的替代方案:数据库唯一约束也能做幂等
如果你的项目里没有Redis,或者不想为防抖增加一个中间件依赖,其实数据库的唯一索引是更底层的兜底方案。
以订单为例,建表时给order_no(业务单号)加唯一约束。前端提交时生成一个UUID作为order_no,带着请求一起提交。后端在创建订单的SQL里直接插入这条记录,如果order_no重复,数据库会抛出DuplicateKeyException,我们捕获这个异常后返回“重复提交”。注意这里要处理好事务:必须确保第一次插入的事务提交之后,第二次插入才会遇到唯一键冲突。在默认隔离级别下,两个并发事务同时插入相同的唯一键,有一个会等待另一个提交后立刻报冲突。
这个方案比Redis更可靠,因为数据库的唯一约束是最终的物理保障。它的缺点是要求每个业务表都有这样的唯一键,而且失败信息只能通过异常翻译,不够灵活。但作为兜底,我强烈建议任何涉及资金的接口都配上。
4.4 令牌与幂等键的取舍:防抖不是万能药,幂等才是兜底
谈到令牌机制,必须分清一个概念:防抖和幂等不是同一回事。
防抖是“窗口期内拦截相同请求”,它默认操作尚未完成或刚刚完成,不希望再来一次。幂等是“同一个操作执行多次和执行一次的结果相同”,它完全允许同一个请求重复到达,但服务端可以识别出这个请求已经处理过了,不重复起作用。比如支付回调,同一笔订单可能收到多次通知,服务端应该根据订单号和支付状态判断,如果已经支付成功则直接返回成功,不再重复处理。这就是幂等设计。
令牌方案本质上是“一次性凭证”幂等,数据库唯一约束是“唯一键”幂等。如果你的接口是创建一个订单、扣减一次库存这种“天然非幂等”的操作,请在防抖之外,再做一层幂等兜底。防抖能挡住99%的重复提交,但极端场景下依然可能有漏网的,比如网络在服务端写库完成但响应超时后发生了重试。这种case只有幂等设计能兜住。
我自己的项目组合是:前端按钮置灰 + 后端Redis防抖(锁方案)+ 数据库唯一约束。三层叠加之后,到目前为止没有再出现过重复订单数据。
5. 三种方案的横向对比与我的最终组合实践
5.1 关键维度对照表:实现成本、性能、一致性、适用场景
很多新人在选型时只知道“Redis方案最好”,但不知道便宜方案也够用。这里给一张对照表,是我在项目评审时常拿出来的:
| 维度 | 本地内存锁 | Redis分布式锁 | 令牌/唯一键幂等 |
|---|---|---|---|
| 实现成本 | 低,一个注解一个切面 | 中,依赖Redis,需要设计命令行和锁释放 | 中高,需要预发Token或建唯一键 |
| 适用部署架构 | 单实例 | 多实例、集群 | 多实例、集群 |
| 防抖时效 | 时间窗口,可配置 | 时间窗口或业务执行期,可控 | 持久有效,一次操作终生一次 |
| 性能损耗 | 极小,纯内存 | 额外一次Redis RTT,可接受 | 额外一次Redis或数据库交互 |
| 并发一致性 | 单实例强一致,多实例失效 | 分布式强一致,依赖锁过期 | 依赖存储原子性,最强 |
| 典型场景 | 内部后台表单 | 用户创建订单、提交表单 | 支付回调、资金操作、领取券 |
本地锁用于低并发、单实例,Redis锁用于集群环境,令牌用于严格意义上的一次性操作。如果还在纠结,我建议项目一开始就直接上Redis方案和数据库唯一键,没必要经历一次扩容后重复事故的洗礼再补课。
5.2 我目前在项目里用的组合:前端禁用 + 分布式锁防抖 + 数据库唯一键幂等
现在我在主力电商项目里使用的是三合一方案,每个环节解决不同层面:
- 前端:点击提交后立即禁用按钮,并显示“提交中”,同时在上拉刷新或返回页面时用
sessionStorage保存提交状态。这层能拦截大量简单重复点击,降低后端压力,但前端不可控,不能作为唯一防线。 - 后端:在创建订单、扣款、发送优惠券等核心写接口上,使用Redis分布式锁方案,锁key为
lock:防抖:{业务码}:{用户ID}:{请求ID},锁过期时间设置为业务P99耗时的3倍。 - 数据库:订单表增加
request_id唯一索引,插入时显式带入该字段,捕获DuplicateKeyException后返回友好提示,同时异步日志记录重复请求的入参和IP,方便后续排查。
这样的设计下,前端拦掉一部分,Redis锁拦掉绝大多数并发重复,数据库唯一键兜住网络重试和极端并发。
5.3 几个值得注意的细节:重复提交成功后的返回码、日志埋点、压测验证
最后分享三个实操细节。
返回码要明确。不要让前端把“重复提交”误判成系统故障。我一般统一返回业务错误码409或自定义10010,响应体里带清楚文案“请勿重复提交”和当前请求的处理状态(是已被处理还是正在处理)。前端拿到这个码时,要么自动跳转结果页,要么提示用户查询已有结果。
日志埋点要有可追踪性。每次拦截重复提交,都应该输出一条包含防抖key、用户ID、请求参数摘要、拦截时间的日志。如果将来真的出现误拦或漏拦,靠日志能快速还原现场。我在切面里专门加了log.warn,并把冲突信息存到一个独立的防抖日志表里,定期分析误杀率。
压测验证不能少。防抖方案上线前,一定要用并发工具模拟同一用户同一业务请求的并发提交。我常用的脚本是50个线程同时提交同一个request_id,断言最终数据库只有一条有效订单,Redis锁的未命中次数等于49。这能直接验证锁的原子性和释放逻辑。只测“能拦住”不够,还要验证“拦得住”且“业务数据正确”。
我自己的体会是,接口防抖表面看是个小功能,但方案的选型和细节决定了事故发生的概率。单实例用本地锁省事,多实例必须要共享存储,但无论哪种,都要把key设计和锁释放作为重点检查项。先想清楚怎么定义“同一次操作”,再去写代码,基本不会出大问题。
最后再分享一个小技巧:如果你还不想引入Redis,又必须支持多实例,可以在数据库里建一张防抖记录表,用request_id作为唯一键,插入成功即放行,插入冲突即拦截,效果和数据库唯一索引幂等一样,只是多了一张表。这算是个零依赖的中间态方案,适合那些Redis还没到位、但已经有多实例部署的过渡期项目。