Skip to content

第 27 章:数字钱包

简介

支付平台通常提供钱包服务,允许用户在应用内存放资金,日后再提取。

用户也可以用钱包支付商品和服务,或向其他数字钱包用户转账。这通常比传统支付网络更快、成本更低。

<img src="/images/chapter-27/digital-wallet.png" alt="数字钱包" width="500" />

第一步:理解问题并确定设计范围

  • 候选人:是否只考虑数字钱包之间的转账?还需要支持其他操作吗?
  • 面试官:目前只考虑数字钱包之间的转账。
  • 候选人:系统需要支持多少笔每秒交易?
  • 面试官:假设为 100 万 TPS。
  • 候选人:数字钱包的正确性要求很严格。可以假设事务保证足够吗?
  • 面试官:可以。
  • 候选人:需要证明正确性吗?
  • 面试官:可以通过对账发现差异,但对账不能解释差异的根本原因。我们希望能够从头重放数据,重建历史。
  • 候选人:可用性要求是否为 99.99%?
  • 面试官:是。
  • 候选人:需要考虑外汇兑换吗?
  • 面试官:不需要,不在范围内。

综上,系统需要:

  • 支持两个账户之间的余额转账;
  • 支持 100 万 TPS;
  • 可靠性达到 99.99%;
  • 支持事务;
  • 支持重放与重建。

粗略估算

部署在云上的传统关系型数据库约可支持 1000 TPS。

要达到 100 万 TPS,需要约 1000 个数据库节点。但每笔转账包含两端的余额变更,因此实际需要支持 200 万次每秒操作。

设计目标之一是提高单节点 TPS,从而减少数据库节点数量。

单节点 TPS节点数量
10020,000
1,0002,000
10,000200

第二步:提出概要设计并达成共识

API 设计

本次设计只需一个接口:

POST /v1/wallet/balance_transfer - transfers balance from one wallet to another

请求参数包括 from_accountto_accountamount(用字符串避免精度损失)、currencytransaction_id(幂等键)。

响应示例:

{
    "status": "success"
    "transaction_id": "01589980-2664-11ec-9621-0242ac130002"
}

内存分片方案

钱包应用维护每位用户账户的余额。

可用 map<user_id, balance> 表示,并使用内存数据库 Redis 实现。

单个 Redis 节点无法承受 100 万 TPS,因此需要将 Redis 集群划分到多个节点。

分区算法示例:

String accountID = "A";
Int partitionNumber = 7;
Int myPartition = accountID.hashCode() % partitionNumber;

ZooKeeper 可作为高可用配置存储,保存分区数量和 Redis 节点地址。

钱包服务负责执行转账,且本身无状态,因而容易水平扩容:

<img src="/images/chapter-27/wallet-service.png" alt="钱包服务" width="500" />

该方案解决了扩展性问题,却无法原子地完成余额转账。

分布式事务

一种办法是在标准的分片关系型数据库之上使用两阶段提交协议:

<img src="/images/chapter-27/distributed-transactions-relational-dbs.png" alt="关系型数据库中的分布式事务" width="500" />

两阶段提交(2PC)的工作方式:

<img src="/images/chapter-27/2pc-protocol.png" alt="2PC 协议" width="500" />
  • 协调者(钱包服务)照常在多个数据库上执行读写。
  • 应用准备提交事务时,协调者请求所有数据库进入准备阶段。
  • 如果所有数据库都回答“是”,协调者要求它们提交事务。
  • 否则,协调者要求所有数据库中止事务。

2PC 的缺点:

  • 锁竞争导致性能不佳。
  • 协调者是单点故障。

使用 Try-Confirm/Cancel(TC/C)的分布式事务

TC/C 是 2PC 的一种变体,通过补偿事务运作:

  • 协调者要求所有数据库为事务预留资源。
  • 协调者收集数据库答复:若全部成功,则执行确认;否则执行取消。

TC/C 与 2PC 的一个重要区别是:2PC 完成一笔事务,而 TC/C 包含两笔独立事务。

