PAYATHON 2026

如何避免本地 database 与外部 payment service 不同步?

支付老李

明确结论

有成熟的处理方案,但仅靠一段同步代码,无法保证本地数据库与外部支付服务”永远原子一致”。

除非支付服务和本地数据库能共同参与同一个分布式事务,例如 2PC,否则两个系统之间一定会出现短暂不一致,也可能暂时无法确定处理结果。常见的工程方案会组合使用:

  • 本地事务与 Transactional Outbox
  • 全局唯一的业务单号
  • 支付接口的 idempotency key
  • 可恢复的交易状态机
  • 自动重试与主动查询
  • Webhook 通知
  • 定期对账和人工兜底

这套方案无法消灭所有故障。它要解决的是:请求能够安全重放,未知结果能够查清,暂时不一致的状态最终能够收敛,每次状态变化也都有记录可查。

最危险的情况是”结果未知”

网络超时不等于扣款失败。

支付服务可能已经完成扣款,只是响应在返回途中丢失。客户端看到 timeout 时,无法判断远端是否已经执行成功。因此,不能把所有异常都直接记为 Failed:

try:
    response = charge_customer()
except TimeoutError:
    record.mark_failed()  # 危险:远端可能已经扣款

交易状态至少应包括:

Created
Pending
Succeeded
Failed
Unknown

各状态的含义如下:

  • Succeeded:已经收到明确的成功结果。
  • Failed:支付服务明确表示交易失败,并且不会继续处理。
  • Unknown:发生超时、连接中断或响应无法解析,暂时不能确定交易结果。

Unknown 和长时间未结束的 Pending 应进入查询或对账流程。此时不能在没有幂等保护的情况下重新发起扣款。

使用业务单号和幂等键

每笔支付都应先生成一个全局唯一且固定不变的业务单号,例如 payment_id。首次请求、超时后的重试以及服务重启后的补偿请求,都必须使用同一个编号。

如果支付服务支持 idempotency key,可以这样调用:

payment_id = "pay_01JXYZ..."

response = Request.post(
    url=remote_url,
    headers={"Idempotency-Key": payment_id},
    data={
        "merchant_transaction_id": payment_id,
        "amount": billed_amount,
        "currency": "CNY",
    },
)

支付服务应保证,同一个 idempotency key 不会造成多次实际扣款。重复请求通常应返回第一次请求的结果,或者提供可用于查询原交易的信息。

接入前需要确认支付服务的具体语义:

  • 幂等键的有效期有多长。
  • 使用相同键但提交不同金额时会如何处理。
  • 并发提交相同键时会如何处理。
  • 超时后能否通过业务单号查询最终结果。
  • 返回结果是否包含支付服务侧的交易 ID。

如果外部服务既不支持幂等键,也不能通过商户业务单号查询交易,客户端就无法安全地自动重试扣款。此时只能依靠对账和人工确认,或者更换接口能力更完整的支付渠道。

使用 Transactional Outbox 保证请求不会丢失

不要在普通业务请求中直接执行”先写数据库,再调用支付 API”。更稳妥的做法是,在同一个本地数据库事务中写入支付记录和待发送事件:

BEGIN;

INSERT INTO payment (
    payment_id,
    amount,
    currency,
    status
) VALUES (
    :payment_id,
    :amount,
    'CNY',
    'Pending'
);

INSERT INTO outbox (
    event_id,
    event_type,
    aggregate_id,
    payload,
    status
) VALUES (
    :event_id,
    'ChargeRequested',
    :payment_id,
    :payload,
    'Pending'
);

COMMIT;

这两次写入属于同一个本地事务,因此只会出现两种结果:

  • 支付记录和 Outbox 事件一起提交成功。
  • 两者都没有提交。

后台 worker 从 Outbox 中读取任务,再调用支付服务:

def process_charge(event):
    payment = load_payment(event.aggregate_id)

    try:
        response = Request.post(
            url=remote_url,
            headers={"Idempotency-Key": payment.payment_id},
            data=payment.post_data,
        )
    except (TimeoutError, ConnectionError):
        mark_payment_unknown_if_possible(payment.payment_id)
        schedule_retry(event.event_id)
        return

    if response.ok:
        save_success(
            payment_id=payment.payment_id,
            provider_transaction_id=response.transaction_id,
            raw_response=response.raw,
        )
        mark_outbox_done(event.event_id)
    elif response.is_definitive_failure:
        save_failure(
            payment_id=payment.payment_id,
            error_code=response.error_code,
        )
        mark_outbox_done(event.event_id)
    else:
        mark_payment_unknown(payment.payment_id)
        schedule_query(payment.payment_id)

