news 2026/10/14 3:26:58

从SST文件格式看RocksDB Compaction源码实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从SST文件格式看RocksDB Compaction源码实现

每次有朋友问我RocksDB Compaction源码该从哪儿看起,我给的回答都出乎意料地一致:先别急着翻compaction相关的文件,先去把SST文件格式啃下来。原因很简单,Compaction的本质就是读一堆SST文件、合并排序、再写出一批新的SST文件,读要解析格式,写也要构造格式,你对这个格式的理解深度,直接决定你看Compaction代码时是通透还是硬背。这个系列我会从RocksDB Compaction源码切入,第一篇先从最底层的一块砖头讲起——SST文件的详细格式源码解析。整篇文章会沿着“写入路径—磁盘布局—读取路径”的顺序展开,带着你从BlockBasedTableBuilder一直看到Footer、DataBlock、IndexBlock和FilterBlock,适合准备读数据库内核源码的开发者,也适合想彻底弄懂sst_dump输出每一列含义的运维同学。

1. 先看整体:SST文件不是“一个文件”,而是Block拼出来的

1.1 一条写入路径:BlockBasedTableBuilder怎么组织文件

RocksDB里写SST文件最核心的类叫BlockBasedTableBuilder,源码在table/block_based/table_builder.cc。我们在Compaction代码里看到它时,通常是这样的调用关系:CompactionJob拿到合并排序后的KeyValue列表,逐条调用TableBuilder::Add,最后调用Finish把整个SST文件落盘。换句话说,BlockBasedTableBuilder就是那个把内存中的KV流变成磁盘上不可变文件的“装配工”。

Add做的事情没有看起来那么高大上,它只是把当前这条内部Key和Value追加到内存里的一个DataBlock缓冲区。这里有个很关键的分水岭:当缓冲区达到一定大小,默认block_size=4KB,它会调用Flush把当前DataBlock压缩、计算校验和、写进WritableFile,然后立刻在IndexBuilder里追加一条索引记录,记录“这个DataBlock的最后一个Key”和“这个DataBlock在文件中的偏移量和大小”。这个点理解了,后面看IndexBlock会非常轻松。

等到所有KV都Add完,Finish再做三件事:把最后一个没有flush的DataBlock写掉;写MetaBlock(包含Filter、Properties这些辅助信息);最后写MetaindexBlock和IndexBlock,再写Footer,把IndexBlock和MetaindexBlock的指针放进Footer里。一句话总结:SST文件里所有可见数据都在DataBlock中,其他Block都是为了让程序能够快速定位到这些DataBlock而存在的辅助结构。

1.2 为什么索引和元信息放在文件尾部

SST文件最反直觉的一点是,索引块不在文件头,而在文件尾部。第一次看RocksDB源码的人普遍会愣一下:索引放前面不是更符合直觉吗,打开文件就能读索引?答案是顺序写。Compaction的输入文件是排好序的,但输出文件的数据量可能很大,索引块本身有多少格点要等写完所有DataBlock才知道?如果索引放在文件头,要么预留空间,等最后再回头填充,破坏顺序写;要么重写整个文件,代价无法接受。放在文件尾部之后,BlockBasedTableBuilder可以一路顺序写DataBlock、MetaBlock,最后把索引一写,Footer一写,文件就完整了。随机读场景下,磁盘读一次尾部拿到Footer,成本极低,所以“尾部索引”是LSM架构下一个非常聪明的取舍。后面读路径的源码也会反复验证这个设计。

2. 核心数据结构源码解析:BlockHandle与Footer

2.1 BlockHandle:文件里所有“指针”的底层

在SST文件里,一个Block在文件中的位置用什么表示?答案不是传统指针,而是一个叫BlockHandle的结构体。它只有两个字段:offset_(uint64)和size_(uint64)。为什么极其精简?因为SST文件格式的目标之一就是省空间,索引和数据块动辄上百万个,如果每个指针都用固定8字节,索引会大很多。所以BlockHandle在写入文件时不是直接输出16字节,而是通过varint64变长编码压缩。

