编程 Idempotency-Key 的值不是存储主键:把键做成元组,三类跨租户事故就消失了

2026-10-30 00:03:55

Idempotency-Key 的值不是存储主键:把键做成元组,三类跨租户事故就消失了

三种症状

线上报警长这样:

  • 租户 B 请求创建订单,返回的却是租户 A 上一笔的响应体,连订单号都一样。
  • 支付路由和退款路由共用同一个客户端键,退款请求被判为「重复」,直接返回了支付成功的结果,退款没发生。
  • 客户端发了一个 Idempotency-Key: replay:evt_x:,几小时后服务端自己投递事件 evt_x,被判 422 键复用,一整条重投递链路卡死。

这三种都不属于并发竞态。抓日志会看到请求是串行到达的,锁没冲突,事务也没打架。问题出在作用域:把 header 里那个字符串当成了存储主键。header 里的值只是客户端的一个意图声明,落库时的键必须是一个元组,元组里每一项都对应一类真实事故。

这类错误的共性是静默。功能测试全绿,单租户环境永远复现不出来,只有真实多租户流量进来,或者客户端传了构造过的键,才会暴露。而且一旦暴露,第一个症状就是数据泄露级别。

draft-08 划定的边界

IETF draft-ietf-httpapi-idempotency-key-header-08(2026-10-22,Standards Track,2027-04-25 到期)在唯一性上写得很克制:键的唯一性 MUST 由 resource owner 定义,MUST 由客户端实现("Uniqueness of the key MUST be defined by the resource owner and MUST be implemented by the clients of the resource."),推荐 UUID(RFC 9562)。也就是说,协议没有规定这个键在服务端怎么存、按什么维度唯一——作用域是服务端自己定的契约。

几个和实现相关的点:

  • Idempotency-Key 是 Item Structured Header(RFC 9651),值 MUST 是 String,示例是 Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324",带引号。
  • 键 MUST NOT 与不同 payload 的请求重用;fingerprint(payload 校验和、选定字段、请求摘要)是可选机制,和 key 一起判定唯一性。
  • 执行语义:首次正常处理;原请求已完成的重试返回与原意图一致的响应;原请求未完成的并发请求按资源冲突处理。
  • 错误码分工:缺失必需的 header → 400,body 用 RFC 9457 problem+json 带文档链接;键被不同 payload 复用 → 422;首个请求仍在跑 → 409。
  • draft 的安全章节明确建议把客户端键与一个服务端已知属性组合,避免一个租户猜到另一个租户的键、读到其缓存响应。

最后一条就是作用域问题在标准层面的表述。HTTP 工作组仓库在 ,渲染版在 。

存储键:一个元组,不是一列

Go 里的最小实现,把作用域拼接收进一个函数,业务代码不允许自己拼键:

var (
ErrMissingScope = errors.New("idempotency: missing scope component")
ErrBadScope     = errors.New("idempotency: scope component contains separator")
)

// scoped 返回 v1 命名空间下的存储键。
// 任何一项为空都 fail-closed,不做「尽量拼上」的容错。
func scoped(tenantID, principalID, route, key string) (string, error) {
parts := []string{tenantID, principalID, route, key}
for _, p := range parts {
if p == "" {
return "", ErrMissingScope
}
if strings.ContainsRune(p, '\x00') {
return "", ErrBadScope
}
}
return "v1\x00" + tenantID + "\x00" + principalID + "\x00" + route + "\x00" + key, nil
}

数据库侧用复合主键承载同一个元组,不要用单列 key 加唯一索引:

CREATE TABLE idempotency_records (
scope_version   text        NOT NULL DEFAULT 'v1',
tenant_id       text        NOT NULL,
principal_id    text        NOT NULL,
route           text        NOT NULL,   -- charge / refund / ...
ns              text        NOT NULL,   -- 'u:' 客户端键, 's:' 服务端自铸键
idem_key        text        NOT NULL,
fingerprint     bytea       NOT NULL,
status          text        NOT NULL,   -- in_progress / completed
response_code   int,
response_body   jsonb,
created_at      timestamptz NOT NULL DEFAULT now(),
expires_at      timestamptz NOT NULL,
PRIMARY KEY (scope_version, tenant_id, principal_id, route, ns, idem_key)
);

route 这一项对应资料里的真实案例:同一单号的「支付」和「退款」如果共用同一个客户端键,退款会被当成支付的重复而不执行。tenant_id 缺了就是跨租户回放。principal_id 缺了,同一租户内不同调用方会互相污染。

scope_version 从第一天就加。后面无论是改分隔符、改参与拼接的字段,还是换哈希方式,都能靠 v1 / v2 并存平滑迁完,而不是被迫一次全量刷库。

另一个常见走偏:拿 payload 哈希当键。客户端合法地发两笔金额相同的请求,第二笔会被静默吞掉。哈希只应该作为 fingerprint 存下来,参与「同键是否同请求」的判定,不参与键的构造。

拼接分隔符:分隔符出现在输入里会撞

