TigerBeetle 数据文件(Data File)内部布局:WAL、Superblock 与 Grid 如何协作
【免费下载链接】tigerbeetleThe financial transactions database designed for mission critical safety and performance.项目地址: https://gitcode.com/GitHub_Trending/ti/tigerbeetle
TigerBeetle 将单个副本的全部持久化状态存放在一个名为 data file 的文件中(惯例扩展名为.tigerbeetle)。本文基于 data_file.md 展开,从物理分区(write-ahead log、superblock、grid)讲到逻辑结构(LSM 森林、manifest 日志),并结合仓库源码给出字节级细节的索引,帮助你理解"一个文件如何承载数 TB 的确定性数据库状态"。读完本文,你将掌握 TigerBeetle 数据文件的整体布局、superblock 原子更新机制、grid 块寻址方式,以及 LSM 树如何以"事件日志"的形式隐式存储在文件中。
数据文件的三大部分:WAL、Superblock 与 Grid
TigerBeetle 的每个副本将全部数据存放在一个单一文件中,即 data file。该文件被划分为若干 zone(区域),最主要的三个是:
- write-ahead log(WAL):预写日志,存放 prepare,代表"应施加到 superblock/grid 所表示状态之上的逻辑增量";
- superblock:位于数据文件的固定位置,保存逻辑"根指针",是启动时定位全部数据的入口;
- grid:占据数据文件的绝大部分体积(可达数 TB),是一个由 512KiB 块构成的弹性数组。
在 src/config.zig 中可以看到两个关键编译期常量:默认block_size = 512 * KiB、superblock_copies = 4;而扇区大小sector_size = 4096定义在 src/constants.zig。这些常量共同约束了数据文件的物理布局。
Grid:弹性块数组与块寻址
Grid 是一个弹性(elastic)的 512KiB 块数组,作为原始存储层为上层数据结构(尤其是 LSM 树)提供映射。其块与块指针的 Zig 定义(与文档一致)为:
pub const Block = [constants.block_size]u8; pub const BlockPtr = *align(constants.sector_size) Block;在源码 src/vsr/grid.zig 中,实际定义更精确:BlockPtr = *align(constants.sector_size) [constants.block_size]u8,即每个块按扇区对齐。由于 TigerBeetle 是确定性的(deterministic),所有同步到最新状态的副本,其 grid 的已使用部分完全相同。这一存储确定性被用来在 grid 块粒度上实现状态同步(sync)与修复(repair),详见 VSR 文档中的 Protocol: Repair Grid。
每个 grid 块由一个u64索引(index)加一个u128校验和(checksum)组成的二元组标识:
pub const BlockReference = struct { index: u64, checksum: u128, };校验和存放在块外部,而不是块内部——这是为了防止"错位写入/读取"(misdirected write/read)损坏数据。因此,要读取某个块,你必须先从"别处"(另一块,或 superblock)得知该块的索引与校验和。grid 整体实现了纯粹的(purely functional)、持久化的、带垃圾回收的数据结构,通过交换指向根节点的指针来原子更新——这正是文件系统中常见的 copy-on-write 技术。可以这样理解:TigerBeetle 的 data file 本质上就是一个文件系统,grid 是存储层,superblock 保存着逻辑根指针。
在 src/vsr/grid.zig 中可以看到地址与偏移的换算:(address - 1) * block_size,即 grid 块地址从 1 开始,物理偏移为(地址 - 1) × 块大小。grid 同时维护了一个内存块缓存(cache_map相关的Grid.Read/Grid.Write结构),通过读写 IOP 并发访问底层存储。
Superblock:逻辑根指针与原子更新
Superblock 保存逻辑"根指针"。物理上,这个根指针由若干块引用(BlockReference)组成,这些块合在一起指定了所有 LSM 树的 manifest(清单)。文档给出的抽象结构为:
pub const SuperBlock = struct { manifest_oldest: BlockReference, manifest_newest: BlockReference, free_set: BlockReference, };Superblock 位于数据文件的固定位置,因此副本启动时可以:读取 superblock → 读取根块的索引与校验和 → 进而访问 grid 中的其余数据。除 manifest 外,superblock 还引用一个压缩位图(free set),该位图本身也存放在 grid 中,标记所有当前未分配的 grid 块。
Checkpoint:superblock 在源码中的真实形态
源码 src/vsr/superblock.zig 中的Checkpoint结构体比文档中的抽象描述更细:它不仅包含manifest_oldest_address/checksum与manifest_newest_address/checksum,还包括free_set_blocks_acquired与free_set_blocks_released两组字段(各自的last_block_address、last_block_checksum、size、聚合checksum)。acquired/released 的区分,是因为 free set 通过"获取/释放"两个增量的方式记录分配变化。superblock.zig 还提供了manifest_reference()与free_set_reference()等辅助函数,把 checkpoint 字段组装成文档中的 BlockReference 形态(见 src/vsr/superblock.zig)。
低频批量刷新:为什么 superblock 不能代表全部持久化状态
Superblock 的持久化更新必须原子完成,且要写入的数据量不小(数 MB)。为了摊薄这一成本,superblock 相对不频繁地刷盘。正常操作模式是:
- 副本启动,将当前 superblock 与 free set 读入内存;
- 随后持续分配并写入新的 grid 块,从位图中挑选空闲项;
- 尽管新分配的 grid 块会被立即写盘,但磁盘上的 superblock不会被同步更新(superblock 可达的逻辑状态保持不变);
- 直到写入的新 grid 块积累到相当大的量,副本才原子地写出新的 superblock,附带新的 free set 与新的逻辑根指针(superblock manifest)。
如果副本崩溃并重启,它会从上一个 superblock 开始;但得益于确定性,崩溃后重放操作会得到与之前完全相同的磁盘与内存状态。
4 份拷贝与仲裁读取:对抗错位读
为实现 superblock 的原子更新,superblock 在物理上以4 份不同的拷贝存于磁盘(superblock_copies = 4,见 src/config.zig;superblock_zone_size = superblock_copy_size * constants.superblock_copies,见 src/vsr/superblock.zig)。启动后,副本挑选至少写入了 2 份拷贝的、最新的 superblock(即 quorum 为 2)。
为什么不直接挑选"最新的一份拷贝"?因为与 grid 块不同,superblock 是自带校验和的,它易受错位读(misdirected read)影响——一次错位读可能恰好"藏起"唯一的、最新的那份拷贝。多拷贝 + 仲裁读正是为了消除这一风险。源码还通过编译期约束保证superblock_copies只能是{ 4, 6, 8 }之一以支持弹性仲裁(见 src/vsr/superblock.zig)。
Write-Ahead Log:网格之外的逻辑增量
由于 superblock(以及它所代表的逻辑 grid 状态)是低频、突发式更新的,它无法单独代表全部持久化状态。其余状态存放在WAL中。WAL 是一个装着 prepare 的环形缓冲区,代表"应该施加到 superblock/grid 所表示状态上的逻辑 diff",将其叠加才能得到系统的实际当前状态。
WAL 的内部细节参见 VSR 文档 Protocol: Normal。高层来看,副本处理一条 prepare 时:
- 将 prepare 写入磁盘上的 WAL;
- 将 prepare 带来的变更应用到代表当前状态的内存数据结构;
- 通过分配并写入新的 grid 块,将变更应用到待定的(pending)磁盘状态。
当积累的 prepare 足够多时,superblock 被更新以指向累积到目前的新磁盘状态。至此,WAL、superblock、grid 三个 zone 协作,共同表示抽象的持久化逻辑状态。
LSM 树:值、表(Table)与分层
TigerBeetle 的持久化状态具体是一个 LSM 树的集合(forest)。LSM 的整体结构在单独的文档中阐述,这里只讨论磁盘上的高层布局。
每个 LSM 树存储一组值(values),这些值具备以下特征:
- 大小统一(uniform in size);
- 很小(数百字节);
- 按键排序;
- 键内嵌在值本身中(例如
Account值用timestamp作为唯一键)。
表(Table)的物理形态
从中间层开始理解:值在磁盘上按"表"(table)组织。每张表是值的排序数组,物理上存储在多个块中:
- value block:每块存一个值的排序数组;
- index block:存指向 value block 的指针,以及边界键(boundary keys)。
文档给出的三个关键结构体:
const TableValueBlock = struct { values_sorted: [value_count_max]Value, }; const TableIndexBlock = struct { value_block_checksums: [value_block_count_max]u128, value_block_indexes: [value_block_count_max]u64, value_block_key_max: [value_block_count_max]Key, }; const TableInfo = struct { tree_id: u16, index_block_index: u64, index_block_checksum: u128, key_min: Key, key_max: Key, };要在一张表内查找某个值:先在 index block 上做二分查找,定位可能持有该值的 value block;再在 value block 内部做二分查找。
表的物理大小受限于单个 index block 能容纳的 value block 引用数量。此外,表还被进一步人为限制为最多持有某个编译期常量数量的条目(在 src/lsm/schema.zig 中体现为ManifestNode.entry_count_max等容量常量)。表被组织成分层(levels),每一层包含的表数量指数级增加。
分层与 Compaction
- 同一层内的表两两不相交(pairwise disjoint);
- 不同层的表可能重叠,但遵循 LSM 的关键不变量:浅层中的值覆盖深层中的值。这意味着所有修改都发生在第一层(纯内存层)。
一个异步 compaction 过程负责重新平衡各层。Compaction 从 A 层取出一张表,找出 A+1 层中与该表相交的所有表,把那些表从 A+1 层移除,并将相交结果插入。其效果可示意为一系列事件:
const CompactionEvent = struct { label: Label table: TableInfo, // points to table's index block }; const Label = struct { level: u6, event: enum(u2) { insert, update, remove }, };Manifest:树状态如何以事件日志存储
更关键的认识是:一棵树的当前状态可以隐式地表示为一系列插入/移除事件,且该序列从"空表集合"开始——这正是它在数据文件中的物理表示方式!
具体来说,每个 LSM 树是一组 layer 的集合,而这些 layer 以事件日志的形式隐式存储。日志由一串ManifestBlock组成:
const ManifestBlock = struct { previous_manifest_block: BlockReference, labels: [entry_count_max]Label, tables: [entry_count_max]TableInfo, };Manifest 是网格内的链表(on-disk, in-grid linked list),每个 manifest 块持有对前一个块的引用。这一点在源码 src/lsm/manifest_log.zig 中得到印证:写入新 manifest 块时,会以当前最新块的地址与校验和填充previous_manifest_block_address与previous_manifest_block_checksum。LSM 文档的 Manifest Log 一节也说明:每个 manifest 块引用其(按时间顺序的)前一个块,且头块(head manifest block)上的引用会"悬空"——它所引用的块已经被压实掉了。
Superblock 随后为所有树存储 manifest 日志的最旧与最新块:
const Superblock = { manifest_block_oldest_address: u64, manifest_block_oldest_checksum: u128, manifest_block_newest_address: u64, manifest_block_newest_checksum: u128, free_set_last_address: u64, free_set_last_checksum: u128, };这与 src/vsr/superblock.zig 中 checkpoint 字段一一对应(manifest_oldest_address/checksum、manifest_newest_address/checksum,以及 free set 相关字段)。
全部串起来:从 Superblock 到具体 Value 的寻址链
将以上各部分串联,TigerBeetle 数据文件的完整逻辑如下:
状态被表示为若干 LSM 树的集合,而Superblock 是一切状态的根。对每棵 LSM 树,superblock 都保存着构成该树 manifest 日志的各块指针——manifest 日志是一串对单张表"添加/删除"事件的记录。通过重放这段 manifest 日志,可以在内存中重建 manifest:
- Manifest描述单棵 LSM 树的层与表;
- 表(Table)是指向其 index block 的指针;
- index block是指向 value block 的指针的排序数组;
- value block是值的排序数组。
于是,一次完整的数据访问路径是:superblock → manifest 日志(事件链)→ Manifest → TableInfo → index block → value block → 排序后的 Value。而写入路径则是:新值写为新的 grid 块 → 更新/追加 manifest 日志事件 → 待 prepare 积累足够后原子更新 superblock 并换新 free set。
进一步阅读
- VSR 文档:WAL 正常工作流见 Protocol: Normal,grid 块粒度的修复与同步见 Protocol: Repair Grid;
- LSM 文档:树的表、compaction、快照与 manifest 的完整讨论;
- 源码索引:src/vsr/grid.zig(grid 实现)、src/vsr/superblock.zig(superblock 与 checkpoint)、src/lsm/manifest_log.zig(manifest 链表)、src/config.zig(block_size 与 superblock_copies)、src/constants.zig(sector_size)。
提示:本文呈现的是数据文件的高层布局,为便于直觉而做了适度简化。字节级细节请以源码为准——正如原文档所言,数据文件的精确定义最终都在源码的注释与断言之中。
【免费下载链接】tigerbeetleThe financial transactions database designed for mission critical safety and performance.项目地址: https://gitcode.com/GitHub_Trending/ti/tigerbeetle
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考