代码 从微信支付切到 Stripe:幂等键从订单号变成了 HTTP Header

2026-09-24 09:01:12

从微信支付切到 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-Key header
  • 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 位
StripeHTTP HeaderIdempotency-Key至少 24h 后被自动清理仅 POST 有效,最长 255 字符
PayPalHTTP HeaderPayPal-Request-Id以各 API 文档为准UUID,38 字符上限,按 API 类型分作用域
AdyenHTTP HeaderIdempotency-Key未核实
Square请求体idempotency_key未核实

几个不好抄的坑

  1. 重试时重新生成了 key。每次重试都新建 UUID,幂等直接失效。这是从"订单号即幂等键"迁过来最容易犯的错:国内习惯是复用同一个单号,海外习惯是复用同一个 key,但很多人忘了复用这回事。
  2. Stripe 的 key 被 24h 清理后复用,会变成一次全新的请求。
  3. PayPal 同一 key 并发时第二个可能失败,要按"可能失败"设计,而不是当成一定幂等成功。
  4. 把订单号塞进 body 就以为万事大吉:Stripe 需要的是 header,body 里带 order_id 没有任何幂等作用。
  5. 只在创建类接口带 key,退款接口忘了带。退款是最不该重复的操作,同样需要 key。
  6. SDK 会自动带 key 并退避重试,自己封装 HTTP 请求时容易漏掉这一步。

未实测 / 待确认

  • PayPal 各 API 的具体存储时长、Stripe 幂等记录是否严格 24h 清理,都以官方文档为准。
  • IETF 草案未成为 RFC,不能当标准依据。
  • 上表中 Adyen、Square 的有效窗口未核实。

如果只是把国内那套逻辑平移到海外渠道,上面这些默认行为和边界都会变成线上事故的来源。多渠道路由层面,幂等键的生成时机和复用边界该由谁负责——支付网关、业务层,还是每个渠道的适配器各管一段——这块目前各家的做法并不一致。

参考链接

复制全文 生成海报 支付 Stripe PayPal 幂等 接口对接

推荐文章

程序员茄子在线接单