TigerBeetle Ruby 客户端实战:Many Two-Phase Transfers 示例中的批量 Pending/Post/Void 转账与余额校验
2026/9/14 18:40:51 网站建设 项目流程

TigerBeetle Ruby 客户端实战:Many Two-Phase Transfers 示例中的批量 Pending/Post/Void 转账与余额校验

【免费下载链接】tigerbeetleThe financial transactions database designed for mission critical safety and performance.项目地址: https://gitcode.com/GitHub_Trending/ti/tigerbeetle

导读

本文围绕 TigerBeetle 官方 Ruby 示例two-phase-many展开,讲解如何在一次会话中创建多笔待定转账(Pending Transfer),随后交替执行过账(Post)作废(Void),并在每一步之后读取账户余额进行断言校验。读完本文,你将掌握 TigerBeetle 两阶段转账(Two-Phase Transfer)的完整编码套路:flags.pending预留资金、flags.post_pending_transfer结算资金、flags.void_pending_transfer释放资金,以及debits_pending/credits_pending/debits_posted/credits_posted四组余额字段在不同阶段的取值规则,并能在 Ruby 中编写可重复验证的余额断言逻辑。

1. 示例概览:这个项目到底做了什么

示例完整代码位于 src/clients/ruby/samples/two-phase-many/main.rb,配套说明即本仓库的 src/clients/ruby/samples/two-phase-many/README.md(该 README 由 src/scripts/client_readmes.zig 自动生成,与各语言客户端示例保持同步)。

整个程序围绕两个账户12展开,执行五个阶段:

  1. 创建两个账户;
  2. 发起 5 笔待定转账,金额从100500每笔递增100
  3. 读取账户并校验待定余额(pending balances)为1500
  4. 逐笔交替"过账/作废"这 5 笔待定转账,每处理一笔都重新读取余额并校验;
  5. 最终校验两个账户只剩已过账余额(posted balances)900,待定余额归零。

与只演示单笔转账的 src/clients/ruby/samples/two-phase/README.md 相比,two-phase-many的关键进阶点在于:同一批待定转账可以被逐笔、交错地结算(过账)或取消(作废),并且每一步都能精确验证余额变化——这正是现实中"多笔未决交易并发结算"场景的最小可运行范本。

2. 环境准备(Prerequisites)

示例 README 明确给出了运行前提:

  • 操作系统:生产环境仅支持 Linux >= 5.6;为便于开发,macOS 与 Windows 也可运行示例。
  • Ruby 版本:>=3.3

3. 安装与运行

3.1 安装 TigerBeetle Ruby 客户端

首先克隆本仓库,并进入示例目录:

cd tigerbeetle/src/clients/ruby/samples/two-phase-many

然后通过 gem 安装官方客户端:

gem install tigerbeetle

安装后即可在 Ruby 代码中require "tigerbeetle"(示例 main.rb 第一行即如此)。

3.2 启动 TigerBeetle 服务端

按照仓库根目录 README.md 中"Running TigerBeetle"一节的步骤启动服务端。示例默认连接localhost:3000;如果你的服务端不在该地址,需要通过环境变量TB_ADDRESS指定完整地址:

export TB_ADDRESS=3000 # 默认值,等价于 localhost:3000 export TB_ADDRESS=localhost:3001 # 非默认端口示例

在 main.rb 中,地址读取逻辑为:

replica_addresses = ENV.fetch("TB_ADDRESS", "3000")

即未设置TB_ADDRESS时默认使用"3000"(本机 3000 端口)。

3.3 运行示例

ruby main.rb

全部断言通过后,程序会输出ok;任一步骤校验失败则会抛出带明确信息的异常并中断执行。

4. 客户端生命周期与请求方法

示例使用TigerBeetle::Client.open的块(block)形式打开客户端,块结束时自动关闭连接,避免手动管理关闭时机:

TigerBeetle::Client.open(cluster_id: 0, replica_addresses:) do |client| # 在此使用 client end

对应的客户端实现见 src/clients/ruby/src/tigerbeetle/client.rb:Client.open内部先new(cluster_id:, replica_addresses:)创建原生客户端,yield给块使用,随后在ensure中调用close等待所有在途请求完成后关闭。客户端可被多线程/多纤程共享,公开请求方法均为同步阻塞,在存在纤程调度器时会主动让出(yield)。

本示例用到的请求方法(同见 client.rb):

方法说明
create_accounts(accounts)批量创建账户,返回与输入一一对应的结果数组
create_transfers(transfers)批量创建转账(含 pending/post/void),返回结果数组
lookup_accounts(ids)按 ID 批量查找账户,未找到的会被省略

