lyyyuna 的小花园

动静中之动, by

RSS

手写 LSM 存储引擎(三):SSTable——磁盘上的数据长什么样

发表于 2026-08

前言

上一篇讲了 Memtable。Memtable 满了以后要 flush 到磁盘,落地的文件就是 SSTable(Sorted String Table)——LSM 引擎最核心的磁盘结构。后续的 compaction、读取、range scan 都绕不开它。

这一篇聊 SSTable 的物理布局。讲完这些你会明白:为什么查一个 key 只需要两次二分查找,为什么 SSTable 的读取放大没有想象中那么糟,以及 Bloom filter 是怎么把"一定不存在"这件事做到 17 纳秒的。

设计目标

SSTable 不是一个普通的"序列化的有序表",它要同时满足好几个要求:

  1. 只读不改——一旦写入就不再修改,这是 LSM 的基本假设
  2. 快速点查——给一个 key,要能在 O(log n) 的磁盘 I/O 内判断是否存在、以及取出 value
  3. 快速范围扫描——给一个范围 [lower, upper),能顺序读取所有 key
  4. 压缩友好——相邻 key 通常有共同前缀,应该省掉
  5. 可校验——磁盘会坏,要能检测出数据损坏

所有这些需求共同塑造了 SSTable 的结构。

整体布局

一个 SSTable 文件看起来是这样的:

┌──────────────────────────┐
│  Data Block 0            │
├──────────────────────────┤
│  Data Block 1            │
├──────────────────────────┤
│  ...                     │
├──────────────────────────┤
│  Data Block N            │
├──────────────────────────┤
│  Meta Entries            │  ← 每个 block 的偏移量 + 首末 key
├──────────────────────────┤
│  meta_off (u32)          │  ← Meta Entries 的起始偏移
├──────────────────────────┤
│  [bloom filter]          │  ← 可选
├──────────────────────────┤
│  [CRC32]                 │  ← 可选,文件级校验
└──────────────────────────┘

三层结构,从小到大:entry → block → table。entry 是一个 KV 对,block 是批量 I/O 的单位,table 是整个文件。

Block:I/O 的基本单位

操作系统的磁盘 I/O 是按页做的(一般 4KB)。从磁盘读数据,读 10 字节和读 4KB 是一样的代价。所以 SSTable 不按 KV 对做最小读取单位,而是攒够一定字节(典型 4KB)打包成一个 block

Block 内部是一段紧凑的编码,外加一个偏移表方便二分查找:

┌─────────────────────────────────────┐
│  entry 0 | entry 1 | ... | entry M  │  ← 紧凑排列的 KV 对
├─────────────────────────────────────┤
│  offset_0(u16) | offset_1(u16) | …  │  ← 每个 entry 在 data 里的起始位置
├─────────────────────────────────────┤
│  num_entries (u16)                  │
└─────────────────────────────────────┘

读入 block 时先看末尾的 num_entries,再从偏移表算出每个 entry 的位置。查某个 key 时对偏移表做二分——O(log M) 次比较(M 通常几十到几百)。

前缀压缩

如果相邻的 key 是 "user:001:name""user:001:age""user:001:city"——它们共享一个很长的前缀。一个 block 里通常都是这种情形,因为 key 有序排列。

每个 entry 的编码是:

┌────────────────┬───────────────┬──────────┬──────────┬────────┐
│ overlap_len    │ rest_key_len  │ rest_key │ val_len  │ value  │
│    (u16)       │     (u16)     │          │  (u16)   │        │
└────────────────┴───────────────┴──────────┴──────────┴────────┘

重建完整 key 时:full_key = block.firstKey[:overlap_len] + rest_key

这种压缩对 LSM 非常友好。实际 benchmark 里,前缀压缩 + block 编码后的 SST 体积大约是原始数据的 60-70%,代价是每次 block 第一次使用时多一次前缀拼接。

