支付系统是几乎所有互联网商业系统的「收款底座」,同时也是对一致性、可用性和资金安全要求最苛刻的系统之一:钱不能多扣、不能少扣、不能扣了不通知、更不能对不上账。本文按照系统设计面试的标准答题结构,设计一个支持多渠道、多币种、每日数千万笔交易的支付平台。
一句话:支付系统的核心不是「转账」,而是「记账 + 对账」——用幂等、状态机和最终一致把每一笔钱的状态收敛到确定值。
一、需求澄清与量级估算
1.1 需求澄清
面试官给出题目「设计一个支付系统」后,先通过提问明确边界:
- 支付场景:只做线上支付(App / Web / 小程序下单扣款),还是包含转账、提现、退款、代扣、扫码线下?
- 渠道:接入哪些渠道?支付宝、微信、银联、银行卡直连,还是仅内部钱包账户?
- 币种:单币种(人民币)还是多币种(跨境支付)?
- 资金流向:是否需要两级账户(平台商户账户 + 用户账户)?
- 合规:是否需要风控、反洗钱、差错处理、T+1 对账?
明确假设(面向面试的合理假设):
| 需求项 | 假设 |
|---|---|
| 支付方式 | 余额钱包、银行卡快捷、支付宝/微信三方代收 |
| 货币 | 人民币,预留多币种字段 |
| 商户 | 电商平台自营 + 外部商户入驻(抽佣) |
| 结算 | T+1 自动结算到商户银行账户,支持手动提现 |
| 可靠性 | 支付成功率 99.99% 以上、资金零差错(对账兜底) |
1.2 量级估算
| 指标 | 估算值 | 推导 |
|---|---|---|
| 日支付订单 | 1000 万笔 | 假设日活 5000 万用户,20% 当日下单 |
| 峰值 QPS | 约 5000 | 日均 1000 万 / 86400 ≈ 116,再乘 40 倍峰值系数 |
| 每秒峰值扣款 | ~5000 笔 | 与支付 QPS 同量级 |
| 年交易额 | ~1.2 万亿 | 客单价 300 元 × 日均 1000 万 × 365 |
| 对账单行数 | 数千万/天 | 每笔支付至少产生 1 支付单 + 1 结算单 + N 个流水 |
一句话:单机 MySQL 扛不住千万级日单量,必须分库分表 + 异步化 + 削峰,同时保证「扣钱」这条链路的强一致。
二、高层架构设计
┌────────────────────────────────────────────┐
│ 客户端 (App / Web / H5) │
└──────────────────────┬─────────────────────┘
│ 下单+支付请求
┌──────────────────────▼─────────────────────┐
│ BFF / 业务网关 │
│ (鉴权、签名校验、参数规整、限流) │
└──────────────────────┬─────────────────────┘
│
┌────────────────────────────▼────────────────────────────┐
│ 支付核心服务 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 订单服务 │ │支付引擎 │ │结算服务 │ │对账服务 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 账户服务 │ │账务流水 │ │风控服务 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└──────────┬────────────────────────────────────────────────┘
│ 通过 MQ / RPC
┌──────────▼──────────────────────────────────────────┐
│ 支付网关(渠道网关层) │
│ 支付宝适配器 微信适配器 银联适配器 银行卡直连 │
│ (协议转换 / 签名验签 / 加密 / 回调接收) │
└──────────┬──────────────────────────────────────────┘
│
┌────────────────▼─────────────────┐
│ 第三方渠道 / 银联 / 网联 │
│ (真正发生资金流动的地方) │
└──────────────────────────────────┘
整体拆为四层:
- 接入层:业务网关,负责鉴权、验签、限流。
- 核心服务层:订单、支付引擎、账户、结算、对账、风控六个领域服务。
- 渠道层:支付网关 + 各渠道适配器,屏蔽不同渠道协议差异。
- 依赖设施:MySQL(分库分表)、Redis(幂等/锁/风控计数)、MQ(异步解耦)、时序/日志(审计)。
2.1 为什么拆成六个领域服务
- 订单服务:只负责业务订单状态,不碰资金。
- 支付引擎:发起到渠道的支付,维护支付单状态机。
- 账户服务:管用户/商户余额与流水,记账必须幂等。
- 结算服务:T+1 汇总商户应收,生成结算单。
- 对账服务:拉取渠道账单与本地流水核对,发现差错并挂账。
- 风控服务:在支付前/中/后拦截风险交易。
一句话:按「单、账、钱、账务核对」分离,让资金链路可审计、可回溯;任何一步出问题都有对账兜底,不会悄悄出错。
三、核心组件设计
3.1 支付引擎与支付单
支付引擎是核心,它接收「支付请求」,生成支付单(payment order),然后驱动一次支付的生命周期:
CREATE TABLE payment_order (
payment_id BIGINT COMMENT '支付单号,雪花ID',
biz_order_id VARCHAR(64) COMMENT '业务订单号',
user_id BIGINT,
merchant_id BIGINT,
channel VARCHAR(32) COMMENT '渠道:ALIPAY/WECHAT/CARD/BALANCE',
amount_cents BIGINT COMMENT '金额,单位分',
currency VARCHAR(8) DEFAULT 'CNY',
status TINYINT COMMENT '0初始 1支付中 2成功 3失败 4已退款 5关闭',
scene VARCHAR(32) COMMENT 'APP/WEB/SCAN',
risk_checked TINYINT,
channel_trade_no VARCHAR(64) COMMENT '渠道侧交易号',
callback_count INT DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
PRIMARY KEY (payment_id),
KEY idx_user (user_id, created_at),
KEY idx_channel_trade_no (channel, channel_trade_no),
UNIQUE KEY uk_biz_order (biz_order_id) -- 关键:一笔业务订单只对应一个支付单
) COMMENT='支付单';
状态机(不允许非法跳转):
┌────────────┐ 创建 ┌────────────┐ 发起 ┌────────────┐
│ PAY_INIT │──────────▶│ PAY_PROCESS │─────────▶│ PAY_PENDING │──┐
└────────────┘ └────────────┘ └────────────┘ │
▼
┌──────────────────────────────┐
┌────────────────────────────┤ PAY_SUCCESS (终态,入账) │
│ 渠道回调/主动查询 └──────────────────────────────┘
│ ┌──────────────────────────────┐
└────────────────────────────▶│ PAY_FAIL / PAY_CLOSED │
超时/用户取消 └──────────────────────────────┘
一句话:支付单是一切的锚点,状态机 + 唯一索引保证一笔订单只被支付一次,重复回调、重复查询都收敛到同一状态。
3.2 支付网关与渠道适配
渠道适配器模式:统一内部接口,各渠道实现差异逻辑。
class ChannelAdapter(ABC):
def pay(self, req: PayRequest) -> PayResponse: ...
def query(self, trade_no: str) -> QueryResponse: ...
def refund(self, req: RefundRequest) -> RefundResponse: ...
def parse_callback(self, raw: dict) -> CallbackEvent: ...
class AlipayAdapter(ChannelAdapter):
def pay(self, req):
# 拼装支付宝请求报文 -> 加签 -> HTTP 调用 -> 解析结果
...
支付网关三件职责:
- 协议转换:内部统一结构 ↔ 渠道报文。
- 安全:请求加签、响应验签、证书管理、敏感信息(卡号)脱敏。
- 回调接收:一个统一
/callback/{channel}端点,验签后写入 MQ 给支付引擎消费(防重放:渠道侧交易号唯一键)。
3.3 账务流水与记账
记账采用「双分录(复式记账)」:每笔资金变动同时记借方与贷方,总额守恒。账务流水表:
CREATE TABLE account_transaction (
txn_id BIGINT PRIMARY KEY,
account_id BIGINT, -- 账户ID
user_id BIGINT,
direction TINYINT, -- 1 加钱 2 减钱
amount_cents BIGINT,
balance_before BIGINT,
balance_after BIGINT,
biz_type VARCHAR(32), -- PAYMENT / REFUND / SETTLEMENT / WITHDRAW
ref_id VARCHAR(64), -- 关联支付单号,全局唯一 -> 幂等
status TINYINT,
created_at DATETIME,
UNIQUE KEY uk_ref (biz_type, ref_id, direction) -- 幂等核心
) COMMENT='账户流水';
记账逻辑伪代码:
def post_entries(account_book, txn_id, debit, credit, ref_id):
with db.transaction():
# 1. 幂等校验:同一 ref_id + 类型 已记账则直接返回成功
if exists(AccountTransaction, ref_id=ref_id, txn_type="PAYMENT"):
return OK
# 2. 乐观锁更新余额(扣减方)
rows = db.update(
"UPDATE account SET balance = balance - :amt, version = version+1 "
"WHERE id = :debit AND balance >= :amt AND version = :expect"
)
if rows == 0:
raise InsufficientBalance
# 3. 写流水(同事务)
db.insert(AccountTransaction(debit...)); db.insert(AccountTransaction(credit...))
# 4. commit
3.4 风控与限额
风控必须与支付主链路解耦,否则一次风控超时就会拖垮整个支付:
风控规则引擎(离线规则 + 在线实时决策)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 设备指纹 │ │ 黑名单/灰名单 │ │ 规则集(命中打分) │
└──────────────┘ └──────────────┘ └──────────────┘
入参: 用户/设备/金额/频次/IP/商户 → 决策: 放行 / 增强验证 / 拒绝 / 人工审核
常用风控维度:
| 维度 | 例子 | 实现 |
|---|---|---|
| 频次限额 | 单用户单日支付笔数上限、单卡单日限额 | Redis 计数 + 阈值 |
| 金额限额 | 单笔上限、日累计上限、单商户集中度 | 限额表 + 实时累加 |
| 行为异常 | 短时间异地登录、设备突变、刷单特征 | 设备指纹 + 规则 |
| 黑名单 | 欺诈用户、风险商户、风险卡 BIN | 布隆过滤器 / 名单表 |
一句话:风控是「异步规则 + 同步最小决策」——能异步判定的绝不同步阻塞,必须同步拦截的高危交易才进主链路。
3.5 退款与差错处理
退款是支付里最容易「多退、重复退」的地方,设计要点:
- 退款单:以
refund_order为锚点,ref_id = payment_id唯一约束,同一支付单的累计退款金额不得超过原支付金额(用事务 + 乐观锁校验paid_amount - refunded_amount >= refund_amount)。 - 原路退回:渠道退款接口,银行卡/钱包原路返回;退款也是异步流程,同样走回调 + 幂等。
- 差错处理:对账发现「我方成功渠道失败」「渠道成功我方未记账」「金额不一致」等情况,进入差错池挂账,由人工/规则引擎逐笔处理,绝不允许静默覆盖。
四、数据模型
核心表族:
| 表 | 用途 | 分片键 | 说明 |
|---|---|---|---|
| payment_order | 支付单 | user_id | 一笔订单一张支付单 |
| account | 账户余额 | user_id/merchant_id | 资金主体 |
| account_transaction | 账务流水 | account_id | 双分录,可对账 |
| settle_order | 结算单 | merchant_id | T+1 汇总 |
| refund_order | 退款单 | payment_id | 原路退回 |
| risk_event | 风控事件 | user_id | 规则命中记录 |
| channel_bill | 渠道对账单 | channel + date | 拉取的文件/明细 |
字段规范:金额一律用 BIGINT(单位分),严禁浮点;时间用 DATETIME 统一 +08:00;所有变更走状态机,不做物理删除。
五、关键流程
5.1 一次「余额支付」的时序
App BFF 支付引擎 账户服务 支付单 Redis/DB
│ 下单支付请求 │ │ │ │
├─────────────▶│ 校验+鉴权 │ │ │
│ ├─────────────▶│ 创建支付单 │ ├── 插入支付单(INIT)
│ │ ├─────────────▶│ 预冻结余额 │
│ │ │ ├─────────────▶│ 锁+扣减余额
│ │ │ │ │ 写流水(幂等)
│ │ │◀─────────────┤ 冻结成功 │
│ │ ├─────────────▶│ 确认支付(记账) │
│ │ │ ├─────────────▶│ 状态→SUCCESS
│ │◀─────────────┤ 成功 │ │
│◀─────────────┤ │ │ │
│ 返回支付结果 │ │ │ │
关键点:余额扣减与写流水在同一数据库事务内完成;「预冻结 → 确认」两步让支付与后续退款、取消都有明确锚点。
5.2 渠道支付回调(异步)
渠道 → 支付网关(验签) → MQ(callback_topic) → 支付引擎(幂等消费)
支付引擎: 更新支付单 SUCCESS → 记账(用户钱包入账/平台应收+)
失败重试: 按指数退避重试,超过 N 次转人工差错池
一句话:渠道回调是异步的,绝不能同步依赖;所有写操作都以「查询支付单当前状态」为准,天然防重放。
六、可靠性与一致性
6.1 双写一致性与最终一致
支付引擎在更新支付单状态后,需要「同时」更新订单服务状态并通知下游。为了避免分布式双写不一致,采用「本地消息表 / 事务消息 + 对账兜底」:
- 支付引擎在本地事务里
update payment_order + insert outbox(message)。 - 后台任务扫描 outbox,把消息可靠投递到 MQ。
- 下游(订单服务、结算服务、通知)消费后幂等落地。
- 定时任务补偿:扫描「支付单成功但订单未同步」的孤儿,重新投递。
6.2 分布式事务方案对比
| 方案 | 一致性 | 吞吐 | 适用 | 局限 |
|---|---|---|---|---|
| 2PC / XA | 强一致 | 低 | 极少数强一致场景 | 阻塞、性能差,支付系统基本不用 |
| TCC(Try-Confirm-Cancel) | 最终一致 | 中 | 需要「预留资源」的扣减场景(余额、库存) | 侵入业务,实现复杂 |
| 本地消息表 + MQ | 最终一致 | 高 | 订单/支付/结算的异步通知 | 需要消息表 + 补偿 |
| Saga | 最终一致 | 高 | 长事务编排(下单→支付→发货) | 需补偿幂等 |
支付链路最常用组合:余额扣减用 TCC(预冻结/确认/解冻)+ 状态通知用本地消息表;不做跨库 2PC。
6.3 幂等设计清单
- 支付单:
biz_order_id唯一约束。 - 账务流水:
(biz_type, ref_id, direction)唯一约束。 - 渠道回调:
(channel, channel_trade_no)唯一键 + 状态机幂等。 - 退款单:
payment_id只能对应有限次退款,退款金额累加不得超过原单。
6.4 对账流程(T+1)
对账是资金安全的「最后一道防线」,即使前面的幂等、事务都做对了,仍可能因为渠道故障、人为 bug 产生差错,必须靠对账兜底:
每日凌晨: 任务调度器触发对账
├─ 拉取各渠道账单(文件/FTP/接口) → 落库 channel_bill
├─ 按 (渠道, 渠道交易号) 与本地 payment_order 比对
│ ├─ 两边都有且金额一致 → 对平
│ ├─ 我方成功/渠道缺失 → 查渠道流水, 可能是掉单, 主动查询确认
│ └─ 我方失败/渠道成功 → 用户可能已扣款, 触发自动退款或挂账
├─ 汇总差异 → 生成对账差异报表 → 差错池分派处理
└─ 输出对账单确认 → 通知财务/商户
对账要点:
- 必须用「渠道侧交易号」比对,不能用我方订单号(渠道侧的号才是资金真实记录)。
- 金额单位统一:两边都换算成分比较,避免精度问题。
- 对账频率可升级:T+1 是保底,大额交易可加「实时对账」(支付成功即异步核对),小额走日终。
一句话:对账不是「锦上添花」,而是支付系统的「安全带」——所有假设都可能被现实击穿,对账保证最坏情况也能发现并修正。
七、性能与扩展
- 分库分表:支付单按
user_id分 128 库 × 32 表;账户按account_id哈希分片;结算单按merchant_id。 - 读写分离 + 主从延迟容忍:支付查询走从库,资金写入走主库;对账、报表走离线数仓(ClickHouse)。
- 异步削峰:回调、通知、结算全部 MQ 化,支付高峰不阻塞主链路。
- 缓存:支付单热状态用 Redis 短暂缓存(TTL 5 分钟),但永远以 DB 为准,缓存只做读加速。
- 网关超时与熔断:渠道 RPC 超时 3 秒快速失败,连续失败熔断,切备用渠道。
容量与热点
- 单库单表 QPS 上限约 2000-5000,分库后轻松支撑 10 万级写入。
- 热点账户(大商户、头部主播)用「账户拆分 + 汇总」或本地缓冲批量记账。
- 对账文件从渠道拉取用异步任务池,避免占满带宽。
八、权衡与备选
| 决策点 | 本文选型 | 备选 | 权衡说明 |
|---|---|---|---|
| 支付单存储 | MySQL 分库分表 | TiDB(分布式 NewSQL) | MySQL 成熟可控;TiDB 免分片运维,适合扩张快的小团队 |
| 消息队列 | Kafka(事务消息) | RocketMQ | RocketMQ 事务消息开箱即用;Kafka 吞吐更高、生态更广 |
| 余额扣减 | TCC 预冻结 | 单库事务直扣 | 预冻结支持超时自动解冻,但实现复杂;直扣简单但难支持部分冻结 |
| 对账引擎 | 自研批处理 | 数仓 + Flink SQL | 自研灵活可控;Flink 适合实时对账与复杂聚合 |
| 多币种 | 金额×汇率快照 | 实时汇率 | 快照汇率保证账目确定;实时汇率账面波动大、难对账 |
关键取舍
- 可用性 vs 一致性:支付链路「宁可慢不可错」,先保证 DB 主库一致性,用异步拉平下游;对账 T+1 兜底。
- 监控 vs 性能:每笔支付写审计日志 + 埋点,成本可接受,但避免在主链路做全文审计。
- 自研网关 vs 买三方网关:起步用三方(如 Ping++),规模大了自建,降低费率与故障依赖。
九、扩展场景与面试追问
9.1 支持钱包转账
如果题目扩展为「钱包 + 转账」,核心变化:
- 账户体系:引入「用户钱包账户 + 平台结算账户 + 商户待结算账户」多级账户树。
- 转账:本质是「扣 A 账户 + 加 B 账户」的原子双写,必须在一个事务/一个分区内完成,或拆为 TCC(Try 冻结 → Confirm 划转 → Cancel 解冻)。
- 余额对账:定期跑「账户余额 = Σ 流水」校验,发现账实不符立即熔断提现。
9.2 多币种与跨境
- 增加
currency与exchange_rate_snapshot(下单时锁定汇率)。 - 结算按「原始币种」记账,换汇走独立换汇服务,避免汇率波动影响账面。
- 合规上需考虑外汇申报、反洗钱,这些是「非功能需求」,提一嘴即可。
9.3 面试常见追问
| 追问 | 关键回答 |
|---|---|
| 支付超时怎么办? | 主动查询渠道 + 状态机收敛 + 定时补偿,不假设渠道会回调 |
| 渠道挂了怎么办? | 网关熔断 + 自动切换备用渠道 + 失败进入重试队列 |
| 用户重复点了支付按钮? | 前端幂等键 + 后端 biz_order_id 唯一约束,第二次直接返回原支付单 |
| 如何保证不超扣? | 记账用乐观锁 + 余额校验;对账用借贷平衡与渠道流水双核对 |
| 分布式事务为什么不用 2PC? | 2PC 阻塞、性能差、协调者单点,支付链路用 TCC + 消息表 + 对账更务实 |
十、总结
| 模块 | 关键设计 | 一句话记忆 |
|---|---|---|
| 支付单 | 状态机 + 唯一业务键 | 一笔订单一个支付单,状态收敛到终态 |
| 渠道网关 | 适配器 + 验签 + 回调防重放 | 屏蔽渠道差异,回调必须幂等 |
| 账户账务 | 复式记账 + 流水唯一键 | 借贷平衡,流水幂等 |
| 一致性 | 本地消息表 + 对账兜底 | 双写靠最终一致,差错靠对账 |
| 风控 | 事前规则 + 事中实时拦截 + 事后分析 | 与支付主链路异步解耦 |
| 扩展 | 分库分表 + MQ 削峰 + 熔断 | 高吞吐靠水平扩展,别单点扛 |
一句话:支付系统的面试核心是讲清楚「为什么需要支付单状态机、怎么做幂等、如何用最终一致 + 对账兜底保证资金零差错」,并把量级估算挂在嘴边上,而不是堆组件。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。