
1. 为什么我会把 Codex 拉来当“反方辩友”重试这件事写过分布式系统的人都不陌生。接口超时了要不要重试消息队列消费失败了要不要重投支付回调没响应要不要再发一次表面上看重试就是个“再试一次”的简单动作但我踩过的坑告诉我重试是分布式系统里最容易埋雷的地方之一而且这些雷往往在流量低谷期不炸偏偏在高峰期炸得你措手不及。我最近在做一个订单履约相关的服务里面涉及支付回调、库存扣减、物流单推送这几条链路每一条都跟重试脱不开关系。团队里对重试的处理方式五花八门有人直接for循环重试三次有人用框架自带的 retry 注解有人干脆把失败消息丢进死信队列人工处理。代码能跑测试能过但我心里一直不踏实——这些重试逻辑到底安不安全幂等性到底有没有覆盖到所有分支后来我想了个办法把 Codex 拉进来当“反方”。具体做法是我把重试相关的代码片段、接口定义、数据库操作逻辑整理成一段上下文然后让 Codex 站在“ adversarial-review ”的角度专门挑重试场景下的幂等性漏洞。它不会顺着我的思路走而是会问一些很刁钻的问题比如“这个接口在第二次重试时如果第一次的写入已经成功但响应丢失了你的代码会怎么处理”这种问题人工 review 的时候很容易被忽略但 Codex 会一条一条给你列出来。这篇文章就是我把这套方法跑通之后的完整记录。我会讲清楚怎么让 Codex 扮演反方角色、怎么把重试风险拆成可验证的检查项、幂等性到底该用数据库还是 Redis 来实现以及沙箱环境里怎么复现那些“看起来不会发生”的边界情况。如果你也在写带重试逻辑的接口或者正在被幂等性问题折磨这篇内容应该能帮你省下不少排查时间。2. 把 Codex 当反方辩友的完整思路拆解2.1 为什么不让 Codex 直接写代码而是让它挑毛病很多人用 Codex 的习惯是让它补全代码、写函数、生成测试用例。这当然没问题但在重试和幂等性这个场景下让 Codex 直接写代码反而容易掩盖问题。原因很简单Codex 会顺着你给的上下文去补全逻辑它默认你的前提是对的。如果你在上下文里写了一个“先查再写”的幂等判断Codex 大概率会帮你把这个判断写得更漂亮但它不会主动质疑“这个先查再写在并发场景下是不是有竞态”。所以我换了个用法不给它完整的代码只给它接口契约、数据库表结构和重试策略描述然后明确要求它站在反方立场找出所有可能导致重复写入、状态不一致、数据错乱的场景。这个角色转换很关键Codex 从“帮手”变成“审查者”之后输出的内容质量完全不一样。我实际用的提示词结构大概是这样你是一个分布式系统的 adversarial reviewer。 下面是一个订单支付回调接口的契约描述和重试策略。 你的任务不是帮我写代码而是找出所有在重试场景下可能导致幂等性失效的场景。 对每个场景给出触发条件、影响范围、验证方法。 不要给出修复建议只负责找问题。最后一句“不要给出修复建议”是我特意加的。因为一旦让 Codex 给修复方案它就会开始“圆场”把问题描述得没那么严重。只让它找问题它反而会更狠。2.2 重试风险的三个层次接口层、存储层、业务层让 Codex 挑毛病之前我自己先把重试风险分了三层这样给它的上下文更有结构它输出的问题也更容易归类。接口层关注的是同一个请求被重复发送时服务端能不能识别出这是同一个请求。这里的关键是请求唯一标识的设计。很多团队用业务单号做唯一键但业务单号在有些场景下会重复使用比如退款单号可能跟原支付单号关联但不同。如果唯一键选错了幂等判断就会失效。存储层关注的是幂等判断和业务写入是不是在同一个原子操作里完成的。我见过最常见的错误写法是“先 select 判断是否存在如果不存在再 insert”。这个逻辑在单线程下没问题但在并发重试场景下两个请求可能同时 select 到“不存在”然后同时 insert结果就是两条记录。正确的做法要么用数据库唯一约束兜底要么用原子性的 insert ignore 或 upsert。业务层关注的是即使存储层挡住了重复写入业务状态机是不是还能保持正确。举个例子支付回调第一次处理时把订单状态从“待支付”改成“已支付”第二次重试进来时如果幂等判断只检查了“有没有处理过”但没有检查“当前状态是否允许再次处理”就可能出现状态回退或者重复触发下游动作。这三层拆完之后我把每一层的描述整理成结构化文本再交给 Codex 做 adversarial review。它输出的问题列表比我预想的要长而且有几个确实是我之前没考虑到的。2.3 沙箱环境在验证环节的角色光让 Codex 列出问题还不够问题必须可验证。我在文章标题里写了“问到可验证”意思就是每一个 Codex 提出的风险点我都要能在沙箱环境里复现出来。沙箱在这里的作用不是“模拟生产”而是提供一个可以随意制造并发、随意注入故障的环境。我在本地用 Docker 起了一个最小化的服务实例数据库用的是 PostgreSQL缓存用 Redis然后用一个简单的压测脚本模拟并发重试。具体做法是同一个请求 ID同时发 10 次观察数据库里最终有几条记录、业务状态是什么。这个沙箱环境不需要很复杂但有几个关键点必须满足第一数据库要开事务和唯一约束第二要能方便地查看每次请求的执行日志第三要能模拟网络超时和响应丢失。第三点最容易被忽略但恰恰是重试场景下最核心的故障模式——请求发出去了服务端也处理了但响应在返回路上丢了客户端以为失败就重试了。3. 核心细节解析幂等性到底该怎么设计3.1 幂等键的选择比幂等逻辑本身更重要很多人一上来就讨论“用数据库还是 Redis 做幂等”但我觉得幂等键的选择才是第一优先级。键选错了后面用什么存储都是白搭。我总结了一个简单的判断标准幂等键必须能唯一标识“一次业务意图”。注意是业务意图不是请求本身。比如用户点击“支付”按钮可能因为网络抖动发了两次请求这两次请求的意图是同一个所以幂等键应该用订单号或者支付单号。但如果用户先支付了一次后来又发起了一次新的支付比如补差价那这就是两个不同的业务意图幂等键必须能区分开。在实际项目里我见过几种常见的幂等键设计幂等键类型适用场景风险点业务单号支付、退款、下单单号生成规则变更时可能冲突请求 ID客户端生成通用接口重试客户端实现不一致可能重复生成数据库自增 ID内部服务调用跨服务传递时容易丢失业务单号 操作类型同一单号多种操作需要额外维护操作类型枚举我最后选的是业务单号 操作类型的组合然后在数据库里建唯一索引。这样即使同一个单号有支付和退款两个操作也不会互相干扰。3.2 数据库幂等和 Redis 幂等的真实差异“幂等性检查用 DB 实现好还是 Redis 实现好”这个问题在热搜里也出现了说明很多人都在纠结。我的结论是看你的幂等判断需不需要和业务写入在同一个事务里。如果幂等判断和业务写入必须原子完成那数据库是更自然的选择。比如支付回调场景我需要“插入一条支付记录”和“更新订单状态”在同一个事务里如果幂等判断用 Redis就变成了“先查 Redis再开数据库事务”这两步之间有一个时间窗口极端情况下还是可能重复。但如果幂等判断只是用来挡掉重复请求不需要和业务写入强绑定那 Redis 的性能优势就很明显。比如一些查询类接口或者通知类接口重复执行不会造成数据错乱只是浪费资源用 Redis 做一层快速过滤就够了。我实际的做法是两层结合Redis 做第一层快速过滤数据库唯一约束做第二层兜底。Redis 里存的是幂等键和过期时间请求进来先查 Redis如果已经处理过就直接返回如果没查到再走数据库逻辑数据库的唯一约束会保证即使 Redis 漏了也不会写入重复数据。这里有个细节要注意Redis 的过期时间不能设得太短。我一开始设了 5 分钟结果发现有些重试间隔超过 5 分钟的场景Redis 里的键已经过期了请求又穿透到数据库。后来改成 24 小时配合数据库唯一约束才算是稳住了。3.3 重试策略本身也需要被审查让 Codex 当反方的时候我特意让它审查了重试策略本身。它提出的几个问题让我印象很深第一个问题是重试次数和重试间隔的设计。如果重试间隔是固定的比如每 1 秒重试一次那在服务端处理慢的情况下重试请求会堆积反而加重服务端负担。正确的做法是指数退避 抖动比如第一次等 1 秒第二次等 2 秒第三次等 4 秒每次再加一个随机抖动避免大量请求同时重试。第二个问题是重试的终止条件。很多代码只判断了“重试次数有没有用完”但没有判断“这个错误是不是可重试的”。比如参数校验失败、权限不足这类错误重试多少次都不会成功反而浪费资源。我在代码里加了一个错误分类判断只有网络超时、服务端 5xx 这类错误才触发重试。第三个问题是重试的幂等性传递。如果 A 服务调用 B 服务B 服务调用 C 服务A 的重试请求到了 BB 又去调用 C那 C 收到的请求是不是同一个幂等键如果 B 在转发时重新生成了请求 ID那 C 的幂等判断就失效了。这个问题在链路比较长的时候特别隐蔽我是让 Codex 把调用链画出来之后才发现的。4. 实操过程从 Codex 审查到沙箱验证的完整闭环4.1 准备给 Codex 的上下文材料让 Codex 做 adversarial review上下文材料准备得好不好直接决定它能不能挑出真问题。我准备的材料包括四部分第一部分是接口契约。我用 OpenAPI 格式写了支付回调接口的定义包括请求方法、路径、请求体字段、响应码。重点是标注了哪些字段是幂等键哪些字段是业务数据。第二部分是数据库表结构。我把支付记录表和订单表的 DDL 贴进去特别标注了唯一索引和事务边界。这里有个技巧把索引信息单独列出来因为 Codex 在判断幂等性时会重点看有没有唯一约束兜底。第三部分是重试策略描述。我用自然语言写了重试的触发条件、重试次数、重试间隔、终止条件。这部分不需要很精确但要把意图说清楚。第四部分是已知的边界场景。我把之前测试时遇到过的一些奇怪情况列出来比如“响应超时但实际处理成功”“重复请求间隔超过缓存过期时间”等让 Codex 在这些场景基础上继续扩展。材料准备好之后我把它们整理成一段结构化的文本加上前面说的 adversarial reviewer 提示词一起发给 Codex。它返回的问题列表大概有十几条我筛选出其中跟幂等性直接相关的整理成了一个检查清单。4.2 用沙箱复现 Codex 提出的风险点Codex 提出的风险点里有一个我觉得特别值得验证“如果第一次请求写入成功但响应丢失第二次重试进来时幂等判断查到了记录但业务状态已经变了代码会不会错误地跳过处理”这个问题的核心是幂等判断不能只判断“有没有处理过”还要判断“当前状态是否允许处理”。我在沙箱里写了一个测试用例来复现# 模拟第一次请求写入支付记录更新订单状态为已支付 def handle_payment_callback(order_id, payment_id): # 幂等判断查支付记录是否存在 existing db.query(SELECT * FROM payment_records WHERE payment_id %s, payment_id) if existing: return {status: already_processed} # 业务处理 db.execute(INSERT INTO payment_records ...) db.execute(UPDATE orders SET status paid WHERE order_id %s, order_id) return {status: success}这个代码在单次请求下没问题但如果第一次请求写入成功、响应丢失第二次重试进来时existing会查到记录直接返回already_processed。看起来没问题但如果业务上需要“即使已经处理过也要返回当前订单状态”呢那这个返回值就不够了。我在沙箱里模拟了这个场景先手动插入一条支付记录然后调用接口观察返回值。结果确实如 Codex 所说接口只返回了already_processed没有返回订单当前状态。后来我改成返回完整的订单信息调用方才能正确判断后续动作。4.3 并发重试的压测验证除了单个风险点的验证我还做了一轮并发压测。具体做法是用 Python 的concurrent.futures起 20 个线程同时调用同一个支付回调接口请求体里的幂等键完全相同。然后观察数据库里最终有几条支付记录、订单状态是什么。第一次跑的时候数据库里出现了两条支付记录。原因是我的幂等判断是“先查再写”两个线程同时查到了“不存在”然后同时插入。虽然我建了唯一索引但插入时抛了异常代码没有正确处理这个异常导致其中一个线程返回了 500 错误。后来我改成了INSERT ... ON CONFLICT DO NOTHING配合事务才把并发场景下的重复写入彻底挡住。这个改动很小但如果没有沙箱压测我可能一直以为“先查再写”是安全的。这里有个经验唯一索引不是万能的代码必须正确处理唯一约束冲突。我见过一些代码唯一索引建了但插入冲突时直接抛异常调用方收到 500 之后又重试陷入死循环。正确的做法是捕获唯一约束冲突然后当作“已经处理过”来处理。4.4 把验证结果反哺给 Codex 做二次审查沙箱验证完之后我把验证结果整理成一段简短的报告再次发给 Codex让它基于这些实际结果做二次审查。这次它的输出更有针对性因为它知道了哪些问题是真实存在的哪些是理论上的。二次审查里Codex 提出了一个我之前没注意到的点Redis 幂等键的过期时间应该和业务数据的保留时间对齐。比如支付记录在数据库里保留 90 天那 Redis 里的幂等键至少也要保留 90 天否则 90 天后的重试请求会穿透到数据库。虽然 90 天后的重试概率极低但从设计上应该保持一致。这个建议我采纳了把 Redis 过期时间改成了和数据库保留时间一致。虽然会增加一些 Redis 内存占用但换来的是设计上的一致性我觉得值得。5. 常见问题与排查技巧实录5.1 重试场景下的典型问题速查表在实际操作过程中我整理了一份常见问题速查表覆盖了大部分重试和幂等性相关的坑问题现象可能原因排查方法解决思路数据库出现重复记录先查再写有竞态查唯一索引是否存在加唯一约束 捕获冲突重试后业务状态回退幂等判断没检查状态查状态机流转日志幂等判断加状态校验Redis 幂等键失效过期时间太短查 Redis TTL对齐业务数据保留时间重试请求堆积重试间隔固定查重试日志时间分布指数退避 抖动不可重试错误被重试错误分类缺失查错误码分布加错误分类判断跨服务幂等键丢失转发时重新生成 ID查调用链日志透传幂等键这张表是我踩坑之后总结的每一条都对应一个实际发生过的问题。特别是“跨服务幂等键丢失”这一条隐蔽性很强因为单看每个服务的日志都正常只有把调用链串起来才能发现。5.2 让 Codex 审查时的三个实用技巧用 Codex 做 adversarial review 有一段时间了我总结了三个让输出质量更高的技巧第一个技巧是限制它的输出范围。一开始我让它“找出所有问题”它返回的内容很泛有些跟幂等性无关。后来我改成“只找出重试场景下可能导致数据不一致的问题”输出就精准多了。第二个技巧是要求它给出验证方法。光有问题描述不够我会要求它对每个问题给出“如何在沙箱里复现”的步骤。这样我拿到问题列表之后可以直接照着步骤去验证不用自己再想怎么复现。第三个技巧是分轮次审查。不要一次性把所有材料都给它而是先给接口契约和重试策略让它提一轮问题然后给数据库表结构让它再提一轮最后给沙箱验证结果让它做第三轮。分轮次的好处是每一轮它都能聚焦在当前材料上不会因为信息太多而漏掉细节。5.3 沙箱环境搭建的注意事项沙箱环境不需要很复杂但有几个点必须注意第一数据库要和生产用同一种。我一开始用 SQLite 做沙箱结果发现 SQLite 的唯一约束行为和 PostgreSQL 不完全一样有些在 SQLite 下能过的测试在 PostgreSQL 下会失败。后来统一用 PostgreSQL才避免了这个问题。第二要能方便地注入延迟和故障。我在沙箱里加了一个开关可以通过环境变量控制是否模拟响应丢失。这样测试重试逻辑时可以精确控制故障发生的时机。第三日志要足够详细。每次请求进来我都会记录幂等键、请求时间、处理结果、数据库操作耗时。这些日志在排查并发问题时特别有用可以清楚地看到两个请求的时间线。第四压测脚本要能控制并发数。我用的是 Python 的ThreadPoolExecutor可以方便地调整并发线程数。从 2 个线程到 50 个线程都跑过观察不同并发下的表现。5.4 幂等性设计的几个反直觉经验最后分享几个我在实际操作中总结的反直觉经验经验一幂等判断越简单越好。我一开始设计了一个很复杂的幂等判断要查三张表、判断五种状态。后来发现复杂的判断逻辑本身就容易出 bug而且性能也差。最后简化成“查唯一键是否存在”配合数据库唯一约束反而更稳。经验二不要依赖客户端的重试次数。有些团队觉得“客户端最多重试三次所以服务端不用做太严格的幂等”。这个想法很危险因为客户端的实现不可控而且网络层的重试比如负载均衡器的重试客户端根本感知不到。服务端必须假设请求可能被重复发送任意次。经验三幂等键的生成规则要文档化。我见过因为幂等键生成规则变更导致线上事故的案例。比如原来用订单号做幂等键后来改成用订单号加时间戳结果同一个订单的两次请求被当成两个不同的请求处理了。幂等键的生成规则一旦确定就要写进接口文档变更时要评估影响。经验四重试日志要能关联到原始请求。排查重试问题时最怕的就是日志里只有重试请求的记录找不到原始请求。我在日志里加了trace_id每次重试都带上原始请求的trace_id这样排查时可以把整条链路串起来。这套方法我跑了两周多从最初的“让 Codex 随便看看”到后来形成固定的审查流程中间也走了不少弯路。最大的体会是重试和幂等性这件事靠人工 review 很难覆盖全但靠 Codex 做反方审查再配合沙箱验证就能把大部分风险提前暴露出来。如果你也在做类似的事情建议先从一个小接口开始试把流程跑通之后再推广到核心链路。