PAYATHON 2026

如何处理失败或严重延迟的 webhook?

支付老李

结论

定时扫描和延迟队列不必二选一。支付场景更稳妥的方案是设置两层保障:

  1. 每笔支付创建后,立即安排一个延迟状态查询任务,用来处理 webhook 丢失或延迟。
  2. 同时运行低频 reconciliation scheduler,扫描长时间没有进入终态的支付,修复队列消息丢失、消费者故障或程序缺陷造成的遗漏。

延迟任务用于及时恢复,定时对账负责兜底。只依赖 webhook 不够可靠;只使用 scheduler 会产生大量无效扫描;只依赖队列又缺少最终对账机制。

推荐的处理流程

支付请求发出后,可以这样处理:

  1. 在本地数据库中创建支付记录,并生成唯一的 paymentId。
  2. 调用第三方支付 API,保存返回的 providerPaymentId。
  3. 提交事务后安排第一次状态查询,例如在 30 秒后执行。
  4. 如果 webhook 先到,更新支付状态。后续查询任务发现支付已经进入终态时直接结束。
  5. 如果查询任务先执行,则调用 status query API:
    • 返回成功或失败时,更新为对应终态。
    • 仍在处理中时,按照退避策略安排下一次查询。
    • 查询发生异常时,记录错误并重试,但不要直接将支付判定为失败。
  6. 低频 scheduler 定期扫描超过预期时间仍未结束的支付,重新查询或触发人工告警。

整体流程如下:

创建支付
   |
   +--> 第三方处理 --> webhook --------+
   |                                   |
   +--> 延迟状态查询 --> status API ----+--> 幂等更新支付状态
                                       |
定时对账扫描 ---------------------------+

无论状态更新来自 webhook、状态查询还是人工处理,都应进入同一套状态更新逻辑。

为什么延迟队列更适合作为主要机制

scheduler 通常需要周期性扫描数据库,例如查询所有创建时间超过一分钟、状态仍为 PENDING 的记录。这种方式实现简单,但随着支付量增加,会逐渐暴露出一些问题:

  • 大量记录会被反复扫描。
  • 故障恢复速度受扫描周期限制。
  • 部署多个 scheduler 实例时,需要处理并发抢占。
  • 数据库索引或批处理设计不合理,可能影响正常交易。

延迟队列可以按单笔支付安排检查,无需反复扫描全部未完成记录,也更容易控制查询时间、并发量和重试次数,所以适合作为主要恢复机制。

不过,队列并不是最终事实来源。配置错误、消费逻辑缺陷或运维问题都可能导致消息未被正确处理,因此仍需保留 reconciliation scheduler。

不要把”超时”直接当成”支付失败”

支付系统需要遵守以下原则:

请求超时 ≠ 支付失败
未收到 webhook ≠ 支付失败
状态查询失败 ≠ 支付失败

发生网络超时时,第三方可能已经完成扣款,只是响应没有成功返回。此时本地状态应保持为 PENDING、PROCESSING 或 UNKNOWN,再通过 status query API 确认实际结果。

只有第三方明确返回失败终态,或者业务规则允许在特定条件下关闭交易,才能把支付标记为失败。否则可能造成重复支付、重复退款或错误补单。

统一且幂等地更新状态

webhook 和查询任务可能同时返回,也可能重复返回,因此状态更新必须幂等,同时限制状态转换方向。

例如,已经确认成功的支付不能被较早返回的 PROCESSING 状态覆盖:

PENDING -> PROCESSING -> SUCCEEDED
                     \-> FAILED

SUCCEEDED 和 FAILED 通常属于终态

下面是一个简化的 TypeScript 示例:

type PaymentStatus =
  | "PENDING"
  | "PROCESSING"
  | "SUCCEEDED"
  | "FAILED";

type StatusSource = "WEBHOOK" | "STATUS_QUERY" | "MANUAL";

