高性能支付系统设计:从并发扣款到分布式事务

4 阅读11分钟

支付系统是互联网业务中技术挑战最密集的领域之一。一笔看似简单的转账操作,背后涉及高并发扣款、资金一致性、异步处理、分布式事务等一系列难题。本文从一个最基础的问题出发——"如何安全地扣钱"——层层递进,完整梳理支付系统在高并发场景下的技术方案选型与架构设计。


一、问题的起点:并发扣款的超扣风险

先看一个最朴素的场景。账户余额 1000 元,同时来了 11 个并发请求,每笔扣 100 元。

如果每个请求的执行流程是"先读余额 → 判断够不够 → 再扣减",那么在高并发下,多个请求可能同时读到余额 1000,判断都够,然后都执行扣减。最终余额变成负数,这就是超扣。

根本原因在于读和写之间存在时间窗口,并发请求在这个窗口里读到了旧值,做出了错误的放行决策。


二、第一道防线:原子 SQL 消灭读写窗口

解决超扣的核心思路是把余额校验和扣减合并成一个不可分割的原子操作

UPDATE account
SET available_balance = available_balance - 100
WHERE user_id = 123
  AND available_balance >= 100

数据库执行这条 SQL 时,会对该行加行锁,读取当前值、判断条件、执行扣减在同一个操作里完成,其他请求必须等待锁释放后才能进入。当余额不足时,WHERE 条件不满足,影响行数返回 0,业务层以此判断余额不足。

这里有一个值得思考的问题:既然本质是行锁串行,那为什么不直接加锁串行处理所有业务逻辑?关键差异在于锁持有的时间

如果锁住账户后还要串行地去调换汇服务、风控服务、出款服务,锁的持有时间可能达到数百毫秒,同一个账户每秒只能处理个位数的交易。而原子 SQL 的行锁只覆盖这一条 SQL 的执行时间(通常不到 1 毫秒),吞吐量差了几百倍。锁的粒度越小,并发能力越强。


三、第二道防线:冻结机制解决异步资金占用

原子 SQL 解决了并发写的竞争问题,但支付系统大量操作是异步的——扣款后需要等待换汇、清算、出款等下游处理。在这段异步窗口内,余额还没有真正变化,用户可以发起第二笔扣款,把同一笔钱花两次。

冻结机制解决的就是这个问题。账户模型拆成两个字段:

available_balance  // 可用余额
frozen_balance     // 冻结余额

转账发起时,通过原子 SQL 同步执行冻结操作:可用余额减少 100,冻结余额增加 100。此时用户看到的余额已经减少,无法二次使用。后续异步处理成功,释放冻结金额(资金已出去);若处理失败,冻结退回可用余额。

冻结机制的本质是同步占位。它和原子 SQL 解决的是两个层面的问题:原子 SQL 防并发写竞争,冻结机制防异步重复使用。两者配合才是完整方案。


四、第三道防线:Redis 前置扛极端并发

数据库行锁在极高并发下会成为瓶颈,所有请求排队等同一把锁。这时候可以在 Redis 层做余额预扣:

DECRBY user:123:balance 100

Redis 单线程天然串行,不需要加锁,如果返回值大于等于 0 则放行请求进入后续流程,如果小于 0 则立即回滚(INCRBY),返回余额不足。DB 层的原子 SQL 降级为兜底校验。

但引入 Redis 就引入了新的风险:Redis 宕机时数据可能丢失

Redis 宕机的风险与应对

Redis 宕机的核心风险不是服务不可用,而是数据丢失:Redis 里扣了余额但还没同步到 DB,宕机后数据丢失,用户余额在 DB 里还是原来的值,相当于这笔扣款凭空消失,超扣风险再次出现。

Redis 自带的持久化机制(RDB 快照最多丢分钟级数据,AOF everysec 策略最多丢一秒数据)在支付场景下都不够可靠。解决方案需要在架构层面考虑。

