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 自动生成,与各语言客户端示例保持同步)。
整个程序围绕两个账户1和2展开,执行五个阶段:
- 创建两个账户;
- 发起 5 笔待定转账,金额从
100到500每笔递增100; - 读取账户并校验待定余额(pending balances)为
1500; - 逐笔交替"过账/作废"这 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 endAccount对象的这四个字段(debits_pending、debits_posted、credits_pending、credits_posted)定义在自动生成的绑定 src/clients/ruby/src/tigerbeetle/bindings.rb 中,对应字段语义可参考 docs/reference/account.md。正是这四个字段构成了整个示例的"可观测校验面"。
6. 阶段一:创建账户
示例先创建两个账户1与2,均处于 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);ledger和code对同一批次内的转账是必填的,它们把账户划分到可互相交易的"账本域"内(见 docs/coding/data-modeling.md 中关于 ledgers 的讨论)。
7. 阶段二:创建 5 笔待定转账(Pending Transfers)
随后程序一次性提交 5 笔待定转账:账户1借记、账户2贷记,金额分别为100、200、300、400、500(id从 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 end7.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 << 2、VOID_PENDING_TRANSFER = 1 << 3等全部转移标志位。
8. 阶段三:读取并校验待定余额
创建完 5 笔待定转账后,程序调用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。
校验逻辑为:
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_id | amount | 标志(flags) | 处理后账户1的debits_posted | 处理后账户1的debits_pending | 处理含义 |
|---|---|---|---|---|---|---|
| 6 | 1 | 100 | POST_PENDING_TRANSFER | 100 | 1400 | 过账待定转账 1(全额 100) |
| 7 | 2 | 200 | VOID_PENDING_TRANSFER | 100 | 1200 | 作废待定转账 2(释放 200) |
| 8 | 3 | 300 | POST_PENDING_TRANSFER | 400 | 900 | 过账待定转账 3(全额 300) |
| 9 | 4 | 400 | VOID_PENDING_TRANSFER | 400 | 500 | 作废待定转账 4(释放 400) |
| 10 | 5 | 500 | POST_PENDING_TRANSFER | 900 | 0 | 过账待定转账 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} } ) end9.1 Post(过账)与 Void(作废)的底层约束
根据 docs/coding/two-phase-transfers.md 与 docs/reference/transfer.md 的定义,这里每笔"结束转账"(finishing transfer)都遵循以下规则:
Post-Pending Transfer(过账):
- 通过
pending_id引用一笔尚处于 pending 状态的转账; - 过账金额等于待定金额时,全额结算(本示例每笔过账的
amount都与对应待定转账金额相等,因此全部全额过账); - 若过账
amount设为AMOUNT_MAX(2^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_id、credit_account_id、ledger、code既可以为 0(此时自动继承自待定转账),也可以显式传入且必须与待定转账一致。示例代码显式传入了与待定转账完全相同的debit_account_id: 1、credit_account_id: 2、ledger: 1、code: 1。
9.2 逐步校验的含义
- 第 1 步过账后:
posted由 0 变为 100,pending由 1500 减为 1400(释放了被过账的 100); - 第 2 步作废后:
posted保持 100 不变(作废不产生入账),pending再减 200 变为 1200; - 如此交替推进,直到第 5 步过账完毕,
posted累计为100 + 300 + 500 = 900,pending归零。
账户2始终呈镜像状态:credits_posted与账户1的debits_posted同步增长,credits_pending与账户1的debits_pending同步递减。这与单笔转账示例(src/clients/ruby/samples/two-phase/README.md)中"过账后 posted 增加、pending 清零"的规律完全一致,只是被扩展到了多笔交错场景。
10. 阶段五:校验最终余额
程序最后再次拉取两个账户,断言最终状态为"只存在 posted 余额,不存在 pending 余额":
- 账户
1:debits_posted = 900、credits_posted = 0、debits_pending = 0、credits_pending = 0; - 账户
2:debits_posted = 0、credits_posted = 900、debits_pending = 0、credits_pending = 0。
这一终态说明:全部 5 笔待定转账均已结算(3 笔过账共 900 + 2 笔作废共 600 = 初始预留的 1500),且两阶段转账不会留下任何"悬空"的待定余额。
11. 深入原理:两阶段转账的完整模型
11.1 两阶段生命周期
TigerBeetle 的两阶段转账参照了两阶段提交协议(two-phase commit protocol)的命名思路,资金移动分两步:
- 预留(Reserve):创建
flags.pending转账,将金额记入 pending 余额; - 结算(Resolve):创建
post_pending_transfer或void_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返回的每个结果都携带status与timestamp字段,应用层应逐条检查status是否等于CreateTransferStatus::CREATED。
11.4 与账户不变量(Account Invariants)的交互
待定转账的预留机制保证了:无论第二步是过账还是作废,都不会破坏账户上配置的余额不变量——例如credits_must_not_exceed_debits或debits_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)以及timeout、timestamp等字段; - 全额过账:用
amount: AMOUNT_MAX((1 << 128) - 1)过账待定转账,debits_pending/credits_pending归零,debits_posted/credits_posted相应增加; - 作废:用
amount: 0加VOID_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.mdAccount余额字段与不变量参考:docs/reference/account.mdcreate_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),仅供参考