我们可以看下面这段简化过的源码逻辑:

void BlockHandle::EncodeTo(std::string* dst) const { PutVarint64(dst, offset_); PutVarint64(dst, size_); } Status BlockHandle::DecodeFrom(Slice* input) { Status s = GetVarint64(input, &offset_); if (!s.ok()) return s; s = GetVarint64(input, &size_); return s; }

这个结构贯穿全文件:DataBlock在IndexBlock里的入口是一个BlockHandle;MetaBlock在MetaIndexBlock里的入口也是一个BlockHandle;IndexBlock和MetaIndexBlock在Footer里的入口还是BlockHandle。可以说,读SST文件的过程,就是一个不断“用BlockHandle定位下一块、再解码下一块”的过程。理解它,文件格式就通了三分之一。

2.2 Footer:通往索引和元信息的入口

Footer是SST文件的最后一部分,也是读取整个文件的起点。它的核心作用是提供两个BlockHandle:MetaIndexBlock的位置和IndexBlock的位置。有了MetaIndexBlock,我们才能进一步找到Properties、Filter等元信息块;有了IndexBlock,我们才能进一步定位所有DataBlock。此外,Footer里还有文件版本号以及Magic Number,用来校验文件合法性。

在较新的RocksDB格式中,Footer不是固定48字节,它会因为format_version不同而有差异。读取端在BlockBasedTable::Open时,先读文件最后一块区域,然后解析Footer,拿出两个BlockHandle,再依次读取MetaIndex和Index。源码上对应ReadFooter逻辑。这里有个重要体会:Footer本身不存储任何用户数据,它只是一个“引导结构”,像一个集装箱的编号和箱单。

2.3 用一张表看懂Footer的字段作用

字段作用备注
MetaIndexBlockHandle指向MetaIndexBlock通过它定位属性、Filter等
IndexBlockHandle指向IndexBlock通过它定位所有DataBlock
版本信息标识文件格式版本不同版本Footer布局不同
Magic Number校验文件底部的合法性读取时Header/Footer都会校验

这张表看起来简单,但实际排查文件损坏时,你会发现“Footer读不出来”往往意味着文件尾部被截断或者被覆盖,问题出在磁盘层而不是逻辑层。看到类似Corruption错误时,第一反应去检查文件尾部字节,而不是怀疑整份数据。

3. DataBlock内部:前缀压缩与restart points

3.1 一条entry的字节长什么样

DataBlock是真正存放用户Key-Value的地方,也是Compaction过程中被读写最频繁的Block。它的格式设计核心是省空间,因为一个SST文件里可能有几百万条数据。RocksDB对DataBlock内的Key做了前缀压缩(delta encoding)。

每条entry在文件里的实际存储结构是:

| shared_key_length (varint32) | unshared_key_length (varint32) | value_length (varint32) | unshared_key_data | value_data

这里的核心思想是:相邻两条Key有序,且通常共享前缀。比如前一条Key是foo:bar1,当前Key是foo:bar2,那么foo:这段共享前缀就不重复存储,只记录shared_key_length=4,再记录unshared_key_length=4(即bar2),最后存bar2和Value。对于递增的连续键,这种压缩效果非常显著,甚至能达到几个数量级的空间节省。

源码上对应table/block_based/block_builder.cc中的BlockBuilder::Add。每次Add时,它会拿当前Key和上一次写入的Key做前缀比较,算出shared bytes和unshared bytes,然后按上面的格式写进缓冲区。这里有个细节:只有最后一个Key需要和当前Key比较,因为数据有序,没必要和更早的Key比较。

3.2 restart points是怎么工作的

如果一直做前缀压缩,会带来一个严重问题:从中间读取一条记录时,你无法直接知道完整Key,必须从某条“完整Key”开始逐步重建。为了解决这个问题,RocksDB引入了restart points(重启点)。

