非事务组件下的健壮支付处理架构设计
结论
信用卡服务、数据库和客户端无法由一个跨组件的 ACID 事务统一覆盖,单靠数据库锁也不能保证支付绝不重复。
更可靠的做法,是把支付设计为由服务端统一执行、能够恢复的状态机,并配合以下机制:
- 所有支付都由服务端集中处理。UI 只提交支付请求,不直接调用外部支付接口。
- 每次业务支付都使用稳定且唯一的
idempotency_key。 - 调用外部服务前,先持久化支付意图并取得订单处理权。
- 外部调用结果先写入独立的持久化日志或消息队列,再异步更新业务数据库。
- 通过数据库唯一约束、租约和带状态条件的更新,阻止多个操作员并发处理同一订单。
- 遇到超时、进程崩溃等结果不明的情况,先向支付服务查询原交易,不能直接重试扣款。
- 设置后台恢复和对账任务,持续处理未完成状态。
外部支付服务是否支持幂等键,或者能否按商户交易号查询,直接决定了系统能否真正避免重复扣款。如果两者都不支持,那么当外部服务已经执行请求、响应却丢失时,技术上无法同时保证不漏扣和不重复扣,只能交由人工核对。
为什么普通事务和数据库锁不够
数据库事务只能保护数据库内部的数据。下面这些操作无法组成一个原子事务:
BEGIN TRANSACTION
调用外部支付服务
写入本地数据库
COMMIT
可能出现以下故障:
- 外部服务已经扣款,数据库写入失败。
- 外部服务已经扣款,但调用方在收到响应前崩溃。
- 请求超时,调用方无法判断外部服务是否已经扣款。
- 数据库连接断开后,锁或未提交事务被释放,其他操作员再次发起支付。
- 某台客户端把恢复信息保存在本机,其他客户端无法看到,仍可能重复处理。
数据库事务不应一直保持到网络调用结束,“是否可以支付”的判断也不能分散在多个 UI 中。判断和执行都应集中到一个服务端支付组件。
推荐架构
Operator UI
│
│ SubmitPayment(orderId)
▼
Payment Processing Service
├── Payment DB:支付意图、状态、租约、唯一约束
├── Durable Journal / Queue:支付结果和恢复事件
├── Payment Worker:调用外部支付服务
└── Recovery Worker:重试持久化、查询交易、执行对账
│
▼
External Payment Web Service
所有操作员都只能调用 Payment Processing Service。外部支付服务的凭据也只保存在这个服务中,防止客户端绕过并发控制。
一次支付可以采用以下状态:
Created
→ Processing
→ Succeeded
→ Persisted
Processing
→ Unknown
→ Succeeded / Failed / ManualReview
任意可恢复状态
→ ManualReview
Succeeded 表示外部服务已经确认支付成功,但业务订单可能还没有更新;Persisted 表示支付结果和订单状态都已写入最终数据库。
第一步:建立稳定的业务幂等键
幂等键不能在每次重试时重新生成。它应对应一次明确的业务支付意图,例如:
payment:{orderId}:{paymentAttemptId}
如果业务规定一张订单只能支付一次,可以对 order_id 建立唯一约束。如果允许分次支付、换卡重试或失败后重新支付,就必须先明确一次”支付意图”的边界,并使用独立的 payment_attempt_id。
示例表结构:
CREATE TABLE payment_attempt (
payment_attempt_id UUID PRIMARY KEY,
order_id UUID NOT NULL,
idempotency_key VARCHAR(100) NOT NULL,
status VARCHAR(30) NOT NULL,
operator_id UUID NOT NULL,
lease_owner VARCHAR(100),
lease_expires_at TIMESTAMP NULL,
provider_txn_id VARCHAR(100) NULL,
amount DECIMAL(18, 2) NOT NULL,
result_payload TEXT NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
CONSTRAINT uq_payment_idempotency UNIQUE (idempotency_key)
);
如果每张订单只能存在一个有效支付,可以根据数据库能力增加条件唯一索引,例如:
CREATE UNIQUE INDEX uq_order_active_payment
ON payment_attempt(order_id)
WHERE status IN ('Created', 'Processing', 'Unknown', 'Succeeded');
不同数据库支持条件索引的方式并不相同,需要根据实际产品调整。
唯一约束是并发控制的最后一道防线。即使两个操作员同时点击支付,也只能成功创建一个有效的支付意图。
第二步:先取得处理权,再调用外部服务
系统必须在调用外部服务之前创建支付意图并取得处理权。这里可以使用短事务和条件更新:
UPDATE payment_attempt
SET status = 'Processing',
lease_owner = :worker_id,
lease_expires_at = :lease_expires_at,
updated_at = CURRENT_TIMESTAMP
WHERE payment_attempt_id = :payment_attempt_id
AND (
status = 'Created'
OR (
status = 'Processing'
AND lease_expires_at < CURRENT_TIMESTAMP
)
);
只有更新行数为 1 的 worker 获得执行权。其他请求应返回当前状态,例如”该订单正在由其他操作员处理”,而不是再次调用支付接口。
使用租约时要注意:
- 租约过期并不表示原支付请求一定没有执行。
- 接管过期任务后,必须先通过
idempotency_key或商户交易号查询外部服务,再决定是否重新发送请求。
如果支付意图存储不可用,原则上不应开始新的支付。此时系统既无法登记处理权,也无法阻止其他操作员重复操作。
第三步:把外部调用设计成幂等操作
调用外部服务时,应传入同一个 idempotency_key:
var request = new ChargeRequest
{
Amount = payment.Amount,
Currency = payment.Currency,
IdempotencyKey = payment.IdempotencyKey,
MerchantReference = payment.PaymentAttemptId.ToString()
};
ChargeResult result = await gateway.ChargeAsync(request, cancellationToken);
重试时仍使用原来的键:
ChargeResult result =
await gateway.ChargeAsync(originalRequest, cancellationToken);
不要这样处理:
// 错误:每次重试生成新键,支付服务会把它视为新交易。
originalRequest.IdempotencyKey = Guid.NewGuid().ToString();
如果接口不支持幂等键,但允许按 MerchantReference 查询,超时后应先查询原交易:
var existing = await gateway.FindByMerchantReferenceAsync(
payment.PaymentAttemptId.ToString(),
cancellationToken);
if (existing is not null)
{
result = existing;
}
else
{
// 只有能够确认原请求未被执行时,才允许重新提交。
result = await gateway.ChargeAsync(request, cancellationToken);
}
网络超时只表示调用方没有及时收到结果,并不表示扣款失败。因此,超时后的状态应记为 Unknown,不能直接标记为 Failed。
第四步:先可靠记录结果,再更新业务数据库
外部服务返回结果后,如果业务数据库可能无法访问,就需要另一个独立的持久化位置,例如:
- 高可用的持久化消息队列;
- 支付服务自己的独立数据库;
- 使用追加写和
fsync的本地持久化日志,同时配合主备或磁盘冗余。
处理顺序如下:
收到支付结果
→ 写入 durable journal / queue
→ 确认写入成功
→ 异步更新 payment DB 和 order DB
→ 标记恢复消息已处理
消息至少应包含以下内容:
{
"eventId": "37c87905-75c0-49ec-aad4-4149cfbcaf93",
"paymentAttemptId": "2fe45ef5-e837-414f-a921-9c873fd34d41",
"orderId": "c2e77eaa-f793-4f12-90ee-d0888f1e9480",
"idempotencyKey": "payment:c2e77eaa:2fe45ef5",
"providerTransactionId": "txn_123456",
"status": "Succeeded",
"amount": 100.00,
"receivedAt": "2026-09-01T10:30:00Z"
}
恢复消费者本身也必须支持幂等处理。例如,可以先登记已经处理的事件:
BEGIN;
INSERT INTO processed_payment_event(event_id, processed_at)
VALUES (:event_id, CURRENT_TIMESTAMP)
ON CONFLICT (event_id) DO NOTHING;
只有插入成功后,才继续更新支付和订单数据。如果事件已经存在,则直接确认消息。实际代码还需要检查受影响行数:
UPDATE payment_attempt
SET status = 'Persisted',
provider_txn_id = :provider_txn_id,
result_payload = :result_payload,
updated_at = CURRENT_TIMESTAMP
WHERE payment_attempt_id = :payment_attempt_id
AND status IN ('Processing', 'Unknown', 'Succeeded');
UPDATE customer_order
SET payment_status = 'Paid',
paid_at = :paid_at
WHERE order_id = :order_id
AND payment_status <> 'Paid';
COMMIT;
消息系统通常只能提供”至少一次投递”。重复投递本身不是问题,只要消费者能够安全地重复执行。
数据库不可用时,其他操作员如何知道订单正在处理
这取决于由哪个组件负责协调。
如果所有 UI 都只通过支付服务操作,支付服务就可以从自己的支付存储或持久化队列中返回:
{
"orderId": "c2e77eaa-f793-4f12-90ee-d0888f1e9480",
"status": "Succeeded",
"message": "支付已完成,订单数据库正在同步,请勿再次处理。"
}
UI 不能只检查业务订单表里的 Paid 字段。支付结果等待恢复时,订单表可能仍然显示未支付。系统应结合支付状态作出判断:
Created / Processing / Unknown / Succeeded
→ 禁止创建新的支付意图
Failed
→ 根据失败类型决定是否允许重新支付
Persisted
→ 显示支付完成
ManualReview
→ 禁止自动重试,交由人工核对
如果支付数据库、持久化队列和业务数据库同时不可访问,系统就没有一个可供所有客户端共同观察的状态。此时无法可靠判断订单是否正在支付。安全做法只能是暂停新的人工支付,不能继续放行操作。
崩溃恢复流程
后台恢复任务可以按以下规则执行:
- 扫描租约已经过期的
Processing记录。 - 使用原来的
idempotency_key或MerchantReference查询外部服务。 - 如果查到成功交易,记录结果,将状态更新为
Succeeded,并继续同步订单数据库。 - 如果查到明确失败,将状态更新为
Failed。 - 如果仍无法确定结果,保持
Unknown,延迟后再次查询。 - 超过规定时间仍无法确认时,转为
ManualReview,禁止自动再次扣款。 - 定期将支付服务的结算文件或交易列表与本地记录进行对账。
示例逻辑:
switch (await gateway.LookupAsync(payment.IdempotencyKey, cancellationToken))
{
case GatewayPayment { Status: GatewayStatus.Succeeded } transaction:
await journal.AppendAsync(
PaymentEvent.Succeeded(payment, transaction),
cancellationToken);
break;
case GatewayPayment { Status: GatewayStatus.Failed } transaction:
await journal.AppendAsync(
PaymentEvent.Failed(payment, transaction),
cancellationToken);
break;
case null:
await MarkUnknownOrScheduleRetry(payment, cancellationToken);
break;
}
恢复任务本身可能重复运行,因此每一步都必须允许重复执行。
需要特别注意的边界
持久化介质也可能失败
如果外部扣款成功后,进程在执行任何持久化操作前突然掉电,支付事实就只保存在外部服务中。要处理这个窗口,需要依靠:
- 外部服务的幂等键;
- 按商户交易号查询;
- 后续结算对账。
仅增加内存队列无法解决这个问题,因为进程退出后,队列内容会随之丢失。
不要在网络调用期间持有数据库锁
长时间持有行锁容易引发阻塞、死锁和连接耗尽。进程崩溃后,锁虽然会被释放,却不能证明外部调用没有成功。这里应使用持久化状态和带期限的租约。
“exactly once”通常指业务效果,不是消息投递属性
实际系统通常采用以下组合:
at-least-once delivery
+ idempotent external request
+ idempotent consumer
+ unique constraint
+ reconciliation
= effectively-once payment effect
不能把消息队列声明的某种投递语义直接理解为信用卡只会扣款一次。请求跨过外部支付接口后,最终仍要依靠业务幂等和对账。
人工操作必须留下审计信息
至少应记录:
- 发起支付的操作员;
- 支付意图和每次状态变化;
- 幂等键及外部交易号;
- 请求和响应时间;
- 超时、重试及恢复过程;
- 人工解除锁定或重新支付的审批记录。
人工执行”强制重试”时不应生成新的支付意图,除非已经确认旧交易没有成功,或者业务明确接受可能重复扣款的风险。
最终原则
解决这类问题,不是把三个组件强行包装成一个事务,而是把支付过程拆成多个可观察、可恢复、可幂等的阶段。
调用外部服务前,必须先登记支付意图。调用完成后,必须把结果写入可靠的持久化介质。任何结果不确定的状态都必须阻止新的支付。恢复程序再通过原幂等键查询、重放和对账,逐步推进状态。这样,即使数据库短暂不可用,结果也能最终写入;多名操作员同时处理同一订单时,也不容易造成重复扣款。
备注:内容仅供参考。
