资讯动态

Redis防重复提交:从原理到实战的完整方案

发布时间:2026/10/22 23:33:33 来源:尧图企业网站定制
接口重复提交我估计做后端的人多少都遇到过。用户手一抖双击了提交按钮前端超时自动重试第三方回调因为网络抖动重发不管哪种情况落到数据库可能就是重复订单、重复扣款、重复发放权益。用Redis做防重复提交核心思路就一句话把一次请求的唯一身份放进Redis快速判断这个身份是不是已经处理过。今天我把这个方案从设计到落地完整讲一遍包括两种常见策略、核心代码实现、压测验证过程还有我踩过的几个坑。适合正在用Redis做缓存、又被接口幂等和重复提交问题弄到头疼的后端开发者尤其是Java技术栈的朋友。1. 重复提交的典型场景以及为什么最后选了Redis1.1 你遇到的重复提交其实是三种完全不同的情况我在不同项目里见过太多次“重复提交”但仔细拆开看大多数纠纷都来自没分清楚它到底属于哪一种。第一种是用户层面的手滑。页面提交按钮没有做loading禁用用户点了两次或者网络卡顿导致用户以为没点上又点了一次。这种重复请求间隔通常在几百毫秒到几秒危害是产生两条一模一样的数据。第二种是客户端或者网关层面的重试机制。很多移动端网络库对POST请求有内置超时重试一旦客户端没有等到底层响应就会自动重发一次。这种情况间隔可能比较久几秒甚至几十秒但请求内容完全一致。第三种是服务间调用和回调通知的乱序重放。比如支付系统异步通知可能连续发送多条MQ消费者消费超时后重新投递合作的第三方系统因为自己没收到响应而重复推送。这种场景下重复请求可能来自不同节点、不同线程也必须保证业务上只处理一次。这三个场景的共性是请求本身是合法的但因为各种原因被重复执行了后端必须有能力识别并拦截。你不能去改用户的浏览器也不能保证所有上游系统都遵循精确一次投递只能在接口层做去重。1.2 为什么不直接用数据库唯一索引或者分布式锁很多团队一开始会想到数据库唯一索引。给业务表加一个唯一约束重复插入时数据库直接报错可以说是最硬的一道防线。但它的缺点也很明显第一你必须在写库阶段才能发现重复意味着前面所有参数校验、查询、组装、事务都已经跑了一遍浪费了性能第二很多接口并不是简单插入一条记录比如发短信、调第三方、做异步扣减根本没有合适的表可以加唯一索引第三数据库唯一索引报错后你需要捕获并翻译成用户友好的提示这套异常处理在某些框架里还挺麻烦。分布式锁也被经常拿来对比。分布式锁解决的是并发互斥问题意思是同一时间只能有一个请求执行其他请求排队等待但防重复提交解决的是去重问题意思是同一个业务标记已经在处理或已经处理过后续请求应该直接拒绝。如果要靠分布式锁做防重你得设置一个足够长的持有时间把已经处理完的和正在处理的都挡在外面锁释放逻辑会变得很别扭而且等待机制会让用户感到明显卡顿。Redis方案的好处正好踩在点上内存操作延迟一般在亚毫秒级对正常请求路径几乎没有影响SETNX命令自带原子性不需要自己处理并发判断key可以设置过期时间天然适合“短时间内去重”这种需求最重要的是它判断重复发生在接口一开始进入的时候根本不用走到数据库。1.3 Redis防重复提交的基本原理一句话讲清核心就一条利用SET key value NX EX seconds只有当key不存在时才写入成功写入成功就代表这个请求拿到了“执行权”写入失败就说明同一标识的请求已经被处理过或正在处理中直接返回重复提交提示。这其实就是拿Redis当一张“判重表”。你用请求里某个唯一标识作为key第一次进来时Redis里没有这个key写入成功请求继续向下执行第二次携带同样标识的请求进来Redis里已有key写入失败请求被拦下。key过期时间一到自动删除新的请求又可以正常提交。这个方案不需要额外的中间件只要项目里已经用了Redis就能做接入成本非常低。我在一个小型电商项目里落地时从写注解到压测通过一共只用了一个下午。2. 两种主流防重策略Token预签发与请求唯一ID2.1 Token预签发机制适合用户表单页面Token机制的做法很经典用户进入提交页面时后端先下发一个token并把它存到Redis用户真正提交数据时必须带上这个token后端拿到token后如果能在Redis里查到就删掉并继续执行如果查不到就说明已经过期或已被消费直接拒绝。这个方案的关键在于“预生成”。token代表的是某一次页面会话的提交凭证一个页面、一次业务操作只对应一个token。即使前端按钮没设禁用用户狂点十次只有第一个带着有效token的请求能通过后面九个请求拿到的token已经被删除自然进不了业务逻辑。我建议在表单类、需要页面交互的接口用这个方案。比如下单页、报名页、预约页用户操作路径明确前端有充足机会先获取token。它的额外好处是可以顺便防范参数被外部篡改后重放因为攻击者不知道当前的token值。不过Token机制也有需要注意的地方。后端下发token时一定要设置合理的过期时间太短用户填表时间长了就失效太长又起不到防重作用。我一般给10分钟如果业务的表单特别复杂视情况延长到30分钟但要做成可配置项。2.2 请求唯一幂等键机制适合服务间调用和回调Token机制在用户页面很好用但到了服务间调用就有点别扭调用方不可能先去拉一个token再调业务接口很多情况下是没有页面交互的比如支付回调、MQ消费、开放平台API。这时候就要用请求唯一幂等键。实现方式有两种。第一种是调用方生成一个全局唯一的requestId随请求一起传过来后端拿这个requestId作为Redis key第二种是后端自己根据业务参数组装幂等键比如用户ID加接口名加订单号。两种方式都行但务必保证幂等键在业务语义上唯一。我个人更倾向后端组装幂等键。依赖调用方传requestId有一个隐患如果调用方代码写得粗糙每个重试请求都重新生成一个requestId那防重就形同虚设。后端根据参数算key至少能保证参数一样时key一样。当然如果调用方系统是合作方你控制不了它的实现那就必须两条腿走路同时校验requestId和核心业务参数。有些业务还需要在幂等键基础上叠加状态校验。比如支付回调场景订单可能已经处于“已支付”状态如果Redis里的key刚好过期了重复回调还是会进入业务逻辑。这种情况下我在Service层里会再加一道判断如果订单状态已经是终态直接返回成功不进任何处理逻辑。Redis负责短时间去重数据库状态负责最终幂等。2.3 两种策略怎么选我给一个判断标准如果你做的接口面向人存在页面、表单、按钮优先用Token预签发。如果你做的接口面向系统是API调用、异步通知、消息消费优先用请求唯一幂等键。如果又有人交互又有系统调用那就两个都用页面入口用Token后端处理时再用幂等键。单测、压测、线上灰度阶段你会发现这个组合基本能覆盖所有重复入口。3. 核心代码实现注解AOPRedis一套复制即用的方案3.1 依赖和基础配置先把地基打好不管你是Spring Boot项目还是普通Spring项目引入Redis和AOP是第一步。Spring Boot项目直接在pom.xml加上两个依赖就行dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency然后确认Redis连接配置正确就一个基础要求生产环境不要用默认的localhost配置。我在这个方案里没有引入Redisson而是直接用Spring Data Redis的StringRedisTemplate。原因很简单防重复提交只需要一个带过期时间的SETNX不需要Redisson那套分布式锁的全部能力。越少依赖线上排查起来越省事。3.2 自定义注解定义控制在最小粒度防重逻辑要加在接口上最优雅的方式是自定义一个注解再用AOP统一处理这样业务代码不需要写任何Redis逻辑。我首先定义注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface NoRepeatSubmit { // key的过期时间单位秒 int expire() default 5; // 自定义幂等键的SpEL表达式比如 #order.orderNo String keyExpression() default ; }注解里只放两个参数。expire控制防重窗口keyExpression允许你指定用请求里哪个字段来组装Redis key。用SpEL是因为不同接口的核心参数不一样比如订单提交用的是订单号签到用的是用户ID加日期硬编码在切面里会导致复用性很差。这里有一个设计心得不要把防重逻辑直接硬编码进业务Service否则每个接口都要写一遍try-catch和Redis操作代码会变得极其啰嗦也容易出现漏删key的问题。注解加切面的方式业务方法根本感知不到防重的存在将来要把防重替换成其他方案也只需要改切面。3.3 AOP切面实现关键是异常时释放key切面是整个方案的核心。它要做三件事解析幂等键、尝试写入Redis、根据结果决定放行或拦截。Aspect Component public class NoRepeatSubmitAspect { private static final String LOCK_PREFIX nrs:; Autowired private StringRedisTemplate redisTemplate; Around(annotation(noRepeatSubmit)) public Object around(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) throws Throwable { // 1. 获取幂等键 String idempotentKey buildIdempotentKey(joinPoint, noRepeatSubmit); String lockKey LOCK_PREFIX idempotentKey; String value UUID.randomUUID().toString().replace(-, ); // 2. 尝试占位写入成功说明第一次请求 Boolean success redisTemplate.opsForValue() .setIfAbsent(lockKey, value, Duration.ofSeconds(noRepeatSubmit.expire())); if (success null || !success) { throw new BizException(操作过于频繁请稍后再试); } try { // 3. 执行真正的业务逻辑 return joinPoint.proceed(); } catch (Throwable throwable) { // 4. 业务失败删除占位允许用户重试 redisTemplate.delete(lockKey); throw throwable; } } private String buildIdempotentKey(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) { // 优先取SpEL表达式中指定的业务字段 if (StringUtils.hasText(noRepeatSubmit.keyExpression())) { // 解析参数简单场景直接取第一个参数的指定属性 Object arg joinPoint.getArgs()[0]; // 通过反射或SpEL工具解析省略具体实现 return parseExpression(arg, noRepeatSubmit.keyExpression()); } // 兜底当前用户ID 接口路径 String userId getCurrentUserId(); MethodSignature signature (MethodSignature) joinPoint.getSignature(); return userId : signature.getMethod().getDeclaringClass().getSimpleName() : signature.getMethod().getName(); } }这里有一个经常被忽略的细节setIfAbsent成功后value不能存死值要存一个UUID。因为清洗key、排查问题时你得能区分同一个key下的多个请求到底是谁占的位。另外如果业务执行抛异常一定要在catch里删除key否则用户会被“卡死”——业务明明失败了防重key还在他再提交就永远提示重复。删key之后要重新抛出异常让事务层正确回滚。3.4 为什么不能先get再delete要用Lua脚本保证原子性防重逻辑里有一处特别容易踩坑Token机制或业务成功后的释放动作不能写成先get再delete两步。假设两个请求同时带着同一个token进来请求A执行get发现token存在紧接着请求B也执行get也发现token存在然后A删掉了tokenB也跟着删两个请求就都通过了防重直接失效。正确做法是用Lua脚本把“判断存在并删除”合并成一个原子操作if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个脚本先校验value是否等于自己写入的随机值相等才删除。这样做有两个好处一是避免误删其他第一次请求写入的key二是把check和delete合并成一次Redis操作不存在并发窗口。我在Spring中注入这个脚本再用DefaultRedisScript调用整个过程非常干净。如果你不想用Lua用StringRedisTemplate的delete方法也行但必须知道它只能做“无脑删除”没法判断value是否是当前请求写入的。在分布式环境下如果前一个请求因为某种原因还没结束key被新请求误删一样会出问题。所以能用脚本就别嫌麻烦。3.5 释放时机短时间去重与业务结果去重的区别加完AOP之后你还要想清楚一个策略问题key是业务成功后就删除还是等过期时间到了自动删除。如果业务成功就主动删除key那这个方案本质上是“防并发重入”同一时间内同一个请求只允许一个在执行。用户第一次请求处理完后马上可以再提交新内容。这种策略适合幂等键由业务参数组装或者Token模式先删后执行的场景。如果业务成功也不删key等过期时间到了自动释放那这个方案就是“防重复提交窗口”在窗口期内同一个幂等键只能提交一次。比如用户提交订单后10秒内不允许再次提交同一个订单号哪怕业务已经成功也照样拦截。这种策略更适合防用户手滑重复点击。我把这两种做法都在项目里用过。如果你的业务对“处理后是否可以马上再来一次”没有明确要求我建议让key在业务成功后保留一段时间牺牲一点点灵活性换来更强的拦截力度。用户双击导致的两个请求通常会落在几百毫秒内5秒的防重窗口足够覆盖绝大多数手滑场景。4. 从零到一完整落地包含压测验证4.1 项目结构和配置按这个搭就不会乱一个标准的落地工程我一般分成四层Controller层只负责接收参数和返回结果接口上标注NoRepeatSubmit。Service层真正的业务逻辑不感知防重逻辑。AOP切面层负责统一的Redis防重判断。Redis配置层负责序列化和Lua脚本注册。这样的分层在做单元测试时特别省心。我可以直接Mock掉RedisTemplate来测试Controller返回逻辑也可以绕过Controller直接测试Service的业务正确性。Redis的key结构我统一用nrs:业务前缀:具体幂等键比如nrs:order:10001。带业务前缀的好处是排查问题时可以通过redis-cli立即定位也避免不同模块之间key撞车。Redis的String类型value就存一个随机UUID不存业务对象保持简洁。4.2 完整落地时幂等键的SpEL解析怎么实现前面代码里buildIdempotentKey方法留了个解析SpEL的口子很多人会卡在这里。我直接把写法补齐。如果接口是单参数且该参数是一个DTO对象我想通过注解指定去重维度PostMapping(/order/submit) NoRepeatSubmit(expire 5, keyExpression #order.orderNo) public ResultVoid submit(RequestBody OrderSubmitDTO order) { return orderService.submit(order); }那切面里解析keyExpression本质上就是从方法参数里取字段值。如果你已经引入了spring-expression可以直接这样写private String parseSpEL(String expression, Object[] args) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Parameter[] parameters signature.getMethod().getParameters(); EvaluationContext context new StandardEvaluationContext(); for (int i 0; i parameters.length; i) { context.setVariable(parameters[i].getName(), args[i]); } Expression exp new SpelExpressionParser().parseExpression(expression); Object value exp.getValue(context); return value null ? null : value.toString(); }SpEL解析有一个容易踩的坑Spring的PathVariable和RequestBody参数名在编译后可能变成arg0、arg1导致表达式绑定不到真正的参数名。所以我的建议是要么在编译参数中加上-parameters参数要么直接用RequestParam注解的value和RequestBody的类型名称来定位参数。生产环境我更推荐限定keyExpression只适用于单DTO参数场景避免过度设计。4.3 压测验证200个并发请求只允许1个成功写好代码之后压测这一步千万不能省。我用JMeter模拟了同一个订单号的200个并发请求线程数200 循环次数1 请求路径POST /order/submit 请求体{orderNo:10001,productId:sku_123} 并发策略同时启动压测预期是1个请求正常处理成功199个请求返回“操作过于频繁”。第一轮压测结果却出乎意料出现了十几个请求同时成功。排查发现这些请求的orderNo都是动态生成的JMeter中每个线程读取CSV里不同的订单号。对后端来说这其实是200个不同请求当然应该全部成功。真正的重复提交压测必须让所有线程携带完全相同的幂等键。修正压测参数后第二轮的日志就很清晰了唯一一个成功请求执行了业务逻辑其余请求全部在AOP层被拦截没有一条打到了Service层。从响应时间看被拦截的请求平均耗时不到3毫秒Redis的延迟基本可以忽略。这里我还特意验证了异常释放逻辑。在Service第一行人为抛出一个运行时异常后再发送第二次请求发现能正常进入业务逻辑说明catch块里的delete确实在执行。很多人在压测时只看成功的正常流程不看失败后的恢复能力这一步容易漏。4.4 生产环境要额外注意的Redis故障降级写代码的时候多考虑一层如果Redis服务挂掉setIfAbsent命令会抛异常。这时候你的接口应该怎么办我见过一些团队直接把异常抛出去结果Redis一抖动所有接口全挂这明显违背了防重组件“旁路”的定位。防重复提交是辅助能力它的职责是防止脏数据不是成为新的故障点。正确的做法是在切面里捕获Redis异常并放行让请求正常进入业务逻辑同时用日志或监控报警记录Redis异常情况。降级策略和业务敏感性要权衡。如果是支付、下单这类强一致业务我倾向于放行并在数据库层用唯一索引兜底如果是优惠券秒杀这类纯粹防刷场景Redis挂了那你宁可接口暂不可用也不能放行。这个判断要在设计阶段就定下来不要等线上故障了再做决定。5. 线上踩过的坑排查实录和补救办法5.1 并发下偶发穿透到底是谁把防重捅破了项目上线后遇到一个诡异的问题高并发下偶尔会出现两个相同订单号都成功创建了订单。理论上SETNX是原子的为什么还会穿透我拉日志排查后发现第一次请求的key过期时间设置成了5秒但它对应的业务链路处理耗时超过了5秒第一个请求还在执行时key已经过期被自动删除第二个请求又成功写入了一个新key自然就把防重穿破了。这个问题本质上不是Redis命令的问题而是防重窗口小于业务处理时长。解决方式有两个第一把过期时间调大单位从秒改成分钟级第二如果业务链路确实很长防重窗口必须覆盖“业务最短完成时间”不能只按用户手动点击间隔来设。我在订单场景中把expire设置了30秒既不影响用户重试体验又足够覆盖业务处理时间。5.2 误拦截正常请求key范围设计得太粗了有段时间用户反馈同一个用户在同一秒内提交两个不同的商品订单第二个请求被拦截了。查了半天发现切面里兜底生成的key只用了用户ID加接口方法名没有拼接订单号等于把“用户在某接口上的并发提交”全部串在一个key下面。这个教训很典型。防重key的粒度必须精确到一次业务操作而不是一个用户或一个接口。如果一个用户允许同时提交多个不同类型的单子key里必须带上订单号、商品ID或请求参数中的唯一标识。设计key时先在纸上写一写同一个key代表什么两个key相同是否一定意味着业务重复如果答案是否定的那key粒度就是错的。5.3 过期时间的取舍给一个可参考的值不同业务的防重窗口差别很大我用一个表格记录一下各场景的参考值改动成本不高但效果差异很大业务场景过期时间参考说明普通表单提交5秒覆盖用户双击和前端超时重试下单支付30秒到1分钟覆盖较长业务链路第三方异步回调1到5分钟回调可能延迟重试签到/浏览类1天按自然日固定幂等键原则是过期时间必须大于单次请求从入口到结束的最长耗时同时小于用户心理上“允许再次操作”的等待阈值。像签到接口我甚至会直接把key设计成nrs:sign:userId:yyyyMMdd跨天自动失效根本不需要手动算过期时间。5.4 集群环境下的主从延迟这个坑比较隐蔽Redis单机版没有太多问题但生产环境一般至少是主从架构。SetIfAbsent命令在主节点执行成功主节点还没来得及同步给从节点这时另一个请求如果走了从节点读旧数据会认为key不存在从而穿透。解决思路有三个等级。第一强制读主节点很多Redis客户端默认读写都走主节点这样可以规避第二改用Redisson的分布式锁它会多做一些一致性保证第三对极端严苛的业务用RedLock或者在业务数据库加唯一索引做最终兜底。普通业务做到第一点就够了不必动不动上RedLock毕竟RedLock本身也有争议和复杂性。5.5 数据库唯一索引依然建议保留作为最后一道防线Redis防重再完善也只是应用层的拦截。真正写入数据库的时候如果多实例同时因为某些极端情况穿透数据库唯一索引是唯一能保证“最终一定不会产生重复数据”的手段。我强烈建议在订单表、流水表、交易记录表这些核心表上把业务唯一键设置为数据库唯一索引比如order_no、transaction_id、source_type加source_id。应用层防重负责拦截99.9%的重复请求数据库唯一索引负责兜底剩下的万分之一。捕获到唯一索引冲突后不要把异常信息直接抛给用户转换成友好提示“您已提交过该请求”。5.6 排查防重问题时我建议你先看这三个位置如果线上仍然出现了重复数据我的排查顺序是先看访问日志里这个幂等键是否真的都到了后端有可能网关层直接重写请求移除了参数再看AOP日志里拦截成功的次数拦截次数很少但数据重复了问题大概率不在防重在业务侧幂等处理最后看Redis监控里key的过期时间valttl一查就知道是不是窗口太短。按这个顺序排查一般十分钟内就能定位。6. 防重复提交的边界与扩展思考6.1 防重不等于幂等很多人把这两个概念搞混了防重和幂等是两个不同层面的能力。防重针对的是“重复的请求不能再次执行”它把请求挡在门外幂等针对的是“同一个请求执行多次和一次效果相同”它在业务内部消化重复。Redis防重只是让你尽量少遇到重复一旦重复进来了业务代码经过状态校验后依然要能安全返回不能假设所有重复都被防住了。一个合理的系统设计是接口入口处用Redis拦截重复提交业务内部用状态机和唯一索引保证即使重复到达也不会产生脏数据消息队列消费端再通过消息ID做一次消费幂等。三层防线各管一段这才是成熟的做法。6.2 消息队列场景下的防重做法略有不同MQ消费者的重复消息和HTTP接口的重复提交成因很相似但处理方式有区别。HTTP接口可以用AOP统一拦截MQ消费者则更习惯在消费逻辑里先查一下消息去重表或者使用Redis记录消息ID。我一般会为每个消费者维护一个去重集合消费前用SETNXredisMsg:消费组名:消息ID写入成功才继续消费。消费完成后不急着删除让key保留一段时间具体时间根据业务接受重复的窗口来定。这个方案和接口防重最大的不同在于MQ消费者允许消息在业务上重复到达但处理结果必须一致所以除了Redis去重消费逻辑内部的状态校验反而更重要。6.3 要不要用布隆过滤器本地缓存能不能也掺一脚防重key的量如果特别大比如百万级用户签到每个用户每天存一个keyRedis内存虽然撑得住但监控起来不太好看。布隆过滤器可以在写入Redis之前先判断一下key是否可能存在减少无效的Redis写入但布隆过滤器本身有误判率且无法删除元素用在精确去重场景要特别小心。我目前只在数据量达到千万级且允许极低误判时才考虑它常规项目直接SETNX就够了。另外我也试过在本地加一层Caffeine缓存做一级防抖命中本地缓存就直接拒绝减少对Redis的压力。这个方案适合极端热点场景比如秒杀按钮。但要注意本地缓存无法跨节点共享多实例部署时可靠性下降了只能作为前置加速不能替代Redis判重。6.4 这个方案的边界条件别忘了考虑网关层已经做过的处理有不少团队在网关层已经做了统一的API限流或者鉴权接入Redis防重时会想是不是重复了。我的观点是网关限流解决的是整体流量控制比如每秒最多1000个请求接口防重解决的是单业务请求的重复性两者关注维度完全不同。已经做了网关限流接口层防重依然要做。如果网关层有SDK可以针对用户维度做频控你也可以用它来覆盖一部分防重场景但我还是建议在业务侧保留注解级别的防重能力。理由很简单网关频控一般只能做到用户级或IP级无法区分同一次业务和不同业务而接口防重必须精确到业务参数。这篇文章从场景到方案从代码到踩坑把Redis防重复提交的完整落地过程都过了一遍。如果你正在做一个接口反复出现重复数据的项目先别急着上重型中间件按这篇文章里的思路用一个注解加一个切面半天时间就能把东墙补起来。真正复杂的从来不是技术选型而是你到底要挡哪一类重复又愿意在一致性上做到多狠。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