支付系统移动钱,而这一个事实改变一切:正确性不可妥协。feed 里的 bug 能显示一个陈旧帖子;这里的 bug 能给客户双重扣款或丢失一笔交易,且没有"最终它会没事"。设计被三个想法主导——幂等(idempotency)(重试的请求绝不能扣两次)、复式记账账本(double-entry ledger)(每次钱移动的不可变、永远平衡的记录),和对账(reconciliation)(持续对照支付提供商核验你的账)。它是我们 DDIA 事务笔记和端到端正确性里正确性想法最直接的应用。
- 正确性优于可用性——这是个偏 CP 的系统;拒绝一笔支付好过处理它两次或丢失它。
- 幂等键强制必需——客户端为每个支付意图生成一个唯一键;用同键重试返回原始结果,绝不第二次扣款。
- 复式记账账本——每笔交易往仅追加、不可变账本写平衡的借 + 贷条目;余额被派生,从不原地编辑。
- 集成 PSP,别碰卡——路由到 Stripe/Adyen 等;tokenize 卡,使原始 PAN 永不进入你的系统(PCI 范围)。
- 异步配 webhook + 状态机——支付移动 pending → authorized → captured → settled;PSP 异步确认。
- 无情对账——每日对照 PSP/银行对账单比较你的账本以抓住并修复任何差异。信任,但要核验。
- 用 saga,不用 2PC——你无法跨外部提供商做两阶段提交;用补偿动作编排。
经客户端提供的键 + 一个去重存储让每笔支付幂等,使重试安全。在一个不可变复式记账账本记录钱移动并从它派生余额。别存卡数据——委托给 PSP 并 tokenize。把支付建模为由异步 webhook 驱动的状态机,用 saga 编排多步流程(补偿动作,而非 2PC),并对照提供商运行每日对账以保证你的账与现实相符。
┌────────┐ pay(idempotency_key) ┌──────────────┐
│ 客户端 │──────────────────────▶│ 支付 │
└────────┘ │ 服务 │
└───┬───┬───────┘
去重 + 账本写 │ │ charge
┌───────────────┐◀───────┘ ▼
│ 账本 DB │ ┌──────────┐ ┌──────────┐
│ (复式记账) │ │ PSP │─▶│ 银行 │
└───────────────┘ │ (Stripe) │ │ 网络 │
┌───────────────┐ webhook └──────────┘ └──────────┘
│ 幂等 │◀── "captured/failed"(异步)
│ 存储 │
└───────────────┘ 每晚对照 PSP 对账单对账
第 1 步 — 澄清需求
功能:为一个订单从客户接受一笔支付(收款);与支付服务提供商(PSP)集成;跟踪每笔支付的状态;支持退款;(可选)给卖家付款。非功能,且这些主导:正确性/一致性高于一切(无双重扣款、无丢失或幽灵支付)、持久性、幂等、可审计性(为合规的完整不可变历史),和合理可用性。我们显式偏好一致性优于可用性:若有疑,安全失败并对账,而非冒移动钱两次的风险。
第 2 步 — 容量估算
相比 feed 支付量适中——比如每天 10M 支付(~120/秒平均,促销事件峰值更高)。数字不是挑战;故障下的正确性才是。每个组件都必须假设网络能在最糟时刻丢一个请求或响应(见分布式系统的麻烦),这正是为什么幂等和对账是一等公民、不是事后想法。
第 3 步 — API 设计
POST /payments
Idempotency-Key: 7f3c-...-a91 # 客户端生成,每次尝试
{order_id, amount, currency, payment_method_token}
→ {payment_id, status}
GET /payments/{payment_id} → {status, amount, ...}
POST /payments/{payment_id}/refund {amount} Idempotency-Key: ...
Idempotency-Key 头是关键(第 5 步),且卡作为一个 token 传递,绝不原始号(第 11 步)。
第 4 步 — 支付流程与状态机
支付不是瞬时的;它在旅行到银行并返回时穿过各状态,大体异步。支付服务调 PSP 授权和捕获资金,但最终确认稍后经一个 webhook 回调到达。把支付建模为一个显式状态机使这可管理:
| 状态 | 含义 | 下一个 |
|---|---|---|
| pending | 已创建,未发送/确认 | authorized / failed |
| authorized | 资金在卡上被冻结 | captured / voided |
| captured | 资金被取(扣款确认) | settled / refunded |
| settled | 钱实际移到你的账户 | refunded |
| failed / voided | 被拒或取消 | 终态 |
每个转换被持久记录;来自 PSP 的 webhook 驱动异步转换(authorized→captured→settled),系统必须处理 webhook 迟到、乱序或多于一次到达(所以 webhook 处理本身幂等)。
第 5 步 — 幂等:绝不扣两次
定义性危险:客户端发"付 $50",请求在服务器成功,但响应丢失;客户端重试;现在你扣了 $50 两次。解药是一个幂等键——客户端每个支付意图生成一次、随每次重试发送的唯一 ID。服务器把键与首次成功尝试的结果一起记录;任何后来带同键的请求返回存储结果而非再次执行。
def pay(key, order, amount):
if seen(key): # 先前尝试的重试
return stored_result(key) # 返回原始结果——不重新扣款
reserve(key) # 原子认领键(唯一约束)
result = psp.charge(amount, idempotency_key=key) # PSP 也幂等
write_ledger(order, amount) # 在与下面同一事务里 ...
store_result(key, result) # ... 记录结果
return result
关键地,PSP 本身接受一个幂等键,所以即便你到 PSP 的重试也不会在他们那端双重扣款。这是端到端论证在行动——去重用一个穿过每层的标识符强制,而非在任何单层打补丁。
第 6 步 — 复式记账账本
钱用复式记账(double-entry bookkeeping)跟踪,这是银行用了几个世纪的会计模型。每笔交易产生至少两个总和为零的条目:从一个账户借(debit)和到另一个账户匹配的贷(credit)。账本仅追加且不可变——你从不编辑或删除一个条目;一次纠正是一个新的补偿条目。余额通过求和条目派生,从不存为一个你覆写的可变数字。
txn 9001 customer_cash −50.00 (借)
txn 9001 merchant_payable +50.00 (贷)
───────
0.00 ← 每笔 txn 必须平衡到零
稍后退款 = 一笔新的平衡 txn,绝不编辑 txn 9001
balance(account) = SUM(entries) — 派生,不可变历史保留
这个模型使系统可审计且自检:因为每笔交易平衡到零,钱不能被静默创造或销毁,且完整历史可为合规和争议解决重建。
第 7 步 — 数据模型
payments (payment_id, order_id, amount, currency, status,
psp_ref, created_at, updated_at)
ledger_entries (entry_id, txn_id, account, amount, ts) # 仅追加
idempotency_keys (key PK, payment_id, response, created_at) # 去重
带 ACID 事务的关系数据库是这里正确的默认(见事务):写账本条目、更新支付行和记录幂等结果应在一个原子事务里发生,使它们不能部分应用。
第 8 步 — Exactly-Once 效果
真正的 exactly-once 投递在不可靠网络上不可能,所以目标是 exactly-once 效果:无论请求、webhook 或 PSP 调用被重试多少次,一笔支付恰好改变钱一次。达到它的组合:(1) 幂等键去重重复客户端请求;(2) PSP 自己的幂等键去重重复 charge 调用;(3) webhook 处理器幂等(处理同一 "captured" 事件两次是 no-op);(4) 原子账本写把钱移动与去重记录绑定,使它们一起提交或都不。
第 9 步 — 处理故障:Saga,不是 2PC
一笔支付常跨多个内部服务和一个外部 PSP(你无法把 Stripe 纳入你的分布式事务)。两阶段提交跨外部系统不工作且故障下阻塞(见一致性与共识)。而是用 saga:一系列本地步骤,各带一个补偿动作(compensating action)去撤销它。若一个较晚步骤失败,你跑较早步骤的补偿(如撤销一个授权、反转一个账本条目)以返回一致状态。步骤被排队并以指数退避重试;持续失败的事件去死信队列调查而非被静默丢弃。
第 10 步 — 对账
无论代码多小心,你必须对照现实核验你的账——磁盘损坏数据、webhook 被漏、bug 溜过。对账(reconciliation)是一个定时作业(通常每晚),逐行比较你的内部账本与来自 PSP 和银行的结算报告/对账单,并标记任何差异:PSP 记录了而你没有的支付、金额不匹配、缺失的结算。差异被升级并用补偿账本条目纠正。这体现"信任,但要核验"原则——系统被设计来检测和从不一致恢复,而非假设它从不发生。
第 11 步 — 安全与合规
直接处理卡数据把你拖进 PCI DSS 的完整范围,一个昂贵的合规负担。标准动作是绝不让原始卡号(PAN)碰你的服务器:客户端把卡细节直接发给 PSP(或 PSP 的托管字段/SDK),它返回一个 token;你的系统只存储和扣这个 token。除此之外:加密静态和传输中的敏感数据、严格控制访问、维护审计日志(不可变账本已提供),并对支付流跑欺诈检测。
第 12 步 — 关键取舍
- 一致性优于可用性。支付系统安全失败——拒绝并重试好过冒双重扣款风险。对账是让你能保守的安全网。
- 同步 vs 异步。授权可能为即时 UX 同步,但捕获/结算经 webhook 异步;状态机吸收延迟。
- 自建 vs 购买。集成一个 PSP(和 tokenization)相比自己造卡处理大砍 PCI 范围和风险——几乎总是正确的选择。
- Saga vs 2PC。Saga 容忍外部系统和部分失败,代价是写补偿逻辑;2PC 概念上更简单但跨第三方 PSP 不可用。
支付系统是一台正确性机器。三个机制承载设计:端到端穿线的幂等键使重试绝不双重扣款、一个不可变复式记账账本使钱绝不被静默创造或丢失且一切可审计,以及对照提供商的持续对账使任何差异被抓住并纠正。偏好一致性、把卡处理委托给 PSP,并用 saga 而非分布式事务编排多方流程。
怎么防止双重扣款?一个客户端生成、服务端记录(并传给 PSP)的幂等键;任何带同键的重试返回原始结果而非再次扣款。
为什么复式记账账本?每笔交易往不可变、仅追加的日志写平衡的借/贷条目;余额被派生,所以钱不能被静默创造/丢失且一切可审计。
为什么对账?代码和网络会失败;每日比较你的账本与 PSP/银行对账单检测并修复差异——信任,但要核验。
为什么 saga 而非 2PC?你无法跨外部 PSP 做两阶段提交;saga 用带补偿动作的本地步骤在故障下保持一致。
怎么避免 PCI 范围?Tokenize——卡细节直接去 PSP,它返回一个 token;原始 PAN 永不碰你的服务器。