从微信支付切到 Stripe:幂等键从订单号变成了 HTTP Header
调用支付接口网络超时后重试,结果产生了两笔扣款、两笔退款,或者两个客户。国内渠道接惯了不太会遇到这种情况,因为微信/支付宝没有独立的幂等键,大家都在靠 out_trade_no 承载幂等;换成 Stripe / PayPal 之后,幂等键被挪到了 HTTP 头里,不显式传,重试就是真的执行两次。
本文只讨论创建类请求的幂等。回调侧的重复入账是另一件事,靠唯一索引 + 状态机解决,跟下面的内容无关。
先分清两种幂等
- 请求幂等(创建类操作):防止重复创建对象,比如重复下单、重复创建 PaymentIntent、重复发起退款。管的是"我发出去请求"这一侧。
- 业务幂等(回调/通知):防止重复入账,靠唯一索引 + 状态机。
下面讲的都是第一种。
微信支付 / 支付宝:订单号本身就是幂等键
国内渠道没有单独的幂等开关,重试的幂等性由商户订单号承载。
微信支付 v3 的 out_trade_no 由商户自定义,只支持字母、数字和 -_|* 半角字符,必须唯一;JSAPI 文档标注为 string(32)。重新发起支付要用原订单号;已支付、已关单/撤销的订单号不能再发起。
支付宝的 out_trade_no 长度是 1–64 位。
结论很直接:国内渠道里"换单号 = 新的一笔",幂等 = 复用同一个单号 + 原参数。参考 微信支付商户订单号规则。
Stripe:Idempotency-Key 放在 HTTP Header
官方文档:Idempotent requests,相关博客:Designing robust and predictable APIs with idempotency。
用法:
curl https://api.stripe.com/v1/customers \
-u sk_test_xxx: \
-H "Idempotency-Key: KG5LxwFBepaKHyUD" \
-d description="..."
机制上有几个点需要记住:
- Stripe 会保存第一次请求对应的状态码和 body,无论成功还是失败(包括 500)。后续带同一个 key 的请求返回同样的结果。
- key 由调用方自己生成,建议 UUIDv4 或足够熵的随机串;最长 255 字符;不要用邮箱、个人标识这类敏感信息当 key。
- key 至少 24 小时后被系统自动清理;清理之后复用同一个 key,会被当成一次新请求。
- 幂等层会比较入参与原始请求的参数,参数不一致会报错,防止误用。
- 只有 endpoint 开始执行后才保存结果。如果参数校验就失败、或与并发请求冲突,不保存幂等结果——这几种情况可以安全重试。
- 所有 POST 都接受幂等键;把
Idempotency-Key放在 GET/DELETE 上没有效果(这些方法定义上就是幂等的)。 - Stripe 官方 Ruby 库会自动带幂等键,并做指数退避 + 抖动的重试。
PayPal:同一个概念,名字叫 PayPal-Request-Id
官方文档:Idempotency。
- REST POST 用请求头
PayPal-Request-Id,值是调用方生成的唯一 ID,服务端存储一段时间。 - 带上之前用过的
PayPal-Request-Id,PayPal 返回那次请求的最新状态;不带这个头,PayPal 会重复执行请求。 - 建议用 UUID,因为要满足 38 个单字节字符的上限。
- 唯一性要求是"每个请求 + 每种 API 调用类型"——比如 authorize payment 和 capture authorized payment 是两个独立作用域。
- 两个同时发出、带同一个 key 的请求:PayPal 处理第一个,第二个可能失败。
- 返回的是"当前状态",而不是"原始请求那一刻的状态"。
- 不是所有 API 都支持这个头,具体支持情况与存储时长要看对应 API 的 reference。
其他渠道
IETF 草案里列了一批已知实现:
- Adyen:
Idempotency-Keyheader - Square:请求体里的
idempotency_key属性 - Google Standard Payments:请求体里的
requestId - Razorpay(payout):
X-Payout-Idempotency - OpenBanking:
x-idempotency-key
也就是说,既有放 header 的,也有放 body 的,还有放自定义头的,没有统一标准。跨渠道抽公共层的时候,位置差异得单独处理。
IETF 草案怎么说并发
draft-idempotency-header-00 是 Internet-Draft,不是 RFC:
- Replay(原请求已完成后再重放):资源服务器必须返回之前已完成操作的结果,成功或错误。
- Concurrent Request(在原请求完成前就重放):资源服务器必须返回资源冲突错误。
草案里提到复用等场景会回 422 之类。实际各家实现与草案有出入,以各家文档为准。
位置、窗口和限制对照
| 渠道 | 幂等键位置 | 字段/头名 | 有效窗口 | 备注 |
|---|---|---|---|---|
| 微信支付 v3 | 业务参数 | out_trade_no | 单号维度 | 已支付/已关单不可重发,string(32) |
| 支付宝 | 业务参数 | out_trade_no | 单号维度 | 1–64 位 |
| Stripe | HTTP Header | Idempotency-Key | 至少 24h 后被自动清理 | 仅 POST 有效,最长 255 字符 |
| PayPal | HTTP Header | PayPal-Request-Id | 以各 API 文档为准 | UUID,38 字符上限,按 API 类型分作用域 |
| Adyen | HTTP Header | Idempotency-Key | 未核实 | — |
| Square | 请求体 | idempotency_key | 未核实 | — |
几个不好抄的坑
- 重试时重新生成了 key。每次重试都新建 UUID,幂等直接失效。这是从"订单号即幂等键"迁过来最容易犯的错:国内习惯是复用同一个单号,海外习惯是复用同一个 key,但很多人忘了复用这回事。
- Stripe 的 key 被 24h 清理后复用,会变成一次全新的请求。
- PayPal 同一 key 并发时第二个可能失败,要按"可能失败"设计,而不是当成一定幂等成功。
- 把订单号塞进 body 就以为万事大吉:Stripe 需要的是 header,body 里带
order_id没有任何幂等作用。 - 只在创建类接口带 key,退款接口忘了带。退款是最不该重复的操作,同样需要 key。
- SDK 会自动带 key 并退避重试,自己封装 HTTP 请求时容易漏掉这一步。
未实测 / 待确认
- PayPal 各 API 的具体存储时长、Stripe 幂等记录是否严格 24h 清理,都以官方文档为准。
- IETF 草案未成为 RFC,不能当标准依据。
- 上表中 Adyen、Square 的有效窗口未核实。
如果只是把国内那套逻辑平移到海外渠道,上面这些默认行为和边界都会变成线上事故的来源。多渠道路由层面,幂等键的生成时机和复用边界该由谁负责——支付网关、业务层,还是每个渠道的适配器各管一段——这块目前各家的做法并不一致。