设计一个 marketplace 支付平台(类似 Airbnb / Uber):向 guest 收款、给 host 打款。日处理 1000 万笔交易,平均写 ~115 QPS、大促峰值 ~3000 QPS。这是少数 correctness 远高于 availability 的系统——宁可拒绝服务,也绝不能重复扣款、重复打款、把账记错一分。
面试要先澄清的关键约束:
unknown——既不能假设成功也不能假设失败。组件职责:① 幂等层拦截重复请求,用 idempotency key 保证一次支付只执行一次;② Orchestrator 用 Saga 串联「收款→记账→打款」跨服务流程;③ Ledger Service 是唯一真相源,所有钱的移动以不可变借贷分录落库;④ PSP Gateway 封装外部支付商;⑤ 对账作业把内部 ledger 与 PSP、银行流水逐笔比对,找出 break。
核心 trade-off:用一点存储和一层状态机,换『任意重试都不会重复动钱』的保证。
【原理】客户端为每次支付生成一个 idempotency key(UUID),随请求上送。服务端把 key → 请求状态持久化:同一 key 重试时直接返回上次结果。关键不止「去重」——要把一次支付拆成多个原子阶段(Stripe 称 recovery points):创建本地记录 → 调 PSP → 记 ledger,每个阶段完成就持久化进度。进程中途崩溃,重试时从上个 recovery point 续跑,而不是从头重来。这本质是一个落库的状态机。
# pseudo-code: 幂等支付(recovery point 续跑)
def charge(key, req):
rec = db.upsert_idempotency(key, fingerprint(req)) # 行锁 + 校验 body 一致
if rec.body_hash != fingerprint(req):
raise Conflict("same key, different request") # Stripe 会拒绝
if rec.response: # 已完成 → 直接回放
return rec.response
if rec.phase < CREATED:
db.create_payment(rec); rec.phase = CREATED # recovery point
if rec.phase < CHARGED:
r = psp.charge(req, idem=key) # PSP 侧也带 key,双层幂等
rec.phase = CHARGED; rec.save()
if rec.phase < LEDGERED:
ledger.record(rec); rec.phase = LEDGERED
rec.response = build_resp(rec); rec.save()
return rec.response
核心 trade-off:放弃「直接改余额」的简单,换可审计、可对账、永不丢账的真相源。
【原理】每一次钱的移动至少写两条分录(借 + 贷),且每笔交易的借贷之和恒为 0——这是会计五百年验证过的不变量,天然防漏记。账本不可变(只 append,不 update/delete):要改账(退款、纠错)靠写一条冲正分录(reversing entry),而非删改原记录。账户余额 = 该账户所有分录的聚合。
SUM 太慢,需周期 snapshot 物化余额 + 增量分录。# 转账:同一 DB 事务内写两条分录,断言守恒
def transfer(from_acct, to_acct, amount, txn_id): # amount 是整数分
with db.transaction():
e1 = Entry(txn=txn_id, acct=from_acct, delta=-amount)
e2 = Entry(txn=txn_id, acct=to_acct, delta=+amount)
db.insert(e1); db.insert(e2)
assert e1.delta + e2.delta == 0 # 双重记账不变量
# 退款不是 delete,而是写一条反向 txn 引用原 txn_id
核心 trade-off:2PC 给你强一致但会锁死外部系统;Saga 给你可用性但只有最终一致,且要手写补偿。
【原理】一次完整支付涉及多个服务和外部 PSP:收 guest 款 → 记 ledger → 触发 host payout。2PC 在这里不可行——外部 PSP 不参与你的事务协调、锁持有跨网络太久、协调者是单点。改用 Saga:一串本地事务,每步失败用补偿动作回滚。「改本地状态」和「发出下一步消息」的原子性,用 Outbox pattern 保证(同事务写业务表 + outbox 表,再由 relay 投递)。
# Outbox:本地状态与消息原子落库;relay 异步投递
def step_charge_then_payout(payment):
with db.transaction():
payment.status = "CHARGED"; db.save(payment)
db.insert(Outbox(event="StartPayout", payload=payment.id)) # 同事务
# 单独的 relay 进程轮询 outbox → 发消息 → 标记已发
# 失败的 payout 不补偿(钱已收),forward retry 直到成功
核心 trade-off:实时对账贵但早发现,T+1 批量便宜但延迟暴露资损。
【原理】内部 ledger 是「你以为的钱」,PSP/银行流水是「实际的钱」,两者必然漂移:手续费、汇率、退款时差、PSP 的 bug、漏处理的 timeout。每日做三方对账:内部 ledger ↔ PSP statement ↔ 银行流水,按 external_id 逐笔 join,找出 break(缺失、金额不符、状态不符),自动归类(手续费类自动平账、疑似资损告警),剩余人工兜底。对账是支付系统最后一道防线——前面所有幂等/Saga 的 bug 最终都会在这里现形。
# pseudo-code: 三方对账匹配
def reconcile(ledger_rows, psp_rows):
by_ext = {r.external_id: r for r in psp_rows}
for L in ledger_rows:
P = by_ext.pop(L.external_id, None)
if P is None: breaks.add("MISSING_IN_PSP", L) # 我记了PSP没有
elif P.amount != L.amount:
breaks.add("AMOUNT_MISMATCH", L, P) # 多半是手续费
for leftover in by_ext.values():
breaks.add("MISSING_IN_LEDGER", leftover) # PSP有我漏记 → 资损风险
account_id 分库分表,跨账户转账落在同一逻辑分片或用 2PC 范围内的本地事务。高频面试追问:① 客户端重试时如何保证不重复打款?② PSP 返回超时,你怎么知道钱到底动没动?③ 退款怎么记账(为什么不能改原记录)?④ 为什么支付不用 2PC?⑤ 怎么做到对账「到分」?哪类 break 可自动平、哪类必须人工?
timeout 是支付系统最危险的状态:请求可能在「PSP 已扣款但响应丢失」处中断。处理链路:
PENDING,绝不写成 success 或 failed。本质:把「不确定」用幂等 + 对账收敛成「确定」,而不是猜。
因为账本不可变——改/删原记录会摧毁审计轨迹和对账能力。退款应写一条冲正分录(reversing entry),引用原 txn_id,方向相反。好处:① 历史完整可追溯,监管和对账都能还原;② 原支付仍真实发生过(手续费、税务、报表都依赖它);③ 部分退款只需写部分金额的反向分录,余额自然正确。这正是双重记账「append-only + reversing」的核心:错误不是抹掉,而是用新分录纠正,和 Git 的 revert 而非 force-push 同理。
按 account_id 分片会让这个账户的所有分录落在同一分片,形成热点:写入争抢、物化余额行成为锁热点(每笔都要更新该账户余额)。解法:
核心是把「单点余额更新」从写路径上移走——分录可以无锁 append,余额可以最终一致地算出来。
因为守恒只保证你自己账本内部自洽,不保证账本反映了外部现实。漂移来自边界:① 你以为 PSP 扣成功并记了账,实际 PSP 失败了(你的账本内部仍守恒,但与 PSP 不符);② PSP 收了手续费,你没记;③ 漏处理的 timeout 让 PSP 动了钱你没记。这些都不破坏内部守恒,却是真实资损。对账是把「内部一致」升级为「与现实一致」的唯一手段——它检验的是系统边界,而守恒只检验系统内部。这也是为什么对账被称作支付系统的最后一道防线。
前提:消费端的幂等判断键必须和「钱的移动」一一对应,且去重记录与副作用原子落库。会破的情况:
所以「effectively-once」不是免费的,它要求幂等键对齐业务语义 + 去重与副作用原子 + 下游也幂等三者同时满足。