PAYATHON 2026

如何确保 external API 调用实现 exactly once?

支付小周

结论

如果外部 Payment Service 既不支持 idempotency key,也不能按业务流水号查询付款结果,调用方就无法严格保证 exactly once。

问题不在于 worker 是否可靠,而在于系统始终存在一个无法消除的“结果未知”窗口:

  1. Payment Service 已经成功付款;
  2. worker 在收到响应前超时或崩溃;
  3. worker 无法判断付款是否成功;
  4. 此时重试可能造成重复付款,不重试又可能漏付。

因此,工程上通常不会追求端到端的绝对 exactly once,而是结合以下措施控制风险:

  • 用本地事务保证每笔付款只有一个执行意图;
  • 用状态机和锁阻止多个 worker 并发发送;
  • 禁止自动重试结果未知的请求;
  • 通过供应商流水、账单、回调或对账文件确认最终结果;
  • 无法自动确认时转入人工审核;
  • 以内部账本和对账结果保证财务正确。

为什么本地去重不能解决问题

假设 worker 在调用 API 前,先把付款标记为已处理:

1. UPDATE payment SET status = 'SENT'
2. 调用 Payment Service

如果第二步失败,系统会认为付款已经完成,钱却可能没有发出。这种方式偏向 at most once,代价是可能漏付。

如果改为 API 调用成功后再更新状态:

1. 调用 Payment Service
2. UPDATE payment SET status = 'SENT'

付款成功后,如果 worker 在第二步之前崩溃,下次执行时就会再次调用 API。这种方式偏向 at least once,代价是可能重复付款。

数据库事务也无法原子地覆盖外部 HTTP API。除非 Payment Service 参与分布式事务,或者提供等效的幂等和结果查询能力,否则本地数据库无法同时提交“状态变更”和“外部付款”。

消息队列的 exactly once 语义也解决不了这个问题。它最多保证消息在队列或特定计算框架中的处理语义,无法让一个不支持幂等的外部付款接口自动获得 exactly once 能力。

推荐的处理流程

1. 为付款建立稳定的业务标识

每笔付款都应先在本地生成永久不变的 payment_id,并记录收款人、金额、币种和业务来源。后续重试、恢复和人工处理必须沿用这个标识,不能重新创建一笔看似不同的付款。

如果外部 API 允许填写 reference、memo、merchant_order_id 等字段,应传入 payment_id。即使这些字段不提供幂等保证,也能提高自动对账和人工定位的效率。

2. 使用明确的付款状态机

不要只用 paid = true/false 表示付款状态,至少应区分:

PENDING      尚未尝试
SUBMITTING   已取得执行权,准备调用
SUCCEEDED    已确认成功
FAILED       已确认失败,可以按规则重试
UNKNOWN      已调用,但无法确定结果
REVIEW       等待人工审核

这里最重要的是 UNKNOWN。网络超时、连接中断和 worker 崩溃都不等于付款失败,不能直接把这类记录转回 PENDING。

3. 用数据库事务抢占执行权

worker 可以通过条件更新,保证同一时刻只有一个执行者取得付款任务:

UPDATE payments
SET
    status = 'SUBMITTING',
    attempt_id = :attempt_id,
    started_at = CURRENT_TIMESTAMP
WHERE
    payment_id = :payment_id
    AND status = 'PENDING';

只有受影响行数为 1 的 worker 才能继续调用外部 API。

这种做法可以防止多个 worker 并发发送,却无法解决调用成功后进程崩溃的问题。它提供的是本地互斥,不是跨系统的 exactly once。

4. 区分明确失败和结果未知

以下错误通常可以理解为“请求未被业务接受”,但最终仍要根据具体 API 合同判断:

  • 本地参数校验失败;
  • 请求发出前连接失败;
  • Payment Service 明确返回业务拒绝;
  • Payment Service 明确说明该错误不会创建付款。

遇到以下情况时,应将付款转入 UNKNOWN,而不是自动重试:

  • 请求超时;
  • 读取响应时连接断开;
  • 收到无法判断处理结果的 5xx;
  • worker 在发出请求后崩溃;
  • Payment Service 返回含义不明确的响应。

示例:

async function submitPayment(paymentId: string): Promise<void> {
  const attemptId = crypto.randomUUID();

  const claimed = await claimPendingPayment(paymentId, attemptId);
  if (!claimed) {
    return;
  }

  const payment = await loadPayment(paymentId);

  try {
    const response = await paymentService.sendPayment({
      recipient: payment.recipient,
      amount: payment.amount,
      currency: payment.currency,
      reference: payment.paymentId
    });

    if (response.confirmedSuccess) {
      await markSucceeded(paymentId, attemptId, response.providerPaymentId);
      return;
    }

    if (response.confirmedRejected) {
      await markFailed(paymentId, attemptId, response.errorCode);
      return;
    }

    await markUnknown(paymentId, attemptId, "ambiguous response");
  } catch (error) {
    /*
     * 除非能够证明请求从未到达 Payment Service,
     * 否则不能假定付款失败,也不能直接重新发送。
     */
    await markUnknown(paymentId, attemptId, describe(error));
  }
}

