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_revenue450
tips_payable(应付给员工的小费)75

结算时,当支付服务商付款时:

账户借方贷方
cash_in_bank 510
provider_fees 15
customer_receivable525

每笔交易中借方等于贷方。分录从不更新或删除。退款会写入新的冲销分录。门店某天的收入是其贷方分录之和;应付给员工的小费是

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 返回冲突。顾客只会被扣款一次。

+系统应如何处理超出允许捕获余量的小费?

在捕获前根据余量校验最终金额。可以捕获到允许的最大金额并单独收取剩余部分,或者请求一笔新的授权。

资料来源与延伸阅读

NextOnline Chess Platform