TigerBeetle交易資料庫簡介
一、簡介
在本教程中,我們將探索TigerBeetle資料庫引擎,並了解如何使用它來建立容錯和高效能應用程式。
2. 金融交易簡而言之
每次我們使用金融卡或信用卡在網路或商店購買商品時,都會發生一筆交易,其中一些貨幣金額將從您的帳戶轉移到商家的帳戶。
在幕後,必須從轉移的價值中扣除費用,然後在所有相關方(收單機構、卡片處理公司、銀行等)之間分配。所有這些實體還必須在會計師過去保存的帳簿之後保存詳細的交易日誌,也稱為[ledgers ,](https://www.investopedia.com/terms/g/generalledger.asp)
。
如今,大多數金融交易系統都依賴資料庫引擎(例如Oracle、SQL Server 和DB2)來儲存交易。典型的系統將有一個保存餘額的帳戶表和一個記錄帳戶的每筆債務或信貸的交易表。
雖然這種方法效果很好,但這些資料庫的通用目的導致效率低下,因此需要更多的資源來大規模部署和操作。
3. TigerBeetle 的方法
TigerBeetle 是擁擠的資料庫市場的新來者,是一個主要專注於金融交易的專業資料庫引擎。透過這樣做,它可以消除與通用資料庫引擎相關的大部分複雜性,作為交換,它聲稱能夠提供高達1000 倍的吞吐量改進。
這些是使這項改進成為可能的主要簡化:
- 固定模式
- 定點運算
- 大量交易
- 沒有一般查詢功能
也許其中最令人驚訝的是固定模式。 TigerBeetle 只有兩個實體: Accounts
和Transfers
。
Account
儲存某些資產的餘額(當前資產、股票、比特幣等),這些資產可以是我們可以從屬於同一ledger
的另一個Account
取得或轉移的任何資產。 Account
還有一些用於保存外部標識符的字段,使我們能夠將其連結到傳統的記錄系統資料庫。
為了在Account
中添加一些信用,我們建立一個Transfer
實例,其中包含金額、從中扣除該金額的來源Account
以及目標Account.
這些是Accounts
和Transfers
的一些重要功能:
-
Account
一旦建立就無法刪除 -
Account
初始餘額始終為零 -
Transfers
是不可變的。一旦提交就無法修改或刪除 -
Transfers
要求兩個Accounts
位於同一個ledger
上 - 在任何時候,所有
Accounts
借方和貸方總和為零
4. 部署 TigerBeetle
TigerBeetle 作為靜態連結的可執行檔分發,可在官方網站上取得。
在使用 TigerBeetle 之前,我們需要建立一個資料檔案來儲存資料。這是使用format
指令完成的:
$ tigerbeetle format --cluster=0 --replica=0 --replica-count=1 0_0.tigerbeetle
我們現在可以使用start
指令啟動一個獨立實例:
$ tigerbeetle start --addresses=3000 0_0.tigerbeetle
5. 在 Java 應用程式中使用 TigerBeetle
這是 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.建立Accounts
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
實用程式類,幫助從數組轉換為其他格式。除了UUIDs
之外,我們還可以使用BigIntegers
或一對常規long
整數。
userData128,
userData32,
和userData64
欄位的主要用途是儲存與該帳號關聯的輔助識別碼。例如,我們可以使用它們將該帳戶的識別碼儲存在外部資料庫中。
現在,讓我們建立一個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();
}
// ... other repository methods omitted
}
實作首先建立一個AccountBatch
物件來保存批次資料。在此範例中,批次由單一帳戶建立命令組成,但我們可以輕鬆擴展此模型以接受多個請求。
請注意AccountFlags.HISTORY
標誌。設定後,我們將能夠查詢歷史餘額,稍後我們將看到。同樣重要的是使用UInt128.id()
產生帳戶識別碼。從該方法返回的值是唯一的並且基於時間,這意味著如果我們比較它們,我們可以確定哪個是第一個創建的。
一旦我們填入了批量請求,我們就使用createAccounts()
方法將其傳送到 TigerBeetle。此方法傳回一個CreateAccountResultBatch
,如果請求成功,結果將為空。否則,每個失敗的建立請求都會有一個條目,其中包含失敗的原因。
為了方便呼叫者,該方法傳回由從資料庫復原的資料填入的Account
域對象,其中包括 TigerBeetle 設定的實際建立時間戳記。
5.3.查詢帳戶
為了實作findAccountById()
我們遵循與前一個情況類似的模式。首先,我們建立一個批次來保存我們想要尋找的識別碼。為了讓事情變得更簡單,我們將每次呼叫限制為一個帳戶。
接下來,我們將此批次提交給 TigerBeetle 並處理結果:
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
,為未找到的任何識別碼留下空白條目。
5.4.建立簡單的傳輸
讓我們從最簡單的情況開始:屬於同一ledger
的兩個帳戶之間的Transfer
。除了來源帳戶和目標帳戶以及分類帳之外,我們還允許儲存庫的使用者添加一些元資料: code
、 userData128
、 userData64
和userData32.
儘管是可選的,但這些元資料欄位對於將此Transfer
連結到外部系統非常有用。
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);
}
如果操作成功,金額將會加入到來源Account
的debitsPosted
欄位和目標Account
的creditsPosted
中。
5.5.餘額查詢
當建立一個設定了HISTORY
標誌的Account
時,我們可以查詢其餘額因轉帳而發生的變更。 API 需要一個包含Account
識別碼和時間範圍的AccountFilter
。此篩選器還支援參數來限制傳回條目的數量和順序。
這就是我們如何使用getAccountBalances()
方法來實作listAccountBalances()
儲存庫的方法:
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 中最有用的——鑑於只有兩個 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
傳輸進行了明確區分。這種差異透過以下事實顯而易見: Account
有四個餘額欄位:兩個用於已過帳值,兩個用於待定值。
在我們先前的Transfer
範例中,我們沒有告知其類型。在這種情況下,API 預設為已發布的Transfer
,這表示金額將直接加入debits_posted
或credits_posted
欄位。
要建立待處理的Transfer
,我們必須設定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);
// ... fill batch data (same as regular Transfer)
batch.setFlags(TransferFlags.PENDING);
// ... send transfer and handle results (same as regular Transfer)
}
待處理的Transfer
應始終由稍後的Transfer
請求確認 ( POST_PENDING
) 或取消 ( VOID_PENDING
)。在這兩種情況下,我們都必須在pendingId
欄位中包含原始的Transfer
標識符:
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 事務並傳回產生的Transfer
標識符。
接下來,ATM 機開始提款。有兩種可能的結果:如果一切順利,ATM 會向授權伺服器發送另一個訊息並確認Transfer
。
然而,如果出現問題(例如沒有可用的鈔票或提款機卡住),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);
// ... prepare batch (same as regular pending Transfer)
batch.setTimeout(timeout);
// ... send batch and handle results (same as regular pending Transfer)
}
如果在收到確認或取消要求之前逾時到期,TigerBeetle 將自動處理待處理的交易。
5.9.聯動操作
通常,確保發送到 TigerBeetle 的一組操作必須整體完成或失敗非常重要。我們可以將其視為常規資料庫事務的模擬,我們可以在其中發出多個插入並在最後一次提交它們。
為了支持這種情況,TigerBeetle 具有linked events
的概念。簡而言之,要建立一組Account
或Transfer
記錄作為單一交易,除最後一項之外的所有項目都必須設定linked flag
:
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);
// Is this the last transfer to add ?
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());
}
在這種情況下,我們看到第一個Transfer
在非連結場景中會成功,但會失敗,因為第二個 Transfer 會導致透支。
六,結論
在本文中,我們探討了 TigerBeetle 資料庫及其功能。儘管查詢能力有限,但它具有出色的效能和運行時保證,使其成為適用複式記帳模型的每個應用程式的良好候選者。
與往常一樣,所有程式碼都可以在 GitHub 上取得。