cswork / 系统设计

第 12 章 / 共 57 章 · 约 9 分钟

案例 10:支付系统与记账账本

本页目录
  1. 1. 澄清需求
  2. 2. 规模估算
  3. 3. API
  4. 4. 数据模型
  5. 5. 高层架构
  6. 6. 深挖
  7. 7. 常见追问
  8. 8. 易错点
  9. 9. 回答分级
  10. 10. 不同级别的期望
  11. 章节测验

先花 10 分钟自己设计:需求、核心实体、API 和高层架构,各写几行。写完或者跳过之后,下面的讲解才会展开。

先写你的思路

只存在这台设备上;登录后会存进你的账号。

这道题难的不是吞吐量,而是“钱不能错”。面试官想看的是:在网络超时、重复请求、外部依赖不可靠的情况下,你能不能保证每一分钱都有据可查。

1. 澄清需求

功能需求

  • 商户发起收款:平台调用外部支付服务商(PSP,例如收单网关)完成扣款
  • 查询支付状态;支持全额和部分退款
  • 内部维护复式记账账本(ledger),能算出每个商户的可结算余额
  • 定期给商户打款(payout)

非功能需求

  • 正确性第一:不重复扣款、不丢账、账永远是平的
  • 每笔资金变动可审计、可追溯,历史记录不可修改
  • 可用性重要,但在“可能扣错钱”和“暂时拒绝服务”之间,选后者
  • 延迟要求适中,同步接口 1~3 秒内返回即可

不在范围:风控模型细节、卡号明文存储(交给符合 PCI 合规的 PSP 或令牌化服务,我们只存 token)。

2. 规模估算

假设每天 1000 万笔支付:平均约 115 TPS,峰值按 10 倍算约 1,200 TPS。每笔支付在账本里产生 2~4 条分录,峰值约 5,000 次写入/秒。一台调优良好的关系型数据库主库就扛得住,分片更多是为了隔离和未来增长。

结论:这是一道正确性题,不是吞吐量题。存储优先选支持 ACID 事务的关系型数据库。

3. API

POST /v1/payments
  Header: Idempotency-Key: <客户端生成的 UUID>
  Body:   { merchant_id, amount: 1999, currency: "USD", payment_method_token, order_id }
  → 201 { payment_id, status }

GET  /v1/payments/{payment_id}
POST /v1/payments/{payment_id}/refunds   (同样带 Idempotency-Key)
POST /internal/psp-webhooks              (PSP 异步通知,必须验签)

金额一律用最小货币单位的整数(1999 表示 $19.99),绝不用浮点数。不同币种小数位不同:日元 0 位,科威特第纳尔 3 位,所以金额必须和币种一起存。

4. 数据模型

表关键字段
paymentspayment_id, merchant_id, amount, captured_amount, refunded_amount, currency, status, psp_reference, created_at
idempotency_keys(merchant_id, key) 唯一, request_hash, response_snapshot, locked_until
ledger_transactionstxn_id, payment_id, type(charge/refund/payout), created_at
ledger_entriesentry_id, txn_id, account_id, direction(debit/credit), amount, currency
accountsaccount_id, owner, type(商户应付/平台收入/PSP 在途资金), balance, version
outboxevent_id, aggregate_id, payload, published

5. 高层架构

支付系统的高层架构
滚动到此处后加载图表…
图表可横向滚动,展开后可缩放查看。
查看图表源码
flowchart TD
  accTitle: 支付系统的高层架构
  client["Client"] --> gw["API Gateway"] --> pay["Payment Service"] --> adapter["PSP Adapter"] --> psp["外部 PSP"]
  psp -->|"webhook"| hook["Webhook Handler"] --> pay
  pay --> pdb[("Payments DB:payments + idempotency + outbox,同一事务")]
  pdb -->|"outbox relay"| mq["Message Queue"] --> ledger["Ledger Service"] --> ldb[("Ledger DB")]
  files["PSP 结算文件"] --> recon["Reconciliation Job"] --> ldb

一次扣款的主流程:

  1. 校验 Idempotency-Key:同一个 key 已完成,直接返回旧结果
  2. 写入 payment,状态 CREATED
  3. 调用 PSP,并把我们自己的 payment_id 作为发给 PSP 的幂等键
  4. 根据结果更新状态,在同一个本地事务里写 outbox 事件
  5. 账本服务消费事件,写入复式分录

6. 深挖

6.1 幂等:防止重复扣款的核心

