资讯详情

turbo-persistence 源码级解析:Next.js 仓库中面向 Turbopack 的分层键值存储设计

📅 2026/9/9 12:53:10 | 华诺云谱 👁 阅读
turbo-persistence 源码级解析:Next.js 仓库中面向 Turbopack 的分层键值存储设计
turbo-persistence 源码级解析Next.js 仓库中面向 Turbopack 的分层键值存储设计【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.jsturbo-persistence是位于本仓库 turbopack/crates/turbo-persistence 下的一个 Rust crate其官方定位见 turbopack/crates/turbo-persistence/README.md是把键值对持久化到一个文件夹中并在之后恢复它们。它借鉴了经典 LSMLog-Structured Merge思想但针对“单次事务写入海量数据、同时支持快速随机读”这一场景做了高度特化的设计。读完本文你将完整掌握它的磁盘文件格式CURRENT/.sst/.blob/.del/.meta、五种值存储形态的分级体系、读写与压缩Compaction协议以及如何在 Rust 中通过TurboPersistence、WriteBatch、FamilyConfig等公开 API 正确使用它。一、设计定位为什么需要这样一个存储 crate从 turbopack/crates/turbo-persistence/src/db.rs 中的结构体注释可以看到TurboPersistence is a persistent key-value store. It is limited to a single writer at a time using a single write batch. It allows for concurrent reads.——即它本质上是一个单写多读的持久化键值存储单写事务任意时刻只允许一个写入事务WriteBatch在途但多个线程可以并发地向该事务填充互不冲突的数据。这是典型的“并行填批、串行提交”模型。写入即落盘、提交才可见数据被 push 进WriteBatch时就已经写到磁盘了但只有在事务提交后才会对读者可见启动时上一次崩溃残留的未提交文件会被自动清理。针对海量单事务写入优化架构优先保证“一个事务写入海量数据”的吞吐同时不牺牲随机读性能。多键族key family支持不同的键族存于不同的文件独立键空间互不干扰——包括性能上也不互相拖累同一个写批次可以同时包含多个键族的数据。从 Cargo 元数据看它是一枚独立的库 crateedition 2024Cargo.toml依赖crc32fast、memmap2、xxhash-rust、lzzzzLZ4、zstd、qfilter快速过滤器等底层组件并对外暴露多个 Cargo featureverify_sst_content、strict_checks、stats/print_stats、verbose_log用于调试与观测。二、存储配置模型Family、Value Type 与公开 API2.1 键族Key Family与数据库配置在 turbopack/crates/turbo-persistence/src/lib.rs 中定义了整套配置类型它是理解一切磁盘布局的入口FamilyKind::SingleValue每个键只对应一个值查询用get不能用get_multiple。压缩时同名键只保留最新版本。FamilyKind::MultiValue每个键可以对应多个值重复值不会被丢弃get_multiple返回值顺序未定义访问必须用get_multiple而非get。pub struct FamilyConfig { pub name: static str, pub kind: FamilyKind, pub compression: Compression, } pub struct DbConfigconst FAMILIES: usize { pub family_configs: [FamilyConfig; FAMILIES], }DbConfig以 const 泛型FAMILIES固化键族数量默认每个键族为SingleValueCompression::Lz4。压缩算法由 compression.rs 定义Compression::Lz4默认快速与Compression::Zstd3并且该压缩设置必须与 meta 文件中记录的压缩算法一致才能正确打开数据库。2.2 值类型体系Value Types对应磁盘上四种文件类型值一共有下列存储形态该列表是原 README 的契约性内容Value Type说明INLINE值 ≤ 8 字节直接存在*.sst的 key block 内SMALL值 9–4096 字节打包进*.sst内共享的 value blockMEDIUM值 4097 字节 – 64 MB存进*.sst内专属的 value blockBLOB值 64 MB存放在独立的*.blob文件KEY DELETED该键的所有值都被删除Key 墓碑 / tombstoneKEY-VALUE DELETED只删除某个具名“键 → 值”对键下的其它值保持完好Key-value 墓碑仅对MultiValue键族有意义Future: MERGE预留的应用自定义更新操作将应用于旧值之上上述分级阈值与内联上限都在 turbopack/crates/turbo-persistence/src/constants.rs 中被定义为编译期常量可直接与实现互相印证pub const MAX_MEDIUM_VALUE_SIZE: usize 64 * 1024 * 1024; // 超过即进入 .blob pub const MAX_SMALL_VALUE_SIZE: usize 4096; // 超过即脱离共享 value block pub const MAX_INLINE_VALUE_SIZE: usize 8; // 内联上限目前为 8 字节 pub const MIN_SMALL_VALUE_BLOCK_SIZE: usize 8 * 1024; // 共享 value block 最小聚合量值得注意的细节内联格式本身最大可支持到 247 字节type 255 − 8但当前MAX_INLINE_VALUE_SIZE 8是刻意为之——与 8 字节的间接寻址开销刚好打平README 中的“break-even”说明。db.rs与static_sorted_file.rs中还存在针对该上限的静态断言。2.3 写入事务 APIWriteBatch核心写入接口在 turbopack/crates/turbo-persistence/src/write_batch.rsWriteBatch通过put/delete/delete_value接收操作pub fn put(self, family: u32, key: K, value: ValueBuffer_) - Result(); pub fn delete(self, family: u32, key: K) - Result(); pub fn delete_value(self, family: u32, key: K, value: ValueBuffer_) - Result();其中delete_value用于MultiValue键族场景下的“精确删除一个键值对”即 key-value tombstone其可删除值的长度受MAX_INLINE_VALUE_SIZE约束。测试 tests.rs 中的用法十分直观例如full_cycle测试里单键族写入batch.put(0, vec![1, i], vec![i].into())、跨 16 键族写入batch.put(u32::from(i), ...)后再通过db.commit_write_batch(batch)提交。三、磁盘布局总览所有状态都存放在一个目录里。唯一的固定文件是CURRENT——一个很小的 JSON 对象记录最新已提交序列号max_sequence_number与该提交发生的时间commit_time。提交时间写入文件内容而不是取自文件 mtime是为了让目录被复制或恢复后时间信息仍然存活外部工具会读取该文件所以CURRENT的字段名是稳定的对外契约代码侧对应 db.rs 中的CurrentDbVersion { max_sequence_number: u32, commit_time: Timestamp }。除CURRENT外其余文件一律以序列号命名形如0000123.sst。所有文件一旦其序列号 ≤ 已提交序列号即为不可变immutable但它们可能在被其它已提交文件取代后被删除。序列号是单调递增的“代际”标记贯穿读写、提交与压缩的整套协议。3.1 四种文件类型类型命名后缀作用Static Sorted TableSST*.sst存放键值对的主体文件Blob 文件*.blob存放超大值 64 MBDelete 文件*.del列出应视为“已删除”的文件序列号清单Meta 文件*.meta描述 SST 文件的元数据内含 hash 区间与 AMQF近似成员过滤器用于快速过滤3.2 值类型取舍权衡表原 README 给出了一张非常关键的分级权衡表它直接决定了 INLINE/SMALL/MEDIUM/BLOB 四档如何选择压缩单元、访问成本与存储开销InlineSmallMediumBlobSize≤ 8 B9 B .. 4 kB4 kB .. 64 MB 64 MBCompression unitkey blockshared value block≥ 8 kBdedicated value blockseparate fileCompression unit size≤ 16 kB8 kB .. 12 kB4 kB .. 64 MB 64 MBAccess costno extra overheaddecompress shared block~8 kBdecompress value sizeopen separate file, decompress value sizeStorage overhead08 B in key block 8 B per ~8 kB in block table2 B in key block 8 B in block table4 B in key block 8 B in blob headerCompactionre-compressedre-compressedcopied compressedpointer copied关于 Small 档的大小区间README 做了补充说明小值块累积满MIN_SMALL_VALUE_BLOCK_SIZE8 kB数据即被刷出因此真实块大小介于 8 kB 与 8 kB MAX_SMALL_VALUE_SIZE4 kB 12 kB 之间。这是一个刻意选择的平衡点块 ≥ 4 kB 时 LZ4 压缩效率良好而每次查找只需解压约 8–12 kB访问成本可控。与之对应constants.rs中还声明了每 SST 文件的条目/数据量阈值如初次写入文件MAX_ENTRIES_PER_INITIAL_FILE 256 * 1024、DATA_THRESHOLD_PER_INITIAL_FILE 64 MB压缩后的文件阈值更大以及两级读缓存预算key block 缓存上限 400 MB、value block 缓存上限 300 MB。四、Meta 文件格式*.metaMeta 文件可以描述多个SST 文件元数据聚合到单个文件以避免产生过多小文件。其布局依次为Header4 字节魔数0xFE4ADA4A4 字节键族 ID1 字节压缩算法必须与打开数据库时的配置一致4 字节“已过时 SST 文件”数量对每个过时 SST 文件4 字节序列号4 字节“被描述 SST 文件”数量对每个被描述 SST 文件4 字节序列号2 字节块数8 字节 min hash8 字节 max hash8 字节 SST 文件大小4 字节 flagsbit 0cold已压缩且近期未被访问bit 1fresh尚未被压缩4 字节 AMQF 结束偏移相对于全部 AMQF 数据起点4 字节“used key hashes”AMQF 的结束偏移相对同一起点对每个被描述 SST 文件序列化后的 AMQF序列化后的 “used key hashes” AMQF可以看到每个 SST 的min_hash/max_hash区间和两级 AMQF每文件一级 全局“被使用的键 hash”一级都集中保存在 meta 中这正是后续读路径过滤与压缩选片的数据基础。实现侧对应 turbopack/crates/turbo-persistence/src/meta_file.rs 与meta_file_builder.rs。五、SST 文件格式*.sstSST 文件只含数据、没有任何文件头主体是一组“块block”。整体结构为两段式foreach block: 4 bytes block header未压缩长度或哨兵值 0 4 bytes checksum对未压缩块数据做 CRC32 block data压缩或未压缩 foreach block: 4 bytes 该块结束偏移相对全部 block 数据起点5.1 块压缩Block Compression块可以压缩存储也可以不压缩压缩算法记录在 meta 文件中。4 字节 header 用于区分两种形态header 0该块按键族配置的算法压缩过header 值即未压缩长度header 0该块未压缩存储实际长度由块偏移表推导。5.2 块校验和Block Checksum每个块都有一个 4 字节 CRC32 校验和big-endian计算对象是磁盘上的块数据即压缩后。读取时先校验、后解压这样磁盘损坏会在数据被交给 LZ4 之前就被发现校验和不匹配会返回“缓存数据已损坏”的错误。Blob 文件的校验策略与此完全一致。5.3 索引块Index Block1 byte block type0index block 2 bytes block index n 次 8 bytes hash 2 bytes block index索引块内含n个 8 字节 hash它们界定n − 1个 hash 区间相等的 hash 归入前一个区间首键除外每两个 hash 之间有一个 2 字节块索引指向覆盖该 hash 区间的那个块。所有 hash 有序排列块容量由n (block size 1) / 10决定即每个索引条目占 10 字节。5.4 定长键块的取数逻辑Key Block变长格式Key block 上限为 16 KB1 byte block type1带 hash 的 key block2不带 hash 的 key block 3 bytes entry count foreach entry: 1 byte type 3 bytes 块内偏移header 之后块类型决定了每个条目是否携带完整 key hash以及条目的存储顺序详见下文“条目排序”。条目的type字段又细分出多种载荷形态type含义与载荷0普通键小值[8B key hash*] key data 2B block index 2B size 4B position in block1blob 引用[8B key hash*] key data 4B sequence number2删除的键 / key tombstone无数据[8B key hash*] key data3普通键中等值[8B key hash*] key data 2B block index7merge 键未来用key data 2B block index 3B size 4B position in block8..16内联值值大小 type − 8格式上支持到 247但MAX_INLINE_VALUE_SIZE目前封顶 8[8B key hash*] key data (type−8) 字节内联值17..25key-value tombstone删除值大小 type − 17镜像内联区间并随MAX_INLINE_VALUE_SIZE平移[8B key hash*] key data (type−17) 字节被删值内联保存其中标注*的 8B key hash 仅在块类型为“带 hash”block type 1时出现。由于 8..16 与 17..25 都是开放区间open-ended的编码解码器必须先检测 key-value tombstone 区间、再检测内联区间顺序不能颠倒。5.5 条目排序Entry Ordering逻辑上键是按hash排序的文件与块的分配都依据 hash但在单个 key block 内部排序依据随块类型不同带 hash类型 1/3按(key hash, key)排序不带 hash类型 2/4仅按key排序。这意味着在无 hash 的块中hash 相等/碰撞的键之间可以直接按键字节序比较定位不需要额外 hash 存储。5.6 Key-value Tombstone 的设计约束为什么只有内联大小的值能被“键值对级删除”README 给出了清晰的理由链key-value tombstone 要指出“删除哪一对”必须携带一份被删值的字节拷贝值内联在 key block 中、其长度又编码在 type 字节里所以delete_value只能删除内联尺寸的值MAX_INLINE_VALUE_SIZE因此成为其边界这个限制来自编码而非比较逻辑匹配只是普通字节比较不关心值如何存储支持删除更大的值并非不可能但没动机——墓碑要存第二份值拷贝删除成本将逼近值本身的大小而“回收空间”恰恰是删除的全部意义。README 还展望了未来改进方向若确有需要自然的做法是在条目类型上增加一个独立的“is-tombstone”位例如最高位使“已删除”与存储类别正交从而消除当前 tombstone 表示在每一层镜像内联编码的重复blob 支撑的值则需要单独设计因为与 blob 比较意味着读 blob会把无界 I/O 引入压缩路径且 blob 存活性记账需处理持有 blob 引用的墓碑。5.7 Key Block定长格式fixed-size当块内所有条目的键大小相同、且值类型或至少值大小也相同时写入器会改用定长格式消除每个条目的偏移表使二分查找可做直接的算术索引1 byte block type3带 hash 的定长块4不带 hash 的定长块 3 bytes entry count 1 byte key size全块统一 1 byte value type全块统一当只共享值大小而不共享类型时为混合标记 FIXED_KEY_BLOCK_MIXED_VALUE_TYPE4 1 byte value size仅当 value type 为混合标记时出现 foreach entry按 stride hash_len key_size val_size 紧密排列: 8 bytes key hash若块类型为 3 key datakey_size 字节 1 byte value type仅混合类型块中每个条目单独出现 value data长度由块级或条目级类型决定第i个条目的位置可直接计算为header_size i * stride无需间接跳转。混合形态mixed-type的存在是为了让“同尺寸的内联值”与“key-value tombstone”可以共享同一个定长块二者值大小相同但 type 字节不同标记 4 之所以可用作混合标记是因为它本身不是合法的条目类型。写入器会自动判定块内所有条目满足条件即选定长格式否则回退到上述变长格式。5.8 值块Value Block值块没有 header所有字节都是被其它块引用的数据最大块大小 4 GB。六、Blob 文件格式*.blob普通值经“动态压缩”后的容器每个 blob 文件带 8 字节头部4 bytes未压缩长度u32 big-endian 4 bytes压缩数据的 CRC32 校验和u32 big-endian 其余字节按该 blob 所属键族的配置压缩后的值数据读取 blob 时同样先对压缩数据校验、再解压。七、读取流程读取从当前序列号向下追溯新数据优先所有 SST 文件被memory mapmmap_helper 模块负责映射语义上“全量驻留”对i CURRENT 序列号 → 0逐个 SST先查该 SST 的 AMQF判断键是否存在——不存在则直接跳过该文件block 0进入内层循环索引块用键的hash做二分查找定位包含该键 hash 区间的块找到则更新block继续否则跳出Key block二分查找键——在存储 hash 的块中比较(hash, key)在不存 hash 的块中只比较 key呼应 5.5 的条目排序规则找到则依据键记录里的块索引去取数内联值直接返回其它值根据 key 中的 block index 定位到文件内对应 value block 返回找不到则跳出。对外TurboPersistence的读接口getSingleValue 族与get_multipleMultiValue 族都围绕该查找管线实现见 db.rs 的pub fn get_multipleK: QueryKey并且 key/value block 采用quick_cache缓存解压结果两级缓存400 MB / 300 MB 上限均为惰性分配——纯写入或空库会话不会支付哈希表的固定开销。打开方式包括open、open_with_config、open_read_only_with_config、empty_in_memory_with_config不触碰文件系统的只读空库用于“noop”存储等。八、写入流程与提交协议写入流程以创建一个新WriteBatch开始WriteBatch 维护一个原子计数器作为下一个空闲序列号每个线程有线程本地缓冲区操作累积到一定阈值后缓冲被排序并写成新的 SST 文件可能伴随若干 blob 文件提交commit时所有线程本地缓冲被合并进一个全局缓冲再写成新 SST数据超阈值时会产生多个文件fsync然后把新序列号写入CURRENT文件——此时数据才对读者可见之后才可能执行压缩优化。这一协议的崩溃安全性要点是写进 WriteBatch 的数据即使尚未提交也已在盘上但只有提交点CURRENT前滚 fsync之后才对读者可见未提交的孤儿文件在下次启动时会被删除db.rs中WriteOperationGuard的 Drop 逻辑也会在写操作失败时清理seq seq_before的全部孤儿文件。每次提交还会返回CommitStats { bytes_written, bytes_deleted }用于衡量一次提交/压缩周期的真实物理写入与回收量。九、压缩Compaction9.1 覆盖度Coverage指标压缩的核心指标是 SST 文件的coverage覆盖度即“要判定某个键不存在平均需要翻阅多少个 SST 文件”。它仅依赖各 SST 的min_hash/max_hash即可计算单个 SST 的覆盖度为(max_hash - min_hash) / u64::MAX全部 SST 覆盖度相加即总覆盖度。压缩策略是选出若干 SST 文件对它们执行merge sort 的合并步产出若干 hash 区间更规整的新 SST从而降低总覆盖度。示意如下key hash range: | 0 ... u64::MAX | SST 1: |----------------| SST 2: |----------------| SST 3: |-----|可被压缩为key hash range: | 0 ... u64::MAX | SST 1: |-------| SST 2: |------| SST 3: |-----|合并后新 SST 的覆盖度均 1总覆盖度随之下降。9.2 合并的位置约束与并发压缩并不是简单改写文件它受“不可变性 序列号顺序”双重约束SST 文件只要序列号 当前序列号即不可变不能原位改写也不能把新文件插进旧序列号处因此正确做法是新合并出的 SST插到当前序列号之后并把原 SST 之后所有 hash 区间重叠的 SST“复制”到更新序列号可只复制重叠区间且用hardlink而非真拷贝之后才前滚CURRENT序列号并删除原来的与复制的全部 SST。合并期间的纪律性约束有三条多个合并操作在key hash 区间不重叠或属于不同键族时可并发执行但拷贝操作必须严格晚于所有合并操作一次合并操作涉及的 SST 之间不得再夹有 hash 区间重叠的其它 SST合并会消除重复键若消除的是 blob 引用则在该 blob 被弃用后、待CURRENT更新完毕才真正删除对应 blob 文件。9.3 用*.del文件保证删除不丢失进程可能随时异常退出。为了不“忘记”删除被取代的 SST压缩器把“待删除文件序列号清单”写进*.del文件并且该文件的写入发生在CURRENT前滚之前重启时只需重放这些删除即可。此外还会限制单次合并的 SST 数量避免单次压缩时间过长。9.4 完整压缩示例来自 README 原文初始状态family 1 与 family 2 的文件交错覆盖 hash 空间CURRENT: 9key hash range: | 0 ... u64::MAX | Family SST 1: |-| 1 SST 2: |----------------| 1 SST 3: |----------------| 1 SST 4: |-----| 2 SST 5: |-----| 2 SST 6: |-------| 1 SST 7: |-------| 1 SST 8: |--------| 2 SST 9: |--------| 2 CURRENT: 9压缩器选择 {SST 2, 3, 6} 与 {SST 4, 5, 8} 两组并行合并此处限制每次最多 3 个并顺带选出需要复制的 SST 7、9{2,3,6} 合并产出新文件 10、12、14{4,5,8} 合并产出 11、13。两个操作并发进行因此各自取得的空闲序列号顺序随机重复键被消除后结果文件数可能少于输入复制 SST 7、9 得到新文件 15、16在序列号 17 写一个DEL 17文件其内容为待删清单(2,3,4,5,6,7,8,9)前滚CURRENT为 17删除 SST 2、3、6、4、5、8、7、9SST 1 全程不变。最终状态key hash range: | 0 ... u64::MAX | Family SST 1: |-| 1 SST 10: |-----| 1 SST 12: |-----| 1 SST 11: |------| 2 SST 14: |-------| 1 SST 13: |-----| 2 SST 15: |-------| 1 SST 16: |--------| 2 DEL 17: (2, 3, 4, 5, 6, 7, 8, 9) CURRENT: 179.5 压缩配置CompactConfig压缩对外暴露为db.compact(CompactConfig)如 tests.rs 中多处调用。其默认值定义于 compaction/selector.rs核心字段与含义如下字段默认值含义min_merge_count2一次合并最少文件数optimal_merge_count8一次合并的理想文件数max_merge_count32一次合并最多文件数max_merge_bytes500 MB一次合并的最大总字节数min_merge_duplication_bytes50 MB一个合并任务至少要有多少重复量才值得合并optimal_merge_duplication_bytes100 MB合并的理想重复字节量max_merge_segment_count8最多判定的合并分段数这与 README 中“Compaction 的两个配置项——单次最多合并的 SST 数、触发压缩的覆盖度阈值”相呼应工程实现进一步把它细化为一整套针对文件数、字节量、重复量、分段数的启发式参数。db.rs中的pub fn compact在覆盖度不足时直接返回 noop对应“coverage 未达触发阈值时 compact 为空操作”。十、打开与关闭Opening见 README 与 db.rs 的open_directory流程读取CURRENT文件删除所有序列号高于CURRENT的文件崩溃残留的未提交文件读取全部*.del文件删除其中列出的文件删除会再次执行幂等重放读取全部*.sst文件并 memory map。若目录为空且非只读则会自动初始化写入序列号 0 的CURRENTopen_directory仅需读少量文件的开头字节与删除文件即可完成清理因此很快。若以只读模式打开则跳过清理。Closingfsync顺带执行此前排队待删的文件删除。十一、兼容性边界README 明确说明该数据库当前不支持跨版本兼容。因此所有更新都应视为破坏性变更唯一可靠的做法是重写整个数据库例如磁盘格式演进后丢弃重建。在把turbo-persistence用作长生命周期缓存时需要把这一点纳入升级策略。十二、源码阅读地图与验证路径如果想继续深入本仓库推荐按以下顺序对照阅读对外契约与类型导出src/lib.rsFamilyKind、FamilyConfig、DbConfig、WriteBatch、TurboPersistence的 re-export常量与阈值总表src/constants.rs各档值大小上限、SST 条目/数据阈值、缓存预算、MIN_SMALL_VALUE_BLOCK_SIZE等打开/提交/读取/压缩的骨架src/db.rsopen系列、commit_write_batch、compact、get_multiple、CommitStats写入事务实现src/write_batch.rsput/delete/delete_value与线程本地缓冲文件格式实现src/meta_file.rs、src/static_sorted_file.rs、src/static_sorted_file_builder.rs块格式、校验和与索引/键块的读写、src/compression.rsLZ4/Zstd 与校验、src/meta_file_builder.rs压缩选择启发式src/compaction/selector.rsCompactConfig及默认值行为验证与用法示例src/tests.rs跨键族读写、10 KB 键 100 KB 值、64 MB blob、压缩全流程、自定义ParallelScheduler等测试可作为集成用例范本。此外crate 还带基准目录turbopack/crates/turbo-persistence/benches/并在 Cargo.toml 中声明了便于排查的命令行 binarysst_inspect以及若干诊断 feature可配合verify_sst_content、strict_checks、stats等 feature 在生产接入前做数据一致性校验与性能观测。【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。