有人会问:为什么不和每一个前驱 key 比,只和 block 第一个 key 比?因为这样会导致 block 内部无法二分——如果 overlap_len 依赖前驱,那跳到中间的 entry 时就不知道它的完整 key 是什么。和固定的 firstKey 比,每个 entry 都能独立解码。

Index:快速找到 block

每个 block 在 SST 里对应一条 meta entry

type BlockMeta struct {
    Offset   uint32   // block 在文件中的起始位置
    FirstKey []byte   // block 的第一个 key
    LastKey  []byte   // block 的最后一个 key
}

所有 meta entry 连起来就是一个小型索引——通常几十 KB,完全可以常驻内存。

查找一个 key 的流程:

1. 在 meta 数组上二分搜索,找到 FirstKey ≤ key ≤ LastKey 的那个 block
2. 读入该 block(从磁盘/缓存)
3. 在 block 内部二分搜索,找到精确位置

两次二分查找,一次磁盘 I/O(如果缓存命中是 0 次)。对于几 MB 到几百 MB 的单个 SST,这个性能是非常好的。

Benchmark 印证了这一点——在 5000 keys 的 SST 上做随机点查,平均 1.1 µs,扫描全表要 164 µs,差 150 倍。

meta_off:入口点

文件的最后 4 字节是 meta_off——meta entries 的起始偏移。打开 SST 时第一步就是读这 4 字节,顺藤摸瓜把索引加载进内存:

func (sst *SsTable) readBlock(idx int) *Block {
    start := int(sst.meta[idx].Offset)
    var end int
    if idx+1 < len(sst.meta) {
        end = int(sst.meta[idx+1].Offset)
    } else {
        // 最后一个 block 的结尾 = meta_off
        metaOff := int(binary.LittleEndian.Uint32(sst.data[len(sst.data)-4:]))
        end = metaOff
    }
    return DecodeBlock(sst.data[start:end])
}

把 meta 放在文件末尾而不是开头,是一个很朴素的选择——写入时不知道 meta 会有多大,如果放开头,要么预留空间要么二次写文件头。放末尾就没这问题:一路顺序写 block,最后把 meta 和 meta_off 追加上去。

Bloom Filter:一定不存在

问题:如果一个 key 压根不在这个 SST 里,我们还是要做"读 meta → 二分 → 读 block → 二分"的全流程才能确认不存在。对 LSM 这种"一个 key 可能分散在多个 SST"的结构,很多次查询是无果的——这部分开销非常浪费。

Bloom filter 专门解决这个问题:它是一个小小的位图(典型 10 bits/key),能以很低的空间成本快速排除"这个 key 一定不在这个集合里"。

工作原理:

插入 key:
  h1 = hash1(key) → 置位 bit[h1 % numBits]
  h2 = hash2(key) → 置位 bit[h2 % numBits]
  ...  (k 个哈希函数)

查询 key:
  if 所有 bit[hi(key) % numBits] 都为 1:
     return "可能存在"  // 但也可能是假阳性
  else:
     return "一定不存在"  // 确定无疑

Bloom filter 有假阳性(false positive)但没有假阴性(false negative)——说"不存在"的时候一定对,说"可能存在"的时候有一定概率误判。误判概率取决于位图大小和哈希函数数量,10 bits/key + 7 个 hash 能做到约 1% 的假阳性率。

为了省掉"算 k 次哈希"的开销,实际实现用double hashing:算一次 32-bit hash,派生出两个 16-bit 值作为 hashdelta,后续哈希用 hash += delta 递推。单次 MayContain 用时 17 纳秒,零内存分配——这是整个引擎里最快的操作。

func (bf *BloomFilter) MayContain(key []byte) bool {
    h := hash32(key)
    delta := (h >> 17) | (h << 15)
    numBits := uint32(len(bf.bits) * 8)

    for i := 0; i < bf.k; i++ {
        bitPos := h % numBits
        if bf.bits[bitPos/8]&(1<<(bitPos%8)) == 0 {
            return false
        }
        h += delta
    }
    return true
}

