1. 简介
本文将深入探讨 TigerBeetle 数据库引擎,学习如何利用它构建具备容错能力和高性能特性的应用程序。
2. 金融事务核心原理
每次使用借记卡或信用卡在线上/线下消费时,都会触发一笔交易:资金从你的账户转移到商户账户。
在后台系统运作中,需要从转账金额中扣除手续费,然后分配给所有参与方(收单机构、卡处理公司、银行等)。所有参与方都必须维护详细的交易记录,也就是俗称的*账本*——这个术语源自过去会计师用来记账的账簿。
当前大多数金融交易系统依赖 Oracle、SQL Server 和 DB2 等数据库引擎存储交易数据。典型系统包含两个核心表:
accounts
表:存储账户余额transactions
表:记录所有账户的借贷变动
虽然这种设计可行,但这些通用数据库的泛化特性导致效率低下,在大规模部署时需要消耗更多资源。
3. TigerBeetle 的解决方案
作为数据库市场的新玩家,TigerBeetle 是一款专注于金融交易的专用数据库引擎。通过舍弃通用数据库的复杂性,它宣称能实现高达 1000 倍的吞吐量提升。
实现这一提升的关键简化点包括:
- 固定数据模式
- 定点数运算
- 批量事务处理
- 无通用查询能力
最令人惊讶的是固定数据模式的设计:TigerBeetle 仅包含两种实体:账户(Accounts) 和 转账(Transfers)。
账户 存储某种资产(现金、股票、比特币等)的余额,这些资产可以在同一 账本 下的不同 账户 间转移。账户 还包含多个用于存储外部标识符的字段,便于与传统记录系统关联。
要增加 账户 余额,需创建包含以下信息的 转账 实例:
- 转账金额
- 扣款来源 账户
- 收款目标 账户
账户 和 转账 的核心特性:
- ✅ 账户 创建后不可删除
- ✅ 账户 初始余额恒为零
- ✅ 转账 不可变,提交后无法修改或删除
- ✅ 转账 要求双方 账户 必须在同一 账本
- ✅ 所有 账户 的借贷总和始终为零
4. 部署 TigerBeetle
TigerBeetle 以静态链接可执行文件形式分发,可从 官网 下载。
使用前需创建数据文件:
$ tigerbeetle format --cluster=0 --replica=0 --replica-count=1 0_0.tigerbeetle
启动单机实例:
$ tigerbeetle start --addresses=3000 0_0.tigerbeetle
5. 在 Java 应用中使用 TigerBeetle
官方客户端 Maven 依赖:
<dependency>
<groupId>com.tigerbeetle</groupId>
<artifactId>tigerbeetle-java</artifactId>
<version>0.15.3</version>
</dependency>
最新版本可在 Maven Central 获取。
⚠️ 重要提示:此依赖包含平台特定的原生代码,确保客户端运行在支持的架构上。
5.1. 连接 TigerBeetle
访问 TigerBeetle 功能的入口是 Client
类。Client
实例线程安全,应用中只需创建一个实例。对于 Spring 应用,最简单的方式是在 @Configuration
类中定义 @Bean
:
@Configuration
public class TigerBeetleConfig {
@Value("${tigerbeetle.clusterID:0}")
private BigInteger clusterID;
@Value("${tb_address:3000}")
private String[] replicaAddress;
@Bean
Client tigerBeetleClient() {
return new Client(UInt128.asBytes(clusterID), replicaAddress);
}
}
5.2. 创建账户
TigerBeetle API 不提供领域对象,我们创建简单的 Account
记录类存储所需数据:
@Builder
public record Account(
UUID id,
BigInteger accountHolderId,
int code,
int ledger,
int userData32,
long userData64,
BigInteger creditsPosted,
BigInteger creditsPending,
BigInteger debtsPosted,
BigInteger debtsPending,
int flags,
long timestamp) {
}
这里使用 UUID 作为账户标识符(方便起见)。TigerBeetle 内部使用 128 位整数作为标识符,Java API 映射为 16 字节数组。领域类中的 accountHolderId
映射到 userData128
字段。
API 多处使用 128 位整数,但 Java 无原生类型支持,因此提供 UInt128
工具类进行数组与其他格式转换。除 UUID
外,还可使用 BigInteger
或一对 long
整数。
userData128
、userData32
、userData64
字段主要用于存储关联该账户的次要标识符(如外部数据库中的 ID)。
现在创建 AccountRepository
并实现 createAccount()
方法:
@RequiredArgsConstructor
public class AccountRepository {
private final Client client;
public Account createAccount(BigInteger accountHolderId, int code, int ledger,
int userData32, long userData64, int flags ) {
AccountBatch batch = new AccountBatch(1);
byte[] id = UInt128.id();
batch.add();
batch.setId(id);
batch.setUserData128(UInt128.asBytes(accountHolderId));
batch.setLedger(ledger);
batch.setCode(code);
batch.setFlags(AccountFlags.HISTORY | flags);
CreateAccountResultBatch result = client.createAccounts(batch);
if(result.getLength() > 0) {
result.next();
throw new AccountException(result.getResult());
}
return findAccountById(UInt128.asUUID(id)).orElseThrow();
}
// ... 其他仓库方法省略
}
实现首先创建 AccountBatch
对象保存批量数据。本示例批量仅包含单个账户创建命令,但可轻松扩展为多请求模式。
注意 AccountFlags.HISTORY
标志:设置后可查询历史余额(后文详述)。重要细节是使用 UInt128.id()
生成账户标识符——此方法返回的值唯一且基于时间,可通过比较确定创建顺序。
填充批量请求后,通过 createAccounts()
发送至 TigerBeetle。成功时返回空的 CreateAccountResultBatch
,否则包含失败请求及原因。
为方便调用方,方法返回从数据库恢复数据填充的 Account
领域对象(包含 TigerBeetle 设置的实际创建时间戳)。
5.3. 查询账户
实现 findAccountById()
遵循类似模式:先创建批量对象存储待查标识符(此处简化为单账户):
public Optional<Account> findAccountById(UUID id) throws ConcurrencyExceededException {
IdBatch idBatch = new IdBatch(UInt128.asBytes(id));
var batch = client.lookupAccounts(idBatch);
if (!batch.next()) {
return Optional.empty();
}
return Optional.of(mapFromCurrentAccountBatch(batch));
}
使用 next()
判断标识符是否存在(得益于单账户限制)。
**支持多标识符的变体实现可参考在线代码**。该方法用返回值填充结果 Map
,未找到的标识符对应 null 条目。
5.4. 创建简单转账
先看最简单场景:同一 账本 下两个 账户 间的 转账。除来源/目标账户和账本外,还允许添加元数据:code
、userData128
、userData64
、userData32
(虽可选,但便于关联外部系统):
public UUID createSimpleTransfer(UUID sourceAccount, UUID targetAccount, BigInteger amount,
int ledger, int code, UUID userData128, long userData64, int userData32) {
var id = UInt128.id();
var batch = new TransferBatch(1);
batch.add();
batch.setId(id);
batch.setAmount(amount);
batch.setCode(code);
batch.setCreditAccountId(UInt128.asBytes(targetAccount));
batch.setDebitAccountId(UInt128.asBytes(sourceAccount));
batch.setUserData32(userData32);
batch.setUserData64(userData64);
batch.setUserData128(UInt128.asBytes(userData128));
batch.setLedger(ledger);
var batchResults = client.createTransfers(batch);
if (batchResults.getLength() > 0) {
batchResults.next();
throw new TransferException(batchResults.getResult());
}
return UInt128.asUUID(id);
}
操作成功时,金额将添加到来源账户的 debitsPosted
字段和目标账户的 creditsPosted
字段。
5.5. 余额查询
当 账户 创建时设置了 HISTORY
标志,可查询其因转账产生的余额变化。API 需要包含账户标识符和时间范围的 AccountFilter
,还支持限制返回条目数量和顺序的参数。
getAccountBalances()
的使用示例:
List<Balance> listAccountBalances(UUID accountId, Instant start, Instant end, int limit, boolean lastFirst) {
var filter = new AccountFilter();
filter.setAccountId(UInt128.asBytes(accountId));
filter.setCredits(true);
filter.setDebits(true);
filter.setLimit(limit);
filter.setReversed(lastFirst);
filter.setTimestampMin(start.toEpochMilli());
filter.setTimestampMax(end.toEpochMilli());
var batch = client.getAccountBalances(filter);
var result = new ArrayList<Balance>();
while(batch.next()) {
result.add(
Balance.builder()
.accountId(accountId)
.debitsPending(batch.getDebitsPending())
.debitsPosted(batch.getDebitsPosted())
.creditsPending(batch.getCreditsPending())
.creditsPosted(batch.getCreditsPosted())
.timestamp(Instant.ofEpochMilli(batch.getTimestamp()))
.build()
);
}
return result;
}
注意:API 结果不包含关联交易信息,实际使用受限。但官方文档提到此 API 可能在未来版本中改进。
5.6. 转账查询
当前 getAccountTransfers()
是最有用的查询 API(虽然总共只有两个查询接口 ^_^)。其工作方式与 getAccountBalances()
类似,同样使用 AccountFilter
指定查询条件:
public List<Transfer> listAccountTransfers(UUID accountId, Instant start, Instant end, int limit, boolean lastFirst) {
var filter = new AccountFilter();
filter.setAccountId(UInt128.asBytes(accountId));
filter.setCredits(true);
filter.setDebits(true);
filter.setReversed(lastFirst);
filter.setTimestampMin(start.toEpochMilli());
filter.setTimestampMax(end.toEpochMilli());
filter.setLimit(limit);
var batch = client.getAccountTransfers(filter);
var result = new ArrayList<Transfer>();
while(batch.next()) {
result.add(Transfer.builder()
.id(UInt128.asUUID(batch.getId()))
.code(batch.getCode())
.amount(batch.getAmount())
.flags(batch.getFlags())
.ledger(batch.getLedger())
.creditAccountId(UInt128.asUUID(batch.getCreditAccountId()))
.debitAccountId(UInt128.asUUID(batch.getDebitAccountId()))
.userData128(UInt128.asUUID(batch.getUserData128()))
.userData64(batch.getUserData64())
.userData32(batch.getUserData32())
.timestamp(Instant.ofEpochMilli(batch.getTimestamp()))
.pendingId(UInt128.asUUID(batch.getPendingId()))
.build());
}
return result;
}
5.7. 两阶段转账
**TigerBeetle 明确区分 pending(待处理)和 posted(已入账)转账**。这种区分体现在账户拥有四个余额字段:两个用于已入账值,两个用于待处理值。
前文转账示例未指定类型,默认为 posted 转账(金额直接计入 debits_posted
或 credits_posted
)。
创建 pending 转账需设置 PENDING
标志:
public UUID createPendingTransfer(UUID sourceAccount, UUID targetAccount, BigInteger amount,
int ledger, int code, UUID userData128, long userData64, int userData32) throws ConcurrencyExceededException {
var id = UInt128.id();
var batch = new TransferBatch(1);
// ... 填充批量数据(与常规转账相同)
batch.setFlags(TransferFlags.PENDING);
// ... 发送转账并处理结果(与常规转账相同)
}
pending 转账必须通过后续转账请求确认(POST_PENDING
)或取消(VOID_PENDING
)。两种情况均需在 pendingId
字段包含原始转账标识符:
public UUID completePendingTransfer(UUID pendingId, boolean success) throws ConcurrencyExceededException {
var id = UInt128.id();
var batch = new TransferBatch(1);
batch.add();
batch.setId(id)
batch.setPendingId(UInt128.asBytes(pendingId));
batch.setFlags(success? TransferFlags.POST_PENDING_TRANSFER : TransferFlags.VOID_PENDING_TRANSFER);
var batchResults = client.createTransfers(batch);
if (batchResults.getLength() > 0) {
batchResults.next();
throw new TransferException(batchResults.getResult());
}
return UInt128.asUUID(id);
}
典型应用场景:处理 ATM 请求的授权服务器。流程如下:
- 客户端提供账户和取款金额
- 授权服务器创建 PENDING 交易并返回生成的转账标识符
- ATM 执行出钞操作
- ✅ 成功:ATM 发送确认消息
- ❌ 失败(如缺钞/卡钞):ATM 取消转账
5.8. 两阶段转账超时
为应对初始授权请求与确认/取消间的通信故障,可在第一步设置可选超时:
public UUID createExpirablePendingTransfer(UUID sourceAccount, UUID targetAccount, BigInteger amount,
int ledger, int code, UUID userData128, long userData64, int userData32, int timeout) throws ConcurrencyExceededException {
var id = UInt128.id();
var batch = new TransferBatch(1);
// ... 准备批量(与常规 pending 转账相同)
batch.setTimeout(timeout);
// ... 发送批量并处理结果(与常规 pending 转账相同)
}
若超时前未收到确认或取消请求,TigerBeetle 将自动回滚待处理交易。
5.9. 链式操作
常需确保发送到 TigerBeetle 的一组操作要么全部成功,要么全部失败。这类似于常规数据库事务,可执行多次插入后统一提交。
为此 TigerBeetle 引入 链式事件(linked events) 概念。核心原则:要将一组 账户 或 转账 记录作为单事务创建,除最后一项外所有项必须设置 linked 标志:
public List<Map.Entry<UUID,CreateTransferResult>> createLinkedTransfers(List<Transfer> transfers)
throws ConcurrencyExceededException {
var results = new ArrayList<Map.Entry<UUID,CreateTransferResult>>(transfers.size());
var batch = new TransferBatch(transfers.size());
for ( Transfer t : transfers) {
byte[] id = UInt128.id();
batch.add();
batch.setId(id);
// 是否最后一个转账?
if ( batch.getPosition() != transfers.size() -1 ) {
batch.setFlags(TransferFlags.LINKED);
}
batch.setLedger(t.ledger());
batch.setAmount(t.amount());
batch.setDebitAccountId(UInt128.asBytes(t.debitAccountId()));
batch.setCreditAccountId(UInt128.asBytes(t.creditAccountId()));
if ( t.userData128() != null) {
batch.setUserData128(UInt128.asBytes(t.userData128()));
}
batch.setCode(t.code());
results.add(new AbstractMap.SimpleImmutableEntry<>(UInt128.asUUID(id), CreateTransferResult.Ok));
}
var batchResult = client.createTransfers(batch);
while(batchResult.next()) {
var original = results.get(batchResult.getIndex());
results.set(batchResult.getIndex(), new AbstractMap.SimpleImmutableEntry<>(original.getKey(), batchResult.getResult()));
}
return results;
}
TigerBeetle 确保链式操作按顺序执行,且要么全部提交要么全部回滚。关键点:链中前序操作的副作用对后续操作可见。
例如,考虑设置了 DEBITS_MUST_NOT_EXCEED_CREDITS
标志的账户。若创建两个链式转账命令导致第二个操作透支,则两个转账均会被拒绝:
@Test
void whenSimpleTransfer_thenSuccess() throws Exception {
var MY_LEDGER = 1000;
var CHECKING_ACCOUNT = 1000;
var P2P_TRANSFER = 500;
var liabilitiesAcc = repo.createAccount(
BigInteger.valueOf(1000L),
CHECKING_ACCOUNT,
MY_LEDGER, 0,0, 0);
var sourceAcc = repo.createAccount(
BigInteger.valueOf(1001L),
CHECKING_ACCOUNT,
MY_LEDGER, 0,0, AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS);
var targetAcc = repo.createAccount(
BigInteger.valueOf(1002L),
CHECKING_ACCOUNT,
MY_LEDGER, 0, 0, 0);
List<Transfer> transfers = List.of(
Transfer.builder()
.debitAccountId(liabilitiesAcc.id())
.ledger(MY_LEDGER)
.code(P2P_TRANSFER)
.creditAccountId(sourceAcc.id())
.amount(BigInteger.valueOf(1_000L))
.build(),
Transfer.builder()
.debitAccountId(sourceAcc.id())
.ledger(MY_LEDGER)
.code(P2P_TRANSFER)
.creditAccountId(targetAcc.id())
.amount(BigInteger.valueOf(2_000L))
.build()
);
var results = repo.createLinkedTransfers(transfers);
assertEquals(2, results.size());
assertEquals(CreateTransferResult.LinkedEventFailed, results.get(0).getValue());
assertEquals(CreateTransferResult.ExceedsCredits, results.get(1).getValue());
}
*此例中第一个 转账(在非链式场景本应成功)因第二个 转账 导致透支而失败。*
6. 总结
本文探讨了 TigerBeetle 数据库的核心特性。尽管查询能力有限,但其卓越的性能和运行时保证使其成为复式记账模型适用场景的理想选择。
所有代码示例均可在 GitHub 获取。