如何处理失败或严重延迟的 webhook?
结论
定时扫描和延迟队列不必二选一。支付场景更稳妥的方案是设置两层保障:
- 每笔支付创建后,立即安排一个延迟状态查询任务,用来处理 webhook 丢失或延迟。
- 同时运行低频 reconciliation scheduler,扫描长时间没有进入终态的支付,修复队列消息丢失、消费者故障或程序缺陷造成的遗漏。
延迟任务用于及时恢复,定时对账负责兜底。只依赖 webhook 不够可靠;只使用 scheduler 会产生大量无效扫描;只依赖队列又缺少最终对账机制。
推荐的处理流程
支付请求发出后,可以这样处理:
- 在本地数据库中创建支付记录,并生成唯一的
paymentId。 - 调用第三方支付 API,保存返回的
providerPaymentId。 - 提交事务后安排第一次状态查询,例如在 30 秒后执行。
- 如果 webhook 先到,更新支付状态。后续查询任务发现支付已经进入终态时直接结束。
- 如果查询任务先执行,则调用 status query API:
- 返回成功或失败时,更新为对应终态。
- 仍在处理中时,按照退避策略安排下一次查询。
- 查询发生异常时,记录错误并重试,但不要直接将支付判定为失败。
- 低频 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。
保证数据库记录与队列消息一致
有一种情况很容易被忽略:
- 数据库中的支付记录创建成功。
- 向队列发送消息失败。
- 这笔支付始终没有状态查询任务。
可以用 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 或类似的人工核对状态并触发告警,不能永久重试。
最终建议
这类支付场景可以按照以下优先级设计:
- webhook 是低延迟的正常通知通道。
- 延迟队列或持久化 timer 用于主动确认单笔支付。
- reconciliation scheduler 是最终安全网。
- 对长期无法确认的支付触发告警,转交人工处理。
选择哪一种触发方式并不是重点。所有通道都应汇入同一个幂等状态机,并以第三方 status query API 返回的权威状态完成对账。这样,即使 webhook、队列或某个服务短暂失效,支付记录也不会永久停留在错误状态。
备注:内容仅供参考。
