Day 17 Hard Payments Idempotency Ledger Reconciliation

支付系统 — 钱不能多也不能少的工程Payment Systems: Idempotency, Double-Entry Ledger, Saga, Reconciliation

问题场景与需求约束

设计一个 marketplace 支付平台(类似 Airbnb / Uber):向 guest 收款、给 host 打款。日处理 1000 万笔交易,平均写 ~115 QPS、大促峰值 ~3000 QPS。这是少数 correctness 远高于 availability 的系统——宁可拒绝服务,也绝不能重复扣款、重复打款、把账记错一分。

面试要先澄清的关键约束:

高层架构

graph TD C[客户端/商户] -->|idempotency-key| API[Payment API
幂等层] API --> ORC[Payment Orchestrator
Saga 编排] ORC --> LED[Ledger Service
不可变双重记账] ORC -->|outbox| GW[PSP Gateway] GW --> PSP[(Stripe / Adyen)] PSP --> BANK[(银行 / 卡组织)] LED --> DB[(Ledger DB
append-only)] RECON[Reconciliation Job
每日三方对账] --> DB RECON --> PSP RECON --> BANK

组件职责:① 幂等层拦截重复请求,用 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
现实案例:Stripe 在 Postgres 里实现 idempotency key + recovery point,请求可在任意阶段崩溃后续跑,并校验「同 key 不同 body」直接报错。Airbnb 在其 SOA 支付链路上建了一个通用 idempotency 框架,靠它在多个微服务间实现「最多一次」的金额移动。

② 双重记账 Ledger — 不可变、append-only

核心 trade-off:放弃「直接改余额」的简单,换可审计、可对账、永不丢账的真相源。

【原理】每一次钱的移动至少写两条分录(借 + 贷),且每笔交易的借贷之和恒为 0——这是会计五百年验证过的不变量,天然防漏记。账本不可变(只 append,不 update/delete):要改账(退款、纠错)靠写一条冲正分录(reversing entry),而非删改原记录。账户余额 = 该账户所有分录的聚合。

选型对比:
# 转账:同一 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
现实案例:Square 的 Books 是一个不可变、双重记账的数据库服务,确保「不可能产生不合逻辑的金额变动」。Uber LedgerStore 是其所有金钱事件的不可变真相源,append-only、支持万亿级索引。Modern Treasury 把双重记账、可审计、不可变作为账本三原则。

③ 跨服务金额移动 — 用 Saga 而非 2PC

核心 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 直到成功
现实案例:Airbnb 在 SOA 下,一个 API 调用会下钻多个下游服务形成复杂分布式事务,他们靠幂等 + 自动重试实现最终一致而非 2PC。Uber 的金钱链路同样以 LedgerStore 为强一致核心、外围异步编排。

④ 对账与核验 — 找出账面与现实的漂移

核心 trade-off:实时对账贵但早发现,T+1 批量便宜但延迟暴露资损。

【原理】内部 ledger 是「你以为的钱」,PSP/银行流水是「实际的钱」,两者必然漂移:手续费、汇率、退款时差、PSP 的 bug、漏处理的 timeout。每日做三方对账:内部 ledger ↔ PSP statement ↔ 银行流水,按 external_id 逐笔 join,找出 break(缺失、金额不符、状态不符),自动归类(手续费类自动平账、疑似资损告警),剩余人工兜底。对账是支付系统最后一道防线——前面所有幂等/Saga 的 bug 最终都会在这里现形。

选型对比:实时对账(流式比对,分钟级发现,成本高)vs T+1 批量(一天一次,便宜,但资损可能跑一天)。容忍策略:零容忍逐笔平账 vs 阈值告警(小额差异先记账后人工)。
# 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有我漏记 → 资损风险
现实案例:对账是所有支付公司的标配能力。Modern Treasury 把可对账作为产品核心;Stripe / Square 的不可变 ledger 设计正是为了让对账可逐笔追溯。

扩展与优化

常见陷阱与面试问题

