第 12 章 / 共 57 章 · 约 9 分钟
案例 10:支付系统与记账账本
先花 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. 数据模型
| 表 | 关键字段 |
|---|---|
| payments | payment_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_transactions | txn_id, payment_id, type(charge/refund/payout), created_at |
| ledger_entries | entry_id, txn_id, account_id, direction(debit/credit), amount, currency |
| accounts | account_id, owner, type(商户应付/平台收入/PSP 在途资金), balance, version |
| outbox | event_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
一次扣款的主流程:
- 校验 Idempotency-Key:同一个 key 已完成,直接返回旧结果
- 写入 payment,状态 CREATED
- 调用 PSP,并把我们自己的 payment_id 作为发给 PSP 的幂等键
- 根据结果更新状态,在同一个本地事务里写 outbox 事件
- 账本服务消费事件,写入复式分录
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,以及哪些情况宁可拒绝请求也不冒险扣款 |