confirmedSuccess 和 confirmedRejected 必须按照 Payment Service 的实际协议定义,不能只凭 HTTP 状态码推断。

5. 查询和对账 UNKNOWN 状态

定时任务不应只是把超时的付款重新入队,而应先确认外部系统中实际发生了什么:

  1. 使用 payment_id、外部流水号或 reference 查询;
  2. 检查异步 webhook;
  3. 获取 Payment Service 的交易列表或结算文件;
  4. 按收款账户、金额、币种和时间窗口匹配;
  5. 只有一个匹配结果时,确认付款成功;
  6. 没有匹配结果,并且能够确定付款未发生时,才允许重新发送;
  7. 如果存在多个匹配结果或证据不足,则转入人工审核。
SELECT payment_id, attempt_id, recipient, amount, currency, started_at
FROM payments
WHERE status = 'UNKNOWN'
  AND started_at < CURRENT_TIMESTAMP - INTERVAL '10 minutes';

这里的十分钟只是示例。实际等待时间应根据供应商的处理延迟、回调机制和结算周期确定。

如果外部系统连查询能力也没有

如果 Payment Service 同时缺少以下全部能力:

  • idempotency key;
  • 客户端业务流水号;
  • 付款结果查询;
  • webhook;
  • 交易明细或结算文件;
  • 可供核对的银行流水;

调用方只能在两种风险之间取舍:

策略行为主要风险
at most once发出请求后不自动重试可能漏付
at least once结果未知时继续重试可能重复付款

对于付款业务,一般更适合采用以下处理方式:

结果未知 → 暂停自动处理 → 对账或人工审核

也就是说,自动流程采用 at most once,异常流程再通过补偿和人工介入完成处理。它无法证明调用恰好发生了一次,但通常比盲目重试更符合资金安全要求。

具体选择还要考虑业务的损失模型。例如,小额权益发放可能可以容忍偶尔重复,大额转账则通常更难接受重复付款。

内部账本应与外部付款分开

成熟的支付系统通常会维护不可变或可审计的内部账本。外部付款只是结算动作,不能成为唯一的财务事实。

系统可以分别记录:

  • 付款意图;
  • 每次调用尝试;
  • 请求摘要;
  • 开始和结束时间;
  • 外部响应及流水号;
  • 状态变更记录;
  • 对账结果;
  • 人工审核人和处理原因;
  • 冲正或补偿记录。

每次尝试都应保存为独立记录,而不是覆盖旧数据:

CREATE TABLE payment_attempts (
    attempt_id UUID PRIMARY KEY,
    payment_id UUID NOT NULL,
    started_at TIMESTAMP NOT NULL,
    finished_at TIMESTAMP,
    outcome VARCHAR(32) NOT NULL,
    provider_payment_id VARCHAR(255),
    error_detail TEXT
);

日志中不应保存银行卡号、完整身份信息、访问令牌等敏感数据。请求摘要可以使用脱敏字段或受控哈希,但哈希不能代替正式的业务标识。

选型时应把幂等能力当成硬性要求

如果业务要求系统能够自动恢复,同时又不能重复付款,最可靠的办法通常不是继续改造 worker,而是升级或更换外部接口。理想的 Payment Service 至少应提供以下一种能力:

  • 接收唯一的 idempotency key,并在重复请求时返回同一结果;
  • 接收唯一的商户付款单号,并拒绝重复单号;
  • 支持按商户付款单号查询最终状态;
  • 提供可靠的 webhook 和交易明细;
  • 为结果未知的请求提供明确的查询和申诉流程。

即使供应商支持幂等,调用方仍需保留本地状态机、持久化的尝试记录和对账机制。幂等键可能有有效期,回调可能重复或乱序,查询结果也可能暂时处于处理中。

需要避免的常见做法

  • 将“HTTP 超时”直接认定为付款失败。
  • 对所有 5xx 无条件重试。
  • 认为数据库锁能够覆盖外部 API 的执行。
  • 依赖单个长期运行的 worker,并假定它永远不会崩溃。
  • 把消息队列的 exactly once 当作资金操作的端到端保证。
  • 只按金额和收款人去重,因为两笔合法付款可能恰好有相同的金额和收款人。
  • 自动重置长期处于 SUBMITTING 的记录并重新付款。
  • 删除或覆盖失败尝试,导致后续无法对账和审计。

严格来说,面对非事务性、不支持幂等且无法查询的外部 API,调用方无法单方面实现 exactly once。实际可行的方案是控制并发、保存付款意图、明确处理不确定状态,再通过查询、对账、补偿和人工审核,把漏付与重复付款的风险控制在业务可接受的范围内。

备注:内容仅供参考。