一个坑:编码解码要一致

这是实现过程中踩过的坑——构建 Bloom filter 时 numBits 算出来是 100(10 keys × 10 bits/key),但实际分配的字节数是 ceil(100/8) = 13,对应 104 个 bit。构建时用 100 取模,查询时用 104 取模,hash 位置对不上,大量假阴性。

修复方案是把 numBits 对齐到字节边界,编解码两端都用这个对齐后的值。这属于"编码/解码不一致"这类经典 bug——位数、字节序、padding,任何一方搞错都是灾难。

写入 SSTable

SST 的构建是增量的。SsTableBuilder 吃一个有序的 KV 流,不断攒到当前 block 里;block 满了就封存到 blockData,开一个新的:

func (sb *SsTableBuilder) Add(key, value []byte) {
    if !sb.builder.Add(key, value) {
        sb.finishBlock()
        sb.firstKey = append(sb.firstKey[:0], key...)  // 新 block 的 firstKey
        sb.builder.Add(key, value)
    }
    sb.allKeys = append(sb.allKeys, cloneBytes(key))
    sb.lastKey = append(sb.lastKey[:0], key...)
}

注意 allKeys 要收集所有 key(不只是 block 边界的 firstKey/lastKey),因为 Bloom filter 要覆盖全集。这也是踩过的一个坑——早期只收集边界 key,导致中间 key 全部假阴性。

Build() 的时候做两件事:

  1. 把所有 block data 拼起来,追加 meta entries 和 meta_off
  2. allKeys 构建 Bloom filter(Bloom 本身也要编码进 SST)

校验和

可靠性要求 SST 能检测出损坏。我们在两个层级加了 CRC32:

文件级的代价是——现在 meta_off 不再是最后 4 字节,而是倒数第 8 字节(前面那 4 字节是 CRC)。这给 readBlock 里那个计算"最后一个 block 结尾"的逻辑带来麻烦,需要一个 hasChecksum 标记区分两种情形:

suffixOffset := 4
if sst.hasChecksum {
    suffixOffset = 8 // 跳过 CRC(4) + meta_off(4)
}
metaOff := binary.LittleEndian.Uint32(sst.data[len(sst.data)-suffixOffset:])

这种"版本兼容"的细节是存储系统里最让人头大的地方——改一个字段,读取路径就可能要改好几处。生产级实现通常在 SST 头部或尾部放一个版本号,解析时根据版本分派到不同的路径。

性能数据

把上面这些设计落到代码上,SST 各层的性能数字:

操作 延迟 备注
Bloom MayContain(命中) 17 ns 零分配
Bloom MayContain(未命中) 97 ns key 生成时的分配
Block 内二分查找 125 ns SeekToKey
SST 点查(5K keys) 1.1 µs meta 二分 + block 二分 + 值重建
SST 全扫描(5K keys) 164 µs 33 ns/entry,和裸 block 迭代同量级

点查比全扫描快 150 倍——这就是 meta index + Bloom filter 存在的意义。Bloom filter 在一个 SST 里可能只占几 KB,但它挡掉了大量无意义的 block 读取。

小结

SSTable 看似朴素,实际上每一层都在围绕磁盘特性做优化:

  1. Block 是 I/O 单位——按页组织,省掉小 I/O 的系统开销
  2. 前缀压缩——利用有序性,typical 节省 30% 空间
  3. 两层二分索引——meta 索引定位 block,block 内偏移表定位 entry
  4. Bloom filter——17 ns 就能排除"一定不存在",读路径的第一道关卡
  5. CRC32——block 级和文件级校验,兼顾精度与开销

下一篇回到引擎层面——写路径全流程,WAL 怎么设计,崩溃后怎么恢复。

lyyyuna 沪ICP备2025110782号-1