TigerBeetle 金融级账本数据库深度实战:从双分录数据模型、严格可串行化到 LSM Forest 存储的工程全解
执行摘要:绝大多数"余额"系统的崩塌,不是因为算错了加减法,而是因为把账本当成了一张普通表。UPDATE accounts SET balance = balance - 100 能跑通,但它在高并发下制造行锁热点、丢失审计轨迹、无法证明任意时点余额的正确性。TigerBeetle 给出的答案很极端:砍掉通用查询能力,只保留两张不可变表(Account / Transfer),把转账做成 128 字节定长记录,强制双分录、强制整数最小单位、强制严格可串行化。本文拆解它的数据模型、两阶段转账与链式转账语义、LSM Forest 存储引擎的设计动机,以及生产接入时的七个工程陷阱。
一、问题的起点:通用数据库做账本,崩在哪一层
一个典型的余额实现长这样:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 'A' AND balance >= 100;
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
INSERT INTO transactions (from_id, to_id, amount) VALUES ('A','B',100);
COMMIT;
看起来事务保证了原子性,但生产环境会逐条暴露问题:
- 行锁热点:所有用户给同一个热账户(平台清算户、商户收款户)转账,都会抢这一行的排他锁。TPS 上限被单行锁死,加机器无效。
- 状态可覆盖:
balance是一个被覆盖的数字。一旦有人写了个UPDATE accounts SET balance = 999的数据修复脚本,历史真相永久丢失,且没人知道什么时候丢的。 - 无法证明正确性:月末对账只能"重新算一遍",而不能"证明"当前余额等于所有历史转账的代数和。
- 精度漂移:只要有一个字段是
NUMERIC(20,8)或更糟的FLOAT,跨系统对账必然出现分位差。 - 缺失语义:预授权冻结、部分请款、链式清算(A→B→C 必须原子)这些金融基本操作,在通用表结构里全靠业务代码糊。
根本矛盾在于:账本的本质是一串不可变事件 + 一个派生余额,而不是一个可变的余额列。把派生量存成基础量,是绝大多数资金事故的源头。
TigerBeetle 的核心洞察是:不要物化余额,让余额成为转账流的函数。
二、数据模型:只有两张表,且都不能 UPDATE / DELETE
TigerBeetle 的整个世界只有两种对象。
Account(账户)——注意它存的不是"余额",而是四个累计量:
| 字段 | 含义 |
|---|---|
id | 客户端生成的 u128 唯一 ID |
debits_pending / debits_posted | 待定/已过账的借方累计 |
credits_pending / credits_posted | 待定/已过账的贷方累计 |
ledger / code | 账套(分片维度)/ 业务科目 |
flags | 约束开关,如 debits_must_not_exceed_credits |
timestamp | 客户端时间戳,参与 ID 单调性 |
余额是派生的:balance = debits_posted - credits_posted。这个公式决定了——没有任何一条 API 能直接写余额,你只能通过提交转账来改变它。
Transfer(转账)——固定 128 字节:
| 字段 | 含义 |
|---|---|
id | u128,客户端生成,全局唯一 |
debit_account_id / credit_account_id | 借方/贷方账户 |
amount | u128 整数,必须是最小货币单位(分) |
pending_id | 非 0 表示这是对某笔 pending 转账的 post/void |
flags | linked / pending / post_pending / void_pending / closing_debit 等 |
ledger / code / timestamp | 同账户 |
128 字节不是巧合。它是两条 cache line,意味着:批量提交时内存布局完全规整,可以直接 memcpy 跨语言边界(Go/Java/Node/Python 客户端共享同一份 ABI 定义),无需序列化/反序列化;写入磁盘时无需长度前缀解析;memtable 可以用最简单的定长数组。这种"为可预测性牺牲灵活性"的取舍贯穿整个项目。
注意 API 面:只有 create_accounts、create_transfers、lookup_accounts、lookup_transfers、get_account_transfers、get_account_balances。**没有 update,没有 delete,没有 SELECT * FROM ... WHERE amount > 100**。想做复杂查询?自己把数据同步到分析库。
三、语义层:一个 flag 就是一条金融规则
TigerBeetle 把业务约束下沉到数据库内核,这是它最有价值的部分。
1. 不允许透支(账户级 flag)
Account.flags.debits_must_not_exceed_credits = true
从此 balance 永远不会为负,且不依赖应用层的 WHERE balance >= 100 检查。竞争条件下,应用层检查永远有 TOCTOU 窗口;内核级检查没有。
2. 两阶段转账(授权—请款)
这是支付、押金、预授权场景的标准模型:
# 阶段一:冻结(pending)——钱从 A 的可用过账余额中划出,但未进入 B
pending = Transfer(
id=next_id(),
debit_account_id=A, credit_account_id=B,
amount=10000, # 100.00 元,单位:分
flags=TransferFlags.PENDING,
)
client.create_transfers([pending])
# 阶段二选一:请款(post)或撤销(void)
if capture:
post = Transfer(
id=next_id(),
debit_account_id=A, credit_account_id=B,
amount=10000, # 可小于原额,实现"部分请款"
pending_id=pending.id,
flags=TransferFlags.POST_PENDING_TRANSFER,
)
else:
void = Transfer(
id=next_id(),
pending_id=pending.id,
flags=TransferFlags.VOID_PENDING_TRANSFER,
)
client.create_transfers([post_or_void])
关键点:pending 阶段资金在借方账户已被"锁定"(体现在 debits_pending),因此不会产生超卖;部分请款通过 amount < pending.amount 实现,剩余额度自动解冻;一笔 pending 只能被 post 或 void 一次,重复提交会返回 pending_transfer_already_posted,天然幂等。
3. 链式转账(linked events)
清算场景常见"A→B→C 要么全成,要么全不成"。传统做法是分布式事务 + 补偿,用 TigerBeetle 只需给链上每一笔打 LINKED flag:
transfers := []tb.Transfer{
{ID: id1, DebitAccountID: A, CreditAccountID: B, Amount: 10000,
Flags: tb.TransferFlags{Linked: true}.ToUint16()},
{ID: id2, DebitAccountID: B, CreditAccountID: C, Amount: 10000,
Flags: tb.TransferFlags{Linked: true}.ToUint16()}, // 链的最后一笔
}
res, err := client.CreateTransfers(transfers)
// 链的最后一笔不带 Linked 即视为链尾,任意一笔失败则整链回滚
linked 的语义是:这条链在内核里按单个原子单元执行,要么全部落盘,要么一个都不落。它替代了业务侧的 Saga 补偿逻辑——这对金融系统来说是把最大的复杂度从应用层搬进了经过严格验证的内核。
四、一致性:为什么必须是严格可串行化
TigerBeetle 只提供最强的一档隔离级别:strict serializability(严格可串行化 = 可串行化 + 线性一致)。没有 read committed,没有 snapshot isolation。
理由很务实:账本场景下,快照隔离下的写偏斜(write skew)是会真实造成资金损失的。两个并发事务各自读取"余额充足",各自扣款,总扣款超过余额——这就是典型的写偏斜。SI 不阻止它,可串行化阻止。
更严苛的是"严格"二字:读也必须看到最新的已提交状态,不能读陈旧副本。否则用户刚完成转账,刷新页面看到旧余额,会重复发起转账。
在 TigerBeetle 中,所有写请求都经过共识层排序(基于 Viewstamped Replication 的变体,面向崩溃容错而非拜占庭容错),读请求默认也走主节点。代价是延迟,收益是:应用开发者永远不需要思考"这个读会不会看到半截状态"。这是把分布式系统的最难问题一次性买断。
五、存储引擎:为什么是 LSM Forest 而不是一棵 LSM Tree
这是 TigerBeetle 在工程上最被低估的设计。
经典 LSM-Tree(RocksDB/LevelDB 模式)的致命问题是compaction 写放大:随着数据量增长,最后一层的数据需要被反复重写,盘上写入量可能是用户写入量的 20~50 倍。对账本这种"只追加、永不删改、数据只增不减"的场景,一棵永远长大的 LSM 树意味着 compaction 成本随历史单调上升。
TigerBeetle 的解法是 LSM Forest:
- 按时间区间把转账切成多棵独立的 LSM 树(例如按固定窗口切分);
- 新数据只写入"当前"这棵树,历史树一旦封闭就完全不可变,永远不再参与 compaction;
- 查询时按需合并跨树结果;老数据可以通过关闭树、归档到对象存储来降低成本。
效果:写放大被限制在单个时间分片内,不随总数据量增长。一棵封闭的树就是一段冻结的历史,可以被校验和(checksum)覆盖、被离线审计、被整体搬迁。对金融系统来说,"历史区块不可变且可独立验证"几乎是对账业务的终极形态——它把账本天然地切成了可归档的账期。
配合的还有:
- journal / WAL:写先落 journal,保证崩溃恢复;journal 与数据文件分离,避免写放大污染延迟。
- 静态内存分配:启动时按配置一次性分配全部内存(arena),运行期零动态分配。这消除了内存碎片与 OOM 不确定性,也让内存使用量成为可预测的常量——一个"内存占用不随负载漂移"的数据库,运维复杂度低一个数量级。
- TigerStyle 编码纪律:断言密度极高(涵盖所有前置/后置条件与不变量)、循环必须有静态上界、禁用递归、显式错误处理、最大函数长度限制。这不是代码洁癖,而是为了让"崩溃"在测试期而非生产期发生。
六、生产落地的七个坑
1. ID 必须由客户端生成,且要单调。 TigerBeetle 的 id 是 u128,推荐客户端用"时间戳高位 + 随机数低位"构造。原因有二:一是批量请求天然幂等,重复提交同一 id 返回 exists 而不是报错,网络重试可以无脑重放;二是时间有序的 ID 让存储层的写入近似顺序,对 LSM 友好。不要用数据库自增 ID——那会引入额外往返,且破坏幂等。
2. 金额必须是整数最小单位。 没有小数类型。100.00 元写成 10000(分)。跨币种系统要用 ledger 字段区分币种账套,绝不能在同一账套里混币种——因为系统根本不知道汇率。
3. 批量请求要逐条检查返回码。 create_transfers 返回的是一个与请求等长的 result 数组,不是异常。常见错误码:exists(幂等命中,可忽略)、exceeds_credits(透支)、accounts_must_be_different、pending_transfer_not_found。批量大小上限为 8191 条,超过要自己切片。
4. linked 链不要跨网络请求。 链必须完整落在一个批量请求内。若一条链被拆成两次请求,第二次失败时你会得到一个物理上无法回滚的半截状态。链长也有上限,超长清算链应改为"多笔 pending + 一次批量 post"。
5. pending 转账需要自己的超时清理。 内核不会自动过期。预授权场景必须有一个定时任务扫描超期 pending 并 void,否则资金永久冻结在对端视角的"待定"状态里。
6. 别把它当成业务库。 用户昵称、订单详情、风控规则不进 TigerBeetle。正确姿势是:业务库存业务实体,TigerBeetle 只存"钱的运动",两边靠 code / user_data(u128 关联字段)对齐。需要分析时,把 transfers 流同步到数仓。
7. 溢出是真实的。 amount 是 u128,但账户累计字段同样有界。极高频账户(平台清算户)的 debits_posted 会持续增长,设计时要用 ledger 做分片,或周期性"结转"到新账户并保留旧账户作为历史归档——而不是指望它无限累加。
七、结论
TigerBeetle 的真正贡献不是"又一个数据库",而是把金融系统的正确性约束从应用代码下沉到存储内核:
- 余额不可写,只能是转账流的函数——消除了整类数据修复事故;
- 约束是 flag 而非 if——透支检查、两阶段转账、链式原子性都不再有 TOCTOU 窗口;
- 一致性只提供最强档——开发者不必在隔离级别上做权衡,写偏斜从根上不存在;
- LSM Forest 让写放大与历史数据总量解耦——账本天然被切成可归档、可校验的不可变账期。
它当然不是银弹:没有通用查询、没有 JOIN、需要你重新设计数据建模思维、运维生态远不如 Postgres 成熟。但对于"钱不能错"这一条硬需求,它给出的工程答案比任何"用 Postgres + 小心一点"的方案都更接近本质。
判断要不要引入的标准只有一条:如果一次余额错误会导致真实的资金损失或合规风险,那么通用数据库带来的灵活性就不值那个风险。 这时候,一个只会做一件事、但把这件事做到可证明正确的数据库,才是正确的选择。

发表评论 取消回复