OpenAI 支付题的完整解答:解读真实题目、预授权与请款、幂等键、支付状态机、复式记账账本、服务商 webhook、使用合适索引的夜间结算、对账、容错以及分片。
作者:The Forward Deployed 编辑团队已审核
本文属于 OpenAI 系统设计题库。该题目能代表该轮面试的水平。分析与解答为本站原创。
题目描述
标题可能写的是“支付”,但问题是一个具体的流程。据反馈,有候选人在给出一个精致但通用的支付系统设计后,却没能回答这个更窄的问题而失败。请把题目读两遍,复述一遍,然后精确地设计这个流程:授权预授权、请款最终金额、夜间结算、并对账。
欺诈评分、积分和库存不在范围内,除非面试官补充加入。
澄清问题
- 是线下刷卡还是线上支付? 假设店内有一台拍卡支付终端,外加提前下单的移动订单。
- 最终金额可以超过预授权金额吗? 可以,最多超出一定比例(如 20%),用于小费。
- 预授权能持续多久? 如果未请款,服务商会在几天后释放它。假设订单在一小时内完成请款。
- 支持部分支付或拆分支付吗? 不支持,每笔订单只用一张卡。
- 有多少个服务商? 两个,为了容错和成本。
- 规模多大? 练习用:10,000 家门店,每家门店每天 300 笔订单,早高峰时达到峰值。
- 金额用什么形式表示? 整数最小货币单位,例如分,绝不用浮点数。
支付系统为什么难
资金要在你无法控制的系统之间流转,而网络偏偏在最糟糕的时刻出故障。
一个请求可能在服务商已经扣款之后才超时,于是客户端重试,客户被扣了两次钱。服务商的结果可能几分钟后才通过 webhook 到达,而且可能到达两次。夜间批处理可能中途失败,重跑时绝不能重复结算任何一笔。以上每一种情况都是真实的资金错误,最终会变成一张客服工单或一次拒付。
因此,核心矛盾是可用性与资金恰好一次流转之间的权衡。收银台绝不能让排队阻塞,资金也绝不能流转两次或凭空消失。答案是在每个边界做幂等、一个显式的状态机,以及一本能证明每一笔余额的账本。
flowchart LR POS([门店终端]):::user --> PS[支付服务]:::svc PS --> PSP[支付服务商]:::store PSP -.webhook.-> PS PS --> DB[(支付 + 账本)]:::store B[夜间结算]:::svc --> DB B --> PSP classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
核心思想。 每一步都可以重试,所以每一步都必须是幂等的。状态机规定接下来可能发生什么;账本证明实际发生了什么。
关键概念
授权、扣款、结算
授权是请求发卡机构预留一笔金额:即冻结。此时钱还没有动。扣款是通知支付服务商按冻结额度收取最终金额。结算是服务商实际把已扣款的资金转给商户,通常按日批量进行。从未被扣款的冻结会过期,预留随之消失。
幂等键
由客户端生成并附加在请求上的键。服务器将该键与结果一起记录。使用相同键的重试会返回已存储的结果,而不是重复执行操作。
状态机
一笔支付始终处于且仅处于一个状态,例如已授权或已扣款,并且只有某些状态转换是合法的。在数据库中强制执行这些规则可以杜绝不可能发生的序列,例如重复扣款。
复式记账账本
每一笔资金变动至少写入两条相互平衡的分录:借方等于贷方。分录永不修改;错误通过冲销分录来纠正。任何余额都是其分录之和,因此始终可以重新计算并审计。
对账
将自己的记录与服务商提供的实际发生情况报告逐行比对,并调查每一处差异。
核心思想。 授权、扣款、结算;让每一步都幂等;强制执行合法的状态转换;把一切记录为平衡且不可变的分录;每日对账。
需求
在继续阅读之前。 用一句话复述题目。然后列出需求,并指出绝不能被破坏的性质。
1.1 功能需求
- 顾客下单时创建冻结。
- 订单就绪时扣取最终金额,可能因小费而更高。
- 取消的订单撤销冻结。
- 处理通过 webhook 稍后到达的服务商结果。
- 每晚按服务商结算所有已扣款的支付。
- 与服务商报告对账并标记差异。
- 按门店报告每日总额。
1.2 非功能需求
- 绝不重复扣款,绝不丢失支付。 任何时候都不允许。
- 快速授权。 p95 下从刷卡到批准约一秒;队伍必须保持流动。
- 支付状态和余额需要强一致性。
- 可审计性。 每一分钱都可追溯到分录和服务商引用。
- 可用性。 服务商故障不能中断销售。
1.3 约束与性质
资金变动的恰好一次(exactly-once)是必须保证的性质。 授权延迟预算是约束。 它迫使刷卡路径保持同步且简短,其余一切推到异步处理。
粗略估算
- 订单:10,000 家门店 × 300 = 每天 300 万单。
- 平均速率:300 万 / 86,400 ≈ 每秒 35 单。
- 早高峰:如果 40% 的订单集中在 3 小时内,那就是 120 万 / 10,800 秒 ≈ 每秒 110 笔,峰值会达到每秒几百笔。
- 每笔订单会发起两次支付服务商调用(预授权和请款),并写入大约五行数据(支付记录、幂等记录、三四条账本分录)。
- 每天行数:约 1500 万行;一年约 50 亿行。支付记录行很小,只有几百字节,所以包括索引在内,存储量每年大约几个 TB。
- 每晚批处理:约 300 万笔已请款的支付,按服务商分组打包成文件。
这个规模用一个配置良好的关系型数据库加一个副本就能支撑多年。先这么说,然后再描述当业务链增长时你会如何分片。
API 设计
在继续阅读之前。 终端发出一笔请款请求,网络在收到回复前断开了。终端接下来会做什么?服务器又会做什么?
终端用同一个幂等键重试。服务器找到该键,发现请款已经完成,于是返回存储的结果。客户只被扣款一次。
3.1 预授权与请款
POST /v1/holds
Idempotency-Key: 7f2c...
{order_id, store_id, card_token, amount: 450, currency: "USD"}
-> 201 {hold_id, status: "authorized", expires_at}
-> 402 {status: "declined", reason}
POST /v1/holds/:hold_id/capture
Idempotency-Key: 91ab...
{final_amount: 525} // 450 + 75 tip, within the allowed margin
-> 200 {payment_id, status: "captured"}
-> 409 {status: "already_voided" | "expired"}
POST /v1/holds/:hold_id/void
Idempotency-Key: c3d0...
-> 200 {status: "voided"}
GET /v1/payments/:id -> {status, amounts, provider_ref, history[]}3.2 服务商 Webhook
POST /v1/webhooks/:provider
Headers: signature, timestamp
{event_id, type: "authorization.succeeded" | "capture.failed" | ..., provider_ref, ...}
-> 200 always once verified and recorded, even if already processed金额是最小货币单位的整数。API 拒绝小数。
数据模型
CREATE TABLE payments (
id uuid PRIMARY KEY,
order_id uuid NOT NULL UNIQUE,
store_id uuid NOT NULL,
provider text NOT NULL,
provider_ref text UNIQUE,
status text NOT NULL CHECK (status IN
('pending','authorized','captured','settling','settled',
'voided','expired','failed')),
hold_amount bigint NOT NULL CHECK (hold_amount > 0),
final_amount bigint,
currency char(3) NOT NULL,
batch_id uuid,
authorized_at timestamptz,
captured_at timestamptz,
updated_at timestamptz NOT NULL,
version int NOT NULL DEFAULT 0
);
CREATE INDEX payments_settle_idx ON payments (status, provider, captured_at);
CREATE INDEX payments_store_day_idx ON payments (store_id, captured_at);
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
request_hash text NOT NULL,
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL
);
CREATE TABLE ledger_entries (
id bigserial PRIMARY KEY,
txn_id uuid NOT NULL, -- groups the balanced entries
payment_id uuid NOT NULL,
account text NOT NULL, -- customer_receivable, store_revenue, tips_payable, ...
direction char(1) NOT NULL CHECK (direction IN ('D','C')),
amount bigint NOT NULL CHECK (amount > 0),
currency char(3) NOT NULL,
created_at timestamptz NOT NULL
);
CREATE TABLE settlement_batches (
id uuid PRIMARY KEY,
provider text NOT NULL,
business_date date NOT NULL,
status text NOT NULL, -- building | submitted | settled | failed
file_ref text,
payment_count int,
total_amount bigint,
UNIQUE (provider, business_date)
);
CREATE TABLE webhook_events (
provider text, event_id text, received_at timestamptz,
PRIMARY KEY (provider, event_id)
);结算索引
(status, provider, captured_at)
正是它让夜间查询变成范围扫描而不是全表扫描。面试官会关注这一点。
高层设计
5.1 一个直接扣款的调用
订单就绪时,终端调用“扣款 $5.25”。它无法处理预授权之后追加的小费,超时会让扣款状态未知,而重试会导致重复扣款。
5.2 修复 1:先预授权、再请款,并引入状态机
把流程拆分为授权和请款,并给每笔支付一个明确的状态。状态转换只通过条件更新发生。
stateDiagram-v2 [*] --> pending pending --> authorized: 服务商批准 pending --> failed: 被拒或出错 authorized --> captured: 请款最终金额 authorized --> voided: 订单取消 authorized --> expired: 预授权过期 captured --> settling: 纳入夜间批处理 settling --> settled: 服务商确认 settling --> captured: 批处理失败,明天重试
5.3 修复 2:每次写入都使用幂等键
每次预授权、请款和撤销都携带一个键。该键、请求的哈希值和响应与状态变更存储在同一个事务中。
5.4 修复 3:复式记账账本
每次涉及资金变动的状态变更都写入平衡的账本分录。
5.5 修复 4:Webhook 与清扫任务
迟到的服务商结果通过同一个状态机更新支付。清扫任务会向服务商查询卡在 pending 状态的支付。
5.6 修复 5:夜间结算与对账
一个批处理任务为每个服务商生成一份结算文件,用各自的幂等令牌提交,第二天早上由对账任务将服务商的报告与账本进行比对。
5.7 组合后的设计
sequenceDiagram autonumber participant T as 终端 participant P as 支付服务 participant D as 数据库 participant V as 服务商 T->>P: POST /holds (key k1, 450) P->>D: 插入幂等键 k1,支付状态 pending P->>V: 授权 450(服务商幂等键 k1) V-->>P: 批准,引用号 r9 P->>D: pending -> authorized,为 k1 存储响应 P-->>T: 201 authorized Note over T: 下单完成,已加小费 T->>P: POST /holds/h1/capture (key k2, 525) P->>D: authorized -> captured(条件更新),写入分录 P->>V: 对 r9 执行扣款 525 V-->>P: 成功 P-->>T: 200 captured Note over P,V: 每晚 23:00 按服务商运行批处理 P->>D: captured -> settling,分配批次 P->>V: 结算文件(批次令牌) V-->>P: 已确认,次日早上返回结算报告 P->>D: settling -> settled,与分录对账
核心思想。 将预授权与扣款分离,为每次写入设置键,强制状态转换,记录借贷平衡的分录,吸收迟到的 Webhook,并以幂等批次进行结算和对账。
深度剖析
6.1 幂等性,端到端
继续阅读之前。 重复扣款究竟可能从哪里混入,又是什么在阻止每一次重复?
| 边界 | 故障 | 保护措施 |
|---|---|---|
| 终端到支付服务 | 超时,终端重试 | 幂等键与结果一同存储 |
| 支付服务到服务商 | 超时,服务重试 | 将同一个键传给服务商,服务商也会去重 |
| 服务商到 Webhook 处理器 | 服务商重试 Webhook | 记录(服务商,event_id);若已见过则跳过 |
| 两次扣款竞争 | 两个终端或重复刷卡 | 条件更新 WHERE status = 'authorized' |
| 每晚批处理重跑 | 任务中途崩溃 | 批次行按服务商和日期唯一;批次令牌发送给服务商 |
存储的幂等记录必须与状态变更在同一个事务中写入。如果先写记录而服务在状态变更前崩溃,重试会返回一个从未发生过的结果。如果先变更状态而记录丢失,重试会重复执行该操作。
同时用键对请求体做哈希。同一个键但金额不同属于客户端 bug;应返回 422 拒绝,而不是返回旧结果。
区分答案的关键:幂等性
弱:指望重试很少发生
没有键,因此超时加重试会导致重复扣款。
良好:在 API 上使用键
为预授权和扣款存储幂等键,重试时返回已存储的结果。
强:在每个边界都使用键
将键传给服务商,按事件 ID 对 Webhook 去重,用条件状态转换对抗竞争,为批次分配独立令牌,并将键与状态写入同一个事务。
6.2 分录账本
继续阅读之前。 为一笔 4.50 美元、含 0.75 美元小费的订单,写出从扣款到结算的分录。
扣款时,一笔借贷平衡的事务:
| 账户 | 借方 | 贷方 |
|---|---|---|
| customer_receivable (provider owes us) | 525 | |
| store_revenue | 450 | |
| tips_payable(应付给员工的小费) | 75 |
结算时,当支付服务商付款时:
| 账户 | 借方 | 贷方 |
|---|---|---|
| cash_in_bank | 510 | |
| provider_fees | 15 | |
| customer_receivable | 525 |
每笔交易中借方等于贷方。分录从不更新或删除。退款会写入新的冲销分录。门店某天的收入是其贷方分录之和;应付给员工的小费是
tips_payable
的余额。由于余额是推导出来的,某个报表中的 bug 无法悄悄改变资金:分录始终是事实的来源。
6.3 延迟结果与 Webhook
支付服务商可能在你的请求超时之后才批准授权,并在几分钟后通过 webhook 通知你。在调用服务商之前先写入
pending
,这样调用中途崩溃也会留下记录。Webhook 处理程序会验证签名和时间戳,通过服务商参考号查找支付,记录事件 ID,并且仅在该状态转换仍然合法时才执行。然后返回 200,即使是重复通知也如此,这样服务商就会停止重试。
一个清扫任务每隔几分钟运行一次,查找处于
pending
状态超过一分钟的支付,并直接向服务商询问。如果服务商说已批准,则转移到
authorized
。如果没有记录,则标记为
failed
,终端会请顾客重新刷卡。
6.4 每日夜间结算
在继续阅读之前。 批处理任务在把一半支付标记为结算中之后、发送文件之前崩溃了。重新运行时会发生什么?
for each provider:
batch = INSERT INTO settlement_batches (provider, business_date, status)
VALUES (:p, :day, 'building')
ON CONFLICT (provider, business_date) DO NOTHING
RETURNING id
if no row returned: batch = SELECT existing; if status = 'submitted' skip
UPDATE payments SET status = 'settling', batch_id = :batch
WHERE status = 'captured' AND provider = :p AND captured_at < :cutoff
write file from SELECT ... WHERE batch_id = :batch -- deterministic
submit file to provider with batch id as idempotency token
UPDATE settlement_batches SET status = 'submitted' WHERE id = :batch重新运行时,批处理行已经存在,因此任务会复用它。
UPDATE
会拾取所有尚未进入批处理的已捕获支付。文件从
batch_id
重新生成,因此它恰好包含批处理中的支付,包括崩溃前已标记的那些。服务商根据批处理令牌去重,所以重新提交是无害的。
在
(status, provider, captured_at)
上的索引使查询变成仅对已捕获行的范围扫描。没有它,任务将扫描有史以来的每一笔支付。
6.5 对账
每天早上服务商发送结算报告。按服务商参考号将其与批处理逐行核对:
- 在批处理中,但不在报告中:服务商未结算。保持结算中状态,在下一批中重试,两天后告警。
- 在报告中,但不在批处理中:你未提交的款项到账了。进行调查;通常是手动捕获或重复提交。
- 金额不一致:部分捕获、手续费或货币问题。进行调查。
每个不匹配项都会进入人工处理队列,并附上支付、批处理和报告行。对账是让静默 bug 显现出来的地方。
6.6 服务商故障
保持刷卡路径同步且简短:终端、支付服务、服务商、应答。不要在授权路径中放入消息队列;在高负载下,它恰恰在队伍最长的时候增加延迟。
当某个服务商出现故障时,断路器会在多次错误后打开,并在短时间内停止向其发送流量。新的预授权请求会转给备用服务商。重试采用带抖动的指数退避。已在故障服务商处完成授权的预授权必须由该服务商完成请款;将这些请款操作放入队列并重试,直到其恢复。作为小额交易的最后手段,一些商户会接受使用已存储的卡片令牌进行线下批准并稍后请款,自行承担被拒付的风险。
6.7 一致性与规模
资金需要强一致性:状态机和账本存放在同一个支持事务的关系型数据库中。余额的最终一致性是这一轮中的经典扣分点。
当链路规模增长十倍时,按门店 ID 进行分片。每个门店的支付、账本条目和日报都保留在同一个分片上,结算任务按分片运行。按支付 ID 分片能更均匀地分散写入,但会把每个门店的报表分散到各个分片上。幂等键必须与其保护的支付位于同一个分片上,因此要从请求中的门店 ID 推导分片。
6.8 逐一分析故障场景
在继续阅读之前。 针对下面的每个故障,说明客户会体验到什么,以及系统会做什么。
| 故障 | 客户看到 | 系统行为 |
|---|---|---|
| 终端在预授权请求时超时 | "处理中...",重试后成功 | 使用相同键重试会返回已存储的结果,或完成挂起的调用 |
| 服务商已批准,但响应丢失 | 重试后无异常 | 支付处于待处理状态;清扫任务或 Webhook 将其移至已授权;终端的重试会返回该结果 |
| 咖啡师双击了请款按钮 | 只产生一笔扣款 | 第二次请款发现状态已是已请款,返回其键对应的已存储响应,或对新键返回 409 |
| 预授权后订单被取消 | 无扣款;预授权已释放 | 作废操作将已授权状态改为已作废,并向服务商释放预授权 |
| 订单从未被请款(Bug) | 几天后预授权消失 | 夜间任务在打烊一小时后作废过期的预授权;对数量进行告警 |
| 早高峰时服务商宕机 | 支付成功但稍慢一些 | 断路器打开;新的预授权路由到备用服务商 |
| 批处理任务中途崩溃 | 无感知 | 重新运行时复用该批次记录;重新生成文件;服务商按令牌去重 |
| 服务商结算了一笔你从未提交的金额 | 无感知 | 对账在次日早上将其标记以供审查 |
这张表正是面试官会记住的那类产物。它表明每个边界都有答案。
6.9 小费与预授权
小费金额可能超过预授权额度。对于刷卡场景下的小费,发卡机构通常允许在卡组织及商户类别设定的限额内,捕获略高于授权金额的金额。设计时要考虑这一限额:在捕获前校验最终金额是否在允许的余量内。如果顾客的小费超出限额,可捕获允许的最大金额,剩余部分作为一笔单独的小额交易扣款,或请求新的授权。
将小费作为独立的账本分录记录,
tips_payable
,这样工资系统可以据此发放,报表也能将其与营业收入区分开来。
区分答案优劣的关键:完整性
弱:只考虑了正常流程
描述了预授权和捕获,但没有涉及任何失败场景。
良:覆盖了主要失败场景
处理了重试、Webhook 和批处理重跑。
优:每个边界都有答案
逐一讲解每种失败场景下顾客看到什么、系统做什么,处理小费超出预授权额度的情况,并清理过期的预授权。
变体
7.1 通用支付系统
如果题目确实是通用支付平台,同样的核心架构在更大规模下依然适用:支付编排器、多个支付服务商、钱包、账本服务和对账,并为满足数据驻留要求进行区域化部署。
7.2 在线结账
在线订单增加了 3-D Secure 验证、授权前的欺诈评分,以及库存预留——支付失败时必须释放预留。
7.3 退款
退款是一笔新交易,拥有自己的幂等键和冲正账本分录。部分退款需引用原支付,且退款总额不得超过已捕获金额。
7.4 门店数量扩大十倍
当门店达到 10 万家时,高峰期授权请求接近每秒 1,100 笔,峰值还会高出数倍。按门店 ID 分片到多个数据库集群,并维护一张从门店到分片的路由表。按分片并行执行结算,按分片对账后再汇总。服务商的速率限制会成为现实问题:需将流量分散到每个服务商、每个地区的多个商户账户上。
可迁移的模式
支付本质上是基于不可变账本的幂等状态机,并与外部事实来源进行对账。同样的架构适用于订单履约、库存,以及任何跨越你无法控制的系统的工作流。让每一步都可以安全地重复执行,让非法状态转换不可能发生,将事实以只追加的方式记录,并每天与外部世界比对。
复盘:30 秒答案
- 读懂题目。 下单时预授权,捕获时含小费,每晚结算。
- 在每个边界保证幂等。 API、服务商、Webhook 和批处理。
- 带条件更新的状态机。 不重复捕获,不允许非法序列。
- 以最小货币单位记账的复式账本。 余额由分录推导得出,分录不可变。
- 每晚批处理使用独立的令牌和正确的索引;每天早晨对账。 同步的刷卡路径、熔断器,以及备用服务商。
测验
+为什么将幂等记录与状态变更放在同一个事务中存储?
如果分开写入,两者之间发生崩溃时,要么返回一个从未发生的结果,要么重复一个已经发生的操作。放在一个事务中可以让它们一起成功或一起失败。
+为什么金额使用整数最小单位?
浮点数无法精确表示大多数十进制小数,因此求和时会产生分以下的误差。以分为单位的整数是精确的。
+每夜结算查询需要什么索引,为什么?
在状态、服务商和捕获时间上建索引。该任务会选取截止时间前每个服务商已捕获的支付;这个索引使查询变成范围扫描,而不是扫描所有历史支付记录。
+批处理任务在崩溃后重新运行时如何保证安全?
批处理记录对每个服务商和日期是唯一的,因此重新运行时会复用它。文件会根据分配给该批次的支付重新生成,服务商通过批次令牌进行去重。
+为什么授权路径要保持同步?
顾客正在柜台前等待。在路径中加入队列会在高负载时增加延迟,而这恰恰是排队最长的时候。只有结算和报表被推送到异步任务。
+咖啡师连点两次“捕获”会发生什么?
第二个请求要么重复第一个幂等键并得到存储的响应,要么使用新键但发现支付已被捕获,此时条件更新不会改变任何内容,API 返回冲突。顾客只会被扣款一次。
+系统应如何处理超出允许捕获余量的小费?
在捕获前根据余量校验最终金额。可以捕获到允许的最大金额并单独收取剩余部分,或者请求一笔新的授权。
资料来源与延伸阅读
- Stripe:幂等请求 介绍了服务商如何实现幂等键。
- Stripe:对支付方式设置预授权 解释了先授权后捕获以及预授权过期机制。
- Martin Fowler:会计模式 涵盖了分录、账户和冲销调整。
- 幂等的 webhook 处理在 webhook 投递 中从发送方角度进行了说明。