用 \x00 或 U+001E 拼接 tenant + principal 是最常见的做法,但前提是输入里不含这个字符。反例很直接:tenant = "A" + \x1E + "B"、principal = "X",与 tenant = "A"、principal = "B" + \x1E + "X",拼出来是同一个字符串,两个完全不同的 (tenant, principal) 组合落到了同一行上。租户名和 principal 来自 Okta group-scoped ID、SCIM per-tenant ID 这类外部系统,字符集不受你控制。

处理方式只有一种:校验并 fail-closed。任一组件包含分隔符就直接报错,而不是剥离、转义或「先替换成下划线」。剥离看着温和,实际上会把两个不同主体映射到同一个命名空间,等于换了个姿势复现同一个 bug。adcontextprotocol 的 store.py 就是这么做的:用 U+001E 拼接,遇到组件自身含分隔符直接 raise ValueError,视为配置错误,SDK 不自动清洗。

顺带说明为什么 principal 必须进这个元组:seller 的 principal id 可能只在租户内唯一。仅按 tenant 分作用域,租户内的两个 principal 之间还是会撞。

客户端键和服务端自铸键必须分命名空间

服务端自己也会产生幂等键,比如事件重投递时给每次投递铸一个键。这类键和客户端传来的键共用一张表,如果不分命名空间,客户端构造一个 Idempotency-Key: replay:evt_x: 写进去,后来真正的事件 evt_x 重投递去写 replay:evt_x:,就是一次键复用,直接被打成 422,并且永久 poison。

tokencanopy/e2a 的 idempotency.go 用两个常量区分:idemUserNS = "u:"(客户端传来的 Idempotency-Key,不可信)、idemAutoNS = "s:"(服务端自铸键,永不来自客户端),存储是扁平的 (user_id, key),前缀进 key。上面的表里 ns 列承担同一职责。

同一个文件里还有两个值得抄的细节:body hash 对原始字节算(route + "\n" + body),不做 JSON 规范化,所以客户端重试必须字节一致,否则 422;claim 与 complete 之间 panic 要 Release 再 re-raise,否则这行永远停在 in_progress,后续所有重试都是 409。

无身份时不能退化成全局命名空间

作用域取不到时的默认行为,决定了这个设计是否安全。

idemlease 的默认键是全局的。多租户 API 下,client A 发 client B 的 key 会拿到 B 的缓存响应,或者用 409 / 422 的差异探测 B 的键使用情况。配置 KeyScope(func(r) tenantID) 之后内部才会拼 scope + "\x00" + key。

adcontextprotocol 的做法更保守:拿不到 caller_identity 就返回 None,退化为不去重并 warn,而不是把所有买家塌缩进同一个命名空间。

两条路都可以,共同点是不能共用。要么拒绝,要么明确不去重;「没有身份就当作全局」等价于把租户隔离开关默认关掉。

分库分表与迁移

键带 tenant 前缀的另一个好处是路由。hanfour/caliber 的 PR #188 修的是 v0.7.6 里一个真实缺陷:Redis 幂等缓存只用客户端的 X-Request-Id、没有租户作用域,两个租户发同一个 ID 会撞,B 可能拿到 A 的缓存响应,也可能因 409 得知 A 有在途请求。修复为 ${api_key_id}:${X-Request-Id},未认证时禁用幂等;409 body 里仍然只回原始 X-Request-Id,不泄露 api key。同一 PR 另建了 idempotency_records 表,复合主键 (api_key_id, request_id),用于按客户端 request id 查计费 / 退款记录(幂等回放不写 usage_logs 行),保留约 1 小时,与去重缓存职责分开。

分片时按 scope 前缀路由,这一点不能省。同键不同租户如果落到不同分片,复合唯一约束就只在单片内成立,跨分片的重复写入没人拦。

验证片段

两个租户、同一个 key、各创建一单,期望拿到两个不同的 id:

KEY='"8e03978e-40d5-43e8-bc93-6894a57f9324"'
for t in a b; do
curl -sS -X POST https://api.example.com/v1/charges \
-H "Authorization: Bearer $(cat /tmp/token-$t)" \
-H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' \
-d '{"amount":1000,"currency":"cny"}' | jq -r '.id'
done

两个输出相同,就是跨租户回放,先回去看主键。再把第二段换成同一租户下的 /v1/refunds 重复一次,确认路由维度也在键里。

未解的一角

作用域元组里最难处理的是身份本身会变。账号合并、租户迁移、principal 在 Okta 里被重新分配 group 之后,旧键空间和新键空间怎么对齐?按旧 scope 写入的行,合并后到底算谁的幂等记录?可以重写历史行,但那会把一次已完成的回放变成不可解释;也可以保留旧 scope 直到过期,代价是合并后的一段时间内同一个逻辑请求在两个命名空间里各执行一次。这块目前没有稳妥答案,能做的是让 scope_version 和 tenant_id 都在主键里,至少保证迁移时能按前缀精确圈定受影响的键空间。

推荐文章

程序员茄子在线接单