支付系统移动钱,而这一个事实改变一切:正确性不可妥协。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——你无法跨外部提供商做两阶段提交;用补偿动作编排。
tldr

经客户端提供的键 + 一个去重存储让每笔支付幂等,使重试安全。在一个不可变复式记账账本记录钱移动并从它派生余额。别存卡数据——委托给 PSP 并 tokenize。把支付建模为由异步 webhook 驱动的状态机,用 saga 编排多步流程(补偿动作,而非 2PC),并对照提供商运行每日对账以保证你的账与现实相符。

payment flow
 ┌────────┐  pay(idempotency_key)  ┌──────────────┐
 │ 客户端 │──────────────────────▶│   支付       │
 └────────┘                       │   服务       │
                                  └───┬───┬───────┘
              去重 + 账本写           │   │  charge
            ┌───────────────┐◀───────┘   ▼
            │  账本 DB      │          ┌──────────┐  ┌──────────┐
            │ (复式记账)    │          │   PSP    │─▶│   银行   │
            └───────────────┘          │ (Stripe) │  │  网络    │
            ┌───────────────┐  webhook └──────────┘  └──────────┘
            │ 幂等          │◀── "captured/failed"(异步)
            │   存储        │
            └───────────────┘   每晚对照 PSP 对账单对账

第 1 步 — 澄清需求

功能:为一个订单从客户接受一笔支付(收款);与支付服务提供商(PSP)集成;跟踪每笔支付的状态;支持退款;(可选)给卖家付款。非功能,且这些主导:正确性/一致性高于一切(无双重扣款、无丢失或幽灵支付)、持久性、幂等、可审计性(为合规的完整不可变历史),和合理可用性。我们显式偏好一致性优于可用性:若有疑,安全失败并对账,而非冒移动钱两次的风险。

第 2 步 — 容量估算

相比 feed 支付量适中——比如每天 10M 支付(~120/秒平均,促销事件峰值更高)。数字不是挑战;故障下的正确性才是。每个组件都必须假设网络能在最糟时刻丢一个请求或响应(见分布式系统的麻烦),这正是为什么幂等和对账是一等公民、不是事后想法。

第 3 步 — API 设计

core API (idempotency key required)
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。服务器把键与首次成功尝试的结果一起记录;任何后来带同键的请求返回存储结果而非再次执行。

idempotent payment handling
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)。账本仅追加且不可变——你从不编辑或删除一个条目;一次纠正是一个新的补偿条目。余额通过求和条目派生,从不存为一个你覆写的可变数字。

double-entry: a $50 payment
txn 9001  customer_cash      −50.00   (借)
txn 9001  merchant_payable   +50.00   (贷)
                             ───────
                              0.00     ← 每笔 txn 必须平衡到零

稍后退款 = 一笔新的平衡 txn,绝不编辑 txn 9001
balance(account) = SUM(entries) — 派生,不可变历史保留

这个模型使系统可审计自检:因为每笔交易平衡到零,钱不能被静默创造或销毁,且完整历史可为合规和争议解决重建。

第 7 步 — 数据模型

schema
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 步 — 关键取舍

总结

支付系统是一台正确性机器。三个机制承载设计:端到端穿线的幂等键使重试绝不双重扣款、一个不可变复式记账账本使钱绝不被静默创造或丢失且一切可审计,以及对照提供商的持续对账使任何差异被抓住并纠正。偏好一致性、把卡处理委托给 PSP,并用 saga 而非分布式事务编排多方流程。

🎯 面试速答

怎么防止双重扣款?一个客户端生成、服务端记录(并传给 PSP)的幂等键;任何带同键的重试返回原始结果而非再次扣款。
为什么复式记账账本?每笔交易往不可变、仅追加的日志写平衡的借/贷条目;余额被派生,所以钱不能被静默创造/丢失且一切可审计。
为什么对账?代码和网络会失败;每日比较你的账本与 PSP/银行对账单检测并修复差异——信任,但要核验。
为什么 saga 而非 2PC?你无法跨外部 PSP 做两阶段提交;saga 用带补偿动作的本地步骤在故障下保持一致。
怎么避免 PCI 范围?Tokenize——卡细节直接去 PSP,它返回一个 token;原始 PAN 永不碰你的服务器。

← 上一篇
设计实时排行榜