TC/C 分阶段执行如下:

阶段操作AC
1Try(尝试)余额变更:-$1不执行操作
2Confirm(确认)不执行操作余额变更:+$1
Cancel(取消)余额变更:+$1不执行操作

阶段 1——尝试:

<img src="/images/chapter-27/try-phase.png" alt="尝试阶段" width="500" />
  • 协调者在 A 的数据库中开启本地事务,将 A 的余额减少 $1。
  • C 的数据库收到空操作(NOP),不更改数据。

阶段 2a——确认:

<img src="/images/chapter-27/confirm-phase.png" alt="确认阶段" width="500" />
  • 如果两个数据库都回答“是”,则进入确认阶段。
  • A 的数据库收到 NOP;C 的数据库通过本地事务将 C 的余额增加 $1。

阶段 2b——取消:

<img src="/images/chapter-27/cancel-phase.png" alt="取消阶段" width="500" />
  • 如果阶段 1 的任一操作失败,就进入取消阶段。
  • A 的数据库将 A 的余额增加 $1;C 的数据库收到 NOP。

2PC 与 TC/C 的比较:

第一阶段第二阶段:成功第二阶段:失败
2PC事务尚未完成提交或取消所有事务取消所有事务
TC/C各事务已经提交或取消根据需要执行新事务撤销已提交的事务

TC/C 也称为通过补偿实现的分布式事务,整体流程由业务逻辑处理。

TC/C 的其他特点:

  • 只要数据库支持事务,就不依赖特定数据库。
  • 分布式事务的细节和复杂性需要在业务逻辑中处理。

TC/C 的故障模式

如果协调者在执行过程中故障,必须恢复中间状态。可以在数据库分片中维护阶段状态表,并与业务变更原子更新:

<img src="/images/chapter-27/phase-status-tables.png" alt="阶段状态表" width="500" />

该表记录:

  • 分布式事务的 ID 和内容;
  • 尝试阶段的状态——未发送、已发送、已收到响应;
  • 第二阶段的名称——确认或取消;
  • 第二阶段的状态;
  • 乱序标记(下文说明)。

TC/C 的一个注意点是:分布式事务进行期间,账户状态会暂时不一致:

<img src="/images/chapter-27/unbalanced-state.png" alt="暂时不平衡的状态" width="500" />

只要系统总能从该状态恢复,且用户无法利用中间状态花费资金,这种短暂不一致可以接受。始终先扣款、再入账即可保证这一点。

尝试阶段的选择账户 A账户 C
选择 1-$1NOP
选择 2(无效)NOP+$1
选择 3(无效)-$1+$1

选择 3 无效,因为不借助 2PC 就无法保证跨数据库事务原子执行。

还需处理乱序执行这一边界情况:

<img src="/images/chapter-27/out-of-order-execution.png" alt="乱序执行" width="500" />

数据库可能在收到尝试操作之前先收到取消操作。可在阶段状态表中添加乱序标记:收到尝试操作时先检查标记,若已设置,则返回失败。

使用 Saga 的分布式事务

Saga 是微服务架构中实现分布式事务的另一种常见模式。

工作方式:

  • 所有操作按顺序排列,每项操作在自己的数据库中独立执行。

  • 按从前到后的顺序执行操作。

  • 某项操作失败时,整个流程通过补偿操作逐步回滚到起点。

    Saga

如何协调流程?有两种方法:

  • **协同编排(Choreography):**Saga 中的各服务订阅相关事件,并各自完成其职责。
  • **集中编排(Orchestration):**由单个协调者按正确顺序指示各服务工作。

协同编排的挑战是业务逻辑分散在多个异步通信的服务中。集中编排更容易管理复杂性,因此数字钱包系统通常更适合采用它。

TC/C 与 Saga 的比较:

TC/CSaga
补偿操作取消阶段回滚阶段
集中协调是(集中编排模式)
操作执行顺序任意线性
能否并行执行不能(线性执行)
是否会观察到部分不一致状态
由应用还是数据库处理应用应用

