题目与适用场景
请设计一个电商支付处理系统。买家提交订单后,系统通过外部支付服务商完成一次性银行卡支付;库存 确认后再扣款,订单取消时释放授权,扣款后支持多次部分退款,累计退款不得超过已扣款金额。支付方式 可能要求 3DS 等额外验证,最终结果也可能晚于浏览器返回。
本题假设每天有 500 万次支付尝试,平均约 58 TPS,峰值 500 TPS;创建支付 API 的 p99 小于 300 毫秒,支付状态查询的 p99 小于 200 毫秒,本地 API 月可用性目标为 99.99%。这个可用性目标不承诺 外部服务商一定完成支付。金额使用最小货币单位的整数表示;一个支付只有一种币种。外部服务商、网络 和本地进程都可能失败。数字与时限是面试约束,不代表任何支付产品的承诺。
范围包括支付创建、额外验证、授权、扣款、取消授权、部分或全额退款、资金账本、服务商回调与对账。 拒付、风控模型、换汇、商家结算、税务和完整 PCI 合规项目不展开,但候选人应说明边界。支付卡号由 服务商托管页面或令牌化组件收集,本系统保存支付方式令牌,不接收或记录原始卡号与安全码。
面试官考察什么
第一项是能否区分业务意图、外部尝试和资金事实。一个购物车对应一个内部 payment_id,但它可能有 多次验证或服务商尝试;一次请求超时也不代表支付失败。高质量回答不会用一列 paid=true 覆盖所有 过程,而是分别保存支付聚合状态、每次操作及其未知结果、服务商引用和账本分录。
第二项是能否准确处理分布式边界。数据库事务不能和外部支付服务商组成同一个原子事务。服务商已经 扣款但响应丢失、回调先于同步响应到达、进程在本地提交后崩溃,都可能发生。答案要用调用方幂等键、 内部操作 ID、服务商幂等键、事务 outbox、回调去重和状态查询,把重复执行变成可收敛的流程,不能 笼统承诺端到端 exactly-once。
第三项是支付状态机是否具备单调、可审计的转换。授权和扣款是不同资金阶段;REQUIRES_ACTION 不 等于失败,服务商超时产生 UNKNOWN 操作,也不能直接把支付标成失败。退款必须引用已扣款支付,使用 精确金额,并在并发下守住“已退款 + 处理中退款不超过已扣款”的不变量。
第四项是账本与业务状态的职责。支付表回答用户当前能做什么;追加写账本回答资金为什么得到这个余额。 已入账事实不能原地改写,退款和修正使用新的反向或补偿分录。每个 journal 的借贷总额、币种和业务 引用都要在事务中校验,随后再与服务商报告和银行入账做对账。
最后是安全和可证伪性。候选人应收窄卡数据范围、验证回调签名、保护服务商密钥、限制权限与日志内容, 并用响应丢失、重复回调、乱序回调、并发扣款/退款、账本不平和对账差异做故障注入。只画服务框图而 没有不变量、恢复路径和验证方法,不能证明系统正确。
回答前要确认的问题
- 谁负责采集支付卡数据? 本题使用支付服务商托管页面或令牌化组件。业务后端只接收支付方式令牌,
不保存原始卡号、磁道、PIN 或安全码;仍需由合规团队确认实际 PCI 范围。
- 授权与扣款何时发生? 下单时授权,库存预留成功后扣款;库存失败或授权到期前取消授权。不同支付
方式是否支持延迟扣款,要通过能力表判断,不能假设全部一致。
- 成功以什么为准? 浏览器跳转只提供用户体验,不能单独触发发货。权威状态来自经过验证的服务商
响应、回调或主动查询,并由本地状态机接受后才能产生业务动作。
- 退款契约是什么? 支持多次部分退款和全额退款,不允许累计超过已扣款金额;不做无原支付的独立
退款。退款可能异步完成,也可能被服务商拒绝。
- 是否需要多服务商路由? 首版只接一个服务商,但接口保存内部操作 ID 和服务商引用。结果未知时
禁止自动换另一家重试,否则可能在两家都扣款。
- 账本覆盖什么? 本题记录服务商应收与商家应付,不展开服务商费用、商家打款和税务。每种币种
独立平衡,不在账本中用浮点数或隐式汇率换算。
- 数据保留与审计要求是什么? 支付、操作、回调收据和账本引用按监管与公司政策保留;敏感负载
需要最小化、加密和访问审计。PCI SSC 明确禁止授权后保存敏感认证数据,即使已加密。
- 可用性和一致性如何取舍? 服务商不可用时可接受并显示“处理中”,但不能显示虚假成功。资金
正确性和可追溯性优先于立即给出终态。
30 秒回答框架
“我会用稳定 payment_id 表达一次付款意图,把支付状态与 authorize、capture、refund 操作分开。 创建和资金操作都用调用方幂等键原子保存请求摘要、操作与 outbox;工作进程复用操作 ID 调服务商。 超时进入 UNKNOWN,靠验签回调、查询和对账收敛,不换 ID 盲目重试。权威结果通过单调状态机落库, 同一事务追加平衡 journal 和业务事件。退款条件预占剩余可退金额,避免并发超额;最终再把内部账本、 服务商报告和银行入账对齐。卡数据由服务商令牌化,浏览器成功页不能触发发货。”
分步深入分析
第一步:从容量、不变量和所有权开始
500 万除以 86,400 秒约为 58 TPS,500 TPS 的峰值不要求一开始就分片所有表。设计重点是正确性、 审计与外部故障恢复。支付 API 可以水平扩展,关系数据库保存权威状态;按 payment_id 路由的持久队列 吸收峰值并隔离服务商延迟。先写下不变量:
amount > 0
currency is immutable after the first provider attempt
captured_amount <= authorized_amount
refunded_amount + pending_refund_amount <= captured_amount
for every journal: sum(debits) == sum(credits), per currency
one merchant + one idempotency_key describes one immutable request intent
one successful business operation produces at most one journal reference在这个量级,单写区域的关系数据库配故障切换,比主动—主动多区域写入更容易守住幂等键、状态版本和 账本顺序。主动—主动能降低区域切换时间,却要解决同键跨区域并发和资金冲突,只有明确的区域可用性需求 才值得采用。完全事件溯源也能保留历史,但会把状态重建、版本迁移和查询复杂度扩散到整个支付流程; 本题只让资金 journal 追加写,工作流聚合使用带版本的当前状态,审计收益和运维复杂度更匹配。
订单服务拥有库存与履约;支付服务拥有支付状态、操作和资金引用;服务商拥有银行卡网络侧状态;账本拥有 内部资金事实。订单不能直接写支付表,支付回调也不能直接把订单标成已发货。支付服务发布稳定业务事件, 订单服务按 payment_id 幂等消费。
第二步:定义 API、幂等会话和数据模型
核心接口可以是:
POST /payments create one payment for an order
POST /payments/{id}/capture capture an authorized amount
POST /payments/{id}/cancel cancel an uncaptured authorization
POST /payments/{id}/refunds request a partial or full refund
GET /payments/{id} return current status and allowed next actions
POST /provider/webhooks persist a verified provider event所有改变资金的调用都需要调用方提供幂等键。(merchantid, operationtype, idempotency_key) 建唯一 约束,记录规范化请求摘要、处理中状态和可重放响应。首次请求在同一数据库事务中创建 payments、 payment_operations 与 outbox。相同键和相同摘要重放已知响应;相同键但金额、币种、支付或操作类型 不同,返回冲突。Amazon 的一手工程说明也强调:由调用方明确请求标识,并将标识记录和业务变更放在 同一个 ACID 边界;同标识不同意图应被拒绝。
建议分开存储:
payments:订单引用、金额、币种、聚合状态、已授权/已扣款/已退款金额、版本;payment_operations:类型、操作 ID、请求金额、状态、服务商、服务商引用、尝试次数与未知原因;provider_events:服务商事件 ID、签名验证结果、接收时间、负载加密引用和处理状态;journal_entries:journal ID、账户、借/贷、金额、币种、操作引用;outbox_events:本地事务已提交、等待发布的业务事件。
内部操作 ID 可以作为服务商幂等键。Adyen 文档明确说明支付超时可用同一键安全重试,并指出键有作用域、 保存期和跨区域边界;因此系统必须了解具体服务商契约,不能把一个“UUID”当成永久全球保证。
第三步:把支付和操作建成两个状态机
支付聚合面向订单和用户:
CREATED -> REQUIRES_ACTION -> AUTHORIZED -> CAPTURED
\-> FAILED \-> CANCELED
CAPTURED -> PARTIALLY_REFUNDED -> REFUNDED每次 authorize、capture、cancel 或 refund 还有独立操作状态:
PENDING -> SUCCEEDED | FAILED | UNKNOWN
UNKNOWN -> SUCCEEDED | FAILED (after query, webhook, or reconciliation)FAILED 只用于收到权威失败且该次操作不会再成功;网络超时、5xx 或进程失去响应都是 UNKNOWN。 支付聚合只接受合法的单调转换。回调报告旧状态时保留收据但不回退聚合;回调与主动查询同时到达时, 通过行版本或条件更新只提交一次转换。Stripe 文档说明一个支付意图覆盖从创建到结账的生命周期,并可能 要求额外认证;人工扣款会先进入可扣款状态,再执行 capture。这支持把支付建成长生命周期资源,而非 把 HTTP 请求结果当成支付本身。
下单授权与库存确认解耦。库存成功事件触发 capture;库存失败触发 cancel。两个操作可能并发,数据库 条件更新只允许一个从 AUTHORIZED 认领当前版本。服务商操作返回前仍可收到回调,所以同步响应和回调 都进入同一个状态应用函数,不能各写一套转换逻辑。
第四步:接受外部非原子性,并让流程收敛
本地事务把操作状态与 outbox 一起提交,relay 至少一次发布,工作进程按操作 ID 认领。调用服务商时 复用该操作 ID 作为幂等键,并设置有界连接、请求和总截止时间。若返回确定成功或失败,保存服务商引用 和原始结果摘要;若响应丢失,标为 UNKNOWN,排入状态查询。绝不能新建操作或换服务商盲目重试。
有三个关键崩溃窗口:
- 本地事务提交前崩溃:没有可见操作,调用方可用同一幂等键重试;
- outbox 已提交但发布确认丢失:relay 会重复发布,工作进程认领相同操作;
- 服务商成功但响应丢失:相同服务商幂等键重试、查询服务商引用或等待回调,最终收敛。
这提供的是可重试、可审计的“同一意图”,不是跨公司事务。服务商幂等保存期过后仍未知时,不能继续 猜测;冻结该操作的自动重试,通过服务商报告和人工处置确认结果。
第五步:安全接收异步回调并容忍乱序
回调端点先保留原始请求体,使用当前与轮换期旧密钥验证签名和时间窗口;验证失败直接拒绝。按 (provider, providereventid) 唯一保存收据,成功持久化后尽快返回 2xx,再由队列异步应用。日志只 记录事件 ID、服务商引用和原因码,不记录完整敏感负载或密钥。
回调可能重复、延迟和乱序。Stripe 文档明确说明生产事件会自动重试,且不保证生成顺序。处理器因此 不能假设“授权事件一定先于扣款事件”。它可以通过事件中的对象引用查询服务商当前资源,或把外部状态 映射为允许的本地转换。任何事件都使用同一个操作 ID 或服务商引用去重,已经入账的 capture 不能再次 入账,旧的 authorization 也不能把 CAPTURED 回退为 AUTHORIZED。
浏览器只轮询或订阅本地支付状态。即使返回 URL 带有“成功”参数,也不能发货;客户端也不能提交 CAPTURED。当本地收到权威成功并提交账本后,通过 outbox 发布 PaymentCaptured,订单服务用事件 ID 幂等履约。
第六步:用追加写账本表达资金事实
支付状态是操作视图,账本是资金审计记录。本题简化为两个账户:服务商应收是资产,商家应付是负债。 扣款 100.00 元(假设货币最小单位为分)可以写:
journal capture-<operation_id>, CNY
debit processor_receivable 10000
credit merchant_payable 10000退款 30.00 元写新的反向 journal:
journal refund-<operation_id>, CNY
debit merchant_payable 3000
credit processor_receivable 3000每个 journal 至少两条分录,借贷金额按币种相等;journal_id 和业务操作引用唯一。状态从权威结果转换 到 CAPTURED 或退款成功时,同一事务写入 journal 和 outbox。手续费、拒付、商家结算若纳入范围, 增加明确账户与新分录,不改写旧分录。余额由分录汇总或由可重建投影加速,投影不能成为资金真源。
并发退款先在支付行上做条件认领:只有 capturedamount - refundedamount - pendingrefundamount 足够时才创建退款操作并增加处理中金额。成功后把处理中移到已退款;确定失败则释放;未知继续占用, 避免另一次退款越过上限。服务商也可能限制超额退款,但本地不变量不能依赖外部兜底。
第七步:用对账发现静默不一致并安全修复
实时路径无法证明永远没有遗漏。第一层对账处理 UNKNOWN 操作:按服务商引用或幂等键查询,比较金额、 币种、操作类型和终态。第二层按日拉取不可变服务商交易或结算报告,对齐内部 payment、operation、 journal 与订单引用。第三层把服务商结算批次与银行实际入账匹配,区分未结算、费用、退款和拒付。
差异分类为:外部有内部无、内部成功外部无、金额或币种不同、状态滞后、重复引用、账本不平、结算批次 缺项。高风险差异阻止相关商家余额释放并告警。修复器必须幂等:补录服务商已经确认的操作时写新的 journal 与审计原因;错误账本用补偿分录,不能 UPDATE 历史金额。Stripe 的报告文档把余额交易描述为 不可变,并用新的退款交易抵销原事实,也说明了支付、结算批次和银行入账需要单独匹配。
指标分层记录 API 成功率与 p99、支付状态分布、操作 UNKNOWN 数量和年龄、服务商错误与限流、回调 验签失败/重复/延迟、授权到扣款耗时、退款余额占用、outbox/队列积压、journal 不平衡拒绝、对账差异 数量和最大未解决时长。业务报表还要把授权率、扣款率、退款率和结算率分开,不能把“请求已受理”算成 “资金已到账”。
第八步:收窄敏感面并做故障注入
卡数据由服务商令牌化组件直接收集;后端只保存无法还原卡号的服务商令牌、品牌和尾号等获准字段。 客户端 secret 不进入 URL 或日志,服务商密钥放入受控密钥系统并轮换。回调密钥独立,生产与测试环境 隔离。读取支付、发起退款、查看账本和人工修复使用不同权限,所有高风险操作带操作者与理由审计。
PCI SSC 明确禁止授权后保存卡验证码、PIN/PIN block 等敏感认证数据,即使加密也不允许。本题采用 托管采集来缩小范围,但是否满足合规要求仍需正式评估,不能在面试中声称“用了 token 就不受 PCI”。
验收覆盖:客户端同键重试和同键改金额;服务商成功后丢响应;回调先于 API 响应;重复、乱序和延迟 回调;outbox 重复发布;授权与取消并发;两笔退款并发争夺剩余金额;部分退款后全额退款;服务商幂等 过期;回调签名轮换;账本单边写入被事务拒绝;对账发现外部单边交易;修复进程重复执行;队列与服务商 长时间停机后的追赶。每个场景都断言支付状态、操作状态、journal 数量、订单副作用和告警结果。
高质量示范回答
“我会把支付设计成长期业务资源,而不是一次 HTTP 调用。一个订单对应稳定 payment_id,支付聚合保存 金额、币种和授权/扣款/退款状态;每次 authorize、capture、cancel、refund 使用独立操作 ID 与 PENDING、SUCCEEDED、FAILED、UNKNOWN 状态。外部超时只进入 UNKNOWN,不能直接失败或换服务商重试。
创建和资金操作 API 都要求调用方幂等键。商家、操作类型和键唯一,首次请求原子保存请求摘要、支付或 操作和 outbox;相同请求返回原结果,同键不同金额或币种报冲突。工作进程用内部操作 ID 作为服务商 幂等键。outbox、队列和工作进程都按至少一次设计,相同操作只应用一次权威转换。
服务商同步响应、经过验签的回调和主动查询进入同一个状态应用函数。回调原始负载验签,事件 ID 去重, 先持久化再快速返回 2xx;处理器容忍重复和乱序,旧事件不能让支付状态回退。浏览器只显示本地状态, 不能触发发货。支付扣款权威成功并在同一事务写入平衡 journal 与业务 outbox 后,订单服务才幂等履约。
扣款 journal 借记服务商应收、贷记商家应付;退款写新的反向 journal,历史分录不可修改。退款创建时 用条件更新预占剩余可退金额,保证已退款加处理中退款不超过已扣款。资金金额用最小单位整数,币种不能 在首次尝试后变化,各币种独立平衡。
恢复分三层:未知操作复用服务商幂等键或查询状态;每日服务商交易报告与 payment、operation、journal 对账;结算批次再与银行入账匹配。差异隔离并告警,修复只追加幂等补录或补偿分录。卡数据由服务商托管 组件采集,后端不保存卡号或安全码。最后用响应丢失、重复/乱序回调、并发扣款退款、服务商停机、账本 不平和对账单边交易做故障注入,证明每个外部结果最终只产生一份可审计资金事实。”
常见错误
- 把浏览器成功页当作付款成功 → 跳转可被伪造,支付也可能仍在等待认证或异步确认 → **只让权威
服务商结果通过本地状态机触发履约。**
- 用一列
paid保存全部状态 → 无法表达额外验证、授权、扣款、部分退款和未知结果 → **拆分支付
聚合与操作尝试状态机。**
- 超时后换新 ID 或换服务商重试 → 第一次可能已经扣款,产生双扣 → **复用同一操作和服务商幂等
键,查询或对账未知结果。**
- 同键请求只比较 key,不比较参数 → 调用方误用同一个键改金额时会返回错误业务结果 → **保存
规范请求摘要,同键不同意图报冲突。**
- 依赖回调顺序 → 服务商可能重试、乱序或延迟 → 事件去重,查询当前资源或只应用合法单调转换。
- 支付表余额就是账本 → 原地更新会丢失资金变更原因,也无法独立对账 → **追加平衡 journal,余额
只做可重建投影。**
- 并发退款先查再写 → 两个请求都看到足够余额并一起超额 → **在事务中条件预占可退金额并唯一绑定
操作。**
- 只监控 API 200 → 已受理、已授权、已扣款和已结算含义不同 → **按状态阶段、未知年龄和对账差异
分层监控。**
- 认为令牌化自动消除 PCI 责任 → 页面、日志、脚本和运维流程仍可能进入范围 → **最小化卡数据并
由合规团队确认实际边界。**
- 修复差异时修改历史分录 → 审计链断裂且历史报表无法重放 → 追加带原因的补录或补偿 journal。
追问及应对
追问一:服务商扣款成功,但同步响应和回调都丢了,怎么办?
操作保持 UNKNOWN,占用对应授权或退款额度,禁止新建同意图操作。先用原服务商幂等键重试支持幂等的 查询或调用,再按服务商引用主动查询;仍无法确定时等待交易报告对账。确认成功后通过同一状态应用函数 写入 journal 和 outbox,确认失败才释放占用。超过服务商幂等保存期仍无证据时进入人工队列,不能凭 时间推断失败。
追问二:两笔 60 元退款同时请求一笔已扣款 100 元的支付,如何阻止超额?
在支付行或专门的退款余额行上执行条件更新:只有剩余可退金额至少为 60 元时才把 pendingrefundamount 增加 60 元并创建操作。两笔事务争用同一版本,只有一笔成功;另一笔重新读取 后返回可退余额不足。未知退款继续占用,确定失败释放,成功从 pending 移到 refunded。服务商限制只是 第二道防线。
追问三:回调先收到 CAPTURED,随后才收到 AUTHORIZED,怎么处理?
两份收据都保存并去重。CAPTURED 若签名、金额、币种和服务商引用均匹配,就完成扣款转换与入账; 后来的 AUTHORIZED 是旧事实,状态机不允许回退,只更新回调审计和延迟指标。若回调负载不含足够版本 信息,就查询服务商当前支付资源,而不是按接收顺序覆盖本地状态。
追问四:为什么同时需要支付表和账本?
支付表是工作流聚合,适合回答是否还能扣款、取消或退款;账本是追加写资金事实,适合回答余额如何形成、 每次变化对应哪项业务。支付状态可以从 CAPTURED 进入 PARTIALLY_REFUNDED,账本则保留原扣款和每次 退款的独立平衡 journal。两者用唯一操作引用连接,并在同一事务提交权威转换,职责不能互相替代。
追问五:怎样验证对账修复器不会重复补账?
为每条外部报告记录建立稳定来源键,例如服务商、报告类型和交易引用;修复动作再绑定差异 ID,并对 (sourcekey, repairtype) 建唯一约束。第一次事务追加 journal、标记差异和写 outbox;崩溃重跑会 命中同一修复记录并返回原结果。测试在事务提交前、提交后和事件发布后分别杀进程,断言 journal 数量、 余额和下游事件都不增加第二次。
追问六:如果以后接入第二家服务商,何时允许故障切换?
只有在第一家明确表示操作未创建或确定失败,并且本地操作尚未产生资金事实时,才可新建绑定第二家的 操作。连接超时、5xx 和未知结果都不满足条件。路由决策、服务商能力、金额、币种和原因要进入审计; 两家结果都可能存在时先冻结履约与余额释放,通过查询和对账消除双扣,不能用自动退款掩盖未知状态。