如何确保 external API 调用实现 exactly once?
结论
如果外部 Payment Service 既不支持 idempotency key,也不能按业务流水号查询付款结果,调用方就无法严格保证 exactly once。
问题不在于 worker 是否可靠,而在于系统始终存在一个无法消除的“结果未知”窗口:
- Payment Service 已经成功付款;
- worker 在收到响应前超时或崩溃;
- worker 无法判断付款是否成功;
- 此时重试可能造成重复付款,不重试又可能漏付。
因此,工程上通常不会追求端到端的绝对 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 状态
定时任务不应只是把超时的付款重新入队,而应先确认外部系统中实际发生了什么:
- 使用
payment_id、外部流水号或reference查询; - 检查异步 webhook;
- 获取 Payment Service 的交易列表或结算文件;
- 按收款账户、金额、币种和时间窗口匹配;
- 只有一个匹配结果时,确认付款成功;
- 没有匹配结果,并且能够确定付款未发生时,才允许重新发送;
- 如果存在多个匹配结果或证据不足,则转入人工审核。
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。实际可行的方案是控制并发、保存付款意图、明确处理不确定状态,再通过查询、对账、补偿和人工审核,把漏付与重复付款的风险控制在业务可接受的范围内。
备注:内容仅供参考。
