如果你做过支付 API 还没为某个幂等性边界 case 失眠过,那只能说明你处理的支付量还不够大。
这是一份我们当年希望就有的 playbook。它来自看过足够多重复扣款事故复盘,知道哪些模式能在现实里活下来、哪些活不下来。
"幂等" 的真正含义
形式化定义:一个操作是幂等的,当且仅当执行 N 次产生的可观察状态与执行 1 次相同。对支付而言:
- 用相同
idempotency_key调 POST/orders→ 不论请求被发了多少次,最多创建一个订单 - 用相同
idempotency_key调 POST/payouts→ 最多发起一次出款 - PUT
/orders/{id}天然幂等(前提是状态推导是确定性的)
重要:幂等性是关于 副作用,不是响应。理想情况下,第二次调用返回相同响应,但具有约束力的契约是 系统状态最多变更一次。
为什么每个支付 API 都需要幂等键
幂等键防御的五种真实失败模式:
- 客户端网络重试。 请求中途断开;客户端重试;没有去重的话操作执行两次。
- 边缘代理重试。 CDN、负载均衡器、服务网格把一个其实在服务端已成功的"出错"请求重试了。
- 多租户队列重试。 Worker 在创建订单后、ack 消息前崩溃;另一个 worker 接到了同一条消息。
- Webhook 重试风暴。 重试 webhook(你应该重试),所以接收方需要按 event_id 去重。
- 运营双击。 商户在你的控制台上点了两次"出款 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_progress、completed、failed。中间状态让你能告诉重试方"我还在处理原始请求——退避后重试",而不是让对方干等或让两个请求竞态。
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 "快" 就拿来用。两个问题:(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— 全局唯一 UUIDevent_type— 例如order.captureddelivery_id— 每次投递尝试唯一(接收方可借此区分重试)signature— 对请求体的 HMACdelivered_at— 服务端派发时间戳
接收方应基于 event_id 去重,对状态变更忽略 delivery_id(仅留作诊断)。
必须测试的边界 case
如果你正在构建这套机制,请跑下面这些场景。它们能抓到真实 bug:
- 同一 key、相同请求体、短时间内发两次 → 第二次返回 200 + 相同响应,或 409(如果第一次仍在处理)
- 同一 key、不同请求体 → 422 + 清晰错误信息
- 同一 key、不同 merchant → 都成功(作用域正确)
- TTL 过期后用同一 key → 第二次按新请求处理(不是错误)
- 执行中进程崩溃、重启后重试 → 幂等记录留在
in_progress,重试返回 409,你的后台 worker 在 TTL 内未完成则标为failed - 执行中数据库主从切换 → 用事务更新可保证 exactly-once 语义
- 你与下游合作方之间网络分区 → 对账作业捕获并解决任何重复
规模化下的样子
给个参考。Kaadxpay 在 PostgreSQL 上的幂等表大致处理:
正确建索引的 PostgreSQL 在 1000 万操作 / 日量级以下都很轻松。在你达到与我们截然不同的规模之前,不需要专用数据存储。
清理与运维提示
- 后台清扫。 跑一个 worker,把超过 X 分钟仍
in_progress的记录标为failed。能捕获进程崩溃留下的悬挂记录。 completed和failed记录保留满 TTL。 不要激进地 GC——事故响应时"为什么第二次调用返回了那个值?"的可调试性极其宝贵。- 在 dashboard 上把幂等状态暴露出来。 商户问"我的请求过去了吗?"时,直接给他幂等记录的状态比人工排查快得多。
- 打结构化日志。 每次幂等命中(cache hit)和冲突(422)都该带上下文打日志。这是你最好的客户端 bug 模式数据源。
工程师 TL;DR
如果你刚接触支付 API、只想要生存包:
- 让每一个状态变更端点(POST 和 DELETE)都要求
Idempotency-Key头 - 哈希请求体,与 key 一起存
- 用三种状态:
in_progress、completed、failed - 按 merchant ID 划分作用域
- 记录保留 24 小时
- 透传到下游系统
- 测上面列的边界 case
- 把所有事都打日志
把这件事做对的团队,能不出重复扣款事故地交付支付。做不对的团队最终也会做对——通常是在最糟糕的那个时刻。
如果你正在 Kaadxpay 上构建或正在评估我们,可以在我们的 API 文档里看到这套设计的生产版本。
来自 Kaadxpay 工程团队的文章,覆盖 API 设计、webhook 可靠性、对账模式,以及运营一家跨境支付平台的真实工程现实。