5. 余额断言辅助函数

在进入正题前,先看 main.rb 中定义的assert_accounts辅助函数。它接收lookup_accounts的返回值和一个以账户 ID 为键、以期望余额为值的哈希,逐项比对四个余额字段:

def assert_accounts(accounts, expected) raise "expected #{expected.length} accounts" unless accounts.length == expected.length accounts.each do |account| values = expected.fetch(account.id) { raise "unexpected account: #{account.inspect}" } unless account.debits_posted == values.fetch(:debits_posted) raise "account #{account.id} debits_posted mismatch" end unless account.credits_posted == values.fetch(:credits_posted) raise "account #{account.id} credits_posted mismatch" end unless account.debits_pending == values.fetch(:debits_pending) raise "account #{account.id} debits_pending mismatch" end unless account.credits_pending == values.fetch(:credits_pending) raise "account #{account.id} credits_pending mismatch" end end end

Account对象的这四个字段(debits_pendingdebits_postedcredits_pendingcredits_posted)定义在自动生成的绑定 src/clients/ruby/src/tigerbeetle/bindings.rb 中,对应字段语义可参考 docs/reference/account.md。正是这四个字段构成了整个示例的"可观测校验面"。

6. 阶段一:创建账户

示例先创建两个账户12,均处于 ledger1、code1

account_results = client.create_accounts( [ TigerBeetle::Account.new(id: 1, ledger: 1, code: 1), TigerBeetle::Account.new(id: 2, ledger: 1, code: 1) ] ) raise "expected 2 account results" unless account_results.length == 2 account_results.each.with_index(1) do |result, index| unless result.status == TigerBeetle::CreateAccountStatus::CREATED raise "account #{index} was not created" end end

要点:

  • create_accounts按批处理(batch)语义返回结果数组,每个结果对应一个输入账户,需要逐一检查status
  • TigerBeetle::CreateAccountStatus::CREATED表示创建成功(其常量值为4294967295,见 bindings.rb);
  • ledgercode对同一批次内的转账是必填的,它们把账户划分到可互相交易的"账本域"内(见 docs/coding/data-modeling.md 中关于 ledgers 的讨论)。

7. 阶段二:创建 5 笔待定转账(Pending Transfers)

随后程序一次性提交 5 笔待定转账:账户1借记、账户2贷记,金额分别为100200300400500id从 1 到 5,金额为id * 100),全部携带TigerBeetle::TransferFlags::PENDING标志:

transfers = (1..5).map do |id| TigerBeetle::Transfer.new( id: id, debit_account_id: 1, credit_account_id: 2, amount: id * 100, ledger: 1, code: 1, flags: TigerBeetle::TransferFlags::PENDING ) end transfer_results = client.create_transfers(transfers) unless transfer_results.length == transfers.length raise "expected #{transfers.length} pending transfer results" end transfer_results.each do |result| unless result.status == TigerBeetle::CreateTransferStatus::CREATED raise "pending transfer was not created" end end

7.1 PENDING 标志的语义:预留资金而非划拨资金

根据 docs/coding/two-phase-transfers.md 中 "Reserve Funds (Pending Transfer)" 一节的说明,带flags.pending的转账会将其amount记入借贷双方账户的debits_pending/credits_pending字段,不会改动debits_posted/credits_posted。也就是说,待定转账把资金"冻结/预留"下来,但尚未真正入账。

在 Ruby 绑定中,TransferFlags::PENDING定义于 bindings.rb,值为1 << 1(即 2);同文件还定义了POST_PENDING_TRANSFER = 1 << 2VOID_PENDING_TRANSFER = 1 << 3等全部转移标志位。

8. 阶段三:读取并校验待定余额

创建完 5 笔待定转账后,程序调用lookup_accounts([1, 2])拉取两个账户,并断言:

  • 账户1debits_posted = 0credits_posted = 0debits_pending = 1500credits_pending = 0
  • 账户2debits_posted = 0credits_posted = 0debits_pending = 0credits_pending = 1500

校验逻辑为:

assert_accounts( client.lookup_accounts([1, 2]), { 1 => { debits_posted: 0, credits_posted: 0, debits_pending: 1500, credits_pending: 0 }, 2 => {debits_posted: 0, credits_posted: 0, debits_pending: 0, credits_pending: 1500} } )

1500 = 100 + 200 + 300 + 400 + 500,正是 5 笔待定转账金额之和。这说明待定转账只影响账户的pending借贷余额,不影响posted借贷余额——这也是两阶段转账最核心的账务语义。

9. 阶段四:交替过账与作废,逐笔校验