主要区别在于 TC/C 可以并行化,因此选择取决于延迟要求;如果需要低延迟,应选择 TC/C。

无论选哪种方案,都还需要审计能力,并能重放历史以从故障状态恢复。

事件溯源

现实中的数字钱包应用可能接受审计,需要回答:

  • 我们能否知道任意时刻的账户余额?
  • 如何知道历史余额和当前余额正确?
  • 代码修改后,如何证明系统逻辑仍然正确?

事件溯源有助于回答这些问题。

它包含四个概念:

  • **命令:**来自现实世界的预期动作,如从账户 A 向 B 转账 $1。命令需要全局顺序,因此放入 FIFO 队列。

    • 与事件不同,命令可能因 I/O 或无效状态等原因失败,也可能具有不确定性。
    • 命令可产生零个或多个事件。
    • 生成事件时可能依赖外部 I/O 等不确定因素,后文会再讨论。
  • **事件:**系统中已经发生的历史事实,如“从 A 向 B 转账 $1”。

    • 与命令不同,事件是已经发生的事实。
    • 事件也需要保持顺序,因此放入 FIFO 队列。
  • **状态:**事件造成的变化,例如保存账户及其余额的键值存储。

  • **状态机:**驱动事件溯源流程,主要负责验证命令及应用事件来更新状态。

    • 状态机必须是确定性的,因此不能读取外部 I/O 或依赖随机性。
    事件溯源

事件溯源的动态过程如下:

<img src="/images/chapter-27/dynamic-event-sourcing.png" alt="事件溯源动态过程" width="500" />

对钱包服务而言,命令就是余额转账请求,可以放入 Kafka 等 FIFO 队列:

<img src="/images/chapter-27/command-queue.png" alt="命令队列" width="500" />

完整流程如下:

<img src="/images/chapter-27/wallet-service-state-macghine.png" alt="钱包服务状态机" width="500" />
  • 状态机从命令队列读取命令。
  • 从数据库读取余额状态。
  • 验证命令;若有效,则为两个账户分别生成事件。
  • 读取下一个事件,更新数据库中的余额状态,以应用该事件。

事件溯源的主要优点是可重放性。在此设计中,所有状态更新都作为不可变的余额变更历史保存。

从头重放事件即可重建历史余额。事件列表不可变、状态机具有确定性,因此任一中间状态都可以重建。

<img src="/images/chapter-27/historical-states.png" alt="历史状态" width="500" />

借助事件溯源,可回答本节开头的审计问题:

  • **任意时刻的余额?**从起点重放事件,直到目标时间点。
  • **历史余额和当前余额是否正确?**从起点重新计算所有事件以验证。
  • **代码修改后逻辑是否仍正确?**用不同版本代码处理同一事件序列,并验证结果一致。

客户端查询余额可采用 CQRS 架构:多个只读状态机依据不可变事件列表查询历史状态:

<img src="/images/chapter-27/cqrs-architecture.png" alt="CQRS 架构" width="500" />

第三步:详细设计

本节探讨性能优化,因为系统仍须达到 100 万 TPS。

高性能事件溯源

第一项优化是将命令和事件保存到本地磁盘,而不是 Kafka 等外部存储。

这可避免网络延迟;由于只进行追加写入,即使使用机械硬盘通常也较快。

第二项优化是将近期命令和事件缓存在内存中,避免从磁盘重新加载。

底层可利用 mmap 实现:数据保存在本地磁盘,同时由内存缓存:

<img src="/images/chapter-27/mmap-optimization.png" alt="mmap 优化" width="500" />

还可以将状态保存在本地文件系统中的 SQLite(一种基于文件的关系型数据库)或 RocksDB。

这里选择 RocksDB,因为它使用针对写入优化的日志结构合并树(LSM);读取性能则通过缓存优化。

