如何避免本地 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 接收端应完成以下处理:
- 验证签名、时间戳和来源。
- 保存原始事件。
- 使用支付服务的
event_id去重。 - 根据本地
payment_id或商户业务单号关联交易。 - 按状态机规则更新状态。
- 遇到异常状态时发出告警,不能直接静默覆盖。
对于长时间处于 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 确认结果,最后用对账处理未能自动收敛的异常。
备注:内容仅供参考。