重复请求的来源很多:用户连点按钮、客户端超时重试、网关重放。做法:

  • 客户端为每一次“业务意图”生成一个 Idempotency-Key,重试时复用同一个
  • 服务端用唯一约束 (merchant_id, key) 抢占这个 key,同时存请求体哈希(request_hash);同 key 但 request_hash 不同,直接返回 422,防止客户端误用同一个 key 发起另一笔支付
  • 处理中的 key 被并发请求命中时返回 409 或让它等待,绝不能并行执行两次
  • 处理完成后存下响应快照,后续重试原样返回

幂等要做两层:客户端→我们,我们→PSP。只做第一层的话,我们自己重试调用 PSP 时仍然可能扣两次。

6.2 超时等于“结果未知”

调用 PSP 超时,不代表失败,钱可能已经扣了。正确处理:

  • 把支付标记为 UNKNOWN,而不是 FAILED
  • 用同一个幂等键重试,或主动查询 PSP 的交易状态
  • 由 webhook 和对账兜底

状态机只允许向前推进:

支付状态机
滚动到此处后加载图表…
图表可横向滚动,展开后可缩放查看。
查看图表源码
flowchart LR
  accTitle: 支付状态机
  created["CREATED"] --> processing["PROCESSING"] --> succeeded["SUCCEEDED"] --> refunded["REFUNDED / PARTIALLY_REFUNDED"]
  processing --> failed["FAILED"]
  processing --> unknown["UNKNOWN"]
  unknown -->|"查询或对账后确认"| succeeded
  unknown -->|"查询或对账后确认"| failed

Webhook 是“至少一次”投递,还可能乱序:先验签,再按 event_id 去重,最后用状态机拒绝倒退(已经 SUCCEEDED 的支付不能被一条迟到的 PROCESSING 通知覆盖)。

6.3 复式记账

每笔业务交易至少两条分录,借方总额 = 贷方总额。例如用户付 $100,平台抽成 $3:

账户借贷
PSP 在途资金100
商户应付97
平台手续费收入3

规则:

  • 分录只追加,不修改,不删除;记错了用反向分录冲正,再记一笔正确的
  • 同一个 txn 的所有分录在一个数据库事务内写入,写入时校验借贷平衡
  • 余额可以由分录求和得到;为了查询快,可以在同一事务里更新 accounts.balance,用 version 做乐观锁

为什么不只维护一个 balance 字段?只有余额没有流水,就回答不了“这笔钱从哪来”,审计和对账都无从做起。

6.4 跨服务一致性:Outbox + Saga,而不是 2PC

支付状态和账本属于两个服务,不要上两阶段提交,外部 PSP 也不可能参与你的 2PC。做法:

  • Transactional Outbox:更新 payment 状态和写 outbox 事件放在同一个本地事务,后台 relay 把事件投递到队列。这样“状态改了,事件就一定会发出去”
  • 消费端按 event_id 幂等处理(队列一般只保证至少一次)
  • 多步流程(扣款→记账→打款)用 Saga,失败时执行补偿动作(退款、冲正分录),而不是回滚

6.5 对账(Reconciliation)

设计再好,也会出现 PSP 成功而我们记成失败、手续费调整、汇率差等问题。每天拿 PSP 的结算文件与内部账本逐笔比对,结果分三类:

  • 完全匹配
  • 双方都有,但金额或状态不一致
  • 只有一方有记录

不一致的条目进入修复队列,修复同样通过追加分录完成。对账是支付系统的最后一道防线,面试时要主动提。

6.6 热点账户

平台手续费收入账户会被每一笔交易贷记,行锁争用严重。思路:

  • 把热点账户拆成 N 个子账户,按哈希或随机写入,查询时汇总
  • 分录照常同步追加,热点账户的余额改为异步汇总
  • 把短时间内的多笔入账合并成一条分录

6.7 分片

按 merchant_id(或 account_id)分片,尽量让一笔交易的所有分录落在同一分片,保持单分片事务。平台类账户在每个分片各建一个子账户,定期跨分片汇总结转。

6.8 授权与请款(authorize / capture)

卡支付在 PSP 那边通常分两步:

  • 授权(authorize):发卡行冻结这笔额度,钱还没有真正划走。授权有有效期,一般只有几天到一周(各卡组织规则不同),过期自动释放
  • 请款(capture):发货或确认履约后再请款,钱才真正扣走。可以只请款一部分(比如缺货少发一件),剩下的授权要主动作废(void),别让用户的额度一直被占着
  • 电商常见做法是下单时授权、发货时请款;数字商品可以授权和请款一步完成

