每次有朋友问我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_propertiescommand=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核心流程。