AI 建议用 Redis `SETNX` 防重复提交,为什么锁过期后仍可能创建两条记录

📅 2026/7/1 2:36:39
AI 建议用 Redis `SETNX` 防重复提交,为什么锁过期后仍可能创建两条记录
很多接口刚上线时都会遇到“重复提交”问题。用户点击一次提交按钮但由于网络慢、页面卡顿或响应迟迟没有返回又点击了一次也可能是调用方没有收到响应后自动重试或者网关因为超时再次转发同一个请求。于是同一笔业务动作可能被执行两次请求 A提交成功但响应超时 ↓ 调用方认为失败 ↓ 请求 B携带相同业务内容再次提交 ↓ 系统再次创建记录面对这种情况AI 很容易给出一个常见方案publicvoidsubmit(SubmitCommandcommand){StringlockKeysubmit:command.getRequestId();BooleanlockedstringRedisTemplate.opsForValue().setIfAbsent(lockKey,1,Duration.ofSeconds(5));if(!Boolean.TRUE.equals(locked)){thrownewBusinessException(请勿重复提交);}try{applicationRepository.save(ApplicationEntity.create(command));}finally{stringRedisTemplate.delete(lockKey);}}这段代码的思路看起来很合理先抢 Redis 锁抢到才执行执行完删除锁后续请求无法重复进入。本地测试时它通常也能工作。但真实环境里一旦业务处理时间变长、数据库短暂阻塞、应用实例重启、锁过期、网络抖动或调用方重试时重复记录还是可能出现。最关键的原因是Redis 锁解决的是一小段时间内的并发互斥业务幂等解决的是同一件事无论提交多少次最终都只能产生一个确定结果。两件事相关但并不相同。一、最常见的误区把“抢到锁”当成“业务只会执行一次”先看下面的代码BooleanlockedstringRedisTemplate.opsForValue().setIfAbsent(lockKey,1,Duration.ofSeconds(5));它能表达的是在未来 5 秒内其他请求暂时不能拿到这个键。它不能保证这次业务处理一定会在 5 秒内完成。假设一次提交会经过参数校验、写入数据库、更新关联记录、写入审计信息、触发后续任务、返回结果。平时可能只需要 300 毫秒但某次数据库出现锁等待整体执行耗时变成了 8 秒T1请求 A 抢到锁锁有效期 5 秒 T2请求 A 进入数据库处理 T3数据库锁等待耗时拉长 T45 秒过去Redis 锁自动过期 T5请求 B 到达成功拿到同一个锁 T6请求 B 也开始创建记录 T7请求 A 恢复执行继续提交最终可能得到两条记录。这里并不是 Redis 出错而是它按照你设置的 5 秒过期时间正常工作了。真正的问题是把“锁的有效时间”误当成了“业务操作完成时间”。二、锁过期后最危险的不是重复进入而是旧请求误删新锁很多人发现锁过期风险后会想到把锁时间调长到 60 秒。这可能降低概率但不能从根本上解决问题。更隐蔽的风险出现在这里finally{stringRedisTemplate.delete(lockKey);}请求 A 拿到锁执行很慢锁过期后请求 B 拿到同一个锁请求 A 恢复执行后进入 finally删除的可能已经不是自己的锁而是请求 B 刚刚创建的新锁。请求 C 随后到达时就可能再次进入。因此分布式锁至少要有“归属标识”StringtokenUUID.randomUUID().toString();BooleanlockedstringRedisTemplate.opsForValue().setIfAbsent(lockKey,token,Duration.ofSeconds(30));删除时不能直接删键而要确认锁仍属于当前请求。比较和删除需要原子执行例如用 Lua 脚本ifredis.call(get,KEYS[1])ARGV[1]thenreturnredis.call(del,KEYS[1])endreturn0但即使做到了“只删自己的锁”它仍然只是让锁机制更正确。它没有回答请求 A 是否已经创建成功请求 B 到达时应该拒绝、等待、返回处理中还是返回 A 的处理结果这才是业务幂等真正需要回答的问题。三、重复提交的关键不是锁而是稳定的业务请求标识很多接口错误地使用随机值StringlockKeysubmit:UUID.randomUUID();每次请求都会生成不同键自然无法识别“同一件事情又提交了一次”。更可靠的做法是使用稳定的幂等标识requestId、clientRequestId、applicationNo、businessKey或外部参考号。最重要的要求是同一次业务意图的重试必须带同一个幂等键。首次提交{requestId:req_9f3a1d,subjectId:subject_1001,content:...}网络超时再次提交时仍然要带req_9f3a1d。如果重试时重新生成 requestId后端只能看到两次不同请求无法判断它们是两次独立操作还是一次超时后的重试。幂等键应该表达业务语义而不是只为了“让 Redis 有个键可用”。四、Redis 锁可以做前置保护但最终结果必须落到持久化记录一个更可靠的设计通常会把请求状态保存下来。示例幂等记录表CREATETABLEidempotency_record(idBIGINTPRIMARYKEYAUTO_INCREMENT,request_idVARCHAR(128)NOTNULL,action_typeVARCHAR(64)NOTNULL,subject_idVARCHAR(128)NOTNULL,statusVARCHAR(32)NOTNULL,result_jsonTEXTNULL,error_codeVARCHAR(64)NULL,created_atDATETIMENOTNULL,updated_atDATETIMENOTNULL,UNIQUEKEYuk_request_action(request_id,action_type));它的核心不是表字段本身而是把“这次业务请求是否已经处理过”变成一条可查询、可恢复、可解释的事实。处理流程可以变成收到请求 ↓ 尝试创建幂等记录 ↓ 创建成功当前请求首次进入 ↓ 创建失败说明同一个 requestId 已经存在 ↓ 读取已有状态 ↓ 返回成功结果、处理中状态或可解释失败结果示例TransactionalpublicSubmitResultsubmit(SubmitCommandcommand){IdempotencyRecordrecordidempotencyRepository.createIfAbsent(command.requestId(),SUBMIT_APPLICATION,command.subjectId());if(!record.isNewlyCreated()){returnresolveExistingResult(record);}try{ApplicationEntityentityApplicationEntity.create(command);applicationRepository.save(entity);SubmitResultresultSubmitResult.success(entity.getId());record.markSucceeded(serialize(result));returnresult;}catch(BusinessExceptione){record.markFailed(e.getCode());throwe;}}这里三者职责不同Redis 锁用于降低同一时刻的并发撞击数据库唯一约束用于阻止同一幂等键被创建两次幂等记录用于保存处理结果与状态。不能只依赖其中一个。五、不要把“处理中”简单当成“失败”请求 A 进入后可能还没有完成请求 B 因网络重试带着同一个 requestId 到达。此时数据库里可能已经有status PROCESSING很多系统会直接返回“重复请求”但这对调用方不够准确。请求 B 可能只是没有收到请求 A 的响应。更清晰的状态可以是状态含义重试请求处理PROCESSING请求正在执行或结果尚未确认返回处理中提示稍后按同一 ID 查询SUCCEEDED请求已经完成返回原始成功结果FAILED_FINAL业务校验失败不适合自动重试返回可解释失败原因FAILED_RETRYABLE临时异常可在规则允许时重试返回可重试状态或进入恢复流程UNKNOWN执行中断结果不明确进入核对或补偿流程尤其要注意 UNKNOWN请求已经写入数据库应用在返回前崩溃幂等记录还没来得及标记成功。再次收到同一请求时不能简单地重新执行因为第一次可能已经成功。应该根据业务主记录、唯一约束和审计状态进行核对。六、让 AI 先区分互斥、幂等和结果复用而不是直接补一段 Redis 锁代码如果只问 AI“接口被重复提交了帮我加 Redis 锁”它很可能给出 SETNX、过期时间和 finally delete。这在某些短任务里可以作为保护但容易漏掉锁过期、锁归属、结果复用、应用重启恢复、唯一约束、稳定请求标识和异常状态核验。更有效的提示方式你是 Java 接口幂等与重复提交评审助手。 场景调用方在网络超时后可能重复提交同一个请求系统有多个应用实例部分业务步骤可能因为数据库锁等待而执行超过 Redis 锁 TTL应用异常重启后需要能判断此前请求是否已经成功。 请不要只给 Redis SETNX 代码。 请输出 1. 同一业务请求的幂等键如何设计 2. Redis 锁、数据库唯一约束和幂等记录分别承担什么职责 3. 锁过期、锁归属和安全释放如何处理 4. PROCESSING、SUCCEEDED、FAILED、UNKNOWN 状态如何设计 5. 超时重试时如何返回原始结果 6. 进程崩溃后如何恢复不确定状态 7. 哪些操作可以重试哪些必须拒绝或人工确认 8. 至少 10 个并发、超时、重启和重复提交测试场景。对已经把 ChatGPT Plus、GPT Plus 用在代码评审、状态机梳理、异常路径检查和测试清单整理中的开发者来说AI 工具长期使用的价值不在于快速生成一个防重复片段而在于形成稳定判断一次请求的锁可以过期但同一件业务的结果必须能长期被识别、复用和追溯。对已经确认有 AI 工具长期使用需求的开发者来说工具准备不只是模型能力还包括使用周期、说明理解、边界意识和异常处理路径相关信息可按实际需要参考gpt985com七、数据库唯一约束才是最后一道不可绕过的防线即使应用层做了 Redis 锁和幂等记录也不应该假设所有写入都会经过同一段代码。真实系统里可能还有后台管理入口、消息消费任务、补偿任务、导入任务、数据修复脚本和其他内部调用链。关键业务对象通常还需要数据库唯一约束CREATETABLEapplication_record(idBIGINTPRIMARYKEYAUTO_INCREMENT,subject_idVARCHAR(128)NOTNULL,request_idVARCHAR(128)NOTNULL,statusVARCHAR(32)NOTNULL,created_atDATETIMENOTNULL,UNIQUEKEYuk_subject_request(subject_id,request_id));这样即使两条请求在极端条件下同时越过了应用层保护数据库仍会阻止同一业务标识被写入两次。唯一约束不负责告诉调用方“第一次的结果是什么”它只能阻止重复落库所以仍需要与幂等记录、结果查询和状态恢复一起使用。八、至少覆盖这些测试场景测试场景预期结果同一幂等键连续提交两次第二次返回第一次结果或处理中状态两个实例并发处理同一幂等键只有一次业务写入成功业务执行超过 Redis TTL不会因锁过期产生两条记录旧请求恢复后释放锁不会删除新请求持有的锁客户端超时但后端已成功重试可得到原始成功结果应用在提交后崩溃后续请求可核对并恢复最终状态数据库唯一冲突不会产生重复业务主记录Redis 暂时不可用按预案失败或降级不静默放开重复写入同一业务不同 requestId按业务规则判断是否允许创建多次幂等记录长期 PROCESSING被监控发现并进入人工核验或恢复流程示例并发测试TestvoidshouldCreateOnlyOneRecordForSameRequestId()throwsException{SubmitCommandcommandnewSubmitCommand(req-10001,subject-1001,example);ExecutorServiceexecutorExecutors.newFixedThreadPool(2);FutureSubmitResultfirstexecutor.submit(()-submitService.submit(command));FutureSubmitResultsecondexecutor.submit(()-submitService.submit(command));SubmitResultfirstResultfirst.get();SubmitResultsecondResultsecond.get();assertEquals(firstResult.applicationId(),secondResult.applicationId());assertEquals(1,applicationRepository.countByRequestId(req-10001));}九、上线后要观察什么建议至少记录idempotency_new_request_total idempotency_reused_result_total idempotency_processing_hit_total idempotency_unknown_state_total idempotency_record_stuck_total duplicate_business_constraint_total redis_lock_expired_suspected_total redis_lock_release_mismatch_total request_timeout_after_success_total重点观察哪些接口重复请求比例高、哪些业务长期停留在 PROCESSING、是否有数据库唯一约束冲突、Redis 锁过期是否集中在高耗时流程、调用方超时重试是否增加以及补偿流程是否长期未处理。十、结语Redis SETNX 很有用适合降低短时间并发竞争、削峰或保护临界操作。但它不是业务幂等的全部答案。真正可靠的重复提交治理需要明确什么字段能稳定代表同一次业务意图锁过期时是否仍可能有旧请求执行锁释放时如何确认归属请求重试时应该返回新结果、旧结果还是处理中状态应用崩溃后如何判断此前是否已成功数据库如何阻止最终重复落库哪些状态可以自动恢复哪些状态必须人工确认每一次重复请求是否都能被解释和追溯。AI 可以帮助你补齐 Redis 锁、幂等表、唯一索引、状态机和测试案例。但真正需要工程设计决定的是你想防止的是“同一时刻有两个人进来”还是“同一件业务无论被提交多少次最终只产生一个确定结果”。前者是互斥后者才是幂等。可靠的接口设计不是让重复请求都被拒绝而是让重复请求在任何异常边界下都不会把同一件事做成两次。