<img src="/images/chapter-27/rocks-db-approach.png" alt="RocksDB 方案" width="500" />

为加快重建,可定期将快照保存到磁盘,避免每次都从起点重放。快照可作为大型二进制文件存入 HDFS 等分布式文件存储:

<img src="/images/chapter-27/snapshot-approach.png" alt="快照方案" width="500" />

可靠的高性能事件溯源

以上优化使服务变为有状态,因此需要复制机制来保证可靠性。

先分析哪些数据必须高度可靠:

  • 状态和快照都可由事件列表重建,因此只需保证事件列表可靠。
  • 命令具有不确定性,不能假设总能从命令列表重新生成相同的事件列表。
  • 因而必须重点保证事件列表的可靠性。

为此,需要跨节点复制事件列表,并保证:

  • 数据不丢失;
  • 各副本日志文件中的数据相对顺序相同。

可以使用 Raft 等共识算法。

在 Raft 中,领导者主动处理工作,跟随者保持被动。领导者故障时,某个跟随者接任。只要超过半数节点仍运行,系统就能继续工作。

<img src="/images/chapter-27/raft-replication.png" alt="Raft 复制" width="500" />

所有节点均依据事件列表更新状态;Raft 保证领导者和跟随者拥有相同的事件列表。

分布式事件溯源

目前的设计兼具高单节点性能与可靠性,但仍有局限:

  • 单个 Raft 组的容量有限,最终需要分片并实现分布式事务。
  • 在 CQRS 架构中,请求响应流程较慢;客户端需要定期轮询,才能得知钱包何时更新。

轮询并非实时,用户可能很久之后才得知余额变化;如果轮询过于频繁,又会压垮查询服务:

<img src="/images/chapter-27/polling-approach.png" alt="轮询方案" width="500" />

为减轻系统负载,可以引入反向代理,代替用户发送命令并轮询响应:

<img src="/images/chapter-27/reverse-proxy.png" alt="反向代理" width="500" />

反向代理可以通过一次请求获取多个用户的数据,从而减轻负载,但仍无法解决实时收到结果的需求。

最后,让只读状态机在结果可用时主动推送给反向代理,使用户感觉更新是实时发生的:

<img src="/images/chapter-27/push-state-machines.png" alt="状态机推送" width="500" />

为进一步扩容,可将系统分成多个 Raft 组,再通过编排器使用 TC/C 或 Saga 在组间实现分布式事务:

<img src="/images/chapter-27/sharded-raft-groups.png" alt="分片 Raft 组" width="500" />

最终系统中,一次余额转账请求的生命周期示例:

  • 用户 A 向 Saga 协调者发送分布式事务,包含 A-1C+1 两项操作。
  • 协调者在阶段状态表中创建记录,跟踪事务状态。
  • 协调者确定命令应发送到哪些分区。
  • 分区 1 的 Raft 领导者收到 A-1 命令,验证并转换为事件,再复制到该 Raft 组的其他节点。
  • 事件结果同步到读取状态机,后者向协调者推送响应。
  • 协调者记录该操作成功,继续执行 C+1
  • 第二项操作以类似方式执行:确定分区、发送并执行命令,由读取状态机推送响应。
  • 协调者记录第二项操作成功,最后通知客户端结果。

第四步:总结

设计的演进过程:

  • 首先使用内存中的 Redis,但它并非持久存储。
  • 接着采用关系型数据库,并通过 2PC、TC/C 或分布式 Saga 执行分布式事务。
  • 然后引入事件溯源,使所有操作都可审计。
  • 起初将数据保存在外部数据库和队列中,但性能不足。
  • 随后改用本地文件存储,发挥追加写入的性能,并使用缓存优化读取路径。
  • 由于单节点仍存在故障风险,引入 Raft 共识和复制,避免单点故障。
  • 同时采用 CQRS 和反向代理,代表用户管理事务生命周期。
  • 最后将数据分布到多个 Raft 组,通过 TC/C 或分布式 Saga 编排跨组事务。