对设计的影响:状态机在 PROCESSING 和 SUCCEEDED 之间多一个 AUTHORIZED,之后走向 CAPTURED(即 SUCCEEDED)或 VOIDED;capture 调用同样要带幂等键,防止重复请款;授权阶段不记收入,请款成功后才写分录;还要有定时任务扫描快过期的授权,及时请款或作废。

6.9 退款

一笔支付可以多次部分退款,但累计退款不能超过已请款金额。检查和写入要放在同一个条件更新里,否则两个并发退款可能都通过检查:

UPDATE payments SET refunded_amount = refunded_amount + :x
WHERE payment_id = :id AND refunded_amount + :x <= captured_amount;

影响行数为 0 就拒绝。其他要点:

  • 每次退款有自己的 Idempotency-Key,重试不会退两次
  • 调 PSP 退款同样可能超时,处理方式和 6.2 一样:先标记未知,再查询或等 webhook 确认
  • 记账上,退款是一笔新交易,分录方向和扣款相反,不修改原来的扣款分录

7. 常见追问

  • 写完 payment、还没写 outbox 时数据库挂了? 两者在同一事务,要么都在,要么都不在
  • PSP 扣款成功,但我们的服务崩了? 重启后扫描 PROCESSING / UNKNOWN 的支付,用原幂等键查询 PSP
  • 怎么保证打款不超过余额? 同一事务内条件更新:UPDATE accounts SET balance = balance - :x WHERE id = :id AND balance >= :x,影响行数为 0 就拒绝
  • 多币种怎么办? 每个币种独立账户;换汇是一笔显式交易,在两个币种的账户里分别记账

8. 易错点

  • 用浮点数存金额
  • 把超时当失败,然后换新 key 重试
  • 只有余额字段,没有流水
  • 修改或删除历史分录
  • 忘了对账
  • 退款不校验累计金额,退得比扣的还多

9. 回答分级

幂等与超时

  • Bad:超时就当失败,换一个新 key 重试,用户被扣两次。
  • Good:每个业务意图一个 Idempotency-Key,服务端用唯一约束占用并保存响应快照;调用 PSP 时把我们的 payment_id 当作幂等键;超时标记为 UNKNOWN,查询或对账后再确认。
  • Great:同一个 key 配不同请求体返回 422,处理中的 key 返回 409,绝不并发执行两次;webhook 至少投递一次且可能乱序,先验签、按 event_id 去重,再由状态机拒绝倒退;服务重启后扫描 PROCESSING/UNKNOWN 的支付,用原 key 查询 PSP。

账本与一致性

  • Bad:只有一个余额字段、没有分录;用浮点数存金额;出错时直接改或删记录。
  • Good:复式记账,借贷相等,同一笔交易的分录在一个数据库事务里写入;分录只追加,错了用反向分录冲正;金额用最小货币单位的整数,并记录币种。
  • Great:用 outbox 加 Saga 代替 2PC(PSP 本来也无法加入你的 2PC);每天和 PSP 结算文件逐笔对账,差异进修复队列;平台收入这类热点账户拆成子账户或批量入账;按商户分片,让一笔交易落在一个分片上。

授权、请款与退款

  • Bad:不区分授权和请款;退款没有累计上限,两个并发退款都能成功。
  • Good:知道卡支付分授权和请款两步,请款时才确认收入;退款用条件更新,保证累计退款不超过已请款金额。
  • Great:处理授权过期:临近过期时自动请款或作废,部分请款后作废剩余额度;每笔退款也有自己的幂等键,PSP 退款超时同样按“结果未知”处理,账本记一笔新的冲正交易,不改原交易。

10. 不同级别的期望

级别期望
Mid(L4)在提示下讲清幂等键、PSP 集成和支付状态,知道金额不能用浮点数
Senior(L5)主动处理“超时即未知”、webhook 乱序、复式记账和 outbox,能写出防止超额退款的条件更新
Staff(L6+)覆盖对账与修复流程、热点账户、分片和多币种,说明为什么用 Saga 而不是 2PC,以及哪些情况宁可拒绝请求也不冒险扣款

章节测验

5 道选择题
  1. 客户端调用 POST /payments 超时,没有拿到响应。客户端最合理的做法是?
  2. 支付服务调用 PSP 时超时,这笔支付应该如何处理?
  3. 关于复式记账账本,以下哪项说法正确?
  4. 支付服务使用 Transactional Outbox 的主要目的是什么?
  5. 平台手续费收入账户被每笔交易写入,出现严重的行锁争用。最合适的缓解方式是?

章节讨论

正在加载讨论…