第 26 章:支付系统
引言
本章设计一个支撑现代电子商务的支付系统。
支付系统负责结算金融交易,转移货币价值。
第 1 步:理解问题并确定设计范围
- 候选人:要构建哪种支付系统?
- 面试官:类似 Amazon.com 的电子商务系统支付后端,处理所有与资金流转有关的事务。
- 候选人:支持哪些支付方式,例如信用卡、PayPal、银行卡?
- 面试官:现实中应支持所有这些方式;面试中只考虑信用卡支付。
- 候选人:由我们自行处理信用卡交易吗?
- 面试官:不,使用 Stripe、Braintree、Square 等第三方提供商。
- 候选人:系统会存储信用卡数据吗?
- 面试官:出于合规原因,系统不直接存储信用卡数据,而依赖第三方支付处理机构。
- 候选人:应用面向全球吗?需要支持多种货币和国际支付吗?
- 面试官:应用面向全球,但面试中假设只使用一种货币。
- 候选人:每天需要处理多少笔支付交易?
- 面试官:100 万笔。
- 候选人:需要支持向收款方按月付款等付款流程吗?
- 面试官:需要。
- 候选人:还有哪些方面需要注意?
- 面试官:需要支持对账,以修复与内部和外部系统通信过程中出现的不一致。
功能需求
- 收款流程:支付系统代表商家从客户处收款。
- 付款流程:支付系统向世界各地的卖家付款。
非功能需求
- 可靠性与容错能力:必须谨慎处理支付失败。
- 建立内部系统与外部系统之间的对账机制。
粗略估算
系统每天需要处理 100 万笔交易,约为每秒 10 笔。
对数据库而言,这不算高吞吐量,因此不是本次面试的重点。
第 2 步:提出高层设计并取得共识
从高层看,资金流转涉及三个参与方:
<img src="/images/chapter-26/high-level-flow.png" alt="高层资金流程" width="500" />
收款流程
收款流程的高层概览:
<img src="/images/chapter-26/payin-flow-high-level.png" alt="收款流程高层设计" width="500" />
- 支付服务:接收支付事件并协调支付流程。通常还会通过第三方机构检查反洗钱(AML)违规或犯罪活动风险。
- 支付执行器:通过支付服务提供商(PSP)执行单个支付订单。一个支付事件可包含多个支付订单。
- 支付服务提供商(PSP):在账户间转移资金,例如从买家的信用卡账户转至电商网站的银行账户。
- 卡组织:处理信用卡业务的机构,例如 Visa、MasterCard。
- 分类账:保存所有支付交易的财务记录。
- 钱包:保存所有商家的账户余额。
收款流程示例:
- 用户点击“下单”,支付事件发送到支付服务。
- 支付服务将事件存入数据库。
- 支付服务针对事件中的所有支付订单调用支付执行器。
- 支付执行器将支付订单存入数据库。
- 支付执行器调用外部 PSP 处理信用卡支付。
- 支付执行器完成支付处理后,支付服务更新钱包,记录卖家收到的金额。
- 钱包服务将更新后的余额存入数据库。
- 支付服务调用分类账,记录所有资金流转。
支付服务 API
POST /v1/payments
{
"buyer_info": {...},
"checkout_id": "some_id",
"credit_card_info": {...},
"payment_orders": [{...}, {...}, {...}]
}payment_order 示例:
{
"seller_account": "SELLER_IBAN",
"amount": "3.15",
"currency": "USD",
"payment_order_id": "globally_unique_payment_id"
}注意事项:
payment_order_id会传递给 PSP,用于去重支付,即作为幂等键。amount字段使用string,因为double不适合表示货币金额。
GET /v1/payments/{:id}此接口根据 payment_order_id 返回单笔支付的执行状态。
支付服务数据模型
需要维护 payment_events 和 payment_orders 两张表。
对支付业务来说,性能通常不是首要因素,强一致性才是。
选择数据库还应考虑:
- 是否有充足的数据库管理员人才可供招聘。
- 其他大型金融机构是否已有可靠的使用记录。
- 配套工具是否完善。
- 传统 SQL 数据库相对 NoSQL/NewSQL 所提供的 ACID 保证。
payment_events 表包含:
checkout_id:字符串,主键。buyer_info:字符串(作者个人认为,外键关联另一张表可能更合适)。seller_info:字符串(同上)。credit_card_info:取决于银行卡服务商。is_payment_done:布尔值。
payment_orders 表包含:
payment_order_id:字符串,主键。buyer_account:字符串。amount:字符串。currency:字符串。checkout_id:字符串,外键。payment_order_status:枚举(NOT_STARTED、EXECUTING、SUCCESS、FAILED)。ledger_updated:布尔值。wallet_updated:布尔值。
注意事项:
- 一个支付事件可以关联多个支付订单。
- 收款流程不需要
seller_info,只有付款流程需要。 - 调用相应服务记录支付结果后,更新
ledger_updated和wallet_updated。 - 后台任务管理支付状态转换:它检查进行中的支付是否有更新,并在支付未于合理时间内完成时触发告警。
复式记账系统
复式记账是支付系统的核心机制。每笔资金操作都记录在两个账户中:一个账户余额增加(贷记),另一个减少(借记)。
| 账户 | 借方 | 贷方 |
|---|---|---|
| 买家 | $1 | |
| 卖家 | $1 |
所有交易分录的总和始终为零。该机制使系统内的每笔资金流转都可以端到端追踪。
托管支付页面
为了避免自行存储信用卡信息并承担繁重的合规义务,多数公司倾向于使用 PSP 提供的组件,由 PSP 存储并处理信用卡支付信息:
<img src="/images/chapter-26/hosted-payment-page.png" alt="托管支付页面" width="500" />
付款流程
付款流程的组件与收款流程非常相似。
主要区别:
- 资金从电商网站的银行账户转至商家的银行账户。
- 可以使用 Tipalti 等第三方应付账款服务商。
- 付款同样涉及大量记账和监管要求。
第 3 步:深入设计
本节重点讨论如何让系统更快、更稳健、更安全。
集成 PSP
如果系统能直接连接银行或卡组织,则无须 PSP 也可以完成支付。但这种连接非常少见,通常只有能承担相应投资的大型公司才会采用。
沿用常规方式时,有两种集成 PSP 的方法:
- 如果支付系统可以收集付款信息,通过 API 集成。
- 通过托管支付页面集成,避免直接处理付款信息带来的监管要求。
托管支付页面的工作流程如下:
<img src="/images/chapter-26/hosted-payment-page-workflow.png" alt="托管支付页面流程" width="500" />
- 用户在浏览器中点击“结账”按钮。
- 客户端将支付订单信息发送给支付服务。
- 支付服务收到订单信息后,向 PSP 发送支付登记请求。
- PSP 收到货币、金额、有效期等支付信息,以及一个用于幂等处理的 UUID,通常是支付订单的 UUID。
- PSP 返回唯一标识本次支付登记的令牌,支付服务将令牌存入数据库。
- 令牌存储后,系统向用户展示由 PSP 托管的支付页面,并使用令牌及成功、失败时的重定向 URL 初始化页面。
- 用户在 PSP 页面填写支付信息,PSP 处理支付并返回状态。
- 用户被重定向到返回地址,例如
https://your-company.com/?tokenID=JIOUIQ123NSF&payResult=X324FSa。 - PSP 通过 webhook 异步调用支付服务,通知后端支付结果。
- 支付服务根据收到的 webhook 记录支付结果。
对账
上一节描述的是支付顺利完成的路径。失败和异常路径由后台对账流程发现并处理。
PSP 每晚发送结算文件,系统用它对比外部系统与内部系统的状态。
<img src="/images/chapter-26/settlement-report.png" alt="结算报表" width="500" />
这一流程也可用于发现分类账和钱包服务等内部系统之间的不一致。
财务团队会处理不匹配的记录,包括:
- 可归类且可按标准流程调整的已知差异。
- 可归类但无法自动处理的差异,由财务团队手工调整。
- 无法归类的差异,由财务团队手工调查并调整。
处理支付延迟
支付通常几秒钟即可完成,但有时可能需要数小时。
原因可能包括:
- 支付被标为高风险,需要人工审核。
- 信用卡需要额外保护,例如 3D Secure 身份验证,需要持卡人补充信息。
可通过以下方式处理:
- 等待 PSP 在支付完成后发送 webhook;如果 PSP 不提供 webhook,则轮询其 API。
- 向用户显示“处理中”状态,并提供可查看支付进度的页面。支付完成后也可以发送电子邮件通知。
内部服务间通信
服务之间的通信模式有两种:同步和异步。
同步通信(如 HTTP)适合小规模系统,但随规模增大会暴露问题:
- 性能较低:调用链涉及的服务越多,请求响应周期越长。
- 故障隔离差:PSP 或其他服务故障时,用户可能收不到响应。
- 耦合紧密:发送方必须了解接收方。
- 难以扩展:缺少缓冲机制,不易承受突然增长的流量。
异步通信又可分为两类。
单接收方:多个接收方订阅同一主题,但每条消息只处理一次:
<img src="/images/chapter-26/single-receiver.png" alt="单接收方" width="500" />
多接收方:多个接收方订阅同一主题,每条消息会转发给所有接收方:
<img src="/images/chapter-26/multiple-receiver.png" alt="多接收方" width="500" />
后一种模式适合支付系统,因为一次支付可能触发由不同服务处理的多个后续操作。
简而言之,同步通信较简单,但限制服务的自主性。异步通信以简单性和一致性为代价,换取可扩展性与韧性。
处理支付失败
所有支付系统都必须处理失败。可以采用以下机制:
跟踪支付状态:失败时,根据支付状态决定重试还是退款。
重试队列:将需要重试的支付发布到重试队列。
死信队列:将最终失败的支付放入死信队列,以便调试和检查。