高频面试追问:① 客户端重试时如何保证不重复打款?② PSP 返回超时,你怎么知道钱到底动没动?③ 退款怎么记账(为什么不能改原记录)?④ 为什么支付不用 2PC?⑤ 怎么做到对账「到分」?哪类 break 可自动平、哪类必须人工?

深入资源

深入思考

1. PSP 调用返回了 timeout——钱可能扣了也可能没扣。你绝不能假设任一种。完整的兜底链路是什么?

timeout 是支付系统最危险的状态:请求可能在「PSP 已扣款但响应丢失」处中断。处理链路:

  • 不在本地标记任何终态:保持 PENDING,绝不写成 success 或 failed。
  • 带 idempotency key 重查/重试:因为发给 PSP 的请求也带了 key,重试是安全的——若已扣,PSP 返回原结果而非二次扣款;若没扣,这次扣成功。
  • 主动 reconcile:调 PSP 的查询接口按 key 拉状态,落地真相。
  • 最终防线是对账:即便上面都漏了,T+1 对账会发现「PSP 有我 ledger 没有」的 break,触发补记账或退款。

本质:把「不确定」用幂等 + 对账收敛成「确定」,而不是猜。

2. 为什么退款不能直接把原支付记录的金额改成 0 或删掉?这背后是什么原则?

因为账本不可变——改/删原记录会摧毁审计轨迹和对账能力。退款应写一条冲正分录(reversing entry),引用原 txn_id,方向相反。好处:① 历史完整可追溯,监管和对账都能还原;② 原支付仍真实发生过(手续费、税务、报表都依赖它);③ 部分退款只需写部分金额的反向分录,余额自然正确。这正是双重记账「append-only + reversing」的核心:错误不是抹掉,而是用新分录纠正,和 Git 的 revert 而非 force-push 同理。

3. 一个明星商户某天产生 100 万笔交易,全压在它那一个 account 上——ledger 按 account_id 分片会怎样?怎么救?

按 account_id 分片会让这个账户的所有分录落在同一分片,形成热点:写入争抢、物化余额行成为锁热点(每笔都要更新该账户余额)。解法:

  • 余额分桶:把单一余额行拆成 N 个子余额(sub-balance),写入随机打散到桶,读余额时 SUM N 个桶——类似高并发计数器的 sharded counter。
  • 批量记账:高频小额先进缓冲,按窗口聚合成少量分录入账(牺牲实时性换吞吐)。
  • 异步物化:分录仍逐笔 append(无锁竞争),余额由下游异步聚合,不在主写路径上更新。

核心是把「单点余额更新」从写路径上移走——分录可以无锁 append,余额可以最终一致地算出来。

4. 既然 ledger 守恒(借贷恒等)能数学上保证不丢账,为什么还要花大力气做对账?

因为守恒只保证你自己账本内部自洽,不保证账本反映了外部现实。漂移来自边界:① 你以为 PSP 扣成功并记了账,实际 PSP 失败了(你的账本内部仍守恒,但与 PSP 不符);② PSP 收了手续费,你没记;③ 漏处理的 timeout 让 PSP 动了钱你没记。这些都不破坏内部守恒,却是真实资损。对账是把「内部一致」升级为「与现实一致」的唯一手段——它检验的是系统边界,而守恒只检验系统内部。这也是为什么对账被称作支付系统的最后一道防线。

5. 「至少一次投递 + 幂等消费 = 有效一次」——这个等式在支付里成立的前提是什么?什么情况下会破?

前提:消费端的幂等判断必须和「钱的移动」一一对应,且去重记录与副作用原子落库。会破的情况:

  • 幂等键选错粒度:用 message_id 去重,但同一笔业务支付被两个不同 message 触发 → 各自幂等通过,钱动两次。应以业务支付 id 为幂等键。
  • 去重与副作用不原子:先调 PSP 扣款再写去重表,中间崩溃 → 重试时去重表没记录,再扣一次。必须同事务,或 PSP 侧也带幂等 key 做二层防护。
  • 幂等记录过期:TTL 太短,重试到来时记录已清,退化成无幂等。

所以「effectively-once」不是免费的,它要求幂等键对齐业务语义 + 去重与副作用原子 + 下游也幂等三者同时满足。