方案一:重新定位 Redis 角色——只做限流,不存真实余额。 Redis 退化为并发控制的令牌桶,每次拿到令牌后去 DB 执行原子扣款 SQL。Redis 宕机时,所有请求直接打到 DB,正确性不受影响,只是并发能力下降。这是最稳的方案。

方案二:WAL 预写日志。 如果 Redis 必须存真实余额,每次扣款前先在 DB 写一条流水记录(status=pending),再操作 Redis,操作成功后更新流水状态为 done。Redis 宕机恢复时,扫描 pending 状态的流水记录,重新对 Redis 执行对应操作,恢复一致状态。

方案三:双写 + 异步对账。 扣款时同时写 Redis 和 DB(先 DB 后 Redis),定时对账任务对比两边余额,不一致时以 DB 为准修正 Redis。


五、兜底防线:异步对账

无论前面的防线设计得多严密,都需要异步对账作为最后一道保障。定时任务按如下逻辑运行:

实际余额 = 初始余额 + 所有入账流水 - 所有出账流水
对比账户表中存储的余额
不一致 → 告警 + 人工介入或自动修正

对账系统能发现所有前面逻辑遗漏的问题,是整个支付架构的安全网。


六、完整防御体系总结

层次手段防御目标
业务层预扣/冻结异步窗口内资金被重复使用
数据库层原子 SQL + 条件更新并发写竞争
缓存层Redis 原子操作极端高并发下的 DB 瓶颈
兜底层异步对账所有逻辑漏洞

七、进入深水区:分布式事务

以上讨论的都是单账户操作。一旦涉及"A 账户扣款 + B 账户打款",就进入了分布式事务领域。核心要求是:要么都成功,要么都失败,不能只成功一半。

如果 A 和 B 在同一个数据库,本地事务即可解决。但钱包系统通常按用户分库,A 和 B 大概率不在同一个库,甚至不在同一个服务。

7.1 经典理论:两阶段提交(2PC)

两阶段提交是分布式事务最经典的理论模型。

准备阶段:协调者向所有参与者发送准备请求。参与者各自执行事务操作(但不提交),写 undo/redo 日志,锁住相关资源,回复协调者 Yes 或 No。

提交阶段:如果所有参与者都回复 Yes,协调者发送 Commit 指令,参与者提交事务释放锁。如果任意一个回复 No,协调者发送 Rollback,参与者回滚。

2PC 的问题很明显。一是同步阻塞:准备阶段完成后,所有参与者持有锁等待协调者指令,参与者越多等待越久。二是协调者单点故障:协调者在发出 Commit 后宕机,部分参与者收到了 Commit 已提交,部分没收到还在等待,数据不一致且无法自动恢复。

7.2 改进理论:三阶段提交(3PC)

3PC 在准备阶段之前增加了一个 CanCommit 阶段——只询问参与者是否有能力提交,不锁资源。同时引入超时机制:参与者等待最终提交指令超时后会自动提交,避免永久阻塞。

3PC 解决了阻塞问题,但引入了新的不一致风险:在网络分区场景下,协调者发出 Rollback,部分参与者收到回滚,部分参与者没收到、超时后自动提交,结果一部分提交一部分回滚,比 2PC 更糟。

7.3 为什么生产环境不用 2PC/3PC

两个方案在极端情况下都无法保证一致性,且性能差(全程同步等待、锁持有时间长)、依赖底层数据库的 XA 协议支持。生产环境中,支付系统普遍放弃强一致性,拥抱最终一致性,在业务层实现补偿机制。


八、生产级方案:TCC

TCC(Try-Confirm-Cancel)是支付系统最常用的分布式事务方案。每个操作需要实现三个接口:

Try 阶段(资源预占):A 账户冻结 100 元(available 减少,frozen 增加),B 账户创建一条待入账记录(pending_in 增加 100)。

Confirm 阶段(正式执行):A 账户扣减冻结金额(资金出去),B 账户将待入账转为正式余额。

