前阵子线上出了个让我印象很深的故障某个订单接口在高峰期被连续提交了两次结果数据库里多出一条重复订单还连带着把库存给超卖了。排查到最后发现前端按钮确实做了 loading 防重但请求在弱网环境超时后小程序框架层自动重试了一次这次重试完全绕过了页面限制直接打到后端。从那天起我就意识到接口重复提交这件事不能只靠前端后端自己必须有一套防重机制。这篇文章把我实际落地的基于 Redis 的防重复提交方案完整整理出来从场景分析、方案选型、核心原理到 Spring Boot 里的代码实现、实测踩坑再到从防重走向幂等的进阶思路。适合正在做交易、支付、库存、表单提交类后端接口的同学参考。我会把关键命令、代码和设计取舍都讲清楚你可以直接照着抄去用。1. 接口重复提交的真实场景与代价1.1 按钮禁用本身就不够用很多人以为接口重复提交只是用户手快点多了一下前端把按钮禁用就万事大吉。但我在实际项目里遇到的情况要复杂得多至少有这么几类用户双击、连点特别是移动端页面卡顿、点击事件延迟时一次点击会被识别成多次。网络超时后前端框架或网关层自动重试。像上面提到的框架级重试前端根本控制不了。客户端和服务端之间用了消息队列做削峰时消息重复投递导致同一个业务被重复执行。负载均衡或注册中心在节点切换、服务重启时把未完成的请求重新分发一次。按钮禁用能挡住正常的用户连点但挡不住后面的所有情况。真正要靠的还是后端能做这次请求是不是重复的判断。1.2 重复数据会带来哪些连锁反应重复提交最直观的代价是脏数据但脏数据的连锁反应往往更严重订单系统里出现两条相同订单用户实际只下单一次却收到两次扣款通知。库存系统里同一件商品被扣两次实际库存小于记录库存盘点对不上账。营销活动里同一个用户对同一个活动领取了两次权益预算超发。积分、优惠券、抽奖这类接口更是一次重复提交直接造成真金白银的损失。这类问题在测试环境很难发现因为测试数据量小、并发低但一旦线上遇到都是要拉会议复盘的事故级别问题。所以我现在做接口设计时防重几乎是默认要求而不是可选项。1.3 先分清防重、幂等与限流在动手写代码前我建议先把三个词理清楚因为它们经常被混在一起防重在请求入口做拦截同一窗口期内同一个请求只放行一次。核心是阻止第二次进来。幂等业务层面允许请求重复执行但结果保持一致。核心是即使进来很多次也只生效一次。限流在单位时间内限制请求总量保护后端不过载。核心是控制流量水位。三者的目标和实现方式都不同。防重通常用短时间窗口 同一标识判断幂等要依赖业务唯一键和存储层约束限流多用计数器或令牌桶。后面我会讲到一套成熟的方案里往往是三者配合而不是只做其中一个。2. 防重方案选型为什么最终选了Redis2.1 四个方案的横向对比防重这件事有很多技术路线我在不同项目里都试过这里直接给出一份我自己整理的对比表。方案实现思路优点缺点前端按钮禁用提交后置灰、loading最简单、体验直接拦不住非浏览器端和框架重试本地锁synchronized / ReentrantLock单机内存锁零依赖、性能极高集群多实例时各自为政没有集中协调数据库唯一约束业务唯一键建唯一索引最可靠天然幂等侵入性强、需要调整表结构、高并发下压力大Redis SETNX分布式占位带过期时间原子、性能好、支持集群依赖Redis可用性Redis挂掉时防重失效从表里能看出来前端按钮是辅助本地锁在分布式环境下不成立数据库唯一约束适合做最终兜底但作为唯一的防重手段太重了。Redis方案在性能和分布式协调之间找到了比较合适的平衡点。2.2 Redis方案的优势与边界Redis 能成为防重的默认选择核心原因有三个SETNX 命令天然自带原子性SET key value NX EX是单条命令Redis 单线程执行并发下不会出现先检查后设置的竞态问题。键可以设置过期时间天然适合短时间窗口内防重的语义不需要自己维护定时清理。分布式环境下所有实例访问同一个 Redis可以做到全局一致。但它的边界我也要说清楚Redis 是实现防重的高效工具不是幂等的最终保障。Redis 宕机或者网络抖动时防重会失效所以严谨的架构里Redis 防重只是第一道防线数据库唯一键和对账任务才是兜底。这个后面详细展开。3. 防重设计的核心原理它不是一把普通锁3.1 请求唯一标识token与指纹两条路线用 Redis 做防重第一步是想清楚怎么判断两次请求是同一个请求。常见的有两条路线。token 模式一次性票据前端进入页面时向后端申请一个一次性 token后端把 token 存到 Redis 并返回给前端。提交时前端把 token 带上后端消费这个 token——消费成功就放行消费失败就拒绝。这个模式对用户最友好适合 H5、小程序、Web 等有前端页面的场景。fingerprint 模式请求指纹后端根据请求的方法、URL、关键参数和当前用户生成一个唯一指纹然后判断这个指纹是否在窗口期内出现过。这个模式不需要前端配合适合开放 API、服务间调用、回调通知等没有页面的场景。我在大多数项目里是两种都保留页面类接口用 token原因是有明确的一次性消费语义服务间调用用 fingerprint因为它零接入成本。3.2 SET NX EX为什么必须一步到位防重占位在最开始很容易写成先 GET 判断再 SET 写入// 错误写法先检查再写入并发下必然有漏洞 if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) { redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(3)); }两个请求同时执行到 hasKey都发现 Redis 里没有 key于是都走到 set都被放行。这个写法的本质问题是检查与写入不是原子操作。正确姿势是用 Redis 的单命令SET key value NX EX 3NX只有 key 不存在时才写入存在则不做任何事。EX设置过期时间防止 key 永远占用。整个命令在 Redis 内部是串行执行的天然原子。对应到 Spring Boot 的StringRedisTemplate就是Boolean success redisTemplate.opsForValue() .setIfAbsent(key, value, 3, TimeUnit.SECONDS);这个setIfAbsent返回值就是我是不是占位成功的那个返回 true 放行返回 false 说明同一个 key 在窗口期内已经被别人占过直接拒绝。3.3 释放与消费也要讲原子性占位要原子释放更不能大意。常见的错误是// 错误写法先取值再比较最后删除 —— 三步之间会有并发间隙 String cached redisTemplate.opsForValue().get(key); if (expected.equals(cached)) { redisTemplate.delete(key); }假设请求 A 执行完毕读到值等于 expected准备删除。此时请求 B 已经占位成功A 的 key 到期后 B 写入新值。A 继续执行 delete把 B 的锁删掉了B 的防重瞬间失效。所以比较并删除必须是一个原子操作。用 Lua 脚本可以完美解决if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) end return 0这个脚本既用于 fingerprint 模式下释放占位锁也用于 token 模式下检测并消费一次性票据。一个脚本解决两个场景后面代码里我会直接用它。4. 完整落地实现基于注解与AOP的Spring Boot实践4.1 工程依赖与整体结构我用的是 Spring Boot 2.x Redis 的老组合依赖只要两个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency项目结构上我习惯分成这样几部分annotation 包定义NoRepeatSubmit注解。aspect 包定义防重切面。handler 包定义业务异常RepeatSubmitException。controller 包提供 token 获取接口和业务演示接口。4.2 自定义注解让接入成本降到最低用注解 AOP 的好处是业务代码零侵入在接口方法上标一行就能生效package com.example.repeat.annotation; 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 { /** * 防重模式FINGERPRINT 请求指纹模式TOKEN 一次性票据模式 */ KeyMode keyMode() default KeyMode.FINGERPRINT; /** * 防重窗口 */ long interval() default 3000L; /** * 窗口时间单位 */ TimeUnit timeUnit() default TimeUnit.MILLISECONDS; /** * 被拦截时的提示语 */ String message() default 提交太频繁请稍后再试; }配套的枚举package com.example.repeat.annotation; public enum KeyMode { FINGERPRINT, TOKEN }这样使用者只需要关心三个参数用什么模式、窗口多长、提示语是什么。默认值保证最懒的用法也不会出事。4.3 切面核心逻辑拦截、占位、放行、清理切面是整套方案的心脏完整代码我贴在这里package com.example.repeat.aspect; import com.example.repeat.annotation.KeyMode; import com.example.repeat.annotation.NoRepeatSubmit; import com.example.repeat.exception.RepeatSubmitException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; 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.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.TimeUnit; Aspect Component public class NoRepeatSubmitAspect { /** * 比较并删除的 Lua 脚本保证释放锁和消费 token 的原子性 */ private static final String RELEASE_LUA if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; Autowired private StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper new ObjectMapper(); Around(annotation(noRepeatSubmit)) public Object around(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) throws Throwable { String key buildKey(joinPoint, noRepeatSubmit.keyMode()); String value UUID.randomUUID().toString(); boolean admitted; if (noRepeatSubmit.keyMode() KeyMode.TOKEN) { // token 是一次性票据值匹配则删除消费只允许消费成功一次 admitted consumeToken(key); } else { // fingerprint 模式相同指纹在窗口内只允许占位成功一次 admitted Boolean.TRUE.equals(stringRedisTemplate.opsForValue() .setIfAbsent(key, value, noRepeatSubmit.interval(), noRepeatSubmit.timeUnit())); } if (!admitted) { throw new RepeatSubmitException(noRepeatSubmit.message()); } try { return joinPoint.proceed(); } finally { if (noRepeatSubmit.keyMode() KeyMode.FINGERPRINT) { releaseLock(key, value); } } } private String buildKey(ProceedingJoinPoint joinPoint, KeyMode keyMode) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); String clazz joinPoint.getTarget().getClass().getName(); String method signature.getMethod().getName(); String userId currentUserId() null ? anonymous : currentUserId(); if (keyMode KeyMode.TOKEN) { String token obtainTokenFromRequest(); return repeat:submit:token: userId : token; } String fingerprint md5(buildFingerprint(joinPoint.getArgs())); return repeat:submit:fp: userId : clazz . method : fingerprint; } private String buildFingerprint(Object[] args) { if (args null || args.length 0) { return empty; } try { ListObject normalizedArgs new ArrayList(); for (Object arg : args) { if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse) { continue; } normalizedArgs.add(normalize(arg)); } return objectMapper.writeValueAsString(normalizedArgs); } catch (JsonProcessingException e) { throw new IllegalStateException(序列化请求参数失败, e); } } private Object normalize(Object arg) { if (arg instanceof Map) { Map?, ? rawMap (Map?, ?) arg; TreeMapString, Object sortedMap new TreeMap(); for (Map.Entry?, ? entry : rawMap.entrySet()) { sortedMap.put(String.valueOf(entry.getKey()), entry.getValue()); } return sortedMap; } return arg; } private String md5(String content) { try { MessageDigest digest MessageDigest.getInstance(MD5); byte[] bytes digest.digest(content.getBytes(StandardCharsets.UTF_8)); StringBuilder hex new StringBuilder(); for (byte b : bytes) { hex.append(String.format(%02x, b)); } return hex.toString(); } catch (Exception e) { throw new IllegalStateException(MD5 计算失败, e); } } private void releaseLock(String key, String value) { stringRedisTemplate.execute( new DefaultRedisScript(RELEASE_LUA, Long.class), List.of(key), value); } private boolean consumeToken(String key) { Long result stringRedisTemplate.execute( new DefaultRedisScript(RELEASE_LUA, Long.class), List.of(key), unused); return Long.valueOf(1L).equals(result); } private String obtainTokenFromRequest() { ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes null) { return anonymous; } HttpServletRequest request attributes.getRequest(); String token request.getHeader(Repeat-Token); if (StringUtils.hasText(token)) { return token; } return request.getParameter(repeatToken); } private String currentUserId() { ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes null) { return null; } HttpServletRequest request attributes.getRequest(); String userId request.getHeader(X-User-Id); return StringUtils.hasText(userId) ? userId : request.getParameter(userId); } }这里有几个我认为比较关键的设计决定单独说明buildKey一定带上用户 ID否则两个不同用户对同一个接口发相同的请求会被互相拦截这是最常见的误伤。fingerprint 的 MD5 内容会过滤掉HttpServletRequest这类非业务参数避免把框架对象序列化进指纹。释放锁和消费 token 共用同一个 Lua 脚本但语义方向相反指纹模式是我的占位到期了我自己删token 模式是这个票据本来就要被消费掉。4.4 token 模式与 fingerprint 模式的差异这个差异我在前文提过但实现之后更明显指纹模式是窗口期内防重执行完立即释放窗口适合拦截瞬时重复点击token 模式是一次性票据消费后就作废它的生命周期更长语义也更严格。所以 token 模式需要配合一个生成接口RestController RequestMapping(/repeat) public class TokenController { Autowired private StringRedisTemplate stringRedisTemplate; GetMapping(/token) public MapString, String acquireToken() { String token UUID.randomUUID().toString().replace(-, ); // 票据默认 60 秒内有效值固定为 unused等待被 AOP 消费 stringRedisTemplate.opsForValue().set(repeat:token: token, unused, Duration.ofSeconds(60)); return Map.of(repeatToken, token); } }流程是这样的前端进入页面时调用/repeat/token拿到 token。提交业务请求时把 token 放在Repeat-Token请求头里。后端 AOP 对repeat:submit:token:{userId}:{token}执行 Lua 脚本值等于 unused 就删除并放行值不存在或已被消费就拒绝。业务接口上只需要标注PostMapping(/order) NoRepeatSubmit(keyMode KeyMode.TOKEN, interval 60, timeUnit TimeUnit.SECONDS) public Result? createOrder(RequestBody OrderRequest request) { // 业务逻辑 }注意 token 模式下 interval 是票据本身的存活期一般建议和生成票据时的过期时间保持一致我这里统一用 60 秒。用户在这个窗口内没有提交票据自动失效重新进入页面再申请一个就行。4.5 前端怎么配合前端要做的事其实不多就两条进页面拉一次 token存到变量里。提交请求时把 token 放到 header。async function fetchToken() { const res await axios.get(/repeat/token); window.repeatToken res.data.repeatToken; } async function submitOrder() { await axios.post(/order, payload, { headers: { Repeat-Token: window.repeatToken } }); }如果后端返回提交太频繁的异常前端弹一个轻提示就行不需要再做额外处理。真正值得提醒的是前端拿到新 token 的时机应该在每次提交完成之后无论成功还是失败而不是只在页面加载时。否则用户第一次提交成功但网络回了错误提示再次提交时用的还是已经消费掉的 token会被后端直接拦截。5. 实测中踩过的坑与排查推荐思路5.1 指纹不一致导致防重直接失效指纹模式上线后我第一次压测就发现防重一点用都没有同一笔请求连发多次Redis 里的 key 每次都不同。排查下来是请求参数里带了一个traceId每次请求都会重新生成导致指纹每次都不一样。这个问题的本质是指纹里混入了每次必然变化的字段。除了 traceId还有时间戳、随机数、Token 之类的字段凡是跟业务语义无关的参数都应该在生成指纹前剔除。我的做法是在buildFingerprint里增加一个忽略列表private static final SetString IGNORED_FIELDS Set.of(traceId, timestamp, random, sign); private Object normalize(Object arg) { if (arg instanceof Map) { TreeMapString, Object sortedMap new TreeMap(); Map?, ? rawMap (Map?, ?) arg; for (Map.Entry?, ? entry : rawMap.entrySet()) { String fieldName String.valueOf(entry.getKey()); if (IGNORED_FIELDS.contains(fieldName)) { continue; } sortedMap.put(fieldName, entry.getValue()); } return sortedMap; } return arg; }另一个坑是 Map 序列化顺序问题。同一个参数对象用 HashMap 装两次请求的 key 顺序可能不同导致 JSON 字符串不同。用 TreeMap 排序后再序列化能保证相同内容永远生成相同的字符串。5.2 窗口时间设置太短和太长都不行窗口时间看似只是一个数字但设错会产生两种完全相反的问题。设得太短第一次请求还没处理完窗口就过期了第二次请求进来时 key 不存在又被放行。我有一次把创建订单接口的窗口设成 2 秒结果接口平均耗时就有 3 秒防重形同虚设。设得太长用户第一次提交成功后马上再点一次会被当成重复请求拒绝。有一种情况特别容易引发投诉网络波动导致前端没收到成功响应用户重试结果一直被拦直到窗口过期。我的经验是窗口时间至少要覆盖接口最长耗时 合理的网络重试间隔通常取接口 P99 耗时的 2~3 倍再留 1~2 秒缓冲。如果接口本身要做异步处理先接单、后处理窗口还要覆盖异步链路的总时长否则这个方案就不适用需要考虑换成幂等令牌或者状态机。5.3 误删他人锁为什么必须用Lua这个坑我在原理部分讲过但在实测中真真切切遇到过一次场景是这样的请求 A 的指纹 key 在 Redis 里占位A 执行业务比较慢超过了窗口时间key 过期。请求 B 带着相同的指纹进来占位成功。此时 A 处理完毕finally 块执行释放逻辑。如果释放逻辑是redisTemplate.delete(key)就会把 B 的占位删掉。紧接着请求 C 又进来了占位成功放行——防重被击穿。我当时用的还是先 get 再判断再 delete的写法在并发测试里偶发出现。改成 Lua 之后击穿现象彻底消失。所以这里再强调一次释放占位时必须比对 value 并原子删除这个不能偷懒。5.4 注解不生效代理失效排查链路还有一个常见问题注解标了切面也写了但请求进来时完全没有走防重逻辑。我遇到过两种情况第一种是同一个类内部方法调用。比如 Controller 里的方法 A 调用了同类的私有方法 BB 上标了NoRepeatSubmit但 Spring AOP 基于代理对象内部调用走的是this引用不经过代理注解自然不生效。第二种是方法不是 public。Spring AOP 默认对 public 方法织入切面非 public 方法不会生效。排查链路我整理成一个清单确认注解在 public 方法上。确认调用方注入的是 Spring 管理的 Bean 代理而不是new出来的对象。确认spring-boot-starter-aop已引入切面类上有Aspect和Component。在切面方法里临时打日志看有没有进around。如果方法是类内部互相调用就拆到另一个 Spring Bean 里或者注入AopContext.currentProxy()来调用。这个坑不影响主流程但很隐蔽排查起来挺磨人的。5.5 Redis抖动时怎么办降级而非阻断防重方案依赖 RedisRedis 一旦不可用如果防重逻辑直接抛异常所有业务接口都会被拖垮。这是绝对不能接受的。我的做法是对 Redis 操作做降级处理private boolean tryAcquire(String key, String value, Duration duration) { try { return Boolean.TRUE.equals(stringRedisTemplate.opsForValue() .setIfAbsent(key, value, duration)); } catch (Exception e) { // 记录告警日志立即放行 log.error(Redis 不可用防重降级放行, e); return true; } }降级的原则是防重是锦上添花不能成为业务可用性的短板。Redis 抖动时可以暂时失去防重能力但不能让用户提交不了订单。真正的兜底交给数据库唯一键和后续的对账任务而不是在入口处把服务打死。如果要更稳一点还可以加上本地兜底用一个带过期时间的 ConcurrentHashMap 记录近几秒的请求指纹。但要注意这只在单实例内有效多实例环境下不能作为统一防重手段只能作为降级时的临时补充。6. 从防重到幂等的进阶思考6.1 数据库唯一键真正兜底的最后一道防线Redis 防重能挡住绝大多数重复但它依赖 Redis 可用性、网络稳定性、以及代码逻辑本身的正确性。任何一环出问题重复请求都可能漏进来。所以对于核心资金、库存类业务我都会再补一层数据库唯一键兜底。比如创建订单接口可以在订单表加一个业务唯一键order_biz_id这个 ID 在业务开始时生成插入时如果唯一索引冲突说明这条业务已经被创建过直接返回已存在的订单信息不再重复创建。ALTER TABLE t_order ADD UNIQUE KEY uk_biz_id (biz_id);唯一索引撞了之后代码里捕获DuplicateKeyException查一次已有数据返回即可。虽然侵入性比 Redis 方案强但它是即使所有前置防重都失效数据也不会脏的最终保证。6.2 与限流结合防重挡单次重复限流挡整体洪峰防重和限流经常被放在一起聊但解决的问题不一样防重针对同一次请求的重复限流针对不同请求的洪峰。遇到秒杀、抢购类接口光有防重是不够的因为即使每个用户只提交一次总体流量也可能把后端打爆。Redis 做简单固定窗口限流非常方便public boolean rateLimit(String key, int limit, Duration duration) { Long count redisTemplate.opsForValue().increment(key); if (count 1) { redisTemplate.expire(key, duration); } return count limit; }生产环境一般建议用更平滑的令牌桶或滑动窗口但固定窗口配合合理的阈值在大多数业务里已经够用了。我在实践中更喜欢把防重和限流分开防重用SET NX EX占位限流用INCR EXPIRE计数两个中间件逻辑互不干扰出了问题也容易排查。6.3 状态机与对账从入口控制走向结果校验防重是入口控制但入口控制永远不是 100% 完备的。真正成熟的系统会把最终一致性作为底牌。以订单为例订单状态应该是一个状态机创建 - 待支付 - 已支付 - 已完成。重复支付回调进来时直接判断当前状态如果已经是已支付直接忽略这次回调。UPDATE t_order SET status PAID, pay_time NOW() WHERE order_id #{orderId} AND status UNPAID这种update ... where 条件的写法可以根据影响行数判断这次更新是否真的发生。影响行数为 0 就说明状态不满足重复回调直接被语义化规避。最后还有一道保险定时对账任务。比如每 5 分钟扫一次支付成功但订单状态未更新的数据主动触发补偿。在非常关键的场景里入口防重、业务幂等、状态机、对账任务四层叠加才算真正闭环。我在实际项目里一般默认采用三层防御Redis 防重拦截绝大多数重复提交数据库唯一键做业务幂等兜底对账任务处理一切漏网之鱼。这套组合跑了一年多线上几乎没有再出现过因为重复提交产生的脏数据。如果你也在设计这类接口我的建议是想清楚自己的核心诉求只是拦截用户短时间内的连点用 fingerprint 占位就够了如果是资金、库存这类绝不能重复执行的接口那就要把幂等设计和数据库约束一起做进去防重只是其中一个环节。