幂等键的请求指纹与重放契约:422、409 与 affected rows=0 的边界
先定契约,再谈存储
一个幂等记录要回答四件事:什么算「同一个」操作、重放时返回什么、保证持续多久、涵盖哪些副作用。业界有三种重放模型,含义不同,不能混用:
| 契约 | 重放返回 | 事实依据 |
|---|---|---|
| 精确重放首次响应 | 首次请求的原始 status + body,成功失败都存 | Stripe 文档:保存某 key 第一次请求的状态码与响应体,包括 500;key 至少 24 小时后可能被清理,清理后再用同一 key 当作新请求;参数与原请求不一致会报错;参数校验失败或与并发同 key 请求冲突时不保存结果,客户端可重试 |
| 返回操作最新状态 | 之前创建的那个操作的当前状态 | 有资料引述 PayPal 文档为此模型(二手引述,需自行核验),与精确重放不是一回事 |
| 只做重复抑制 | 阻止重复执行,replay 响应可能很简略 | — |
两种契约都合理,但「重放 500」和「返回最新状态」对客户端的含义完全不同:前者要求客户端把 500 也当成终态来读,后者要求客户端能查询。先定契约再选存储;反过来先选存储再编语义是错的。
请求指纹的规范化清单
指纹 = 对请求体按字段白名单做规范化后哈希,建议 SHA-256。规范化要点:
- JSON 字段按字典序排序
- 数值按 decimal 精度归一,
100与100.00等价 - 剔除
client_ip、timestamp、签名等环境字段 - 二进制用 base64
参考表结构:idem_key(规范化后的键,如 租户:用户:接口:客户端键)、state(0 处理中 / 1 已完成 / 2 已失败)、request_hash CHAR(64)、resp_status、resp_body、biz_no、locked_at、expire_at,UNIQUE KEY uk_idem_key。
两种事故形态,方向相反
不做指纹:调用方复用键、body 不同,服务端静默回放旧响应,表现为「我改了 A 却返回 B」。同一单号复用键时更危险,支付与退款会互相顶掉。
指纹算错:把 client_ip、timestamp 算进哈希,同一 key 在不同时间重试被判定为冲突,返回 4xx,客户端退避后重试,重试被放大成重试风暴。
两种事故一个吞掉合法请求,一个拒绝合法请求,都源于指纹边界没画清。
不能拿 payload 哈希当幂等键
服务端按请求体哈希生成键,用户连续两笔合法且金额相同的请求,第二笔会被静默吞掉。幂等键应由客户端生成,重试时原样携带。
状态码分工
- 同键不同参:4xx,IETF draft 2.7 节为 422 Unprocessable Content
- 处理中:409 +
Retry-After - 不要回 429:等于叫客户端退避重试
- 不要回 500:客户端会继续重试,无限重试打满链路
该草案仍是 Internet-Draft,未成为 RFC,引用时按业界共识整理看待,不要写成标准。
响应快照必须带业务单号
上游拿返回的单号做查询、发货、对账。回放响应缺单号,调用方会走到空指针或「重新下单」分支。响应体过大时可只存关键字段、回放时重组,但不能退化成「只返回成功标志」。响应缓存要带 status、响应头白名单、响应体。
占位靠唯一索引,不靠先查后插
错误模式:实例 A 查 key 不存在,实例 B 也查不存在,两者都执行,都写数据才发现冲突。先 SELECT 再 INSERT 有窗口,常规隔离级别下两个事务的 SELECT 都看不到对方未提交的 INSERT,加事务不解决;SELECT ... FOR UPDATE 需要这一行已存在,而创建场景恰恰不存在。
靠唯一索引抢占:
- PostgreSQL:
INSERT ... ON CONFLICT (key_hash) DO NOTHING RETURNING key_hash,RETURNING 有值 = 抢到;无值 = 已存在,改为 SELECT 读出记录。 - MySQL:INSERT 报 1062 Duplicate entry,查出已有记录;不要用异常字符串匹配判断哪个索引冲突,用错误码 + 索引名。
- MySQL
INSERT ... ON DUPLICATE KEY UPDATE的 affected rows:0 = 冲突但值未变,1 = 插入,2 = 更新。0 不是失败。 - 唯一索引列必须 NOT NULL:MySQL 的 NULL 不参与唯一性判定,多行 NULL 可共存。
占位 INSERT 立即提交,处理完业务再开短事务回填 status + 响应快照;长事务会把幂等表锁成热点。自增 ID 跳号是冲突前已分配,属正常现象。
TTL 是契约的一部分
TTL 定义客户端能在多长时间内依赖重复抑制或回放,不是清理策略。SQS FIFO 的去重有时间窗口(默认 5 分钟),不是永久保证,API 幂等应同样看待。
保留期按所有调用方最长重试窗口定:对账补发、人工重放、离线重跑都可能隔几小时甚至几天用同一 key。资金交易 3–12 个月,订单 30–90 天,一般写接口 7 天,高频轻量(点赞/埋点)几小时。逻辑过期与物理删除不是一回事。
边界:幂等不等于端到端 exactly once
API 层幂等只保护「客户端重试同一个逻辑 API 请求」这一个边界。一次调用还要发消息、发邮件、触发 webhook、写两个服务,每一步仍需各自的去重或对账。Kafka 的 exactly-once 也只限于它自己的协调模型。可行做法是把稳定的 operation id 传给下游,让下游自己判定重复。
未解点
处理中记录的持有者崩溃后,靠租约接管存在「旧持有者其实还活着」的僵尸风险,因此业务表唯一约束不能撤。租约时长怎么定,取决于业务最长执行时长的实测分布,没有通用值。