默认每16条记录设置一个restart point,即一个“从零开始存储完整Key”的位置。从该位置往后,才允许做前缀压缩。这些restart point的偏移量会统一记录在DataBlock尾部的restart array里,restart array的最后一个uint32存的是restart point的数量。简单类比:前缀压缩是砖块,restart point是每隔一段距离放一根钢柱,保证这一段塌不了。

restart_interval默认是16,由BlockBasedTableOptions::block_restart_interval控制。增大这个值,压缩率会更高,但查找时需要扫描更长的记录;减小这个值,压缩降低、查找更快。生产中通常保持默认,只有遇到极端长Key或极小Value的场景才考虑调整,后面我会在问题篇展开。

3.3 查找一个key时到底走了哪几步

从DataBlock内部读一个Key时,流程大致是:首先二分搜索restart array,找到距离目标Key最近且小于等于它的restart点;然后从那个restart点开始,逐条解码后续entry,线性扫描直到找到目标Key或者超过它。这个设计本质是“二分定位 + 线性扫描”,把随机查找和压缩率做了平衡。

很多人容易忽略的是,这个过程只发生在DataBlock内部。DataBlock之上还有IndexBlock在起作用:IndexBlock先帮你缩小范围,告诉你目标Key可能落在哪个DataBlock,然后才进入这里描述的DataBlock内部查找。两层查找加起来,才是RocksDB一次点查的真正开销。理解了这条链路,你自然就能看懂为什么sst_dump里会有block index和data block的区分。

4. IndexBlock:支持二分查找的“目录”

4.1 index entry的结构与shorten key

IndexBlock的每条entry,Value部分是对应DataBlock的BlockHandle,Key部分却是一个经过特殊处理的“shortened key”。它既不是DataBlock的第一个Key,也不是DataBlock的最后一个Key,而是介于上一个DataBlock最后一个Key和当前DataBlock最后一个Key之间的某个分隔符。

为什么要缩短?数据量大的时候,索引块如果完整保存每个DataBlock的最后一个Key,内存占用会非常夸张。RocksDB在table/block_based/index_builder.cc中通过ShortenKey去压缩这个Key,比如上一个Block的最后Key是abcdef,这个Block的最后Key是abcxyz,那索引里可能只需要存abc,因为它已经能区分出前后两个Block的边界,又比完整Key短很多。注意这个操作必须保证排序语义不被破坏,所以它实际使用的是类似FindShortSuccessor和ShortenKey的配合。

IndexBlock本身也是Block格式,所以它也有自己的restart array,也可以被压缩。这一点初次读源码时容易忽略,但非常重要:IndexBlock并不是裸的键值数组,而是和DataBlock一样遵循相同的Block编码规则,只是存储内容变成了“分隔符Key + BlockHandle”。

4.2 单层索引与两层索引

默认情况下,RocksDB把所有DataBlock的索引项写进一个IndexBlock,读取时把它整体加载到内存。但如果SST文件巨大,DataBlock数量极多,单个IndexBlock会膨胀得很大。为了控制内存,RocksDB支持partitioned index(分区索引)。

分区索引的做法是:把原本巨大的IndexBlock按固定大小切成多个“二级索引”子Block,每个子Block就是一个小型的IndexBlock,存一部分DataBlock的索引;然后再生成一个小的“顶层索引”Block,存这些二级索引子Block的Handle和边界Key。这样读取时不必一次加载全部索引,只需要加载顶层索引和当前需要的那个二级索引Block。这种“索引的索引”在和Compaction结合时很有价值——Compaction读取一个超大SST时,它可以按需逐块加载二级索引,不会因为文件巨大而导致内存击穿。

4.3 索引块与Compaction的关联