接下来是示例的精华部分。程序定义了一张操作表,依次对 5 笔待定转账做"过账、作废、过账、作废、过账"的交替处理,并在每笔处理之后立即断言账户余额:

结束转账id引用的待定转账pending_idamount标志(flags)处理后账户1debits_posted处理后账户1debits_pending处理含义
61100POST_PENDING_TRANSFER1001400过账待定转账 1(全额 100)
72200VOID_PENDING_TRANSFER1001200作废待定转账 2(释放 200)
83300POST_PENDING_TRANSFER400900过账待定转账 3(全额 300)
94400VOID_PENDING_TRANSFER400500作废待定转账 4(释放 400)
105500POST_PENDING_TRANSFER9000过账待定转账 5(全额 500)

对应源码(main.rb):

operations = [ [6, 1, 100, TigerBeetle::TransferFlags::POST_PENDING_TRANSFER, 100, 1400], [7, 2, 200, TigerBeetle::TransferFlags::VOID_PENDING_TRANSFER, 100, 1200], [8, 3, 300, TigerBeetle::TransferFlags::POST_PENDING_TRANSFER, 400, 900], [9, 4, 400, TigerBeetle::TransferFlags::VOID_PENDING_TRANSFER, 400, 500], [10, 5, 500, TigerBeetle::TransferFlags::POST_PENDING_TRANSFER, 900, 0] ] operations.each do |id, pending_id, amount, flags, posted, pending| transfer_results = client.create_transfers( [ TigerBeetle::Transfer.new( id: id, debit_account_id: 1, credit_account_id: 2, amount: amount, pending_id: pending_id, ledger: 1, code: 1, flags: flags ) ] ) raise "expected 1 finishing transfer result" unless transfer_results.length == 1 unless transfer_results[0].status == TigerBeetle::CreateTransferStatus::CREATED raise "finishing transfer #{id} was not created" end assert_accounts( client.lookup_accounts([1, 2]), { 1 => { debits_posted: posted, credits_posted: 0, debits_pending: pending, credits_pending: 0 }, 2 => {debits_posted: 0, credits_posted: posted, debits_pending: 0, credits_pending: pending} } ) end

9.1 Post(过账)与 Void(作废)的底层约束

根据 docs/coding/two-phase-transfers.md 与 docs/reference/transfer.md 的定义,这里每笔"结束转账"(finishing transfer)都遵循以下规则:

Post-Pending Transfer(过账)

  • 通过pending_id引用一笔尚处于 pending 状态的转账;
  • 过账金额等于待定金额时,全额结算(本示例每笔过账的amount都与对应待定转账金额相等,因此全部全额过账);
  • 若过账amount设为AMOUNT_MAX2^128 - 1),同样表示按待定金额全额过账;
  • 若过账金额小于待定金额,则只过账该部分,剩余部分自动释放回原账户(部分过账,见 docs/coding/two-phase-transfers.md 中 "Post Partial Pending Amount" 的表格示例);
  • 若过账金额大于待定金额(且不等于AMOUNT_MAX),返回exceeds_pending_transfer_amount错误(见 docs/reference/requests/create_transfers.md)。

Void-Pending Transfer(作废)

  • 同样通过pending_id引用待定转账;
  • amount为 0 时自动采用待定转账的金额(完全释放);非 0 则必须等于待定金额。本示例作废时显式传入与待定金额相等的200/400,语义等价于全额释放。

公共约束:在 post/void 转账中,debit_account_idcredit_account_idledgercode既可以为 0(此时自动继承自待定转账),也可以显式传入且必须与待定转账一致。示例代码显式传入了与待定转账完全相同的debit_account_id: 1credit_account_id: 2ledger: 1code: 1

9.2 逐步校验的含义

  • 第 1 步过账后:posted由 0 变为 100,pending由 1500 减为 1400(释放了被过账的 100);
  • 第 2 步作废后:posted保持 100 不变(作废不产生入账),pending再减 200 变为 1200;
  • 如此交替推进,直到第 5 步过账完毕,posted累计为100 + 300 + 500 = 900pending归零。

账户2始终呈镜像状态:credits_posted与账户1debits_posted同步增长,credits_pending与账户1debits_pending同步递减。这与单笔转账示例(src/clients/ruby/samples/two-phase/README.md)中"过账后 posted 增加、pending 清零"的规律完全一致,只是被扩展到了多笔交错场景。

10. 阶段五:校验最终余额

程序最后再次拉取两个账户,断言最终状态为"只存在 posted 余额,不存在 pending 余额":

  • 账户1debits_posted = 900credits_posted = 0debits_pending = 0credits_pending = 0
  • 账户2debits_posted = 0credits_posted = 900debits_pending = 0credits_pending = 0

