代码 支付接口幂等性:支付宝 out_trade_no、Stripe Idempotency-Key 与 PostgreSQL 去重表

2026-10-03 21:31:38

支付接口幂等性:支付宝 out_trade_no、Stripe Idempotency-Key 与 PostgreSQL 去重表

用户点击"立即支付",请求超时,页面没有任何响应。接下来会发生什么?

如果 Payment API 没有实现幂等,答案是:用户被重复扣款,订单被重复创建,Webhook 触发后服务器崩溃,库存已经扣减但订单记录从未提交。这些不是边缘案例,而是双十一大促、支付网关响应变慢、移动端网络断连、Kubernetes Pod 在数据库写入和 API 响应之间被驱逐时会真实出现的故障。

幂等操作指无论执行多少次,结果都与执行一次相同。放到支付语境里:无论"创建扣款"请求因网络超时、客户端重试还是 Webhook 重复投递被发送多少次,系统只会创建一笔扣款,金额固定,用户账户只被扣一次。实现这个保证的机制是幂等键(Idempotency Key)——由客户端生成的唯一标识符,绑定到每次支付尝试,并在每次重试时随请求一并发送。服务端拿到 Key 后返回缓存结果,而不是重新处理。

三种需要幂等的故障模式

  • Pre-server failure:请求从未到达服务器,直接重试是安全的。
  • Mid-processing failure:请求到达服务器并开始处理,但在完成前失败。没有幂等保护的重试 = 重复扣款。
  • Post-processing failure:服务器处理成功,但确认响应没有回到客户端。没有幂等保护的重试 = 重复扣款。

只有第一种场景可以直接重试。第二、第三种在生产环境里更常见,需要在支付网关侧和自身后端侧都建立幂等层。

在国内和东南亚出海场景下,风险还会被放大:微信支付、支付宝的异步通知(notify_url 回调)采用 at-least-once 投递,同一笔交易的成功通知可能到达多次;跨境收款链路更长,超时率更高;重复交易记录会让增值税专用发票数据不一致;交易日志的完整性在监管上有合规意义。

各网关的幂等实现方式

支付宝:靠请求参数里的 out_trade_no 实现幂等,即商户侧唯一交易号。同一 out_trade_no 的重复请求会返回原始交易结果,不产生新扣款。

def build_alipay_payment_request(order_id, amount_cny):
    out_trade_no = f"ORDER-{order_id}"  # 幂等键,必须全局唯一

    payload = {
        "app_id": "YOUR_APP_ID",
        "method": "alipay.trade.create",
        "charset": "utf-8",
        "sign_type": "RSA2",
        "timestamp": ...,
        "version": "1.0",
        "biz_content": {
            "out_trade_no": out_trade_no,
            "total_amount": f"{amount_cny:.2f}",
            "subject": f"Order {order_id}",
            "product_code": "FAST_INSTANT_TRADE_PAY",
        },
    }

关键约束:out_trade_no 必须在调用支付宝之前生成并持久化到数据库。如果在构建请求的函数内部动态生成,重试时会产生新的 ID,支付宝无法识别为重复请求,幂等保护直接失效。

微信支付:使用 out_trade_no 字段,机制与支付宝一致,相同 out_trade_no 在有效期内只会产生一笔支付。notify_url 回调同样是 at-least-once 投递,需要按 out_trade_no 去重。

Stripe:通过 Idempotency-Key HTTP Header 实现,客户端生成 UUID 随请求发送,Stripe 缓存响应 24 小时。约束是:相同的 Idempotency-Key 配上不同参数会返回 400,同一个 Key 必须始终对应同一个支付意图。Stripe 也会缓存失败响应,避免客户端和服务端之间出现状态不一致。

2C2P:通过 payload 中的 invoiceNo 实现,由商户提供每笔交易的唯一标识。相同的 invoiceNo 提交两次,返回第一次的处理结果。

网关侧幂等为什么不够

支付网关只能保护 PSP 层面的重复扣款,保护不了你自己的数据库:

  • 超时后客户端重试,订单记录被创建两次;
  • 两个并发重试,库存被扣减两次;
  • Webhook 重复投递,用户收到两封订单确认邮件。

这些问题要在自己的服务端建一层幂等。

PostgreSQL 去重表

核心模式:在执行任何与支付相关的写操作之前,先检查幂等键是否已经处理过。处理过就返回存储的结果;没处理过就获取锁、执行处理、存储结果。