Compaction时,输入文件往往非常多,尤其L0到L1的合并,一次可能涉及几十个SST。每一个输入SST都要打开自己的TableReader,而打开时最重的开销之一就是读IndexBlock。如果你曾调过max_sequential_compactions或者用工具观察过compaction过程中的block cache miss,应该能感受到索引块的读取频率有多高。

RocksDB在Compaction读取输入文件时,使用的是TableReader::NewIterator,这个迭代器本质上就是通过IndexBlock去定位DataBlock的。所以,SST文件的索引布局好不好,直接影响Compaction的扫描效率。这也是为什么在做Compaction性能调优时,除了看压缩率,还要看索引块大小、是否分区。

5. MetaBlock、Filter和校验和

5.1 MetaIndexBlock和PropertiesBlock

MetaIndexBlock是元信息的索引,它本身也是一个Block,里面的条目结构类似IndexBlock:Key是元信息块的名称,Value是BlockHandle。常见的元信息块包括rocksdb.properties(属性块)、FilterBlock(布隆过滤器)、rocksdb.compressiondict(压缩词典)、RangeDelBlock(范围删除块)等。

PropertiesBlock里存的是一堆统计属性,比如rocksdb.num_entries表示键值对数量、rocksdb.total_raw_key_size表示原始Key总字节数、rocksdb.file_size表示文件大小。sst_dump --show_properties把这些字段打出来时,很多人只当作报表看,但Compaction相关的代码是会真的读取里面某些字段来做估算的,比如判断这个文件是否包含范围删除信息。如果文件损坏,PropertiesBlock读不出来,可能影响到Compaction的调度决策。

5.2 FilterBlock三种形态的取舍

FilterBlock是RocksDB快速的“负向过滤”利器,用于判断一个Key是否存在,避免无谓的磁盘IO。源码里有三种常见形态:老式的BlockBasedFilter、FullFilter、PartitionedFilter。

  • BlockBasedFilter:早期基于每个DataBlock构建过滤器,对应每个Block都有一段过滤字节。优点是构建简单,但过滤粒度粗,且容易产生额外内存开销,现在默认一般不再用。
  • FullFilter:整个SST文件只建一个大的布隆过滤器,对应FullFilterBlockBuilder。它一次性判断整个文件,命中率更高,是当前很多场景的默认选择。
  • PartitionedFilter:和分区索引类似,把Filter分成多个子Block,再用一个顶层索引来定位。它解决了大文件布隆过滤器过大的问题,适合超大SST。

需要强调的是,FilterBlock并不参与实际Key-Value数据的写入,它只是为了加速“这个Key是否可能存在”的判断。Compaction扫描大量文件时,如果配置了合适的Filter,可以大幅减少无意义的数据块读取,这也是为什么我们调优Compaction时总会关注bloom_bits_per_key的原因。

5.3 每个Block尾部的trailer与校验

每个Block写到文件尾部时,并不只有block本体,还会带一小段trailer信息:压缩类型(1字节)和校验和(4字节)。读取端读到一个Block后,会先校验校验和,再按压缩类型解压。这个设计保证了SST文件在磁盘上出现静默错误时能被及时发现。

这个细节和Compaction有什么关系?因为Compaction在读取输入SST时,默认也会做校验和验证(除非verify_checksums被显式关闭)。有时候Compaction明明很慢,查到最后是校验和计算占了大量CPU,尤其是用了高压缩率且有坏块时会反复触发。看到这样的案例,不要只盯着compaction线程数,先把checksum粒度想清楚。

6. 读取路径源码导读:从Footer到DataBlock

6.1 Open(): 建立TableReader的第一步

BlockBasedTable::Open是SST文件读取的入口。它的第一个动作就是读文件末尾的Footer,然后通过Footer里的MetaIndexBlockHandle读取MetaIndexBlock,拿到一个叫rocksdb.properties的句柄,再读取PropertiesBlock来做一些初始化判断。之后是读取IndexBlockHandle,把IndexBlock加载进来(或者验证它是否已在BlockCache里)。

