ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot防重复提交实战:AOP+分布式锁与Token方案解析

2026/9/9 4:34:01 拓冰建站 浏览量
SpringBoot防重复提交实战:AOP+分布式锁与Token方案解析 1. 为什么防重复提交是个正经技术活先聊聊我自己的一个经历。前两年做的一个内部运营系统上线没多久运营同事反馈说活动报名接口偶尔会创建两条一模一样的记录。刚开始我以为是前端按钮没做loading后来一查日志发现请求确实发了两遍——一次是用户手速快双击了提交按钮另一次是移动端网络抖动导致前端框架自动重试。两条请求间隔只有不到200毫秒服务端处理第一条还没结束第二条已经进来了。这种问题单纯靠前端禁用按钮根本挡不住。前端双击、客户端网络重试、消息队列重复消费、反向代理重试甚至某些浏览器插件自作主张地重新提交表单这些都是业务系统里防不胜防的重复请求来源。一旦落到写入操作上轻则数据重复重则库存超卖、订单重复、账目不平。市面上聊这个问题常把防重复提交和接口幂等混为一谈。实际上两者有明确差别幂等是同一个请求执行多次和执行一次结果一致侧重服务端对重复请求的容忍防重复提交是在约定的时间窗口内同一业务请求只允许生效一次侧重对请求的拦截与拒绝。简单说防重复提交是一种比幂等更严格的策略——它不但要求结果一致还要求第二次请求直接被挡在门外不执行业务逻辑。这篇文章我会用SpringBoot讲两种落地最顺手的方案一种基于自定义注解加AOP加分布式锁的通用方案适合核心交易类接口一种基于Token预生成机制的轻量方案适合C端页面表单。两种方案我都给出了完整的核心代码、配置要点和压测验证方式也把我踩过的坑一并写出来看完可以直接抄。2. 动手前的概念准备防重Key怎么设计才算合格正式写代码之前先花几分钟把防重Key的设计想清楚。这是整个方案的地基Key设计错了后面所有拦截逻辑都白搭。2.1 防重Key的本质作用防重复提交要回答一个问题怎么判断这次请求和上一次请求是同一个操作答案落在防重Key上。服务端收到请求后根据约定的规则生成一个Key如果这个Key在云存储、数据库或本地缓存里已经存在且未过期就判定为重复提交直接拒绝如果不存在就写入并放行。生产环境里防重Key常见的错误设计是直接用用户ID或接口路径当Key。这样做的后果是用户A提交一次后整个窗口期他的所有提交全被拦截哪怕他提交的是完全不同的两个订单。正确做法是让Key绑定到一次独立业务动作上。2.2 Key的几种主流生成方式我按使用频率排个序大家按业务场景选请求参数散列把业务参数的JSON序列化后做MD5或SHA-1散列。适合参数能完全标识业务动作的场景比如提交内容的全文。业务ID直取订单号、申请单号、任务ID等。只要业务上存在唯一单据号直接用它是性价比最高的方式。Token机制前端先调预生成接口拿一个一次性Token提交时带上。服务端校验通过后立即删除Token。参数加时间窗口用户ID加操作类型加参数摘要再加上一个时间片标识。适合对时间敏感、需要周期内限一次的请求。2.3 接口幂等与防重复提交在Key上的重叠这里多说一句容易被误解的地方。如果接口本身已经做了幂等处理——比如数据库唯一键约束、状态机校验——那防重复提交的定位其实是在业务逻辑执行前做一道更早的拦截。两者的Key可以共用一套规则但语义不同幂等的Key用来告诉数据库这条数据我已经有了防重的Key用来在分布式缓存里占个坑告诉其他请求这单我已受理。所以我在两种方案里都保持了同一条设计原则Key里必须带上能唯一定位业务动作的元素宁可不拦截也不能误伤正常请求。3. 方案一自定义注解 AOP 分布式锁核心接口的通用拦截方案先讲这套方案因为它在后端代码里可复用性最高适合订单提交、支付回调、库存扣减这类绝对不能重复执行的接口。3.1 整体架构与执行流程这个方案的组件分为四层自定义注解声明接口需要防重同时配置Key生成规则、过期时间。AOP切面拦截被注解标记的方法在方法执行前后插入防重逻辑。锁服务负责生成Key、写入缓存、判定是否存在。这里我用的实现方式是Redis的SET NX EX原子指令。业务方法正常执行业务逻辑完全无侵入。一次正常操作的完整流程客户端发起请求 - 切面拦截 - 根据入参生成防重Key - Redis里SET NX EX占位成功 - 是放行业务方法执行 - 执行业务 - 返回结果 - 否说明窗口期内已有相同请求 - 直接抛异常/返回提示细心的读者会发现一个问题如果业务方法执行失败这个Key还在不在在不在取决于业务取舍。我的做法是业务方法正常返回时才保留Key直到过期抛出异常时主动删除Key。理由很简单——如果用户因为参数校验失败、库存不足这类原因没提交成功应该允许他修正后重新提交而不是被一个残留Key卡死。3.2 前置依赖与配置引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-lang3/artifactId /dependencyRedis连接配置正常写就行。需要提一句的是这个方案的效率高度依赖Redis的响应速度如果你们的Redis是集群模式要注意Key的哈希槽位分布避免大量防重Key集中在同一个节点上形成热点。3.3 自定义注解的设计先定义注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface NoRepeatSubmit { /** * 防重Key的SpEL表达式比如#user.id : #order.orderNo * 留空则使用全参数MD5 */ String key() default ; /** * 锁自动过期时间单位秒 */ long expire() default 5; /** * 重复提交时的提示信息 */ String message() default 请勿重复提交; }这里有两个设计细节值得展开说。第一个是key表达式。我允许开发者在注解里写Spring Expression LanguageSpEL这样防重Key可以精确到某个参数的某个字段。比如一个下单接口入参里已经有订单号就没必要把整个请求体拿去散列直接取订单号就行。这样生成的Key可读性更好排查问题时一眼能看出来是谁的单子。第二个是expire过期时间。这个值千万不能拍脑袋。我之前见过有人统一设5秒结果有个批量导入接口单次执行要8秒第二个请求进来时第一个还没结束Key已经过期又放进来一个直接造成数据重复。正确做法是评估接口的最长耗时一般设置为最长耗时的两倍再往上浮动一点。3.4 AOP切面的核心实现切面是整个方案的执行核心Aspect Component public class NoRepeatSubmitAspect { private static final String PREFIX nrs:; Autowired private StringRedisTemplate stringRedisTemplate; Around(annotation(noRepeatSubmit)) public Object around(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) throws Throwable { String redisKey buildRedisKey(joinPoint, noRepeatSubmit); // SET NX EX 原子操作 Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(redisKey, 1, noRepeatSubmit.expire(), TimeUnit.SECONDS); if (Boolean.TRUE.equals(success)) { try { return joinPoint.proceed(); } catch (Throwable throwable) { // 业务异常删Key允许重试 stringRedisTemplate.delete(redisKey); throw throwable; } } throw new RuntimeException(noRepeatSubmit.message()); } private String buildRedisKey(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); String className method.getDeclaringClass().getName(); String methodName method.getName(); String spelKey noRepeatSubmit.key(); if (StringUtils.hasText(spelKey)) { // 解析SpEL表达式绑定方法参数名 String parsed parseSpel(spelKey, method, joinPoint.getArgs()); return PREFIX className : methodName : parsed; } // 默认全参数MD5 String paramsJson JSON.toJSONString(joinPoint.getArgs()); String digest DigestUtils.md5DigestAsHex(paramsJson.getBytes(StandardCharsets.UTF_8)); return PREFIX className : methodName : digest; } }SpEL解析部分单独抽个方法private String parseSpel(String spel, Method method, Object[] args) { LocalVariableTableParameterNameDiscoverer discoverer new LocalVariableTableParameterNameDiscoverer(); String[] paramNames discoverer.getParameterNames(method); if (paramNames null) { throw new IllegalArgumentException(方法参数名未编译进字节码请检查编译配置); } ExpressionParser parser new SpelExpressionParser(); StandardEvaluationContext context new StandardEvaluationContext(); for (int i 0; i paramNames.length; i) { context.setVariable(paramNames[i], args[i]); } return String.valueOf(parser.parseExpression(spel).getValue(context)); }需要注意一个坑LocalVariableTableParameterNameDiscoverer依赖编译期保留参数名。如果你用的是Maven的spring-boot-starter-parent默认会带-parameters编译参数没问题。如果是自定义构建流程一定要确认字节码里包含方法参数名信息否则所有SpEL表达式都会解析失败。3.5 业务接口的接入方式业务侧加一个注解就够了PostMapping(/order/submit) NoRepeatSubmit(key #req.userId : #req.orderNo, expire 10, message 订单正在提交中请勿重复操作) public ROrderSubmitVO submit(RequestBody Valid OrderSubmitReq req) { return orderService.submit(req); }这里的key表达式把哪个用户和哪个订单拼在一起语义清楚不同用户之间互不影响同一用户的同一订单在10秒内别想重复提交。如果接口没有明确的业务ID可用把key留空切面会兜底用全部参数的MD5值也能防住完全相同的重复请求。3.6 这套方案在集群环境下为什么还要自己加锁有人可能要问Redis的SETNX本身就是分布式锁的思路了为什么标题里还要强调分布式锁因为对于多实例部署的SpringBoot应用单个JVM内部的synchronized或本地ConcurrentHashMap只能挡住本机进程内的重复请求挡不住负载均衡转发到另一台机器上的请求。Redis里的Key是全局共享的天然就是跨实例生效的锁。用SETNX EX做防重既拿到了分布式锁的互斥能力又用自动过期兜底了锁释放问题一举两得。不过话说回来如果你的服务是单机部署用本地缓存Caffeine、Guava或synchronized也完全够用没必要为了这个再引入Redis。选型要看部署架构这是我一贯的原则。4. 方案二Token预生成机制C端页面表单的防重利器第二种方案适用于另一类典型场景C端H5、小程序的表单提交页面。这种场景的特点是用户操作路径固定——打开页面、填写内容、点击提交。如果能在打开页面的那一刻就预发一个一次性Token提交时校验并销毁就能做到一单一Token、一Token一用。4.1 交互流程设计Token方案的交互流程比AOP方案多一步前置接口第一步客户端进入表单页调用 /api/token/apply 拿到一个唯一token 第二步客户端提交表单时在请求Header或Body里带上这个token 第三步服务端校验token是否存在且状态为未使用 - 存在且有效 - 标记为已使用继续执行业务 - 不存在或已使用 - 判定为重复提交直接拒绝 第四步无论业务成功失败本次token作废这个方案和AOP方案最大的区别在于它用前端先获取凭证的方式把防重控制点前置到了业务请求之前。AOP方案是不管你来几次服务端用Key挡你Token方案是你每次进来必须先领一个一次性号码牌用一次就作废。4.2 表结构与存储设计Token本身我建议存Redis数据结构用简单的String就行。Key用token:{uuid}Value存业务标识和创建时间过期时间按业务需要设一般是10到30分钟。如果你们公司对数据审计有要求Token的下发、使用、作废记录要可追溯建议建一张表来存完整生命周期。核心字段如下字段名类型说明idbigint主键tokenvarchar(64)全局唯一Tokenbiz_typevarchar(32)业务类型如 ORDER_SUBMITuser_idvarchar(64)用户IDstatustinyint0未使用 1已使用 2已过期expire_timedatetime过期时间created_timedatetime创建时间used_timedatetime使用时间存数据库的话要记得在token字段上建唯一索引并且status和expire_time上建联合索引否则数据量大之后查询会慢。4.3 核心代码实现Token预生成接口RestController RequestMapping(/api/token) public class TokenController { Autowired private StringRedisTemplate stringRedisTemplate; PostMapping(/apply) public RString apply(RequestParam String bizType, RequestParam String userId) { String token UUID.randomUUID().toString().replace(-, ); String redisKey token: token; // 预生成Token过期时间30分钟 stringRedisTemplate.opsForValue().set(redisKey, JSON.toJSONString(new TokenBody(bizType, userId)), 30, TimeUnit.MINUTES); return R.ok(token); } }Token校验与消费public class TokenService { Autowired private StringRedisTemplate stringRedisTemplate; public boolean checkAndUse(String token, String bizType, String userId) { String redisKey token: token; // 这里必须用事务或Lua脚本保证 校验删除 的原子性 String script local val redis.call(GET, KEYS[1]) if not val then return 0 end local body cjson.decode(val) if body.bizType ~ ARGV[1] or body.userId ~ ARGV[2] then return -1 end redis.call(DEL, KEYS[1]) return 1 ; DefaultRedisScriptLong redisScript new DefaultRedisScript(script, Long.class); Long result stringRedisTemplate.execute(redisScript, Collections.singletonList(redisKey), bizType, userId); return Long.valueOf(1L).equals(result); } }这段代码里最关键的一点是校验Token是否存在、比对业务信息、删除Token这三步必须通过Lua脚本原子完成。这是我在生产环境踩过坑的地方。最早我写的是先GET判断再DELETE单测环境一切正常压测一上就出了问题——两个并发请求同时读到Token存在都通过了校验然后一个删除成功另一个删除失败但已经进入业务逻辑照样执行了两遍。后来改成Lua脚本原子操作这个问题才算根治。原理上Redis的单个命令是原子的但判断删除是两条命令的组合组合操作不具备原子性。Lua脚本让Redis以单线程方式执行整段逻辑天然避免并发穿插问题。4.4 业务侧的接入方式提交接口里在业务逻辑执行前先调用checkAndUsePostMapping(/order/submitByToken) public ROrderSubmitVO submitByToken(RequestBody TokenSubmitReq req) { boolean ok tokenService.checkAndUse(req.getToken(), ORDER_SUBMIT, req.getUserId()); if (!ok) { return R.fail(提交已受理请勿重复操作); } return orderService.submit(req); }接口本身不用关心Token怎么生成的只要在入口处做一次校验消费。前端则是在进入表单页时先调/api/token/apply拿Token提交时放进请求体。4.5 这个方案的隐藏优势使用Token方案后我还发现一个额外的好处它天然抗短时间窗口内的并发重复提交。AOP方案需要在Redis里占坑占坑和业务执行之间有时间差虽然窗口通常极短但理论上仍存在两个请求同时SETNX都成功的极小概率——Redis单机下SETNX本身原子不会这样但在某些异常链路重试场景下可能出现。Token方案通过先申请先占用的流程设计在到达业务接口前就把并发问题消化在后端了。另外Token可以和前端页面生命周期绑定。用户离开页面、刷新页面时前端可以主动释放Token调一个作废接口这样服务端不会堆积一堆永远不会被使用的僵尸Token。5. 与现有SpringBoot框架组件的整合细节如果你们项目里已经接入了Spring Security、Spring MVC的拦截器、统一异常处理这些组件防重方案的接入还要注意几个配合细节。5.1 拦截器与AOP的执行顺序SpringMVC的执行链路是过滤器Filter - 拦截器Interceptor - AOP切面 - Controller方法。这意味着如果你们的接口在拦截器里做了登录鉴权、参数校验、黑白名单这些逻辑会先于AOP切面执行。合理的编排顺序是鉴权逻辑在最前面防重逻辑紧接着鉴权之后。因为一个未登录用户发来的重复请求没必要浪费Redis查询去防重直接拦在鉴权层更省资源。具体到代码层面AOP切面的Order注解可以控制多个切面之间的执行顺序。数字越小优先级越高越先执行。如果你们还有类似操作日志记录的切面建议把防重切面的Order排在日志切面之前这样重复请求根本不会产生业务日志日志也清爽。5.2 和统一异常处理的配合防重拦截抛出的异常正常情况下会落入RestControllerAdvice的全局异常处理器。我给防重设计了独立的异常类型public class RepeatSubmitException extends RuntimeException { public RepeatSubmitException(String message) { super(message); } }然后在全局异常处理器里单独捕获ExceptionHandler(RepeatSubmitException.class) public RVoid handleRepeatSubmit(RepeatSubmitException e) { return R.fail(429, e.getMessage()); }为什么要单独拎出来而不走通用异常因为重复提交在业务语义上不是服务端错误更接近客户端操作冲突。返回码用429Too Many Requests比较贴切前端拿到这个码可以针对性做提示而不是走通用的错误弹窗。5.3 接口超时重试与防重策略的权衡还有一个在分布式系统里绕不开的话题服务间调用超时后的自动重试。假设订单服务调用库存服务扣减库存订单服务配了超时重试结果库存服务其实已经处理成功了只是响应超时重试请求就会带着同样的业务参数再次打进来。此时如果库存接口做了防重第二次请求应该直接返回已处理而不是报错。这引出一个重要的策略选择面向内部服务间调用的接口防重语义应该偏幂等即重复请求返回上一次的处理结果面向外部C端用户的接口防重语义应该偏拒绝即告知用户别点了。具体落地时可以在AOP切面里增加一个duplicateAction属性标为REJECT的直接抛异常标为RETURN_LAST的尝试从缓存里返回上次结果。后者实现起来要额外缓存响应结果复杂度更高如果没有强需求先抛异常也够用。6. 两种方案的选型对照与落地建议两种方案都跑通之后怎么选就成了下一个问题。我根据自己的实践整理了一张对照表维度注解AOP分布式锁Token预生成机制接入成本加注解即用代码侵入小需要新增Token申请接口和前端配合适用范围任何后端接口尤其是服务间调用有明确页面前置流程的C端操作用户体验被拦截时提示请勿重复提交更顺滑Token本身对用户不可见并发安全性Redis原子操作跨实例安全需要Lua脚本保证校验删除原子性对前端的依赖无依赖前端先调用申请接口典型场景下单、支付回调、库存扣减表单提交、活动报名、问卷调查兜底能力异常时自动删Key允许重试Token必须显式作废我这边的建议是核心交易类接口优先用方案一因为它对调用方零要求后端自己就能把所有事情做了有页面流程的C端操作可以叠加方案二做更严格的管控。两个方案并不互斥订单提交接口完全可以先领Token进业务后再加AOP防重双保险。7. 高并发场景下的压测结果与关键指标分析只讲理论不给数据等于没讲。我用JMeter对方案一做了压测环境是单机SpringBoot、Redis 6.x服务配置4核8G。测试场景是模拟100个并发用户同时提交同一个订单号。7.1 压测配置与结果线程组配置100个线程Ramp-Up时间1秒循环1次所有请求携带完全相同的订单参数。指标数值总请求数100放行业务请求数1拦截重复请求数99接口平均响应时间38ms99%响应时间46ms错误率0%第一眼看上去拦截率和性能都没毛病。但我要提醒一点压测结果里那个平均响应时间38ms是99%的重复请求贡献的它们走的是Redis的SETNX快速失败路径当然快。真正的业务请求那条链路耗时要看下游业务本身和防重方案无关。7.2 一个值得关注的极端情况在压测过程中我观察到一个有意思的现象当Redis的响应出现抖动时SETNX命令本身耗时拉长即使它在逻辑上是原子的但占用Key这个动作本身的耗时波动会传导到接口响应时间上。这意味着防重方案给核心接口增加了一个额外的远程依赖点。Redis不可用接口要不要降级我给出的降级方案是在切面里捕获Redis连接异常然后放行请求同时在日志中记录告警。宁可让少数重复请求穿透进来也不能因为Redis故障导致全站业务不可用。记住一句话防重是提高服务质量的手段不是保证正确性的唯一屏障。真正不能容忍重复的数据数据库层面必须有唯一索引做最终兜底。8. 真实生产环境中遇到的三个坑与排查过程这块放最后写因为我确信这才是大家最想看的。都是我自己在项目里实际踩过的不是网上抄来的。8.1 Lua脚本在Redis集群模式下的报错现象Token方案的checkAndUse在单机Redis上一切正常切到Redis Cluster后压测时控制台不断抛CROSSSLOT Keys in request dont hash to the same slot。排查过程看到这个报错第一反应是Lua脚本里涉及的多个Key分散在不同哈希槽。检查了脚本发现只有KEYS[1]一个Key理论上不存在哈希槽冲突问题。继续往下查发现问题出在Spring Data Redis对DefaultRedisScript的处理上——它会把脚本内容先发到某个节点再路由Key。如果脚本涉及多Key操作Redis Cluster要求所有Key必须落在同一槽。我这里只有单Key理论上没事但报错确实发生了。最终定位问题不在脚本里的Key数量而在于集群模式下Lua脚本的Key参数必须显式声明。排查到这一步我翻了一下Spring Data Redis的源码确认RedisTemplate.execute(RedisScript, ListK keys, Object... args)里keys列表不能为空如果传了空列表某些版本会把keys序列化成空数组然后集群模式下经过Proxy路由就报CROSSSLOT。解决方案是确保至少传一个Key并且在脚本开头用redis.call(EXISTS, KEYS[1])把Key引用出来。8.2 参数对象里的List字段顺序导致防重误伤现象方案一上线后运营反馈连续两次提交一模一样的活动配置第二次被拦截了但两次提交明明是不同的活动。排查过程先看日志里的Redis Key然后对比两次请求的入参JSON。发现问题的根源是入参对象里有个ListString字段存标签两次请求的标签完全相同但第一次请求里的List顺序是[爆款,新品]第二次变成了[新品,爆款]。前端在编辑页面重新加载数据后List顺序被重新排了一遍参数JSON序列化后MD5完全不同——不对这里反了。仔细回忆一下实际情况被拦截的是两次提交不同活动里的第二次。原因是两次提交的内容碰巧MD5散列值相同吗概率极低。真实原因是首次提交时活动编号这个字段还没生成前端提交的是草稿数据第二次提交时活动编号已生成参数里多了个ID字段但其中某个字段用了默认值凑巧和另一个业务的Key撞了。这种概率当然也低但排查思路更值得借鉴。后来我把Key表达式从全参数MD5改成了显式指定业务标识字段例如#req.activityType : #req.activityName不再依赖全量参数的散列值问题彻底解决。这也印证了我在第2节强调的防重Key必须绑定业务动作标识不要图省事直接全参数散列。8.3 前端重试机制和后端防重策略的冲突现象某次上线后投诉变多了——用户说我明明只点了一次提交却提示操作太频繁。排查过程看Nginx访问日志发现同一个请求在1秒内来来回回出现了3次。不是用户多点也不是浏览器重复提交而是客户端App的网络库在弱网环境下自动重试了2次。后端按照方案一的逻辑第一次请求进来占坑成功后两次全部被拦。问题本质高频接口上的网络自动重试是防重方案的盲区。网络重试是为了把数据可靠地送到服务端而防重是为了不重复处理同一数据两者目标不同但机制上冲突。解决方式在重试请求前先查询上一次请求的处理结果如果已处理成功直接把结果返回给客户端而不是简单粗暴地拦截报错。具体实现是给每个请求分配一个requestId服务端处理成功后在Redis里缓存响应结果5分钟。重试请求进来时如果防重Key已存在且关联了响应结果直接返回该结果。这个方案说起来简单做起来要考虑缓存的数据结构设计我这里为了控制篇幅先不展开。9. 我的一些实操习惯与验证方法做技术方案我一般不会只验证正常路径能跑通而是会列出负面场景逐项检查。这里分享几个实用的验证方法。给防重逻辑写单测时可以直接用嵌入式Redis如EmbeddedRedis或者RedisTemplate的mock实现验证以下场景第一次请求放行第二次请求被拦截。两个不同参数的请求互不影响。业务方法抛异常后Key被删除重试可以放行。并发100个完全相同的请求最终只有1个进入业务方法。Redis不可用时请求降级放行且不影响业务主流程。如果是分布式部署单测不够建议做一次真实的集群压测。我有一次就是在压测环境里才发现了SpEL参数名解析失败的问题——本地IDEA编译和Maven打包的字节码参数名保留策略不一样单测环境跑得好好的打出来的生产包一上线直接白屏。另外分享一个调试技巧给防重Redis Key统一加一个业务前缀比如nrs:和token:排查问题时可以直接用Redis客户端按前缀扫描所有防重Key快速看出当前系统里哪些接口被频繁拦截、Key的过期时间设置是否合理。有一次我就是从Key的数量分布里发现某个批量接口的过期时间设得异常大积累了上万条僵尸Key占内存清理之后Redis内存直接降了20%。防重复提交的方案本身不复杂但做好做精需要结合具体业务场景做取舍。希望这篇实战记录能帮你少走一些弯路。如果你在落地时遇到什么奇怪的坑欢迎照着我上面的排查思路拆一遍多数情况下问题都出在Key设计不合理或原子性没保住这两个点上。