TigerBeetle Rust 客户端两阶段转账实战:pending 预留与 post 结算全流程解析
【免费下载链接】tigerbeetleThe financial transactions database designed for mission critical safety and performance.项目地址: https://gitcode.com/GitHub_Trending/ti/tigerbeetle
本文以 TigerBeetle 仓库自带的 Rust 两阶段转账示例(src/clients/rust/samples/two-phase)为主线,完整讲解如何在 Rust 应用中用pending(预留)与post_pending_transfer(结算)两个原生原语实现"先冻结资金、再完成入账"的两阶段转账流程。读完本文,你将掌握该示例的完整代码走读、TigerBeetle 服务端的启动方式、两阶段转账的余额语义与状态机,并能直接复用这套模式实现支付预授权、托管结算等业务场景。
示例概览:这个 sample 到底做了什么
示例的官方定位见 Two-Phase Transfer Rust Sample:创建两个账户,在两者之间发起一笔 pending 转账,然后再将这笔转账 posted(结算)。完整代码只有约 160 行,位于 src/main.rs,自始至终通过assert!/assert_eq!校验每一步的账户余额与转账标志位——因此它既是可运行的演示程序,也是一份可读性极高的"可执行文档"。
整个流程分六个阶段:
- 创建账户
1和账户2; - 从账户
1向账户2发起一笔金额为500的pending转账; - 查询两个账户,验证余额只体现在
debits_pending/credits_pending上; - 创建第二笔转账,用
post_pending_transfer标志把第一笔转账结算; - 查询两笔转账,验证第一笔仍带
pending标志、第二笔带post_pending_transfer标志; - 再次查询账户,验证金额已从 pending 转为 posted。
前置条件与示例目录结构
原文档明确的环境要求(README.md):
- Linux >= 5.6 是唯一的生产环境支持目标;为了开发便利,同时支持 macOS 与 Windows;
- Rust 1.68+。
示例目录只包含两个关键文件:
src/clients/rust/samples/two-phase/ ├── Cargo.toml # 依赖声明与发布配置 └── src/ └── main.rs # 完整示例代码Cargo.toml 的内容非常精简:
[package] name = "tigerbeetle-sample-two-phase" version = "0.1.0" edition = "2021" [dependencies] tokio.version = "=1.38.1" tokio.features = ["rt-multi-thread"] tigerbeetle.path = "../.." [profile.release] overflow-checks = true两点值得注意:
- 客户端以本地路径引用:
tigerbeetle.path = "../..",即直接依赖仓库根下的 src/clients/rust 客户端源码,构建时会静态链接tb_client原生库(该库为所有官方客户端共享,具体机制见 lib.rs 顶部说明)。 - 开启溢出检查:Cargo.toml 中的注释指出,强烈建议所有使用 TigerBeetle 的 Rust 应用开启 overflow checks,因为账务的溢出错误是灾难性的;当前配置只对该 crate 的测试生效,最终应用需要在自身
Cargo.toml中同样开启overflow-checks = true。
搭建环境:克隆仓库并定位到示例目录
按原文档步骤,先克隆仓库,再进入示例目录:
$ git clone <仓库地址> $ cd tigerbeetle/src/clients/rust/samples/two-phase随后安装 TigerBeetle 客户端依赖(cargo build或直接cargo run时自动完成)。Rust 客户端本身是异步接口,但不绑定特定运行时,而是自带独立线程的事件循环;本示例选择用tokio运行时驱动:
fn main() -> Result<(), Box<dyn std::error::Error>> { tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .unwrap() .block_on(main_async()) }启动 TigerBeetle 服务端
示例代码要连上一个真实运行的 TigerBeetle 集群。仓库根 README.md 给出了在 Linux 上启动单副本集群的完整命令:
$ curl -Lo tigerbeetle.zip https://linux.tigerbeetle.com && unzip tigerbeetle.zip $ ./tigerbeetle version $ ./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 --development 0_0.tigerbeetle $ ./tigerbeetle start --addresses=3000 --development 0_0.tigerbeetle关键参数说明:
format --cluster=0:集群 ID 为0,客户端连接时传入的 cluster_id 必须与之匹配;--replica-count=1:单副本开发集群;--development:开发模式,便于本地单机运行;start --addresses=3000:监听3000端口。
Windows 下可用zig/download.ps1、macOS 可参考 docs/operating/installing.md 的安装指引;需要容器化部署可参考 docs/operating/deploying/docker.md。
地址配置(TB_ADDRESS):如果你不是在默认的localhost:3000上运行服务端,就设置环境变量TB_ADDRESS为服务端完整地址。示例代码的默认地址逻辑为:
let port = std::env::var("TB_ADDRESS").unwrap_or_else(|_| "3000".to_string()); let client = tb::Client::new(0, &port)?;Rust 客户端支持的合法地址格式(见 src/clients/rust/README.md):
| 传入值 | 实际连接地址 |
|---|---|
3000 | 127.0.0.1:3000 |
127.0.0.1:3000 | 127.0.0.1:3000 |
127.0.0.1 | 127.0.0.1:3001(默认端口 3001) |
运行示例
服务端就绪后,在示例目录执行:
$ cargo run若所有assert全部通过,程序正常退出(无任何输出),说明两阶段转账的每一步余额与标志位都符合预期;任何一步断言失败都会panic并打印对应的账户/转账状态,便于定位问题。
代码走读:六步两阶段转账全流程
以下代码均出自 src/main.rs,与官方 README 的 walkthrough 一一对应。
第 1 步:创建账户
创建两个账户(ID 分别为1和2),同属ledger = 1、code = 1:
let account_results = client .create_accounts(&[ tb::Account { id: 1, ledger: 1, code: 1, ..Default::default() }, tb::Account { id: 2, ledger: 1, code: 1, ..Default::default() }, ])? .await?; assert!(account_results.len() == 2); assert!(account_results[0].status == tb::CreateAccountStatus::Created); assert!(account_results[1].status == tb::CreateAccountStatus::Created);Rust 客户端的Account结构体(定义于 src/clients/rust/src/tb_client.rs)包含debits_pending、debits_posted、credits_pending、credits_posted四个余额字段,以及ledger、code、flags、timestamp等字段——这正是后续第 3、6 步校验的对象。示例中用..Default::default()填充其余字段,余额与用户数据字段保持为零。
第 2 步:创建 pending 转账(预留资金)
发起一笔金额为500的转账,关键在flags: tb::TransferFlags::Pending:
let transfer_results = client .create_transfers(&[tb::Transfer { id: 1, debit_account_id: 1, credit_account_id: 2, amount: 500, ledger: 1, code: 1, flags: tb::TransferFlags::Pending, ..Default::default() }])? .await?; assert!(transfer_results.len() == 1); assert!(transfer_results[0].status == tb::CreateTransferStatus::Created);这笔转账此刻只是预留(reserve)资金:金额计入账户的 pending 余额,尚未真正完成结算。
第 3 步:查询账户并验证 pending 余额
用lookup_accounts批量查询两个账户,逐一断言余额。原文档要求的校验值如下:
- 账户
1(借方账户):debits_posted = 0、credits_posted = 0、debits_pending = 500、credits_pending = 0; - 账户
2(贷方账户):debits_posted = 0、credits_posted = 0、debits_pending = 0、credits_pending = 500。
代码实现:
let accounts = client.lookup_accounts(&[1, 2])?.await?; assert_eq!(accounts.len(), 2); for account in &accounts { if account.id == 1 { assert_eq!(account.debits_posted, 0, "account 1 debits, before posted"); assert_eq!(account.credits_posted, 0, "account 1 credits, before posted"); assert_eq!(account.debits_pending, 500, "account 1 debits pending, before posted"); assert_eq!(account.credits_pending, 0, "account 1 credits pending, before posted"); } else if account.id == 2 { assert_eq!(account.debits_posted, 0, "account 2 debits, before posted"); assert_eq!(account.credits_posted, 0, "account 2 credits, before posted"); assert_eq!(account.debits_pending, 0, "account 2 debits pending, before posted"); assert_eq!(account.credits_pending, 500, "account 2 credits pending, before posted"); } else { panic!("Unexpected account: {}", account.id); } }如原文档所述:pending 转账只影响账户的 pending 借/贷,不影响 posted 借/贷。这正是两阶段模型与单阶段转账的本质区别——posted 余额未发生变化,资金只是被"冻结"在了 pending 余额中。
第 4 步:post 这笔 pending 转账(结算)
创建第二笔转账,pending_id = 1指向第一笔,flags = PostPendingTransfer:
let transfer_results = client .create_transfers(&[tb::Transfer { id: 2, debit_account_id: 1, credit_account_id: 2, amount: 500, pending_id: 1, ledger: 1, code: 1, flags: tb::TransferFlags::PostPendingTransfer, ..Default::default() }])? .await?; assert!(transfer_results.len() == 1); assert!(transfer_results[0].status == tb::CreateTransferStatus::Created);注意这里创建了一笔全新的转账(id = 2),而不是修改第一笔转账。第二笔转账的amount = 500与 pending 转账金额相等,因此整笔金额被 posted;这笔转账拥有自己独立的id,绝不与第一笔的id相同(详见 docs/coding/two-phase-transfers.md 的"所有转账均不可变"一节)。
第 5 步:查询并验证两笔转账的标志位
let transfers = client.lookup_transfers(&[1, 2])?.await?; assert_eq!(transfers.len(), 2); for transfer in &transfers { if transfer.id == 1 { assert!( transfer.flags.0 & tb::TransferFlags::Pending.0 != 0, "transfer 1 pending" ); assert!( transfer.flags.0 & tb::TransferFlags::PostPendingTransfer.0 == 0, "transfer 1 post_pending_transfer" ); } else if transfer.id == 2 { assert!( transfer.flags.0 & tb::TransferFlags::Pending.0 == 0, "transfer 2 pending" ); assert!( transfer.flags.0 & tb::TransferFlags::PostPendingTransfer.0 != 0, "transfer 2 post_pending_transfer" ); } else { panic!("Unknown transfer: {}", transfer.id); } }校验逻辑:
- 第一笔转账:始终带
pending标志,且不带post_pending_transfer标志; - 第二笔转账:始终带
post_pending_transfer标志,且不带pending标志。
这印证了不可变性:完成两阶段转账(post 或 void)不会修改原 pending 转账,其pending标志永远保留;结算动作完全由第二笔独立转账携带的post_pending_transfer标志表达。
第 6 步:验证最终账户余额
最后再次查询账户,确认金额已经从 pending 转为 posted:
- 账户
1:debits_posted = 500、credits_posted = 0、debits_pending = 0、credits_pending = 0; - 账户
2:debits_posted = 0、credits_posted = 500、debits_pending = 0、credits_pending = 0。
代码实现:
let accounts = client.lookup_accounts(&[1, 2])?.await?; assert_eq!(accounts.len(), 2); for account in &accounts { if account.id == 1 { assert_eq!(account.debits_posted, 500, "account 1 debits"); assert_eq!(account.credits_posted, 0, "account 1 credits"); assert_eq!(account.debits_pending, 0, "account 1 debits pending"); assert_eq!(account.credits_pending, 0, "account 1 credits pending"); } else if account.id == 2 { assert_eq!(account.debits_posted, 0, "account 2 debits"); assert_eq!(account.credits_posted, 500, "account 2 credits"); assert_eq!(account.debits_pending, 0, "account 2 debits pending"); assert_eq!(account.credits_pending, 0, "account 2 credits pending"); } else { panic!("Unexpected account: {}", account.id); } }此时两笔转账的账务效果完全相同于一笔 500 的单阶段转账,但中间经历了"预留 → 结算"两个明确阶段,业务上可以在两者之间插入人工审核、超时判断或取消逻辑。
深入原理:TigerBeetle 的两阶段转账模型
示例背后是 TigerBeetle 在数据库层直接内建的两阶段转账原语,官方概念文档见 docs/coding/two-phase-transfers.md。其命名借鉴了分布式事务中的两阶段提交协议,但在这里被用于资金的分阶段移动:
- 预留资金(Reserve):
flags.pending将金额计入账户的debits_pending/credits_pending; - 结算资金(Resolve):通过
flags.post_pending_transfer(post)、flags.void_pending_transfer(void)或超时(expire)三种方式之一结束这笔预留。
预留阶段(Pending Transfer)
pending 转账在账户上的效果:amount分别计入debit_account.debits_pending与credit_account.credits_pending,而debits_posted/credits_posted保持不变。示例第 3 步验证的正是这一点。
结算阶段:Post / Void / Expire
- Post(post_pending_transfer):将 pending 转账全部或部分 posted。TigerBeetle原子地回滚
debits_pending/credits_pending的变化,并将其应用到debits_posted/credits_posted(见 src/clients/rust/README.md 的 Two-Phase Transfers 一节)。 - Void(void_pending_transfer):回滚
debits_pending/credits_pending的变化,但不计入 posted 余额——资金原地退回,如同未发生过。 - Expire(超时):pending 转账可附带 timeout 字段(以秒为单位的间隔,而非绝对时间戳,零表示无超时)。若超时到达仍未 post/void,转账自动过期,全额退回原账户。注意过期语义是 best-effort:过期转账不能再被手动 post/void,但客户端请求可能仍短暂观察到 pending 余额(详见 docs/reference/transfer.md 与 docs/coding/time.md)。
部分结算与 AMOUNT_MAX 语义
post 时的amount存在三种情况(当前客户端版本语义,见 docs/reference/transfer.md):
- posted
amount小于pending 转账金额:只结算该金额,剩余部分恢复到原账户; - posted
amount等于pending 金额,或等于AMOUNT_MAX(即2^128 - 1):结算整笔金额; - posted
amount大于pending 金额(但小于AMOUNT_MAX):返回exceeds_pending_transfer_amount错误(状态码 31,见下文源码证据)。
void 时同理:amount为 0 则自动取 pending 金额;非 0 则必须与 pending 金额相等。
状态转移:三种典型路径
官方概念文档用三张表总结了账户余额与转账标志在三个时间点的变化。全额 post(对应本示例):
账户A(借方) | 账户B(贷方) | 转账 | |||||
|---|---|---|---|---|---|---|---|
| pending | posted | pending | posted | debit_account_id | credit_account_id | amount | flags |
w | x | y | z | - | - | - | - |
w+ 123 | x | y+ 123 | z | A | B | 123 | pending |
w | x+ 123 | y | z+ 123 | A | B | 123 | post_pending_transfer |
部分 post(结算 123 中的 100,剩余 23 退回):
账户A(借方) | 账户B(贷方) | 转账 | |||||
|---|---|---|---|---|---|---|---|
| pending | posted | pending | posted | debit_account_id | credit_account_id | amount | flags |
w | x | y | z | - | - | - | - |
w+ 123 | x | y+ 123 | z | A | B | 123 | pending |
w | x+ 100 | y | z+ 100 | A | B | 100 | post_pending_transfer |
void(作废,资金全额退回):
账户A(借方) | 账户B(贷方) | 转账 | |||||
|---|---|---|---|---|---|---|---|
| pending | posted | pending | posted | debit_account_id | credit_account_id | amount | flags |
w | x | y | z | - | - | - | - |
w+ 123 | x | y+ 123 | z | A | B | 123 | pending |
w | x | y | z | A | B | 123 | void_pending_transfer |
错误处理:一笔 pending 只能结算一次
一笔 pending 转账只能被 post 或 void 一次,不能 post 两次,也不能先 void 再 post。重复结算会返回对应错误(见 docs/reference/requests/create_transfers.md):
pending_transfer_already_posted(状态码 33);pending_transfer_already_voided(状态码 34);pending_transfer_expired(状态码 35)。
同时,post/void 转账还必须遵守字段约束:pending_id必须引用一笔 pending 转账;post_pending_transfer与void_pending_transfer两个标志互斥;debit_account_id、credit_account_id、ledger、code等字段可以为零(自动继承 pending 转账的值),非零则必须与 pending 转账完全一致(见 docs/reference/transfer.md)。
与账户不变量的关系
两阶段设计的一个精妙之处在于:pending 预留的金额保证第二步(post 或 void)永远不会破坏账户配置的余额不变量(debits_must_not_exceed_credits或credits_must_not_exceed_debits,对应 Rust 客户端中的 AccountFlags)。这是悲观的(pessimistic)检查:例如某账户设置debits_must_not_exceed_credits,其credits_posted = 100、debits_posted = 70,此时发起一笔使debits_pending = 50的 pending 转账会当场失败——它不会等到 posted 阶段才失败。因此,示例中的两阶段流程天然与账户限额/冻结类业务兼容。
转账的不可变性
再次强调(官方概念文档"All Transfers Are Immutable"一节):完成两阶段转账不修改原 pending 转账,而是创建一笔新转账。第一笔转账永远保留pending标志;第二笔转账携带post_pending_transfer或void_pending_transfer标志,并通过pending_id指向第一笔的id,且拥有全新的id。这让整条资金链路完全可审计、可回溯。
从源码看实现:flag 位与状态码
Rust 客户端的类型定义由 rust_bindings.zig 自动生成到 src/clients/rust/src/tb_client.rs。TransferFlags是一个 16 位 bitfield:
pub struct TransferFlags(pub u16); impl TransferFlags { pub const Linked: TransferFlags = TransferFlags(1 << 0); pub const Pending: TransferFlags = TransferFlags(1 << 1); pub const PostPendingTransfer: TransferFlags = TransferFlags(1 << 2); pub const VoidPendingTransfer: TransferFlags = TransferFlags(1 << 3); pub const BalancingDebit: TransferFlags = TransferFlags(1 << 4); pub const BalancingCredit: TransferFlags = TransferFlags(1 << 5); pub const ClosingDebit: TransferFlags = TransferFlags(1 << 6); pub const ClosingCredit: TransferFlags = TransferFlags(1 << 7); pub const Imported: TransferFlags = TransferFlags(1 << 8); }示例用到的三个标志正是Pending(1<<1)、PostPendingTransfer(1<<2);void 场景则用VoidPendingTransfer(1<<3)。同文件中tb_transfer_t结构体的内存布局(id、debit_account_id、credit_account_id、amount、pending_id、user_data 系列、timeout、ledger、code、flags、timestamp)与 C ABI 严格对应,这也是所有官方客户端共享同一tb_client原生库的原因。
同一文件还给出了两阶段相关错误的状态码常量,可直接作为排查依据:
pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_POSTED: TB_CREATE_TRANSFER_STATUS = 33; pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_VOIDED: TB_CREATE_TRANSFER_STATUS = 34; pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_EXPIRED: TB_CREATE_TRANSFER_STATUS = 35; pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXCEEDS_PENDING_TRANSFER_AMOUNT: TB_CREATE_TRANSFER_STATUS = 31;进阶:void 作废、超时与批量结算
Void 示例代码
void_pending_transfer与 post 对称,但效果是退回资金。Rust 客户端 README 中的示意如下:
let transfer0 = tb::Transfer { id: 8, debit_account_id: 101, credit_account_id: 102, amount: 10, ledger: 1, code: 1, ..Default::default() }; let transfer_results = client.create_transfers(&[transfer0])?.await?; let transfer1 = tb::Transfer { id: 9, amount: 0, // void 时 amount 为 0 表示全额作废 pending_id: 8, flags: tb::TransferFlags::VoidPendingTransfer, ..Default::default() }; let transfer_results = client.create_transfers(&[transfer1])?.await?;void 时debit_account_id、credit_account_id、ledger、code都可省略(为零时自动继承 pending 转账的值),amount为 0 表示整笔作废。
超时自动过期
若在创建 pending 转账时设置timeout(单位:秒,32 位无符号整数),例如:
let transfer = tb::Transfer { id: 1, debit_account_id: 1, credit_account_id: 2, amount: 500, ledger: 1, code: 1, flags: tb::TransferFlags::Pending, timeout: 3600, // 1 小时后若未结算则自动过期并退回 ..Default::default() };那么到达timestamp + timeout后,若既未 post 也未 void,全额自动退回。业务上可用于订单超时未支付自动取消等场景。
同族示例与批量建议
- basic 示例:单阶段转账,直接创建账户并转一笔账;
- two-phase-many 示例:创建两个账户后发起多笔 pending 转账,并交替 post 与 void,展示批量两阶段处理;
- 生产建议:TigerBeetle 在事件大批量提交时才能达到峰值性能,Rust 客户端单次请求默认最大批次为8189个事件(见 lib.rs 的 Request batching 说明),应用层应尽量批量提交;同一 Client 实例是线程安全的,可跨并发任务共享以便自动合并请求。
延伸阅读
- 两阶段转账概念与状态转移:docs/coding/two-phase-transfers.md
Transfer字段完整约束(pending/post/void 各模式字段表):docs/reference/transfer.mdcreate_transfers请求与全部错误码:docs/reference/requests/create_transfers.md- 账户余额字段与不变量标志:docs/reference/account.md
- 关联事件(批量原子提交):docs/coding/linked-events.md
- 时间与超时语义:docs/coding/time.md
【免费下载链接】tigerbeetleThe financial transactions database designed for mission critical safety and performance.项目地址: https://gitcode.com/GitHub_Trending/ti/tigerbeetle
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考