系统设计:Design a Payment Ledger
支付系统最危险的 bug 往往发生在“请求到底成功没有”的灰色地带。客户端发起付款,服务调用支付供应商;供应商已经扣款,但响应丢了。客户端重试,如果系统把它当成新付款,用户会被扣两次。
更深一层,即使供应商只扣一次,我们自己的余额和账本也可能因部分失败对不上。因此支付系统要同时维护两种东西:一个会变化的支付流程状态,以及一份只能追加、可审计的资金事实。
这道题的核心是:用幂等状态机协调外部副作用,用双重记账账本守住内部资金不变量,再用对账处理无法靠在线事务消除的现实差异。
配套实验:打开 Payment Ledger Lab。先打开重复请求,再制造 PSP timeout;观察“API 幂等”和“外部实际只扣一次”为什么是两层问题。
一次付款里有两个不同的“成功”
用户支付 100 美元。系统可能经历:
PaymentIntent created
-> PSP charge accepted
-> internal ledger posted
-> merchant payout scheduled
PSP charge 成功不等于内部 ledger 已记账;ledger 已记账也不等于商户已经收到 payout。把所有阶段压成 paid=true,发生故障时就无法判断下一步应该 retry、查询还是补偿。
至少要区分:
- Payment state:这笔业务流程走到哪里;
- Ledger posting:资金在内部账户间如何移动;
- Provider state:外部 PSP 实际做了什么;
- Settlement state:资金何时真正结算到银行或商户。
双重记账为什么不是“存两条重复记录”
假设用户向商户支付 100 美元,平台收 3 美元手续费。一个简化 ledger transaction 可以有:
Debit user_cash 100 USD
Credit merchant_payable 97 USD
Credit platform_revenue 3 USD
每个 ledger transaction 内:
这是一个结构性不变量。不是把余额从 100 改成 0,再把商户余额从 0 改成 97;而是追加一组平衡分录。当前余额是分录聚合或其可信物化结果。
退款不会删除原交易,而是追加反向分录。历史不可变,审计才能回答“为什么今天的余额是这样”。
题目边界
本文设计一个平台支付与账本核心:
- 创建 payment intent;
- 通过外部 PSP authorize/capture;
- 把成功业务动作原子记入双重账本;
- 支持退款和冲正;
- 查询 payment、transaction 和 account balance;
- 通过 webhook 与日终文件对账;
- 所有写操作幂等并可审计。
第一版不展开风控、银行卡网络协议、税务和完整清结算。金额均以单币种 transaction 为主;外汇需要独立汇率与多币种分录。
非功能目标:
- 任何已确认 ledger transaction 都 durable;
- 同一业务意图重试不会重复扣款或重复记账;
- 单 transaction 永远借贷平衡;
- 资金账户不允许未经定义的负余额;
- 外部 PSP 状态最终可通过 reconciliation 对齐;
- 所有 actor、版本和状态变更可审计;
- 正确性优先于跨 Region 写可用性。
第一版:一台应用、一个关系数据库、一个模拟 PSP
先不要 Kafka 和微服务。关系数据库事务非常适合守住账本不变量。
API
POST /v1/payment-intents
Idempotency-Key: checkout-session-991
{
"payerId":"user-42",
"merchantId":"merchant-8",
"amount":{"currency":"USD","minorUnits":10000},
"paymentMethodToken":"pm-token"
}
{
"paymentIntentId":"pi-77",
"state":"PROCESSING",
"amount":{"currency":"USD","minorUnits":10000}
}
使用整数 minor units,禁止浮点金额。币种使用 ISO 代码,但要知道不同币种的小数位不同,不能全都假设 2 位。
查询和退款:
GET /v1/payment-intents/pi-77
POST /v1/payment-intents/pi-77/refunds
退款也有独立 idempotency key,且累计退款不能超过可退金额。这个约束必须在数据库事务中检查。
Payment 状态机
CREATED
-> PROCESSING
-> AUTHORIZED
-> CAPTURED
-> FAILED
CAPTURED -> PARTIALLY_REFUNDED -> REFUNDED
CAPTURED -> DISPUTED
只允许合法状态转换,并使用 version compare-and-swap:
UPDATE payment_intents
SET state = 'CAPTURED', version = version + 1
WHERE payment_intent_id = :id
AND state = 'PROCESSING'
AND version = :expected_version;
重复 webhook 或 worker retry 命中已完成状态时返回幂等成功,而不是再次记账。
State machine 保存外部 provider reference:
PaymentIntent(
payment_intent_id,
tenant_id,
idempotency_key,
payer_id,
merchant_id,
amount_minor,
currency,
state,
provider,
provider_payment_id,
version,
created_at,
updated_at
)
Ledger 数据模型
LedgerAccount(
account_id,
owner_type,
owner_id,
currency,
account_type,
normal_balance,
state
)
LedgerTransaction(
ledger_transaction_id,
business_type,
business_reference UNIQUE,
effective_at,
created_at,
description,
state
)
LedgerEntry(
ledger_transaction_id,
entry_sequence,
account_id,
direction,
amount_minor,
currency,
PRIMARY KEY (ledger_transaction_id, entry_sequence)
)
business_reference 例如 payment:pi-77:capture,保证同一 capture 只 posting 一次。
数据库在 commit 前验证:
- 至少两条 entry;
- 每条 amount 为正整数;
- 所有 entry currency 满足 transaction 规则;
- Debit 总额等于 credit 总额;
- 受限账户余额不会越过允许边界;
- Business reference 唯一。
不要靠每日脚本才发现借贷不平。平衡必须是写入事务的硬约束。
Balance:派生值也要能证明
精确余额可以从所有 entry 聚合,但账户有数亿分录时太慢。维护物化余额:
AccountBalance(
account_id,
posted_debits,
posted_credits,
available_balance,
version,
updated_at
)
Posting transaction 在同一个数据库事务中插入 ledger entries 并更新受影响 balances。Balance 是优化,ledger entries 是事实来源。
定期从 entries 重算并与 materialized balance 比较。发现差异时告警和冻结相关操作,不能直接“把数字改对”而没有修正分录。
Pending authorization 与 posted balance 分开。预授权可以减少 available balance,但尚未成为最终 posted capture;不要把 pending hold 伪装成最终交易。
外部 PSP 调用:数据库事务包不住互联网
不能开启数据库事务,调用 PSP 等两秒,再 commit。长事务会锁资源,而且数据库回滚无法撤销 PSP 已完成的扣款。
采用状态机 + worker:
- API 事务创建 PaymentIntent 和 outbox;
- Worker 领取 intent,状态改 PROCESSING;
- 使用
payment_intent_id作为 PSP idempotency key 调用; - PSP 返回成功后,数据库事务写 provider reference、post ledger、改 CAPTURED、写后续 outbox;
- 超时则进入
UNKNOWN_PROVIDER_STATE或保持 PROCESSING,由 reconciler 查询。
flowchart LR C[Checkout] --> API[Payment API] API --> DB[(Payment DB + outbox)] DB --> Q[(Payment work queue)] Q --> O[Payment orchestrator] O --> PSP[Payment provider] O --> L[(Ledger)] PSP --> W[Webhook receiver] W --> DB DB --> R[Reconciliation] PSP --> R
Ledger 可以与 Payment 表位于同一强事务数据库,第一版最安全。规模和组织需要拆服务时,用明确 posting API、idempotency 和 saga;不要为了“微服务”提前失去原子性。
Idempotency 分三层
API idempotency
同一 checkout retry 返回同一个 PaymentIntent。Key 绑定请求 payload hash;同 key 不同金额必须报冲突,不能返回旧结果。
IdempotencyRecord(
tenant_id,
key,
request_hash,
resource_id,
response_ref,
state,
expires_at
)
Provider idempotency
向 PSP retry 使用稳定 key。若 PSP 不支持,则先查询 provider state 或人工处理 ambiguous outcome。
Ledger idempotency
business_reference 唯一,capture/refund 只能 posting 一次。即使 webhook 和 polling 同时确认成功,也只有一个事务获胜。
任何一层缺失,都可能造成不同形式的重复。
Webhook:不可信、重复、乱序的外部输入
Webhook receiver:
- 验证签名和时间窗口;
- 按 provider event ID 去重;
- 原始 payload 加密存档;
- 快速返回 2xx;
- 异步根据 provider payment ID 找本地 intent;
- 通过状态机应用事件。
refund_succeeded 可能在我们看到 charge_succeeded 前到达。状态机不能只按接收顺序盲改;必要时查询 PSP authoritative state。
Webhook 不是唯一真相。它会延迟或丢失,因此 reconciler 仍需轮询 pending/unknown payments。
Reconciliation:在线正确也需要离线核对
每日获取 PSP settlement report:
provider payment id
gross amount
fee
refund
settlement status
currency
与本地 Payment + Ledger 对比,产生:
- PSP 有、本地无;
- 本地 captured、PSP failed;
- 金额或币种不一致;
- Fee/settlement 差异;
- 重复 provider reference。
ReconciliationCase(
case_id,
provider,
provider_reference,
local_reference,
discrepancy_type,
amount_delta,
state,
resolution_entry_id
)
修正通过明确 adjustment ledger transaction 完成,不编辑历史 entry。Reconciliation 不是系统失败的补丁,而是支付系统面对外部边界的正式组成部分。
容量估算
假设高峰 50K payment attempts/s,成功率 90%。每个成功 payment 产生平均 3 条 ledger entries:
45K × 3 = 135K ledger entries/s
加退款、费用、payout 和 adjustment,真实 entry 可能更多。数据库按 account/ledger partition 设计,但单 transaction 的所有 entries 必须在同一事务边界。
若每天 1B entries、每条含索引约 200 bytes:
1B × 200B = 200GB/day raw ledger data
Ledger 历史长期保留,采用按时间/账户分区、只读归档和独立分析副本。OLTP 主库不承担任意年度报表扫描。
热门平台 clearing account 会出现在大量 transaction 中,可能成为 balance row 热点。可按业务/时间拆多个子账户,结算时汇总;但每个 transaction 仍须平衡。不能为了并发丢掉 accounting 语义。
延迟预算:付款完成不必等所有后续动作
用户付款可能需要外部网络,p99 数百毫秒到数秒。可以拆:
| 阶段 | 预算示例 |
|---|---|
| API 校验、幂等 | 30 ms |
| PSP authorize/capture | 1,500 ms |
| Ledger posting | 50 ms |
| 响应与余量 | 420 ms |
Notification、analytics、merchant payout scheduling 都在 outbox 后异步,不进入 checkout latency。
如果 PSP 超过同步 deadline,返回 PROCESSING 和 PaymentIntent ID,让客户端查询或接收 webhook;不要把未知状态报成失败并鼓励创建新付款。
多 Region:资金写入宁可有明确 Owner
同一个 account 或 PaymentIntent 在多个 Region 同时写,会让余额与状态机冲突。常见设计:
- 按 merchant/account 选择 home Region;
- 所有资金写命令路由到 owner;
- 远端 Region 提供只读副本和 API ingress;
- Owner 故障通过 consensus/lease 切换;
- 切换期间短暂拒绝写,避免 split brain。
支付创建的可用性不值得用双扣风险交换。读取和报表可以最终一致,ledger posting 通常选择强一致单 owner。
跨币种与跨账本边界可以用 clearing accounts 和 saga,但每个本地 ledger transaction 仍保持平衡。全局业务最终完成,不意味着本地账本可以暂时不平。
故障与恢复
API 响应丢失
客户端用同一 idempotency key 重试,拿回同一 intent 和状态。
PSP timeout
标记 provider outcome unknown,查询 PSP。不要用新 key 再 charge。
PSP 成功,DB commit 失败
Reconciler/Webhook 最终看到 provider success,使用同一 business reference 完成 ledger posting。期间 payment 保持 PROCESSING/REQUIRES_RECONCILIATION。
DB commit 成功,queue publish 失败
Transactional outbox 保证后台最终发布。
重复 webhook 与 worker race
Payment version CAS + ledger business reference unique。一个完成,另一个读到已完成状态。
错误分录
追加 reversal/adjustment,不更新或删除旧 entry。修正记录引用原 transaction 与审批人。
观测与不变量
业务:payment create/capture/refund success、processing age、unknown outcome、conversion。
系统:API/PSP/ledger p99、queue age、retry、webhook lag、DB lock/conflict。
资金:
- 每 transaction debit-credit 差必须为 0;
- Materialized balance 与 entry 重算差;
- Provider settlement 与本地 ledger 差;
- Pending authorization age;
- Duplicate business reference attempt;
- Negative balance violation。
不变量告警优先级高于普通 error rate。某个 endpoint 99.99% 可用,但账本出现 1 美分不平,仍然是严重事故。
关键取舍
同步等 PSP 给用户立即结果,却受外部 tail latency;异步 PROCESSING 更稳但 UX 复杂。
Payment 与 Ledger 同库事务 正确性简单,组织和扩展耦合;拆服务需要更严格幂等和 reconciliation。
强一致单 owner 降低多 Region 写可用性,却守住资金顺序。
物化余额 读取快,但必须和 immutable entries 同事务更新并定期重算。
更多自动 retry 恢复 transient failure,也增加 ambiguous duplicate 风险。支付 retry 必须有稳定身份和状态查询。
用 Lab 亲自制造支付事故
实验一:重复请求
关闭 idempotency,连续提交同一 checkout;再加入 key + payload hash,验证同 key 不同金额会被拒绝。
实验二:PSP timeout
让 PSP 成功但丢响应。比较“直接重试新 charge”和“查询同一 provider key”的结果。
实验三:跨 Region
让两个 Region 同时更新一个 account balance。看到 race 后,选择 owner/consensus,而不是用 last-write-wins 处理钱。
面试表达:先把状态机和账本分开
可以这样开场:
I would separate the payment workflow from the ledger. The payment intent is an idempotent state machine that coordinates an external PSP; the ledger is an immutable double-entry record where every transaction balances before commit. Reconciliation connects the two when network outcomes are ambiguous.
演化顺序:
single DB payment state machine
-> double-entry posting in one transaction
-> outbox worker + PSP idempotency
-> webhook and unknown-state recovery
-> reconciliation and adjustments
-> partitioned ledger with single-writer ownership
最后提供深入方向:
I can go deeper into double-entry invariants, provider timeout semantics, ledger partitioning, or reconciliation and multi-region ownership.
这条主线不会把支付题讲成普通订单 CRUD,也不会用“exactly once”掩盖外部世界的模糊状态。