源码上这个初始化过程虽然不长,但是充满了细节判断:文件大小是否足够、Magic Number是否匹配、Index类型是binary search还是two-level、是否启用partitioned filter等。我在看源码时最大的体会是:Open不只是“打开文件”,它是在构建一整套“怎样按需读取Block”的元信息结构。这也是为什么同样一个SST文件,放在BlockCache里和没有放在BlockCache里,性能能差一个数量级。

6.2 ReadBlock(): 按handle加载并解码

真正读取某个Block时,核心函数是ReadBlock,它接收一个BlockHandle,从文件中读出对应区间,然后做三步:校验CRC、解压、构造Block对象。这里有一个容易被忽视的点:即使数据已经在BlockCache里,校验和解压的时机也是高度优化的,能避免就避免重复解码。

这个流程在Compaction的Iteration里每读一个DataBlock就会发生一次。这就解释了为什么Compaction读文件不是常驻内存顺序读取,而是“按需随机读取”各个Block。如果block cache命中率高,Compaction会快得多;如果每次都要从磁盘拉块,Compaction就成了磁盘随机读。所以,调Compaction的时候,监控BlockCache命中率比监控CPU使用率更直接。

6.3 Compaction输入文件如何复用这条读取路径

Compaction读取输入SST时,并不是为压缩单独开发一套文件格式,而是完全复用TableReader体系。合并排序迭代器(MergingIterator)会持有多个输入SST的Iterator,每个Iterator在需要时通过IndexBlock找到对应的DataBlock,然后逐个输出Key。当某个Key在多个输入文件中同时存在时,还需要比较序列号来决定保留哪个版本。这套流程里面,所有数据的定位都离不开前面讲的BlockHandle和IndexBlock。

所以我们看Compaction代码时,会频繁看到NewIterator、Seek、Next这些调用。它们看起来很上层,但每一步最终都会落到“解码BlockHandle、读取Block、遍历Entry”这一套地基上。也正是这个原因,我坚持把SST格式作为Compaction源码系列的第一讲:不先把地基夯实,后面聊CompactionJob的并行策略、原子写、文件选择,都会少一根弦。

7. 常见问题与排查技巧实录

7.1 用sst_dump和ldb检查SST格式

RocksDB自带的sst_dump是理解SST格式最好的调试工具。我常用的两行命令:

# 查看文件内容,会打印索引块、数据块和每一条KV sst_dump --file=/tmp/example.sst --command=raw # 查看文件属性、统计信息 sst_dump --file=/tmp/example.sst --show_properties

command=raw的输出会把Footer、MetaIndex、IndexBlock和DataBlock的字节布局都展示出来,非常适合拿来和我前面讲的BlockHandle、restart array做对照。遇到看不懂的数据,直接拿十六进制工具看文件尾部,再顺着BlockHandle一步步往前推,这是最笨但最有效的“源码落地”方法。

7.2 文件损坏场景速查表

常见错误现象可能原因排查方向
Footer中的Magic Number不匹配文件尾部被截断或覆盖检查文件末尾8字节和版本信息
IndexBlockHandle越界索引损坏或文件被改写用sst_dump raw确认index handle读取结果
Checksum mismatch磁盘静默错误、块写入不完整检查机器硬件,开启每次读校验
PropertiesBlock缺失MetaIndexBlock损坏查看文件中是否存在rocksdb.properties条目

这张表不是让你背的,而是提供一条排查思路:任何SST文件解析失败,先从Footer开始逐层验证,而不是盲目重刷文件。很多“数据损坏”其实是磁盘坏道或者写入过程中掉电导致的尾部截断,和RocksDB逻辑没有关系。

7.3 我的几个格式调优建议

