幂等键有保质期:过期后,同一个键的重试就是一次新请求
线上有一笔重复扣款,查下来链路是这样的:客户端第一次下单超时,按退避策略重试。前几次都在服务端键的保留期内,被正确回放,没有问题。最后一次重试发生在保留期之后——键已经被清理,服务端把它当成一个全新的请求执行了一遍。于是渠道侧有两笔流水,我们库里只有一条幂等键记录。
这件事的关键不在于中间件写得对不对,而在于我们从来没定义过“这个键能活多久”,也没定义过“客户端的重试能跑多久”。幂等能力是有保质期的,过了期的键,和从没来过的键,在服务端眼里没有区别。
规范里到底怎么规定的
现行文本是 draft-ietf-httpapi-idempotency-key-header-08,2026-10-22 发布,2027-04-25 过期,httpapi WG 的 Standards Track 草案。工作组的仓库在 ietf-wg-httpapi/idempotency。
几条和重放窗口直接相关的:
Idempotency-Key是一个 Item Structured Header Field(RFC 9651),值 MUST 是 String(RFC 9651 §3.3.3)。草案给的示例是带双引号的:Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"。- 键 MUST 唯一,也 MUST NOT 和不同 payload 的请求复用。唯一性由资源方定义、由客户端实现,建议用 UUID(RFC 9562)或类似随机标识。
- §2.3 Idempotency Key Validity and Expiry:资源 MAY 要求按时间的键,以便在过期后清除。资源 SHOULD 定义过期策略并写进文档。注意这里是“应该公布”,不是“必须”,但对你自己的运维来说,不公布等于没定义。
- §2.4 Idempotency Fingerprint:指纹 MAY 和键一起用来判定请求的唯一性。算法可以是:整个 payload 的校验和、选定元素的校验和、逐字段值匹配、选定元素的字段值匹配、请求 digest/签名。
- §2.6 Idempotency Enforcement:第一次见到(键和指纹都没见过)正常处理;原请求已完成后的重复请求,返回一个与“按用户意图已完成”一致的响应;原请求还在处理时的并发请求,返回资源冲突。
- §2.7:端点声明要求键但请求没带 → 400,响应体带文档链接;试图用同一个键配不同 payload → 422(RFC 9110 §15.5.21);原请求仍在处理时重试 → 409。客户端 MUST 在重试前修正请求,409 是唯一的例外,不需要修正。
也就是说,规范明确承认键会过期,并把过期策略的定义权交给资源方。它没规定保留多久,只要求你写出来。窗口本身是你要自己算的。
三个错误码别混用
| 状态码 | 触发条件 | 客户端该做什么 | 常见误用 |
|---|---|---|---|
| 400 | 端点文档声明要求 Idempotency-Key,请求头缺失 | 补上键再发,不要换 URL 或方法 | 直接放行;或用 401/403 表示“你没资格” |
| 409 | 同一个键的请求当前仍在处理中 | 等待,然后用同键同参重试 | 返回 200 空响应,或包一层 500 |
| 422 | 同一个键已经被用于不同的请求 payload(需要指纹支持) | 先确认原请求是否成功,再用新键发修正后的请求 | 没做指纹就返回 422,等于宣告所有重试非法 |
409 返回 200 是最常见的偷懒写法:客户端和上游队列拿到的语义是“成功了”,但实际业务还没跑完,订单就这么丢了。反过来返回 500 更糟——客户端把 5xx 当可重试,立刻再来一次,而原请求还在执行,重试风暴就起来了。
422 只有在服务端真的算过指纹之后才有意义。没有 fingerprint,服务端无法区分“客户端重试同一个请求”和“客户端拿同一个键发了另一个请求”,这时返回 422 纯粹是误伤。
草案给了错误体示例,格式不强制,但应该带一份实现文档的链接,application/problem+json(RFC 9457)是一个选择:
{
"type": "https://example.com/idempotency/docs",
"title": "Server processing previous request.",
"detail": "A request with the same key is currently/still being processed."
}
头的语法坑
规范要求 Structured Field 的 String,示例里带双引号;现实里绝大多数客户端直接送裸 UUID。服务端如果严格按 RFC 9651 解析,第一天上线的接入方就得全部返工。取舍是宽进严出:入口尽量接受,出口只按规范生成带引号的值。
// 宽进:规范示例带双引号,但真实客户端基本不给。
// 严出:我们回给上游的键一律按 RFC 9651 String 格式生成。
func parseIdempotencyKey(h http.Header) (string, error) {
raw := strings.TrimSpace(h.Get("Idempotency-Key"))
if len(raw) >= 2 && raw[0] == '"' && raw[len(raw)-1] == '"' {
raw = raw[1 : len(raw)-1]
}
if raw == "" {
return "", errMissingKey
}
// 255 是 Stripe 的公开上限,也足够覆盖 UUID(36) / ULID(26)。
// 再长基本是客户端把整段 JSON 塞进来了,不该进主键。
if len(raw) > 255 {
return "", errKeyTooLong
}
// 只放行可见 ASCII(去掉空白、引号、反斜杠):
// 这个值会进数据库唯一键、日志、trace tag,放宽字符集等于让每一层都要转义。
for i := 0; i < len(raw); i++ {
c := raw[i]
if c <= 0x20 || c >= 0x7f || c == '"' || c == '\\' {
return "", errKeyCharset
}
}
return raw, nil
}
缺失时按 §2.7 返回 400,并给出文档链接:
func writeProblem(w http.ResponseWriter, status int, typ, title, detail string) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{
"type": typ, "title": title, "detail": detail,
})
}
// 调用处
key, err := parseIdempotencyKey(r.Header)
if errors.Is(err, errMissingKey) {
writeProblem(w, 400,
"https://api.example.com/idempotency/docs",
"Idempotency-Key is missing",
"This operation is idempotent and requires correct usage of Idempotency Key.")
return
}
实现:把响应也存下来
只存键、不存响应,能做到的只是“不重复执行”;要做到“返回一致结果”,必须把第一次的响应落库。回放语义的前提是存响应。
CREATE TABLE idempotency_keys (
idem_key text NOT NULL,
scope text NOT NULL, -- 例如 'POST /v1/payments',同键不同端点不算复用
params_hash bytea NOT NULL, -- 请求体 + 关键头的指纹,对应草案 §2.4
state text NOT NULL, -- processing | done | archived
resp_status int,
resp_body bytea,
resp_ctype text,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
PRIMARY KEY (scope, idem_key)
);
CREATE INDEX ON idempotency_keys (expires_at) WHERE state = 'done';
抢占用 INSERT ... ON CONFLICT DO NOTHING,谁插进去谁负责执行:
func (s *Store) Begin(ctx context.Context, scope, key string, fp []byte, ttl time.Duration) (*Record, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
// 用 DO NOTHING 而不是让唯一键冲突抛错:后者会把事务打挂,
// 而这里在冲突后还要继续读旧行做比对。
// 若冲突行的插入者尚未提交,PostgreSQL 会在此阻塞,等它落定再继续,
// 所以紧接着的 SELECT 读到的一定是最终状态。
tag, err := tx.ExecContext(ctx, `
INSERT INTO idempotency_keys (scope, idem_key, params_hash, state, expires_at)
VALUES ($1, $2, $3, 'processing', now() + $4)
ON CONFLICT (scope, idem_key) DO NOTHING`,
scope, key, fp, ttl)
if err != nil {
return nil, err
}
if tag.RowsAffected() == 1 {
return &Record{Owned: true}, tx.Commit()
}
var rec Record
err = tx.QueryRowContext(ctx, `
SELECT params_hash, state, resp_status, resp_body, resp_ctype
FROM idempotency_keys WHERE scope = $1 AND idem_key = $2`,
scope, key).Scan(&rec.ParamsHash, &rec.State, &rec.RespStatus, &rec.RespBody, &rec.RespCType)
if err != nil {
return nil, err
}
// 同键不同 payload 不是重试,是复用键。指纹对不上就该 422,
// 绝不能拿旧响应回放给一个内容不同的请求。
if !bytes.Equal(rec.ParamsHash, fp) {
return nil, ErrFingerprintMismatch
}
return &rec, tx.Commit()
}
Handler 侧三种分支:
rec, err := store.Begin(ctx, scope, key, fingerprint(r), 24*time.Hour)
switch {
case errors.Is(err, ErrFingerprintMismatch):
// 客户端必须先判断原请求结果,再换新键发修正后的请求。
writeProblem(w, 422, docURL, "Idempotency-Key reused with a different payload",
"This key is already associated with another request payload.")
return
case err != nil:
// 存储不可用时不放行:宁可让客户端稍后重试,也不要冒重复执行的风险。
writeProblem(w, 503, docURL, "Idempotency store unavailable", "Retry later.")
return
}
switch rec.State {
case "processing":
// 409 的语义是“等”,所以必须给 Retry-After,否则客户端只能盲猜退避曲线。
w.Header().Set("Retry-After", "1")
writeProblem(w, 409, docURL, "Server processing previous request.",
"A request with the same key is currently/still being processed.")
return
case "done":
// 标记这是回放,方便上游区分“新执行的结果”和“缓存的结果”,
// Stripe 用的就是这个头名。
w.Header().Set("Idempotent-Replayed", "true")
w.Header().Set("Content-Type", rec.RespCType)
w.WriteHeader(rec.RespStatus)
w.Write(rec.RespBody)
return
}
// rec.Owned:真正执行,执行完把状态码和响应体写回去再提交。
resp := execute(w, r)
store.Finish(ctx, scope, key, resp.Status, resp.Body, resp.ContentType)
保留期怎么定
Stripe 给了两个公开数字做参照:API v1 的键至少保留 24 小时,满 24 小时后可以清理;v2 的窗口是 30 天。差别不是随手改的——v2 的重试语义变了,见下一节。
我们自己按调用类型分层:
- 下单、创建类:30 分钟到 24 小时。覆盖客户端最常见的网络超时重试和网关重放,再长就是浪费存储。
- 支付、退款:24 小时以上,并且不短于渠道侧的幂等/查询窗口。渠道还在用旧流水号做事后对账,我们这边键已经没了,就没法解释第二笔是怎么产生的。
- 跨天重试、离线补单:必须覆盖到自己的对账窗口。如果 T+1 还有补偿任务在重发,键的寿命就得撑过 T+1。
过期不要物理删。改成 state = 'archived' 或者迁到审计表,把 resp_status 一起留着。理由很实际:线上被问“为什么同一笔单扣了两次”时,你需要掏出那条过期记录说清楚第一次什么时候成功、第二次什么时候被当成新请求放行。删干净了就只能靠猜。
Stripe 的两个细节值得抄
执行开始后才缓存。 v1 只在端点开始执行之后才保存第一次请求的状态码和响应体。参数校验失败、API key 无效、限流这些发生在执行之前的错误不写缓存——客户端可以安全重试。并发冲突同样不留幂等结果。这条很关键:把“请求根本没能开始”的失败也缓存下来,等于把自己的错误永久钉在客户端身上。
缓存包括 500。 一旦执行开始,哪怕结果是 500,也会被存下来并在同键重试时回放。因为执行已经发生,副作用可能已经产生,此时让客户端重试只会再执行一次。
v1 和 v2 的语义不要混:
- v1:保存第一次请求的状态码和响应体,同键重试返回保存的响应(含 500)。这个键会被清理,清理之后再用,会生成一次全新的请求。Stripe 还会比较入参,如果和原请求不一致会报
idempotency_error——这是 2015-09-03 changelog 之后的行为,此前只在跨端点时才报错。 - v2:重试不会重复已经成功的操作,而是把失败或部分失败的工作重试到完成,返回更新后的响应。窗口 30 天。
其他可直接照搬的细节:键最长 255 字符;别拿邮箱、身份证之类敏感信息当键;响应头 Idempotent-Replayed: true 标识回放。
409 的处理方式也值得抄:等一会儿,用同一个键、同一组参数重试。想开第二个操作就换新键。要改参数,前提是先确认原操作既没成功也不在执行中——否则就该走对账,而不是继续重试。Stripe 给的建议是把未解决操作的重试限制在最初 24 小时内,之后去查对象、看 dashboard、用 webhook 事件对账,别拿旧键或新键硬顶。
最后一个问题
键保留期到底该跟着渠道的重试窗口,还是跟着自己的对账窗口?前者短,省存储,但渠道还在重发的时候你已经失忆了;后者长,能解释清楚每一笔重复,代价是你得为一个可能永远不来的请求留着记录。
这个选择没有技术上的正确答案,取决于你能不能接受一次人工对账。