PAYATHON 2026

典型购物商城是否必须使用 idempotency 防止重复支付?

支付老李

结论

后端必须实现幂等。禁用支付按钮只能减少重复点击,无法保证支付只执行一次。

支付系统最难处理的并不是用户连续点击,而是下面这种情况:

第一次请求已经执行成功,但调用方没有收到明确结果。

请求超时不代表支付失败。后端、任务队列、网关或用户都可能随后重试,造成再次扣款。直接把用户送到失败页面,只是掩盖了结果不确定的状态,并没有消除重复支付的风险。

前端控制为什么不够

请求超时,但扣款已经成功

生产环境中经常出现这样的情况:

  1. frontend 发起支付。
  2. backend 调用支付服务。
  3. 支付服务完成扣款。
  4. 响应在返回途中丢失,或者 backend 在保存结果前崩溃。
  5. frontend 等待超时,将用户重定向到失败页面。
  6. 用户稍后重新进入支付页,再次付款。

如果每次进入页面都生成新的 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 去重。

备注:内容仅供参考。