第 26 章 支付系统
引言
本章我们将设计一个支付系统(Payment System),它是所有现代电子商务(E-commerce)的基础。
支付系统用于结算金融交易,完成货币价值的转移。
第 1 步:理解问题并确定设计范围
- 候选人:我们要构建的是什么样的支付系统?
- 面试官:一个电商系统的支付后端,类似于 Amazon.com。它负责处理与资金流转相关的一切。
- 候选人:支持哪些支付方式?信用卡、PayPal、银行卡等等?
- 面试官:在现实中,系统应该支持所有这些方式。就这次面试而言,我们可以只用信用卡支付。
- 候选人:信用卡支付处理由我们自己来做吗?
- 面试官:不,我们使用 Stripe、Braintree、Square 等第三方提供商。
- 候选人:我们的系统里会存储信用卡数据吗?
- 面试官:出于合规原因,我们不会在自己的系统中直接存储信用卡数据。我们依赖第三方支付处理商。
- 候选人:这个应用是全球性的吗?需要支持不同币种和跨境支付吗?
- 面试官:应用是全球性的,但就这次面试而言,我们假设只使用一种货币。
- 候选人:每天需要支持多少笔支付交易?
- 面试官:每天 100 万笔交易。
- 候选人:是否需要支持付款(Pay-out)流程,例如每月向付款方付款?
- 面试官:是的,需要支持。
- 候选人:还有什么需要我特别注意的吗?
- 面试官:我们需要支持对账,以修正与内部和外部系统通信时产生的任何不一致。
功能需求
- 收款(Pay-in)流程:支付系统代表商家从客户处收取资金
- 付款(Pay-out)流程:支付系统把资金发送给世界各地的卖家
非功能需求
- 可靠性和容错性。失败的支付需要被谨慎处理
- 需要在内部系统和外部系统之间建立对账(Reconciliation)机制。
粗略估算(Back-of-the-envelope Estimation)
系统每天需要处理 100 万笔交易,也就是每秒 10 笔交易。
对任何数据库系统来说这都算不上高吞吐量(Throughput),因此它不是本次面试的重点。
第 2 步:提出高层设计并获得认可
从高层来看,参与资金流转的有三个角色:
收款流程
下面是收款流程的高层概览:
- 支付服务(Payment Service):接收支付事件并协调支付过程。它通常还会借助第三方提供商做风险检查,排查反洗钱(AML)违规或犯罪活动。
- 支付执行器(Payment Executor):通过支付服务提供商(PSP)执行单个支付订单。一个支付事件可能包含多个支付订单。
- 支付服务提供商(Payment Service Provider,PSP):把资金从一个账户转移到另一个账户,例如从买家的信用卡账户转到电商网站的银行账户。
- 卡组织(Card Schemes):处理信用卡业务的组织,例如 Visa、MasterCard 等。
- 账本(Ledger):保存所有支付交易的财务记录。
- 钱包(Wallet):保存所有商家的账户余额。
下面是一个收款流程示例:
- 用户点击“下单”,一个支付事件被发送到支付服务
- 支付服务把该事件存储到自己的数据库中
- 支付服务针对该支付事件所包含的全部支付订单调用支付执行器
- 支付执行器把支付订单存储到自己的数据库中
- 支付执行器调用外部 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 用于支付去重,也就是说,它是幂等键(Idempotency Key)。- amount 字段的类型是
string,因为double不适合表示货币金额。
GET /v1/payments/{:id}
这个端点根据 payment_order_id 返回单笔支付的执行状态。
支付服务的数据模型
我们需要维护两张表:payment_events 和 payment_orders。
对支付来说,性能通常不是重要因素,强一致性(Strong Consistency)才是。
选择数据库时的其他考虑因素:
- 人才市场上有充足的 DBA 可供招聘来管理数据库
- 有可靠的过往记录,即该数据库已被其他大型金融机构使用过
- 配套工具丰富
- 相比 NoSQL/NewSQL,更倾向于传统 SQL,因为它提供 ACID 保证
payment_events 表包含以下字段:
checkout_id:string,主键buyer_info:string(个人注:用一个指向另一张表的外键可能更合适)seller_info:string(个人注:同上)credit_card_info:取决于卡片提供商is_payment_done:boolean
payment_orders 表包含以下字段:
payment_order_id:string,主键buyer_account:stringamount:stringcurrency:stringcheckout_id:string,外键payment_order_status:enum(NOT_STARTED、EXECUTING、SUCCESS、FAILED)ledger_updated:booleanwallet_updated:boolean
注意事项:
- 一个支付事件关联着多个支付订单
- 收款流程不需要
seller_info,它只在付款流程中才需要 - 当调用相应服务记录支付结果时,会更新
ledger_updated和wallet_updated - 支付的状态流转由一个后台任务管理,它会检查进行中支付的更新情况,如果某笔支付没有在合理的时间内处理完成,就触发告警
复式记账账本系统
复式记账(Double-entry Accounting)机制是任何支付系统的关键。它是一种跟踪资金流转的机制:每次资金操作总是同时作用于两个账户,其中一个账户的余额增加(贷记,Credit),另一个账户的余额减少(借记,Debit):
| 账户 | 借方 | 贷方 |
|---|---|---|
| 买家 | $1 | |
| 卖家 | $1 |
所有交易分录的总和始终为零。这一机制为系统内的所有资金流转提供了端到端的可追溯性。
托管支付页面
为了避免存储信用卡信息,以及必须遵守各种繁重的监管规定,大多数公司更愿意使用 PSP 提供的小组件(Widget),由它替你存储和处理信用卡支付:
付款流程
付款流程的组件与收款流程非常相似。
主要区别如下:
- 资金从电商网站的银行账户转移到商家的银行账户
- 可以使用 Tipalti 之类的第三方应付账款(Accounts Payable)服务商
- 付款方面同样有大量记账和监管要求需要处理
第 3 步:深入设计
本节重点关注如何让系统更快、更健壮、更安全。
PSP 集成
如果我们的系统能直接连接银行或卡组织,那么不经过 PSP 也可以完成支付。 这类连接非常罕见,通常只有能证明这笔投入物有所值的大公司才会这么做。
如果走传统路线,PSP 可以通过以下两种方式之一集成:
- 通过 API 集成,前提是我们的支付系统可以收集支付信息
- 通过托管支付页面集成,以免去应对支付信息相关的监管规定
托管支付页面的工作流程如下:
- 用户在浏览器中点击“结账”按钮
- 客户端携带支付订单信息调用支付服务
- 收到支付订单信息后,支付服务向 PSP 发送支付注册请求。
- PSP 接收币种、金额、过期时间等支付信息,以及一个用于幂等目的的 UUID,这个 UUID 通常就是支付订单的 UUID。
- PSP 返回一个唯一标识此次支付注册的令牌(Token)。该令牌被存储在支付服务的数据库中。
- 令牌存储完成后,用户会看到一个由 PSP 托管的支付页面。该页面使用令牌以及成功/失败时的重定向 URL 进行初始化。
- 用户在 PSP 页面上填写支付信息,PSP 处理支付并返回支付状态
- 用户随后被重定向回 redirectURL。重定向 URL 示例:
https://your-company.com/?tokenID=JIOUIQ123NSF&payResult=X324FSa - PSP 异步地通过 Webhook 调用我们的支付服务,把支付结果告知我们的后端
- 支付服务根据收到的 Webhook 记录支付结果
对账
上一节讲的是支付的正常路径(Happy Path)。异常路径则由后台对账流程来发现并完成对账。
每天夜里,PSP 都会发送一份结算文件,我们的系统用它来比对外部系统的状态和内部系统的状态。
这个流程也可以用来发现内部的不一致,例如账本服务和钱包服务之间的不一致。
不匹配的情况由财务团队人工处理,分为以下几类:
- 可分类,即属于已知的不匹配,可以按标准流程进行调整
- 可分类,但无法自动化,由财务团队人工调整
- 不可分类,由财务团队人工调查并调整
处理支付处理延迟
虽然一笔支付通常几秒钟就能完成,但有些情况下可能需要数小时。
原因可能是:
- 一笔支付被标记为高风险,需要有人人工审核
- 信用卡需要额外的保护措施,例如 3D Secure 认证,需要持卡人提供额外信息才能完成
这些情况的处理方式是:
- 等待 PSP 在支付完成时向我们发送 Webhook;如果 PSP 不提供 Webhook,就轮询它的 API
- 向用户展示“待处理”状态,并提供一个页面供他们查看支付进展。也可以在支付完成后给他们发一封邮件
内部服务之间的通信
服务之间的通信模式有两种:同步和异步。
同步通信(即 HTTP)适用于小规模系统,但随着规模增长就会暴露问题:
- 性能低:调用链中涉及的服务越多,请求-响应周期就越长
- 故障隔离差:如果 PSP 或其他任何服务出现故障,用户就收不到响应
- 紧耦合:发送方需要知道接收方
- 难以扩展:由于没有缓冲,难以应对流量的突然增长
异步通信可以分为两类。
单接收方:多个接收方订阅同一个主题(Topic),但消息只会被处理一次:
多接收方:多个接收方订阅同一个主题,消息会被转发给所有接收方:
后一种模型很适合我们的支付系统,因为一笔支付可能触发多个副作用,分别由不同的服务处理。
简而言之,同步通信更简单,但服务无法做到自治。 异步通信则以牺牲简单性和一致性为代价,换取可扩展性和弹性。
处理失败的支付
每个支付系统都必须处理失败的支付。以下是我们为此采用的一些机制:
- 跟踪支付状态:每当一笔支付失败时,我们可以根据支付状态决定是重试还是退款。
- 重试队列:需要重试的支付会被发布到重试队列
- 死信队列(Dead-letter Queue):彻底失败的支付会被推送到死信队列,在那里可以对失败的支付进行调试和检查。
恰好一次投递
我们需要确保一笔支付恰好被处理一次(Exactly-once),避免重复向客户扣款。
如果一个操作同时满足至少一次(At-least-once)执行和至多一次(At-most-once)执行,那么它就是恰好一次执行。
为了实现至少一次的保证,我们会使用重试机制:
以下是确定重试间隔的几种常见策略:
- 立即重试:失败后客户端立即再发送一次请求
- 固定间隔:等待一段固定时间后再重试支付
- 递增间隔:每次重试之间的间隔逐步增加
- 指数退避(Exponential Back-off):每次后续重试的间隔翻倍
- 取消:客户端取消请求。当错误不可恢复或达到重试次数上限时会这样做
根据经验,默认采用指数退避重试策略。一个良好的实践是由服务器通过 Retry-After 响应头指定重试间隔。
重试带来的一个问题是,服务器有可能把一笔支付处理两次:
- 客户端点了两次“支付按钮”,因此被扣款两次
- 支付已被 PSP 成功处理,但下游服务(账本、钱包)没有处理成功。重试导致 PSP 再次处理这笔支付
为了解决重复支付问题,我们需要使用幂等性(Idempotency)机制:即一个操作被执行多次,也只会被处理一次。
从 API 的角度看,客户端可以多次调用并得到相同的结果。
幂等性通过请求中的一个特殊请求头(例如 idempotency-key)来管理,它通常是一个 UUID。
可以利用数据库添加唯一键约束的机制来实现幂等性:
- 服务器尝试向数据库插入一行新数据
- 由于违反唯一键约束,插入失败
- 服务器检测到该错误,转而把已存在的对象返回给客户端
PSP 一侧同样会应用幂等性,用的是前面讨论过的随机数(nonce)。PSP 会确保不会把具有相同 nonce 的支付处理两次。
一致性
在一笔支付的生命周期中,会调用多个有状态的服务:PSP、账本、钱包、支付服务。
任意两个服务之间的通信都可能失败。 通过实现恰好一次处理和对账,我们可以确保所有服务之间数据的最终一致性(Eventual Consistency)。
如果使用复制,就必须应对复制延迟(Replication Lag),它可能导致用户在主数据库和副本数据库之间看到不一致的数据。
为了缓解这一问题,我们可以让所有读写都由主数据库提供,副本只用于冗余和故障转移。 或者,可以借助 Paxos 或 Raft 之类的共识算法,确保副本始终保持同步。 也可以使用基于共识的分布式数据库,例如 YugabyteDB 或 CockroachDB。
支付安全
以下是一些可以用来保障支付安全的机制:
- 请求/响应被窃听:可以使用 HTTPS 保护所有通信
- 数据篡改:强制加密并进行完整性监控
- 中间人攻击:使用带证书锁定(Certificate Pinning)的 SSL
- 数据丢失:在多个区域之间复制数据并做数据快照
- DDoS 攻击:实施限流和防火墙
- 卡片盗用:使用令牌,而不是在系统中存储真实的卡片信息
- PCI 合规:一项面向处理品牌信用卡的组织的安全标准
- 欺诈:地址验证、卡片验证码(CVV)、用户行为分析等
第 4 步:总结
其他可以讨论的话题:
- 监控和告警
- 调试工具:我们需要能轻松弄清支付失败原因的工具
- 货币兑换:设计面向国际使用的支付系统时很重要
- 地域:不同地区可能有不同的支付方式
- 现金支付:在印度和巴西等地非常普遍
- Google/Apple Pay 集成