这一终态说明:全部 5 笔待定转账均已结算(3 笔过账共 900 + 2 笔作废共 600 = 初始预留的 1500),且两阶段转账不会留下任何"悬空"的待定余额。

11. 深入原理:两阶段转账的完整模型

11.1 两阶段生命周期

TigerBeetle 的两阶段转账参照了两阶段提交协议(two-phase commit protocol)的命名思路,资金移动分两步:

  1. 预留(Reserve):创建flags.pending转账,将金额记入 pending 余额;
  2. 结算(Resolve):创建post_pending_transfervoid_pending_transfer转账,把待定金额转为已入账或释放回原账户。

此外,待定转账还可以带timeout(单位为的间隔,而非绝对时间戳):若超时前既未过账也未作废,则自动过期并将全额释放回原账户。过期后的待定转账不能再被手动过账或作废,会返回pending_transfer_expired错误。关于时间间隔为何采用相对值而非绝对时间戳,可参考 docs/coding/time.md。

11.2 转账不可变:结束转账是"新转账"而非"修改"

需要特别强调:无论过账还是作废,都不会修改原始的待定转账。TigerBeetle 中的转账一旦创建即不可修改、不可删除(见 docs/reference/transfer.md 的 Guarantees 一节)。第二笔转账携带post_pending_transfer/void_pending_transfer标志、pending_id指向第一笔待定转账的id,并且拥有自己独立且唯一id(本示例中为 6~10)。

11.3 错误处理与幂等

一笔待定转账只能被过账或作废一次。重复结算会得到对应的错误结果码(均定义在 bindings.rb 的CreateTransferStatus中):

  • PENDING_TRANSFER_ALREADY_POSTED:已被过账;
  • PENDING_TRANSFER_ALREADY_VOIDED:已被作废;
  • PENDING_TRANSFER_EXPIRED:已过期。

create_transfers返回的每个结果都携带statustimestamp字段,应用层应逐条检查status是否等于CreateTransferStatus::CREATED

11.4 与账户不变量(Account Invariants)的交互

待定转账的预留机制保证了:无论第二步是过账还是作废,都不会破坏账户上配置的余额不变量——例如credits_must_not_exceed_debitsdebits_must_not_exceed_credits(见 docs/reference/account.md 的 Flags 部分)。反之,如果账户在不变量约束下已经没有足够余量,待定转账创建时(而不是过账时)就会失败,即"悲观式"地拒绝预留。

12. 测试佐证:官方集成测试如何验证同一套行为

Ruby 客户端的集成测试 src/clients/ruby/tests/integration/test_two_phase_transfer.rb 对本文讨论的机制提供了源码级印证,覆盖了更多边界场景:

  • 创建带timeout的待定转账后,lookup_transfers能读回flags(该测试中断言transfers[0].flags == 2,即PENDING = 1 << 1)以及timeouttimestamp等字段;
  • 全额过账:用amount: AMOUNT_MAX(1 << 128) - 1)过账待定转账,debits_pending/credits_pending归零,debits_posted/credits_posted相应增加;
  • 作废:用amount: 0VOID_PENDING_TRANSFER作废(触发金额自动继承语义),待定余额释放且 posted 余额不变;
  • 过期:创建timeout: 1的待定转账,sleep(1.5)后待定余额被自动清除,再尝试作废会得到PENDING_TRANSFER_EXPIRED状态。

这套断言与two-phase-many示例中的余额校验互为补充:示例验证"多笔交错结算的余额演进",测试验证"全额/作废/过期等边界语义"。

13. 相关资源

  • 示例源码:src/clients/ruby/samples/two-phase-many/main.rb
  • 单笔两阶段转账示例:src/clients/ruby/samples/two-phase/README.md
  • 两阶段转账官方指南:docs/coding/two-phase-transfers.md
  • Transfer字段与标志位参考:docs/reference/transfer.md
  • Account余额字段与不变量参考:docs/reference/account.md
  • create_transfers请求与错误码参考:docs/reference/requests/create_transfers.md
  • Ruby 客户端实现:src/clients/ruby/src/tigerbeetle/client.rb(请求方法)、src/clients/ruby/src/tigerbeetle/bindings.rb(数据结构与状态常量)
  • 两阶段转账集成测试:src/clients/ruby/tests/integration/test_two_phase_transfer.rb

【免费下载链接】tigerbeetleThe financial transactions database designed for mission critical safety and performance.项目地址: https://gitcode.com/GitHub_Trending/ti/tigerbeetle

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询