Cancel 阶段(释放预占):A 账户解冻,退回可用余额。B 账户删除待入账记录。

TCC 的核心优势在于隔离性强。Try 阶段就锁定了资源,其他事务看不到这部分资金,不会出现中间状态暴露的问题。且每一步都是本地事务,没有长事务锁,性能远优于 2PC。

TCC 的三个经典异常

网络超时是这些异常的根源。TCC 框架发出 Try 请求后网络超时,框架认为失败触发 Cancel,但 Try 请求可能还在路上或已经执行完毕。由此产生三个问题:

空回滚:Try 没有执行,Cancel 却被调用了。如果 Cancel 正常执行回滚操作,会操作不存在的数据。

悬挂:Cancel 比 Try 先执行完。如果 Try 随后到达并正常执行,冻结的资源将永远无法释放。

幂等:网络重试导致同一接口被多次调用,必须保证多次执行的结果一致。

统一解法:事务控制表

三个问题用同一套机制解决。引入一张事务控制表,记录每个分支事务的执行状态:

tcc_transaction_log (
  global_tx_id,   -- 全局事务ID
  branch_tx_id,   -- 分支事务ID
  status,         -- init / tried / confirmed / cancelled
  created_at
)

Try 接口进入时先查询事务控制表。如果已存在 tried 记录,说明是重复请求,直接返回成功(幂等)。如果已存在 cancelled 记录,说明 Cancel 已经先到,拒绝执行(防悬挂)。都没有则正常执行业务逻辑,在同一个本地事务里完成资金冻结和事务日志写入。

Cancel 接口进入时同样先查询。如果没有 Try 记录,说明是空回滚,插入一条 cancelled 记录(为后续到达的 Try 设置屏障),直接返回成功。如果已是 cancelled 状态,直接返回成功(幂等)。否则正常执行解冻和状态更新。


九、生产级方案:Saga

当事务链路很长——比如跨境转账涉及扣款、KYC 风控、换汇、跨境清算、出款、打款六个步骤——TCC 要求每一步都实现三个接口,复杂度太高。Saga 是更适合长流程的方案。

Saga 的思路是将长事务拆成一系列本地事务,每个本地事务都有对应的补偿操作。正向执行到某一步失败时,按逆序执行已成功步骤的补偿操作。

两种协调模式

编排模式(Choreography):没有中央协调者,各服务自己监听事件决定下一步。下单服务完成后发出 OrderCreated 事件,库存服务监听到扣库存,支付服务监听到扣款,依次传递。失败时反向发出补偿事件。优点是去中心化,缺点是流程分散在各服务中,难以追踪全局状态。

协调模式(Orchestration):有一个中央协调者控制整个流程,按预定义的步骤依次调用各服务,失败时协调者按逆序触发补偿。流程清晰可控,是复杂业务场景的主流选择。

Saga 的隔离性问题

与 TCC 的关键区别在于 Saga 没有 Try 阶段的预占。每一步直接执行真实操作,其他事务可以看到中间状态。比如 A 的余额已经扣了,B 还没有收到,这个中间状态对外可见。这意味着 Saga 只保证最终一致性,不保证过程中的隔离性。


十、方案选型

场景推荐方案理由
A、B 同库本地事务无分布式问题
同服务跨库,步骤少TCC隔离性强,快速失败
跨服务,允许最终一致本地消息表实现简单,可靠性高
多步骤长链路Saga补偿逻辑清晰,适合复杂流程

实际生产系统中,这些方案往往组合使用。以跨境转账为例:Saga 管理整体流程编排(扣款 → 换汇 → 清算 → 出款 → 打款),每个关键节点内部用 TCC 保证强一致性。全局再加上异步对账作为兜底,形成完整的资金安全体系。

支付系统的设计没有银弹,核心是理解每种方案解决的是哪个层面的问题,在正确性、性能和复杂度之间找到适合业务场景的平衡点。