从格式源码角度看,影响SST文件效率和Compaction性能的关键配置不外乎:block_size、block_restart_interval、format_version、index_type、filter_policy。我的个人经验是,普通在线服务保持默认的4KB block和16 restart interval就够用;如果Value比较大,可以适当调大block_size减少索引项;如果Key前缀很长,可以尝试微微调大block_restart_interval提升压缩率,但一定要压测点查性能。

另外,format_version建议不要随意升,除非你明确知道新旧格式差异,因为高版本格式在某些旧工具下不兼容,出现问题时排查成本会很高。稳定压倒一切。

最后再分享一个我阅读源码时的小习惯:每看一个格式结构,我都会构造一个小SST文件,然后用sst_dump和hexdump对它亲身做一次字节级对齐。源码里那些PutVarint64、ShortenKey、BlockHandle::EncodeTo在纸面上看容易飘,但当你亲手从文件尾部往前面推出一段IndexBlock时,整个RocksDB的读取链路就彻底通了。后面再聊Compaction的Job调度、输入文件选择、并行压缩,你就知道每一个决策底层到底踩在什么样的磁盘结构上。这个系列的第一篇就到这里,下一篇我们直接进Compaction核心流程。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 3:26:52

阿里:多模态视觉与推理分开优化

📖标题:Token-Disentangled Latent Test-Time Scaling for Vision-Language Reasoning 🌐来源:arXiv, 2609.35228v1 🛎️文章简介 🔸研究问题:在多模态大模型推理时,现有的测试时计算方法通常对所有隐藏状态使用统一的全局奖励信号,这忽略了不同Token有的负责看图…

作者头像 李华
网站建设 2026/10/14 3:26:33

梯控门禁控制器参数解析与智能场景落地指南

在智慧楼宇的改造项目中,电梯控制系统往往是最容易被忽视却又至关重要的环节。很多项目初期只关注门禁或监控,等到实际运营时才发现,早晚高峰的梯控响应延迟、老旧线路的电压波动导致设备重启,甚至是消防联动时的逻辑混乱&#xf…

作者头像 李华
网站建设 2026/10/14 3:26:21

轻量级前端发型插件 ponytail:人脸关键点检测与图像合成实操解析

前阵子有个插件叫“ponytail”,在开发圈和内容创作的小圈子里突然被频繁提到。搜索热词能看到很多人在问“ponytail 插件如何使用”,但能查到的资料七零八落,官方文档又写得过于简略。我自己在项目里从零开始接入、调参、踩坑,用了…

作者头像 李华
网站建设 2026/10/14 3:25:55

ChatGPT dots 实战指南:从交互点到自动化工作流

1. 从“点一下”到“操碎心”:ChatGPT dots 的真实定位第一次看到“ChatGPT dots”这个说法,很多人会愣一下——dots,点?是界面上的小圆点,还是某种隐藏功能?其实在圈子里,dots 更多是一种口语化…

作者头像 李华
网站建设 2026/10/14 3:25:31

【用于更准确 G×E 基因组预测的大规模作物数据集与基于深度学习的多模态融合框架】论文学习

用于更准确 GE 基因组预测的大规模作物数据集与基于深度学习的多模态融合框架 文章目录0.摘要1.简介2.材料与方法2.1.我们大规模数据集的构建2.2.我们的多模态融合框架2.3.基准方法3.实验结果3.1.实验设置3.2.预测精度比较3.3.环境变量的影响3.4. 可解释性分析4.讨论5.结论代码…

作者头像 李华
网站建设 2026/10/14 3:25:31

MySQL 从入门到实战(01):MySQL 是什么——从「为什么需要数据库」讲起

MySQL 是什么:从「为什么不用文件存数据」讲起 本篇是导论篇(无代码),只讲清「为什么需要 MySQL」和「它在技术栈里的位置」。 从第 02 篇开始动手,所有命令均在本机 MySQL 8.0.46 实测。 一、为什么不能用文件存数据 很多人第一次存数据是这样开始的: # 用 JSON 文件存…

作者头像 李华