worker 可能在支付成功后、更新数据库之前崩溃。恢复后,它会再次处理同一条 Outbox 事件。由于请求仍使用原来的 idempotency key,支付服务不会再次扣款,而是返回原交易结果,worker 再将结果补写到本地数据库。

这是整个方案中最重要的一环。

用状态机控制合法转换

交易状态应通过明确的状态机更新,避免重试任务、Webhook 和对账程序相互覆盖结果。

例如,只允许以下转换:

Created -> Pending
Pending -> Succeeded
Pending -> Failed
Pending -> Unknown
Unknown -> Succeeded
Unknown -> Failed

普通异步任务一般不能反向修改终态。更新数据库时,可以将当前状态写入更新条件:

UPDATE payment
SET status = 'Succeeded',
    provider_transaction_id = :provider_transaction_id,
    updated_at = CURRENT_TIMESTAMP
WHERE payment_id = :payment_id
  AND status IN ('Pending', 'Unknown');

以下字段还应设置唯一约束:

UNIQUE (payment_id)
UNIQUE (provider_transaction_id)

这样即使多个 worker、Webhook 和对账任务同时处理同一笔交易,也能减少重复入账的风险。

Webhook、主动查询和对账缺一不可

如果支付服务提供 Webhook,可以将它作为确认最终状态的重要信息来源,但不能将其作为唯一保障。Webhook 可能延迟、重复、乱序,也可能长时间投递失败。

Webhook 接收端应完成以下处理:

  1. 验证签名、时间戳和来源。
  2. 保存原始事件。
  3. 使用支付服务的 event_id 去重。
  4. 根据本地 payment_id 或商户业务单号关联交易。
  5. 按状态机规则更新状态。
  6. 遇到异常状态时发出告警,不能直接静默覆盖。

对于长时间处于 Pending 或 Unknown 的交易,应主动查询支付服务:

result = payment_api.get_by_merchant_transaction_id(payment_id)

if result.status == "SUCCEEDED":
    mark_succeeded(payment_id, result.transaction_id)
elif result.status == "FAILED":
    mark_failed(payment_id, result.error_code)
else:
    schedule_query(payment_id)

系统还需要定期获取支付服务的结算单或交易流水,并与本地不可变账本逐笔核对。实时流程负责处理大多数情况,对账流程则用于发现长尾异常,包括遗漏的 Webhook、程序缺陷、人工操作和渠道侧数据修正。

金融记录应尽量采用不可变账本

不要在同一条记录上反复覆盖余额或交易结果。更稳妥的做法是保留不可变的交易事件或复式记账分录:

PaymentRequested
PaymentConfirmed
PaymentFailed
RefundRequested
RefundConfirmed
ReconciliationAdjusted

需要修正时,应追加冲正或调整记录,而不是删除或篡改历史交易。这样既能保留完整的审计链,也便于解释本地余额的计算过程。

支付状态表可以作为方便查询的投影,但账务事实应以可审计的账本为准。

2PC 和 Saga 是否适用

2PC 理论上可以让多个资源共同提交或回滚,但前提是数据库和支付服务都支持同一套分布式事务协议。大多数第三方支付 API 不提供这种能力,因此实际系统通常无法用 2PC 包住一次外部扣款。

Saga 更适合处理由多个本地事务组成的长流程,并通过补偿操作处理部分成功。不过,支付场景中的补偿操作通常是”退款”,而退款不等于原扣款从未发生:

  • 退款可能失败或延迟。
  • 手续费可能已经产生。
  • 用户会看到扣款和退款两条记录。
  • 汇率、额度和结算周期可能发生变化。

因此,Saga 可以组织扣款、记账、发货和退款流程,但无法消除支付操作本身的不确定性。

实施时还要注意

  • 金额应使用最小货币单位的整数或精确 decimal 类型,避免浮点误差。
  • idempotency key 必须持久化,不能在每次重试时重新生成。
  • 重试应使用指数退避和随机抖动,并设置限速。
  • 超过自动恢复时限的交易应触发告警,并进入人工处理队列。
  • 保存请求摘要、响应、渠道交易 ID、错误码和状态变化时间。
  • 日志中不能记录银行卡号、验证码、密钥等敏感数据。
  • 对账发现差异后不能直接覆盖原记录,应生成明确的调整记录。
  • “扣款失败”和”无法确认扣款结果”必须使用两个不同的状态。

可行的架构不追求跨系统的瞬时强一致,而是用 Outbox 保证任务不丢失,用幂等机制避免重复扣款,通过主动查询和 Webhook 确认结果,最后用对账处理未能自动收敛的异常。

备注:内容仅供参考。