async function applyPaymentStatus(
  paymentId: string,
  newStatus: PaymentStatus,
  source: StatusSource
): Promise<void> {
  await db.transaction(async (tx) => {
    const payment = await tx.payment.findForUpdate(paymentId);

    if (!payment) {
      throw new Error(`Payment not found: ${paymentId}`);
    }

    if (
      payment.status === "SUCCEEDED" ||
      payment.status === "FAILED"
    ) {
      return;
    }

    if (
      payment.status === "PROCESSING" &&
      newStatus === "PENDING"
    ) {
      return;
    }

    await tx.payment.update(paymentId, {
      status: newStatus,
      statusSource: source,
      updatedAt: new Date()
    });

    await tx.paymentStatusHistory.insert({
      paymentId,
      oldStatus: payment.status,
      newStatus,
      source,
      createdAt: new Date()
    });
  });
}

实际状态机需要按照第三方的状态定义调整。有些支付渠道允许在成功后撤销、退款或冲正,这些状态不能简单归入同一个终态。

状态查询任务示例

查询任务不应无限高频重试。常见做法是前期查询频率较高,之后逐步拉长间隔,例如:

30 秒 -> 1 分钟 -> 2 分钟 -> 5 分钟 -> 15 分钟 -> 1 小时

具体间隔应根据第三方的正常处理耗时、API 限流规则和业务时效要求确定。

const RETRY_DELAYS_SECONDS = [30, 60, 120, 300, 900, 3600];

async function handleStatusQueryJob(job: {
  paymentId: string;
  attempt: number;
}): Promise<void> {
  const payment = await db.payment.findById(job.paymentId);

  if (!payment) {
    return;
  }

  if (
    payment.status === "SUCCEEDED" ||
    payment.status === "FAILED"
  ) {
    return;
  }

  let providerStatus: string;

  try {
    providerStatus = await paymentProvider.queryStatus(
      payment.providerPaymentId
    );
  } catch (error) {
    await recordQueryError(payment.id, error);
    await scheduleNextQuery(job);
    return;
  }

  switch (providerStatus) {
    case "SUCCESS":
      await applyPaymentStatus(
        payment.id,
        "SUCCEEDED",
        "STATUS_QUERY"
      );
      return;

    case "FAILED":
      await applyPaymentStatus(
        payment.id,
        "FAILED",
        "STATUS_QUERY"
      );
      return;

    case "PROCESSING":
    case "PENDING":
      await applyPaymentStatus(
        payment.id,
        "PROCESSING",
        "STATUS_QUERY"
      );
      await scheduleNextQuery(job);
      return;

    default:
      await recordUnknownProviderStatus(
        payment.id,
        providerStatus
      );
      await scheduleNextQuery(job);
  }
}

async function scheduleNextQuery(job: {
  paymentId: string;
  attempt: number;
}): Promise<void> {
  const nextAttempt = job.attempt + 1;
  const delay =
    RETRY_DELAYS_SECONDS[nextAttempt];

  if (delay === undefined) {
    await createReconciliationAlert(job.paymentId);
    return;
  }

  await paymentStatusQueue.enqueue(
    {
      paymentId: job.paymentId,
      attempt: nextAttempt
    },
    {
      delaySeconds: delay,
      deduplicationKey:
        `${job.paymentId}:${nextAttempt}`
    }
  );
}

重试间隔最好加入少量随机抖动,以免大量支付同时调用第三方 API。

保证数据库记录与队列消息一致

有一种情况很容易被忽略:

  1. 数据库中的支付记录创建成功。
  2. 向队列发送消息失败。
  3. 这笔支付始终没有状态查询任务。

可以用 Transactional Outbox 处理这个问题。在创建支付记录的同一个数据库事务中,同时写入一条待投递事件:

BEGIN;

INSERT INTO payments (
    payment_id,
    provider_payment_id,
    status,
    created_at
) VALUES (
    :payment_id,
    :provider_payment_id,
    'PENDING',
    CURRENT_TIMESTAMP
);