CREATE TABLE idempotency_keys (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  key TEXT NOT NULL UNIQUE,
  request_path TEXT NOT NULL,
  request_hash TEXT NOT NULL,       -- 请求体的 SHA-256 哈希
  response_status INT,
  response_body JSONB,
  locked_at TIMESTAMPTZ,            -- 处理开始时设置(分布式锁)
  completed_at TIMESTAMPTZ,         -- 处理完成时设置
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '24 hours'
);

CREATE INDEX ON idempotency_keys (key);
CREATE INDEX ON idempotency_keys (expires_at);

locked_at 是这套设计的关键:它防止两个使用相同 Key 的并发请求同时通过"尚未处理"检查。ON CONFLICT DO NOTHING 插入加上锁检查,组合成一个不依赖 Redis 的分布式互斥锁。

FastAPI 里的 IdempotencyGuard

三个基础操作:

  • get_idempotency_record:SELECT ... WHERE key=:key AND expires_at > now()
  • acquire_idempotency_lock:INSERT INTO idempotency_keys (key, request_path, request_hash, locked_at) VALUES (...) ON CONFLICT (key) DO NOTHING
  • complete_idempotency_key:UPDATE ... SET response_status, response_body, completed_at=now() WHERE key=:key

IdempotencyGuard.__call__ 的分支逻辑:

  1. 读 Idempotency-Key header,缺失返回 400;
  2. 计算 request_hash = sha256(body);
  3. 查到 record 且 request_hash 不一致 → 422;
  4. completed_at 有值 → 返回缓存响应,并带上 Idempotency-Replayed: true;
  5. locked_at 有值但 completed_at 为空 → 409,说明另一个并发请求正在处理;
  6. 新 Key → 获取锁并继续处理。

支付端点 create_charge 调用 charge_via_alipay(out_trade_no=body.order_id),out_trade_no 直接复用 order_id,保证支付宝侧也幂等;成功后用 complete_idempotency_key 存下 201 结果。异常分支:临时性错误(网络问题、PSP 5xx)删除 Key,允许重试;终端错误(余额不足、账户受限)保留错误响应,防止客户端陷入重试循环。

Webhook 去重

支付宝和微信的异步通知都是 at-least-once,Handler 必须以事件 ID 为去重键:支付宝用 out_trade_no,微信用 transaction_id。给 webhook key 加前缀,比如 webhook:alipay:{out_trade_no},存进同一张去重表。

区别在响应格式:

  • 支付宝要求处理成功后返回纯文本字符串 success,否则支付宝服务器会持续重试,直到第 25 次;
  • 微信支付要求返回 JSON {"code": "SUCCESS"}。

两者的去重逻辑相同,只有响应体不一样。

几个设计决策

Key 有效期窗口要与网关缓存窗口对齐。Stripe 是 24 小时;支付宝/微信的 out_trade_no 有效期通常 90 天,但服务端去重窗口设 24 小时已经足够覆盖重试。夜间跑清理:

DELETE FROM idempotency_keys WHERE expires_at < now();

失败时的策略:

失败类型处理原因
网络超时 / PSP 5xx删除 Key临时性错误,需要允许重试
余额不足等终端错误保留错误响应重试也不会成功,避免重试循环
无效参数 / 400保留 4xx 响应请求本身有问题,重试无意义

并发请求:ON CONFLICT DO NOTHING 保证同一时刻只有一个并发请求拿到锁,第二个请求看到 locked_at IS NOT NULL, completed_at IS NULL,收到 409,客户端应做指数退避重试。

Key 的生成职责:幂等键必须始终在客户端侧生成,不能由服务端生成。服务端生成的话,超时后的每次重试都会产生新 Key,去重层完全被绕过。对支付宝/微信而言,out_trade_no 实际上就是 Order ID,应该在创建 Order 记录时一并持久化。

对照表

通道幂等字段说明
支付宝out_trade_no 请求参数商户订单号去重,有效期 90 天
微信支付out_trade_no同上
StripeIdempotency-Key Header响应缓存 24 小时
2C2Ppayload 中的 invoiceNo商户提供唯一标识
自身 APIFastAPI + PostgreSQL idempotency_keys 表ON CONFLICT DO NOTHING 当锁
Webhook支付宝 out_trade_no / 微信 transaction_id同一去重表,Key 带前缀

去重表模式每次请求增加的开销不超过 2ms(一次索引查找),换来的是彻底消除一类重复扣款 Bug。

复制全文 生成海报 支付 接口对接 幂等 PostgreSQL Webhook

推荐文章

程序员茄子在线接单