engineering

支付 API 的幂等性:工程实践 Playbook

如何设计能扛住重试、网络分区和客户端各种创意行为的幂等键。Kaadxpay 在生产环境用的模式,写给真要去排查重复扣款工单的工程师。

2026年4月15日12 min read作者:Kaadxpay 工程团队

如果你做过支付 API 还没为某个幂等性边界 case 失眠过,那只能说明你处理的支付量还不够大。

这是一份我们当年希望就有的 playbook。它来自看过足够多重复扣款事故复盘,知道哪些模式能在现实里活下来、哪些活不下来。

"幂等" 的真正含义

形式化定义:一个操作是幂等的,当且仅当执行 N 次产生的可观察状态与执行 1 次相同。对支付而言:

  • 用相同 idempotency_key 调 POST /orders → 不论请求被发了多少次,最多创建一个订单
  • 用相同 idempotency_key 调 POST /payouts → 最多发起一次出款
  • PUT /orders/{id} 天然幂等(前提是状态推导是确定性的)

重要:幂等性是关于 副作用,不是响应。理想情况下,第二次调用返回相同响应,但具有约束力的契约是 系统状态最多变更一次

为什么每个支付 API 都需要幂等键

幂等键防御的五种真实失败模式:

  1. 客户端网络重试。 请求中途断开;客户端重试;没有去重的话操作执行两次。
  2. 边缘代理重试。 CDN、负载均衡器、服务网格把一个其实在服务端已成功的"出错"请求重试了。
  3. 多租户队列重试。 Worker 在创建订单后、ack 消息前崩溃;另一个 worker 接到了同一条消息。
  4. Webhook 重试风暴。 重试 webhook(你应该重试),所以接收方需要按 event_id 去重。
  5. 运营双击。 商户在你的控制台上点了两次"出款 USD 50K"。

按我们的生产遥测,重试导致的重复尝试发生率约 每 200 次支付调用 1 次。没有幂等键,基础重复执行率就是 0.5%。设计良好的幂等键能让它实际上为零。

朴素方案(以及为何失败)

直觉做法大概长这样:

def create_order(req):
    key = req.idempotency_key
    existing = db.orders.find_one({"idempotency_key": key})
    if existing:
        return existing
    order = create_order_in_db(req)
    return order

这在竞态下崩溃。两个并发请求都先通过 find_one 检查再各自提交——两个订单被创建。

你需要原子性。要么数据库级的唯一约束,要么事务性的 check-and-create。

真正能用的模式

下面是把要点提炼到本质后的标准模式:

def create_order(req):
    key = require_idempotency_key(req)
    request_hash = sha256(canonical_form(req.body))

    # Step 1: insert the idempotency record OR find existing
    inserted = db.idempotency.insert_or_get(
        key=key,
        request_hash=request_hash,
        endpoint="POST /orders",
        status="in_progress",
        merchant_id=req.merchant_id,
        ttl_seconds=86400,  # 24h retention
    )

    # Step 2a: re-request with the SAME body — return cached response
    if inserted.was_existing and inserted.request_hash == request_hash:
        if inserted.status == "completed":
            return cached_response(inserted.response_id)
        if inserted.status == "in_progress":
            # Caller is retrying while we're still processing the original.
            # Return 409 Conflict with a Retry-After header.
            return conflict_response(retry_after=2)

    # Step 2b: re-request with a DIFFERENT body — semantic mismatch
    if inserted.was_existing and inserted.request_hash != request_hash:
        return error_response(
            422,
            "Idempotency-Key reused with different request body"
        )

    # Step 3: do the actual work
    try:
        order = create_order_in_db(req, idempotency_key=key)
        response = serialize(order)
        db.idempotency.update(
            key=key,
            status="completed",
            response_id=response.id,
        )
        return response
    except Exception as e:
        db.idempotency.update(key=key, status="failed", error=str(e))
        raise

五件值得讲清楚的事:

1. 对请求体做哈希。 客户端不能用同一个 key "复用" 不同参数。把请求体规范化(key 排序、去空白)后哈希存下。重试时重新哈希再比对。

2. 三种状态,不是两种。 in_progresscompletedfailed。中间状态让你能告诉重试方"我还在处理原始请求——退避后重试",而不是让对方干等或让两个请求竞态。

3. 按 merchant 划分作用域。 泄露或被猜到的幂等键,不应该让攻击者 A 干扰商户 B 的交易。唯一约束应是 (merchant_id, idempotency_key),不是单独的 idempotency_key

4. 给记录设置 TTL。 多数支付场景下 24 小时是甜点。Stripe 用 24h,我们用 24h,业界共识。

5. 唯一约束才是真的锁。 不要依赖应用层"先查再写"。用数据库级唯一性。PostgreSQL 的 INSERT ... ON CONFLICT DO NOTHING RETURNING * 是理想原语。

不要用 Redis 存幂等性状态

我们见过这个反模式很多次:开发者因为 Redis "快" 就拿来用。两个问题:(1) 多数生产 Redis 默认没有持久化,节点重启就丢 key;(2) 仅用 Redis 的唯一性有微妙的 race 窗口。请用事务性数据库存幂等性。如果量级足以需要缓存,把 Redis 叠在持久记录之上,而不是替代它。

真正复杂的部分:分布式出款

上面的单表模式适用于"创建订单"。当操作触发下游副作用——比如向银行合作方发起转账——情况就难起来了。

考虑:你成功创建了出款记录,然后调合作方 API 真正划款。合作方响应超时。合作方收到你的请求了吗?你不知道。

