AI 建议用 Redis `SETNX` 防重复提交,为什么锁过期后仍可能创建两条记录
很多接口刚上线时,都会遇到“重复提交”问题。
用户点击一次提交按钮,但由于网络慢、页面卡顿或响应迟迟没有返回,又点击了一次;也可能是调用方没有收到响应后自动重试,或者网关因为超时再次转发同一个请求。于是同一笔业务动作可能被执行两次:
请求 A:提交成功,但响应超时 ↓ 调用方认为失败 ↓ 请求 B:携带相同业务内容再次提交 ↓ 系统再次创建记录面对这种情况,AI 很容易给出一个常见方案:
publicvoidsubmit(SubmitCommandcommand){StringlockKey="submit:"+command.getRequestId();Booleanlocked=stringRedisTemplate.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 锁解决的是一小段时间内的并发互斥;业务幂等解决的是同一件事无论提交多少次,最终都只能产生一个确定结果。两件事相关,但并不相同。
一、最常见的误区:把“抢到锁”当成“业务只会执行一次”
先看下面的代码:
Booleanlocked=stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"1",Duration.ofSeconds(5));它能表达的是:在未来 5 秒内,其他请求暂时不能拿到这个键。它不能保证这次业务处理一定会在 5 秒内完成。
假设一次提交会经过参数校验、写入数据库、更新关联记录、写入审计信息、触发后续任务、返回结果。平时可能只需要 300 毫秒,但某次数据库出现锁等待,整体执行耗时变成了 8 秒:
T1:请求 A 抢到锁,锁有效期 5 秒 T2:请求 A 进入数据库处理 T3:数据库锁等待,耗时拉长 T4:5 秒过去,Redis 锁自动过期 T5:请求 B 到达,成功拿到同一个锁 T6:请求 B 也开始创建记录 T7:请求 A 恢复执行,继续提交最终可能得到两条记录。这里并不是 Redis 出错,而是它按照你设置的 5 秒过期时间正常工作了。真正的问题是:把“锁的有效时间”误当成了“业务操作完成时间”。
二、锁过期后,最危险的不是重复进入,而是旧请求误删新锁
很多人发现锁过期风险后,会想到把锁时间调长到 60 秒。这可能降低概率,但不能从根本上解决问题。
更隐蔽的风险出现在这里:
finally{stringRedisTemplate.delete(lockKey);}请求 A 拿到锁,执行很慢;锁过期后,请求 B 拿到同一个锁;请求 A 恢复执行后进入 finally,删除的可能已经不是自己的锁,而是请求 B 刚刚创建的新锁。请求 C 随后到达时,就可能再次进入。
因此,分布式锁至少要有“归属标识”:
Stringtoken=UUID.randomUUID().toString();Booleanlocked=stringRedisTemplate.opsForValue().setIfAbsent(lockKey,token,Duration.ofSeconds(30));删除时不能直接删键,而要确认锁仍属于当前请求。比较和删除需要原子执行,例如用 Lua 脚本:
ifredis.call("get",KEYS[1])==ARGV[1]thenreturnredis.call("del",KEYS[1])endreturn0但即使做到了“只删自己的锁”,它仍然只是让锁机制更正确。它没有回答:请求 A 是否已经创建成功?请求 B 到达时应该拒绝、等待、返回处理中,还是返回 A 的处理结果?这才是业务幂等真正需要回答的问题。
三、重复提交的关键不是锁,而是稳定的业务请求标识
很多接口错误地使用随机值:
StringlockKey="submit:"+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){IdempotencyRecordrecord=idempotencyRepository.createIfAbsent(command.requestId(),"SUBMIT_APPLICATION",command.subjectId());if(!record.isNewlyCreated()){returnresolveExistingResult(record);}try{ApplicationEntityentity=ApplicationEntity.create(command);applicationRepository.save(entity);SubmitResultresult=SubmitResult.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{SubmitCommandcommand=newSubmitCommand("req-10001","subject-1001","example");ExecutorServiceexecutor=Executors.newFixedThreadPool(2);Future<SubmitResult>first=executor.submit(()->submitService.submit(command));Future<SubmitResult>second=executor.submit(()->submitService.submit(command));SubmitResultfirstResult=first.get();SubmitResultsecondResult=second.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 锁、幂等表、唯一索引、状态机和测试案例。但真正需要工程设计决定的是:你想防止的是“同一时刻有两个人进来”,还是“同一件业务无论被提交多少次,最终只产生一个确定结果”。前者是互斥,后者才是幂等。
可靠的接口设计,不是让重复请求都被拒绝,而是让重复请求在任何异常边界下,都不会把同一件事做成两次。
