重试之后发现同一件事做了两遍,幂等键该怎么设计
数据截至 2026-07,各平台的重试语义、错误码口径与幂等参数命名以官方最新说明为准。
同一件事被做了两遍,八成不是重试次数配多了,而是你的写操作压根没有幂等键,同时把「超时」当成了「失败」。 超时只说明你没拿到回执,不说明对面没干活。请求已经落到对方的数据库、消息已经发出去、外部工单已经建好,只是响应在回程路上被掐断——这时候你的重试逻辑一脚油门踩下去,第二份结果就诞生了。把重试次数从 3 改成 1,只是把出错概率调低,没有改变机制;哪天网络抖一下,它还会再来一次。
站内已有两篇相邻的文章,分工要说清楚:Agent 失败重试 讲的是调用方该不该重试、怎么退避、重试预算怎么控;API 超时中断 讲的是连接层面怎么定位断在哪一段。本篇只管另一侧的一件事——被调用的那一方,怎么让第二次、第三次调用不再产生第二份、第三份结果。
一、先把「重复」拆成三种,成因完全不同
排查顺序的第一步不是改代码,是先判断你遇到的是哪一类重复。三类的成因和修法差别很大,混在一起修必然反复。
第一类,调用方重复发起。 最常见。HTTP 客户端超时后自动重试、agent 循环里的错误分支重新调一次工具、用户在界面上多点了一下提交、前端和后端各自都做了一层重试。特征是:两次请求的业务参数完全一致,时间间隔接近你配置的退避间隔,或者干脆是几百毫秒内的连击。
第二类,服务端重复执行。 队列是 at-least-once 投递、消费者处理完还没 ack 就被重启、定时任务在多实例上都触发了、消息中间件做了 rebalance 导致分区被重新分配。特征是:调用方只发了一次,但你在服务端日志里能看到同一条消息 ID 被消费多次,两次消费的间隔通常接近你给这条队列配的可见性超时或消费者心跳超时——具体数值去队列配置里对,别凭印象猜。
第三类,看起来重复其实是两个合法意图。 用户确实想连着下两笔一样的单,或者两个终端窗口同时在跑同一个任务。这一类如果你按重复处理掉,就是把正常业务吞了。判断依据只有一个:业务上这两次操作是不是同一个意图。这也是后面为什么坚决反对用请求体哈希当幂等键的原因。
第一类靠幂等键解决,第二类靠消费端去重加状态机解决,第三类靠让调用方显式携带意图标识解决。先分类,再动手。
二、判别表:现象、成因、验证方法、处置动作
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 客户端收到超时或 ECONNRESET,但业务侧已生效 | 请求已到达并执行,响应在回程丢失,重试又执行一遍 | 用业务唯一键去服务端查记录数,看创建时间是否落在超时那一刻之前 | 写操作加幂等键;超时后先查询再决定是否重试 |
| 两条记录内容一模一样,创建时间相差几百毫秒 | 前端重复提交或双层重试叠加 | 看接入日志同一 trace 下的请求条数,以及 User-Agent 与来源 IP 是否一致 | 提交按钮置灰只是缓解,真正的修法是服务端唯一约束 |
| 两条记录相差几十秒到几分钟,间隔规律 | 退避重试或队列可见性超时后重投 | 对齐重试间隔配置和队列的重投间隔,看是否吻合 | 消费端按消息 ID 去重,处理与 ack 之间的窗口要收紧 |
| 只有多实例部署后才出现重复 | 定时任务或调度器在每个实例上都跑了 | 停掉一个实例观察是否消失,或在日志里打实例 ID | 调度加分布式锁或改成单点触发,业务侧仍需幂等兜底 |
| 加了幂等键仍然重复 | 每次重试重新生成了新的键 | 在日志里 grep 同一 trace 下携带的键值,看是不是每次都不同 | 键在进入重试循环之前生成一次,整个重试周期复用 |
| 幂等命中率忽高忽低 | 幂等记录过期太早,或存储层做了多副本弱一致读 | 对比重复请求的时间跨度与记录保留窗口;查询走主库再试一次 | 保留窗口长于调用方最大重试窗口;幂等查询走强一致读 |
| 网关返回 502 / 504 之后重试,结果做了两遍 | 网关这一跳超时了,但请求早已透传到后端并执行完成,错误码只代表网关没等到回复 | 拿同一个 trace ID 去后端应用日志里查有没有对应的处理记录,有记录就说明业务已执行 | 5xx(尤其 502/504)只有在带幂等键或能先查状态时才允许重试 |
| 被限流后重试,反而多出一份结果 | 429 本身通常是网关直接拒绝、请求没到后端,多出来的那份多半另有来源(双层重试或队列重投) | 先确认后端日志里 429 这次到底有没有处理记录:没有就说明重复不是 429 造成的,回到前几行重新定位 | 429 读 Retry-After 后退避重试即可;重复的真因按前几行的验证方法另查 |
| 返回 4xx(401/403/422 等)之后仍在原样重试 | 重试条件写成了「非 2xx 都重试」 | 看重试分支的判断条件,再看重试的请求和第一次是不是逐字节相同 | 除 408 与 429 外的 4xx,原样重发不会变成功;401 若是凭证过期,要先刷新凭证再发一次(这已经是一个新请求,不算重试) |
这张表的用法:先从左边找到最像的现象,按第三列验证一遍再动手。跳过验证直接按第四列改,很容易改错对象——比如明明是队列重投,却去改客户端退避。
三、幂等键怎么设计
幂等键的本质是:调用方声明「这几次调用是同一个意图」,服务端凭这个声明只执行一次并回放同一份结果。设计上有六个点,每个都有人踩过。
谁生成。 必须是调用方生成。服务端生成的键在超时场景里根本传不回来,等于没有。HTTP 上常见的做法是放在请求头里:
curl -X POST https://api.example.com/v1/orders \
-H "Idempotency-Key: 8f3a1c2e-5b7d-4a9f-9c1e-2d6b4a8f0c31" \
-H "Content-Type: application/json" \
-d '{"sku":"A-100","qty":2}'
什么时候生成。 在进入重试循环之前生成一次,循环内部只读不改。这是最高频的错误:把生成语句写在了重试函数体里面,每次重试拿到新的 UUID,幂等形同虚设。
import uuid
key = str(uuid.uuid4()) # 循环之外,只生成一次
for attempt in range(3):
resp = post_order(payload, idempotency_key=key)
if resp.ok:
break
键的内容。 优先用业务自然键(订单号、任务 ID、外部系统的单据号),没有自然键就用调用方生成的随机 UUID。不要用请求体的哈希做唯一依据——用户完全可以合法地连下两笔完全一样的单,哈希去重会把第二笔正常业务吞掉,而且这种问题在测试环境几乎复现不出来,上线后才会以「客户说钱扣了单没了」的形式暴露。
键的作用域。 按「租户 + 接口 + 业务域」组合,不要用一个全局命名空间。全局命名空间的问题是跨租户撞键,虽然概率低,但撞上一次就是把 A 的响应回放给了 B,属于数据泄露级别的事故。
执行顺序。 先占位,再执行,最后回填结果。顺序反了就等于没做:如果先执行业务再写幂等记录,两个并发请求会同时通过检查。占位靠数据库唯一索引或者原子写入:
CREATE UNIQUE INDEX uk_idem ON idempotency_record (tenant_id, endpoint, idem_key);
插入成功说明你抢到了执行权,插入报唯一键冲突说明别人已经在做或做完了。状态机至少三态:pending、succeeded、failed。命中 succeeded 就回放当初存下来的响应体和状态码;命中 pending 说明前一次还在跑,返回 409 让调用方稍后再查,不要直接执行;命中 failed 要区分是业务失败(回放失败结果)还是系统异常(允许重新执行)。
保留窗口与参数校验。 幂等记录的保留时间必须长于调用方可能的最大重试跨度,否则记录先过期、重试后到,照样重复。另外要校验同一个键对应的请求参数是否一致,不一致直接拒绝——这说明调用方复用错了键,静默执行比报错危险得多。
顺带一句:回放响应时把当初的响应体原样存下来。有些实现命中重复就返回 200 空体,调用方拿不到订单号,只好再发一次,反而制造了新的重复。这类响应结构上的坑,API 返回结构变化 里那套「按契约校验而不是按字段猜」的思路同样适用。
四、哪些操作重试前必须先查
不是所有操作都能加幂等键。第三方接口不支持、老系统改不动、命令行工具没有这个概念——这些场景下唯一安全的做法是:重试之前先查一次当前状态,确认没做过再做。
判断标准很简单:这个操作重复执行一次,结果会不会变。 覆盖式写入(PUT 整体替换)、删除到不存在、设置某个字段为固定值,这些天然幂等,重试无所谓。下面这些不是,重试前必须先查:
- 资金与配额类:扣款、转账、退款、积分变动、余额扣减。这类没有商量余地,查不到确切状态就停下来人工确认。
- 对外发送类:邮件、短信、群机器人通知、Webhook 推送。重复发送不会损坏数据,但会损坏信任,收件方连收三条一样的告警之后就不再看告警了。
- 资源创建类:建工单、建仓库、开分支、提 PR、创建云资源。查询方式通常是按名称或外部引用 ID 列一遍。
- 累加与追加类:计数器自增、日志追加写、列表 append、消息入队。这类最阴险,因为每次都「成功」,只是数字悄悄多了。
- 触发外部流程类:发起构建、触发部署、启动一个长任务。重试前查任务列表里有没有同参数的在跑。
- 版本控制类:打 tag、push 一个新提交。git 本身对同一个提交的 push 是幂等的,但「生成一个新提交再 push」这个组合动作不是。重试前用
git log --oneline -5或git ls-remote --tags origin看一眼当前状态,比盲目重跑安全。
查询本身也有两个坑。一是查询走了只读副本,副本还没同步到刚写入的数据,查出来「没有」,于是又做了一遍——涉及幂等判断的查询必须走强一致读。二是查询条件用的不是调用方能控制的标识,比如按创建时间查,时间戳有误差就查不准;正确做法是让创建请求带上你自己生成的外部引用 ID,查询时按这个 ID 查。
对 AI agent 场景,这件事要前移到工具定义上。给模型的工具应该在描述里明确区分只读和有副作用,所有有副作用的工具都接受一个幂等参数,并且在返回值里明确告诉调用方「这次是新建的还是命中了已有记录」。模型看到「已存在,未重复创建」这样的返回,才不会在下一轮继续折腾。工具接口的边界怎么划,Agent 工具设计 里那套「工具要自解释、返回要可判断」的原则可以直接套用。
五、什么情况下别再折腾
重试是有成本的,而且成本会在你看不见的地方累积。以下几个信号出现时,停手比继续更划算。
同一类错误连续出现且错误内容完全不变,就停。 重试的前提假设是「这次失败是暂时的」。连续三次返回同样的 403、422,说明问题在请求本身,再试一百次也是同样的结果,只是把日志刷脏(401 是例外:先刷新凭证,改好了再发的那次不算重试)。408、429、5xx 和网络层错误(ETIMEDOUT、ECONNRESET)才是重试的合理对象,但要分两种情况:429 是明确的拒绝,请求没被执行,退避后重发是安全的;408、5xx 和网络层超时都属于「不知道对面做没做」,只有在写操作带幂等键、或者能先查一次状态的前提下才允许重发,否则请往下看第二条。
无法确认对面状态,且对方没有查询接口,就停。 这是最该被写进值班手册的一条。你不知道刚才那笔到底成没成,又没有办法查,这时候第二次重试是在赌博。正确动作是把这条记录标记成「状态未知」,进人工核对队列,而不是让程序替你赌。
发现已经产生了重复数据,先止血再修因。 顺序是:关掉自动重试的开关 → 冻结受影响的入口 → 拉出重复数据的清单 → 决定补偿方式(撤销、合并还是标记)。带副作用的操作往往没法简单回滚,撤销本身也是一次写操作,同样需要幂等保护,否则补偿脚本跑两遍又是新一轮重复。系统状态和记录对不上之后怎么收拾,回滚后状态不一致 那篇讲的对账思路可以接着用。
改了两轮还在重复,换条路。 与其在同步调用链上一层层补幂等,不如把这个操作改成「提交任务 + 查询结果」的两段式:提交接口只负责落一条待办记录(唯一约束在这里生效),真正的执行由单一消费者从队列里取,调用方通过任务 ID 轮询结果。这样重复提交只会命中同一条待办,执行侧天然只有一个入口。改造成本比想象中低,收益是把幂等收敛到一个地方,而不是散在每个接口里。
六、避坑清单
每次重试重新生成幂等键。 为什么会踩:生成语句和请求构造写在一起,重试时整个函数重跑了一遍。怎么避:把键作为参数从外层传入,函数签名里显式要求,让漏传变成编译期或类型检查期的错误。
用请求体哈希当唯一依据。 为什么会踩:看起来省事,不用改调用方。怎么避:哈希只能用来校验「同一个键对应的参数是否一致」,不能用来判定「是否同一个意图」。意图必须由调用方显式声明。
先执行业务,后写幂等记录。 为什么会踩:写代码时按自然顺序想问题,先干活后记账。怎么避:把占位写在业务逻辑的最前面,并且用数据库唯一约束抢占,而不是先 SELECT 再 INSERT——后者在并发下有窗口。
幂等记录保留期短于重试窗口。 为什么会踩:保留期是随手填的,重试窗口是后来调大的,两边没人对齐。怎么避:把两个值放在同一处配置里,加一条启动时的校验,不满足关系就拒绝启动。
只在网关或前端做去重。 为什么会踩:网关层加拦截见效快。怎么避:业务往往有多个入口(内部服务直连、定时任务、运维脚本),绕过网关就失效。幂等必须落在离数据最近的那一层。
命中重复时返回空响应。 为什么会踩:实现者觉得「反正已经做过了,返回个 200 就行」。怎么避:把首次执行的响应体和状态码一起持久化,命中时原样回放,调用方拿到的东西必须和第一次一致。
把超时直接判成失败并通知用户。 为什么会踩:错误处理里 catch 到异常就走失败分支。怎么避:超时要有独立的第三态「未知」,界面上说「结果确认中」,后台异步去查,别急着告诉用户没成功。
外部调用和本地事务混在一起。 为什么会踩:图省事,在事务里直接调了第三方接口。怎么避:本地事务只写自己的库和一张出站记录表,外部调用由独立的投递器从出站表读取并重试,投递器自身按记录 ID 幂等。
agent 自动重试没有全局预算。 为什么会踩:每个工具各自带重试,外层循环又重试,层层相乘。怎么避:整个任务共享一个重试预算计数,任何一层扣同一个数,用完即停并上报。
日志里没打幂等键。 为什么会踩:键被当成实现细节,没进日志字段。怎么避:把它和 trace ID 一起作为结构化日志的固定字段,出事时能一条命令拉出同一意图的所有请求,否则事后对账基本靠猜。
收束:一份上线前自检清单
重复执行这类问题的特点是,平时全绿,一次网络抖动就集中爆发,而且爆发时最难受的不是修,是搞不清哪些数据被做了两遍。所以真正省事的做法是在写这个接口的当天就把机制装好,而不是等对账对不上再补。
上线前过一遍这六条:
- 这个接口的写操作重复执行一次,结果会不会变?会变就必须有幂等键或前置查询。
- 幂等键由调用方生成,且在整个重试周期内不变——去代码里确认生成语句的位置。
- 幂等记录先占位后执行,占位靠唯一约束,状态至少区分进行中、已成功、已失败。
- 幂等记录的保留窗口大于调用方最大重试跨度,且幂等查询走强一致读。
- 命中重复时回放首次响应,不返回空体;同键不同参数时明确拒绝。
- 超时有独立的「未知」状态,不直接判失败;有一条人工核对的兜底通道。
六条都能答上来,这个接口基本不会因为重试制造第二份结果。答不上来的那条,就是下一次事故的入口。