恰好一次处理
必须确保每笔支付只被处理一次,以免重复向客户扣款。
当一项操作同时满足“至少一次”和“至多一次”时,就是恰好执行一次。
为保证“至少一次”,可以使用重试机制:
<img src="/images/chapter-26/retry-mechanism.png" alt="重试机制" width="500" />
常见的重试间隔策略:
- 立即重试:失败后客户端立即再次发送请求。
- 固定间隔:每次重试前等待固定时长。
- 递增间隔:逐次增加等待时间。
- 指数退避:每次重试将等待时间加倍。
- 取消:错误无法恢复或达到重试次数上限时,客户端取消请求。
一般可默认采用指数退避。较好的做法是由服务器通过 Retry-After 响应头指定重试时间。
重试的问题在于服务器可能重复处理支付:
- 客户端两次点击“支付”按钮,导致重复扣款。
- PSP 已成功处理支付,但下游服务(分类账、钱包)尚未完成处理。重试又让 PSP 处理同一笔支付。
为避免重复支付,需要幂等机制:同一操作即使应用多次,也只产生一次处理结果。
从 API 看,客户端可以多次调用而得到相同结果。通常使用请求中的特殊头字段(例如 idempotency-key)管理幂等性,其值一般为 UUID。
<img src="/images/chapter-26/idempotency-example.png" alt="幂等示例" width="500" />
可以利用数据库的唯一键约束实现幂等性:
- 服务器尝试向数据库插入新行。
- 如果违反唯一键约束,插入失败。
- 服务器识别该错误,改为向客户端返回已有对象。
PSP 侧也会使用前文提及的一次性随机数(nonce)实现幂等性,避免处理两次具有相同 nonce 的支付。
一致性
支付生命周期中涉及多个有状态服务:PSP、分类账、钱包和支付服务。
任意两个服务之间的通信都可能失败。通过实现恰好一次处理与对账,可以让各服务的数据最终一致。
如果使用复制,还需处理复制延迟,因为用户可能从主数据库与副本数据库读到不一致的数据。
一种缓解办法是所有读写都使用主数据库,副本仅用于冗余与故障切换。也可以利用 Paxos 或 Raft 等共识算法保持副本同步,或者采用 YugabyteDB、CockroachDB 等基于共识的分布式数据库。
支付安全
可采用以下机制保护支付:
- 请求和响应被窃听:使用 HTTPS 保护所有通信。
- 数据被篡改:实施加密与完整性监测。
- 中间人攻击:使用 SSL 和证书固定。
- 数据丢失:跨区域复制数据并生成数据快照。
- DDoS 攻击:实施限流和防火墙防护。
- 银行卡信息被盗:使用令牌,避免在系统中存储真实卡片信息。
- PCI 合规:遵守针对处理品牌信用卡的机构制定的安全标准。
- 欺诈:使用地址验证、卡片验证码(CVV)、用户行为分析等方法。
第 4 步:总结
其他可讨论的话题:
- 监控与告警。
- 调试工具:需要能方便地查明支付失败原因的工具。
- 货币兑换:设计国际支付系统时十分重要。
- 地域差异:不同地区可能采用不同支付方式。
- 现金支付:在印度、巴西等地十分常见。
- 集成 Google Pay 和 Apple Pay。