典型购物商城是否必须使用 idempotency 防止重复支付?
结论
后端必须实现幂等。禁用支付按钮只能减少重复点击,无法保证支付只执行一次。
支付系统最难处理的并不是用户连续点击,而是下面这种情况:
第一次请求已经执行成功,但调用方没有收到明确结果。
请求超时不代表支付失败。后端、任务队列、网关或用户都可能随后重试,造成再次扣款。直接把用户送到失败页面,只是掩盖了结果不确定的状态,并没有消除重复支付的风险。
前端控制为什么不够
请求超时,但扣款已经成功
生产环境中经常出现这样的情况:
- frontend 发起支付。
- backend 调用支付服务。
- 支付服务完成扣款。
- 响应在返回途中丢失,或者 backend 在保存结果前崩溃。
- frontend 等待超时,将用户重定向到失败页面。
- 用户稍后重新进入支付页,再次付款。
如果每次进入页面都生成新的 idempotency key,第二次请求会被当成一笔全新的支付,仍有可能再次扣款。
此时系统状态应该是 UNKNOWN 或 PROCESSING,而不是 FAILED。在查询支付渠道或收到 webhook 之前,系统无法判断第一次扣款是否成功。
后端组件会独立重试
即使浏览器只发送了一次请求,内部系统仍可能多次执行支付:
- API Gateway 或反向代理在上游连接中断后重试。
- backend 调用支付服务超时后自动重试。
- 消息队列因为消费者没有及时确认而重新投递。
- worker 完成扣款后,在提交消息确认前崩溃。
- 定时补偿任务重新处理长时间停留在
PROCESSING状态的支付。 - 两个服务实例同时领取同一个支付任务。
这些行为都发生在 frontend 之外,禁用按钮对它们不起作用。
页面级 idempotency key 生命周期太短
用户可能刷新支付页面、返回上一页、复制页面到新标签页,也可能在请求超时后重新进入订单。如果每次打开页面都创建新 key,同一个订单就会对应多个 key。
更可靠的做法是:
- 创建一次支付尝试时生成 key。
- 请求超时后,继续使用同一个 key 查询或重试。
- 只有明确开始新的支付尝试时才生成新 key,例如更换支付方式。
- 同时在业务层限制一个订单不能产生多个有效的成功扣款。
webhook 通常会重复投递
支付渠道的 webhook 一般采用“至少一次”投递方式。如果商户没有及时返回成功响应,渠道会再次发送同一个事件。
如果 webhook handler 没有幂等保护,系统可能重复执行以下操作:
- 将订单标记为已支付;
- 发货;
- 增加会员余额或积分;
- 发送优惠券;
- 写入多条支付流水;
- 触发重复退款或通知。
所以,即使扣款接口从未被重复调用,支付完成后的业务处理仍然需要幂等。
生产中常见的实际故障模式
不必假设极端的网络故障,下面这些情况在生产环境中都可能发生:
- 移动网络切换时,请求已在服务端完成,但客户端连接已经断开。
- 支付渠道的响应时间超过 backend 的超时配置,backend 将其视为失败并重试。
- worker 扣款成功后进程重启,队列消息因未确认而再次被消费。
- webhook 已经处理完成,但服务在返回
2xx前崩溃,渠道随后重新投递。 - 用户看到“支付失败”,但银行卡已经扣款,于是重新进入订单付款。
- 同一个账号在手机和电脑上同时支付同一订单。
- 客服补单、定时补偿任务和用户支付请求同时操作一个订单。
这些问题都无法只靠 frontend 控制解决。
推荐的解决方案
1. 为支付尝试创建稳定标识
不要把 key 绑定到页面加载,而要绑定到一次业务支付尝试:
order_id: order_123
payment_attempt_id: pay_attempt_456
idempotency_key: 4f1c9c1e-...
同一次尝试的所有重试都必须复用相同的 idempotency_key。服务器还要检查同一个 key 对应的请求参数是否一致,避免客户端使用同一个 key 提交不同金额。
2. 在数据库中建立唯一约束
“先查再写”无法避免并发竞争,最终的去重应由数据库保证:
CREATE TABLE payment_attempts (
id UUID PRIMARY KEY,
order_id UUID NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
amount BIGINT NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL,
provider_payment_id VARCHAR(128),
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (idempotency_key)
);
如果业务规定每个订单只能成功支付一次,还要通过事务、订单状态锁或符合业务模型的唯一约束来保证。不能因为使用了 idempotency key,就假设两个不同的 key 不会对同一个订单重复扣款。
3. 原子地创建或读取支付尝试
下面是简化的 TypeScript 伪代码:
async function createPayment(req: PaymentRequest): Promise<PaymentResponse> {
const attempt = await db.transaction(async (tx) => {
const existing = await tx.paymentAttempt.findByIdempotencyKey(
req.idempotencyKey
);
if (existing) {
assertSameRequest(existing, req);
return existing;
}
return tx.paymentAttempt.insert({
orderId: req.orderId,
idempotencyKey: req.idempotencyKey,
amount: req.amount,
currency: req.currency,
status: "PROCESSING",
});
});
if (attempt.status === "SUCCEEDED") {
return attempt.responseBody;
}
if (attempt.status === "PROCESSING" && attempt.providerPaymentId) {
return { status: "PROCESSING", paymentId: attempt.id };
}
const providerResult = await paymentProvider.charge({
amount: attempt.amount,
currency: attempt.currency,
idempotencyKey: attempt.idempotencyKey,
});
return await saveProviderResult(attempt.id, providerResult);
}
调用支付渠道时也要继续传递同一个 key。如果本地实现了幂等,但每次调用渠道都使用新的 key,那么本地服务在渠道调用成功后崩溃时,仍可能产生第二笔扣款。
实际实现还要处理并发锁、处理中的请求和失败恢复,不能让两个请求在看到 PROCESSING 后同时调用支付渠道。
4. 将超时表示为“结果未知”
前端可以显示:
支付结果确认中,请勿重复付款。
然后通过支付查询接口轮询:
GET /payments/pay_attempt_456
接口可能返回:
{
"status": "PROCESSING"
}
只有确认渠道明确拒绝后,状态才能进入 FAILED;确认扣款完成后,状态进入 SUCCEEDED。长期无法确认的记录应交给对账或补偿任务处理,不能直接视为失败。
5. webhook 也要去重
可以根据渠道事件 ID 建立唯一约束:
CREATE TABLE processed_webhook_events (
provider VARCHAR(32) NOT NULL,
event_id VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (provider, event_id)
);
处理 webhook 时,应在同一个数据库事务中记录事件并更新订单。重复事件命中唯一约束后,直接返回成功响应,不再重复发货或发放权益。
前端按钮仍然有价值
禁用按钮应该保留。它能改善用户体验,也能减少没有意义的请求,但它只是交互层的防抖措施,不能充当资金安全边界。
比较合理的职责划分是:
- frontend:防止连续点击,显示处理中状态,不把超时描述成支付失败。
- backend:校验金额和订单状态,维护支付状态机,实现业务幂等。
- 数据库:通过唯一约束和事务处理并发竞争。
- 支付渠道:复用渠道支持的 idempotency key。
- webhook 和消息消费者:按照可能重复投递的情况设计。
- 对账系统:处理长期结果未知、状态不一致和异常扣款。
注意事项
幂等不是简单地缓存一次 HTTP 响应。实现前需要明确以下问题:
- key 的作用域是用户、订单还是支付尝试;
- key 要保存多久;
- 相同 key 携带不同参数时如何拒绝;
- 如何处理
PROCESSING状态下的并发请求; - 哪些失败允许使用原 key 重试;
- 更换支付方式时是否创建新的支付尝试;
- 一个订单是否允许分期、部分支付或多次扣款;
- 支付成功后的发货、积分和通知如何分别去重。
典型商城未必需要复杂的幂等框架,但只要涉及真实扣款,就不能依赖 frontend 保证“最多支付一次”。最低限度应具备稳定的支付尝试 ID、数据库唯一约束、明确的支付状态机、渠道侧幂等和 webhook 去重。
备注:内容仅供参考。