朴素重试:再调一次合作方。可能会划两次款。

正确模式叫 端到端幂等性:把同一个幂等令牌(或确定性派生值)传给下游系统,依赖 它们的 幂等性。

def execute_payout(payout_id, idempotency_key):
    # Generate a deterministic downstream key
    downstream_key = f"kxp-{payout_id}-{idempotency_key}"

    response = partner_api.create_transfer(
        body=payout.to_partner_format(),
        headers={"X-Idempotency-Key": downstream_key},
    )
    return response

API 成熟的合作方(Stripe、Wise、Currencycloud、主要卡组织)会基于这个去重。API 不成熟的合作方是负债——和他们合作要自担风险,并且一定要做一个跑在第二天的对账作业,用于发现和回滚重复交易。

Webhook 这一侧:幂等消费者

同一个理念的镜像。当你发布 webhook 时,每个事件有稳定的 event_id。接收方按 event_id 去重。这至关重要,因为任何理智的 webhook 发布方都会在失败时重试,而且常常很激进地重试。

Kaadxpay 的每一条 webhook 都带:

  • event_id — 全局唯一 UUID
  • event_type — 例如 order.captured
  • delivery_id — 每次投递尝试唯一(接收方可借此区分重试)
  • signature — 对请求体的 HMAC
  • delivered_at — 服务端派发时间戳

接收方应基于 event_id 去重,对状态变更忽略 delivery_id(仅留作诊断)。

必须测试的边界 case

如果你正在构建这套机制,请跑下面这些场景。它们能抓到真实 bug:

  1. 同一 key、相同请求体、短时间内发两次 → 第二次返回 200 + 相同响应,或 409(如果第一次仍在处理)
  2. 同一 key、不同请求体 → 422 + 清晰错误信息
  3. 同一 key、不同 merchant → 都成功(作用域正确)
  4. TTL 过期后用同一 key → 第二次按新请求处理(不是错误)
  5. 执行中进程崩溃、重启后重试 → 幂等记录留在 in_progress,重试返回 409,你的后台 worker 在 TTL 内未完成则标为 failed
  6. 执行中数据库主从切换 → 用事务更新可保证 exactly-once 语义
  7. 你与下游合作方之间网络分区 → 对账作业捕获并解决任何重复

规模化下的样子

给个参考。Kaadxpay 在 PostgreSQL 上的幂等表大致处理:

峰值插入 / 秒
~150
预留 5 倍增长空间
P99 延迟
< 8 ms
查询 + 插入
存储(24h TTL)
~80 MB
每百万日交易

正确建索引的 PostgreSQL 在 1000 万操作 / 日量级以下都很轻松。在你达到与我们截然不同的规模之前,不需要专用数据存储。

清理与运维提示

  • 后台清扫。 跑一个 worker,把超过 X 分钟仍 in_progress 的记录标为 failed。能捕获进程崩溃留下的悬挂记录。
  • completedfailed 记录保留满 TTL。 不要激进地 GC——事故响应时"为什么第二次调用返回了那个值?"的可调试性极其宝贵。
  • 在 dashboard 上把幂等状态暴露出来。 商户问"我的请求过去了吗?"时,直接给他幂等记录的状态比人工排查快得多。
  • 打结构化日志。 每次幂等命中(cache hit)和冲突(422)都该带上下文打日志。这是你最好的客户端 bug 模式数据源。

工程师 TL;DR

如果你刚接触支付 API、只想要生存包:

  • 让每一个状态变更端点(POST 和 DELETE)都要求 Idempotency-Key
  • 哈希请求体,与 key 一起存
  • 用三种状态:in_progresscompletedfailed
  • 按 merchant ID 划分作用域
  • 记录保留 24 小时
  • 透传到下游系统
  • 测上面列的边界 case
  • 把所有事都打日志

把这件事做对的团队,能不出重复扣款事故地交付支付。做不对的团队最终也会做对——通常是在最糟糕的那个时刻。

如果你正在 Kaadxpay 上构建或正在评估我们,可以在我们的 API 文档里看到这套设计的生产版本。

作者
Kaadxpay 工程团队
平台工程

来自 Kaadxpay 工程团队的文章,覆盖 API 设计、webhook 可靠性、对账模式,以及运营一家跨境支付平台的真实工程现实。

相关文章

Webhook 可靠性:支付系统的生存指南

Webhook 可靠性:支付系统的生存指南

为什么你的 webhook 投递系统需要重试策略、签名密钥、死信队列和手动回放 UI——以及如何在不破坏 exactly-once 语义的前提下把这些都交付出去。

2026年4月1日12 min read
深入解读 Labuan FSA PSO 牌照:为何它对 ASEAN 跨境支付如此关键

深入解读 Labuan FSA PSO 牌照:为何它对 ASEAN 跨境支付如此关键

纳闽金融服务管理局支付系统运营商(PSO)牌照的实操指南——授权范围、适用对象,以及与 BNM、MAS 及离岸方案的横向对比。

2026年4月28日10 min read
ASEAN 支付走廊 2026:市场现状盘点

ASEAN 支付走廊 2026:市场现状盘点

逐条走廊拆解 ASEAN 跨境支付如今真实流动的样子——从 MY-SG 的 QR 直连,到 IDR-PHP 这种被遗忘的支流。哪些走得通、哪些走不通、哪些值得接入。

2026年4月22日10 min read

订阅 Kaadxpay Insights

每月一封邮件,覆盖跨境支付走廊、监管动态与工程实践。