INSERT INTO outbox_events (
    event_id,
    event_type,
    aggregate_id,
    payload,
    published_at
) VALUES (
    :event_id,
    'PAYMENT_STATUS_QUERY_REQUIRED',
    :payment_id,
    :payload,
    NULL
);

COMMIT;

独立的 outbox publisher 持续读取未发布事件,将事件写入队列,并在成功后标记 published_at。即使发送队列时发生临时故障,事件也不会无声丢失。

如果现有基础设施不适合实现 outbox,低频 reconciliation scheduler 仍可修复这类遗漏,只是恢复时间通常更长。

webhook 本身仍需加固

主动查询只是补偿机制,不能取代 webhook 的基本防护:

  • 校验签名、时间戳和来源。
  • 使用事件 ID 或业务 ID 去重。
  • webhook 接口应快速返回,将耗时处理放入内部队列。
  • 保存原始 payload、接收时间和处理结果,以便审计。
  • 对乱序通知进行状态转换校验。
  • 如果第三方要求用特定 HTTP 状态码表示成功,应严格遵守其协议。

接收接口可以只负责验证和入队:

async function receiveWebhook(
  request: HttpRequest
): Promise<HttpResponse> {
  verifyWebhookSignature(
    request.headers,
    request.rawBody
  );

  const event = parseWebhook(request.rawBody);

  await webhookInbox.insertIfAbsent({
    providerEventId: event.id,
    paymentId: event.paymentId,
    payload: request.rawBody,
    receivedAt: new Date()
  });

  await webhookQueue.enqueue(
    { providerEventId: event.id },
    { deduplicationKey: event.id }
  );

  return {
    status: 200,
    body: "OK"
  };
}

insertIfAbsent 应由数据库唯一约束保证,不能只靠先查询再插入,否则并发请求仍可能产生重复记录。

SQS 等队列的使用注意事项

如果使用 SQS,需要留意平台对消息延迟、可见性超时和保留时间的限制。不要假设消息可以在队列中等待任意长的时间。

需要跨数小时或数天安排多次查询时,可以采用以下方式:

  • 每次消费后发送下一条延迟消息。
  • 使用专门的任务调度服务。
  • 使用支持持久化 timer 的工作流引擎。
  • 将下一次查询时间写入数据库,再由小范围 scheduler 拉取到期任务。

无论选择哪种组件,消费者都要按照”至少一次投递”设计。消息可能重复,因此处理逻辑必须幂等,也不能依赖队列提供业务层面的 exactly-once 保证。

reconciliation scheduler 应如何设计

兜底任务不需要频繁扫描全部支付,只查询超过阈值且仍处于非终态的记录即可:

SELECT payment_id
FROM payments
WHERE status IN ('PENDING', 'PROCESSING')
  AND next_reconciliation_at <= CURRENT_TIMESTAMP
ORDER BY next_reconciliation_at
LIMIT :batch_size
FOR UPDATE SKIP LOCKED;

应为查询条件建立合适的联合索引,并采用分页或固定批次,避免一次加载大量记录。多个 worker 并行运行时,可以使用数据库锁、租约字段或任务认领机制,防止同一记录被大量重复处理。

对账任务还要设置最长自动处理时间。支付超过阈值后仍无法确定状态,应转为 UNKNOWN 或类似的人工核对状态并触发告警,不能永久重试。

最终建议

这类支付场景可以按照以下优先级设计:

  1. webhook 是低延迟的正常通知通道。
  2. 延迟队列或持久化 timer 用于主动确认单笔支付。
  3. reconciliation scheduler 是最终安全网。
  4. 对长期无法确认的支付触发告警,转交人工处理。

选择哪一种触发方式并不是重点。所有通道都应汇入同一个幂等状态机,并以第三方 status query API 返回的权威状态完成对账。这样,即使 webhook、队列或某个服务短暂失效,支付记录也不会永久停留在错误状态。

备注:内容仅供参考。