VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 架构演进:从 TSDB 到 MergeSet 的设计取舍

VictoriaMetrics 1.146.0 源码专题【左扬精讲】------ 架构演进:从 TSDB 到 MergeSet 的设计取舍

在时序数据库(TSDB)的世界里,存储引擎的设计直接决定了写入性能、查询效率和资源利用率。VictoriaMetrics 采用了独特的 MergeSet 存储架构,与传统 TSDB(如 Prometheus、InfluxDB)的 LSM Tree 或 B-Tree 有着本质区别。本篇文章将从源码层面深入剖析 MergeSet 的设计哲学,以及它相比传统 TSDB 的优势与取舍。
本文目录

  1. [TSDB 存储引擎演进史](#TSDB 存储引擎演进史)
  2. [MergeSet 核心设计:只合并不分层](#MergeSet 核心设计:只合并不分层)
  3. [源码解析:MergeSet vs LSM Tree](#源码解析:MergeSet vs LSM Tree)
  4. 设计取舍与适用场景
  5. 面试高频提问

一、TSDB 存储引擎演进史

思考记忆提示 --- 理解 TSDB 存储引擎的演进,才能理解 MergeSet 为什么会这样设计

  • 第一代 TSDB:基于 B-Tree(如 InfluxDB 1.x)
  • 第二代 TSDB:基于 LSM Tree(如 Prometheus 2.x、Cassandra)
  • 第三代 TSDB:MergeSet(VictoriaMetrics独创)
  • 面试高频提问:MergeSet 和 LSM Tree 的核心区别是什么?

1.1 传统 TSDB 的存储架构

在讨论 MergeSet 之前,我们需要了解传统 TSDB 的存储架构。主流的 TSDB(如 Prometheus 2.x)采用 LSM Tree(Log-Structured Merge Tree)作为底层存储引擎。

LSM Tree 的核心思想是:

  1. 写入时:数据先写入内存中的 MemTable(类似 WAL),达到阈值后刷盘生成 SSTable
  2. 合并时:多个 SSTable 按层次合并,小表合并成大表(这就是"分层"的概念)
  3. 查询时:需要读取多个层次的 SSTable,可能影响查询性能
text 复制代码
  LSM Tree 架构
┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                             │
│  Level 0 (L0)                                                              │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐                                     │
│  │ SSTable │ │ SSTable │ │ SSTable │ ← 新刷出的文件,小而多               │
│  └────┬────┘ └────┬────┘ └────┬────┘                                     │
│       │           │           │                                            │
│       └───────────┴───────────┘                                            │
│                     │                                                        │
│                     ▼                                                        │
│  Level 1 (L1)                                                              │
│  ┌───────────────────────────┐                                              │
│  │        SSTable            │ ← 合并后的文件,较大                         │
│  └─────────────┬─────────────┘                                              │
│                │                                                             │
│                ▼                                                             │
│  Level 2 (L2)                                                              │
│  ┌───────────────────────────┐                                              │
│  │        SSTable            │ ← 更大                                      │
│  └─────────────┬─────────────┘                                              │
│                │                                                             │
│                ▼                                                             │
│           ...                                                                │
│                                                                             │
│  问题:查询需要遍历所有层级,Level 越多,查询越慢                             │
└─────────────────────────────────────────────────────────────────────────────┘

1.2 Prometheus TSDB 的局限性

Prometheus 2.x 的 TSDB 基于 LSM Tree 设计,虽然相比 1.x 版本有了巨大提升,但在超大规模场景下仍面临挑战:

问题 描述 影响
分层合并开销 LSM Tree 需要多层合并,Level 越多 IO 越重 写入放大、写放大问题严重
查询延迟不稳定 查询需要遍历多个 Level,数据分散 P99 延迟难以控制
内存占用高 多层索引、BloomFilter 需要维护 RAM 消耗大

注意

Prometheus 的 LSM Tree 实现与 Cassandra/RocksDB 有一定区别,但核心问题类似。对于超大规模场景(如 100 万+ series),LSM Tree 的分层合并策略会成为性能瓶颈。

二、MergeSet 核心设计:只合并不分层

思考记忆提示 --- MergeSet 的精髓在于"只合并不分层"------这是它与 LSM Tree 的本质区别

  • MergeSet 不分层,所有 Part 文件在同一层级
  • 合并策略:小型 Part 合并成大型 Part,永远变大的单向合并
  • 设计优势:查询只需扫描少量大文件,IO 更高效

2.1 MergeSet 的核心概念

MergeSet 是 VictoriaMetrics 独创的存储架构,其核心设计哲学可以用一句话概括:"只合并不分层"。这与 LSM Tree 的"分层合并"形成鲜明对比。

在 lib/mergeset/table.go 中,MergeSet 的设计理念被清晰定义:

go 复制代码
 // lib/mergeset/table.go
// MergeSet 核心设计:只合并不分层

// MergeSet 与 LSM Tree 的本质区别:
// - LSM Tree: 分层合并,Level N 合并到 Level N+1
// - MergeSet: 不分层,所有 Part 文件在同一目录,按大小合并

// Part 文件的生命周期:
// InMemoryPart (新建)
//     ↓ (1秒后刷盘)
// Small Part (小文件,KB级别)
//     ↓ (合并)
// Big Part (大文件,MB级别)
//     ↓ (合并)
// 更大的 Part
//     ↓
// 最终的超大 Part

// 关键设计点:
// 1. Part 文件永不删除,只合并成更大的文件
// 2. 查询时扫描所有 Part,但利用 BloomFilter 快速跳过无关 Part
// 3. 后台任务持续合并小 Part 成大 Part,保持 Part 数量可控

2.2 MergeSet vs LSM Tree 对比

text 复制代码
  MergeSet 架构(VictoriaMetrics)
┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                             │
│  /data/                                                                     │
│  ├── 2024_01/                                                               │
│  │   ├── small_001.tar / small_002.tar / small_003.tar  ← 小文件,合并中     │
│  │   ├── big_001.tar                                ← 大文件,已稳定         │
│  │   ├── big_002.tar                                                              │
│  │   └── super_001.tar / super_002.tar              ← 更大文件             │
│  │                                                                     │
│  ├── 2024_02/    ...                                                        │
│  └── 2024_03/    ...                                                        │
│                                                                             │
│  特点:                                                                     │
│  - 所有 Part 文件在同一目录层级                                               │
│  - 小文件持续合并成大文件(单向合并)                                         │
│  - 查询扫描所有 Part,但用 BloomFilter 过滤                                  │
│  - IO 模式:顺序读大文件,而非随机读多层小文件                                 │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
维度 LSM Tree (Prometheus) MergeSet (VictoriaMetrics)
文件层级 多层(L0, L1, L2...) 单层(所有 Part 在同级目录)
合并方向 逐层向上合并 小 Part → 大 Part(单向)
查询方式 遍历所有层级 扫描所有 Part + BloomFilter
IO 模式 大量小文件随机读 少量大文件顺序读
写放大 严重(多层重复写) 轻量(只写一次)
查询延迟 不稳定(P99 难控制) 稳定(可预测)

源码视角:MergeSet 合并调度

MergeSet 的合并调度逻辑在 lib/mergeset/table.go 的 scheduleMerges() 函数中实现:

  • 默认配置 :defaultPartsToMerge=15,每次合并最多 15 个小 Part
  • 合并策略:优先合并"最老"的小 Part,避免大量小文件堆积
  • 并行合并 :通过 rawItemsShards 实现 CPU 级别的并行合并
  • ZSTD 压缩 :合并时自动选择压缩级别,getCompressLevel() 根据数据量动态选择

三、源码解析:MergeSet vs LSM Tree

思考记忆提示 --- 源码是理解 MergeSet 设计取舍的最佳途径

  • lib/mergeset/ 是 MergeSet 的核心实现
  • lib/storage/ 中的 Table/Partition 对接 MergeSet
  • 面试高频提问:MergeSet 为什么不需要 WAL?

3.1 InmemoryPart:1秒刷盘的原子性保证

MergeSet 不使用 WAL(Write-Ahead Log),而是通过 InmemoryPart 的原子性刷盘实现数据可靠性。这在 lib/mergeset/inmemory_part.go 中实现:

go 复制代码
 // lib/mergeset/inmemory_part.go
// InmemoryPart 核心设计:原子性刷盘

// 刷盘流程:
// 1. 内存中构建完整的 Part 数据(4 个 buffer 并行写入)
// 2. 调用 MustStoreToDisk() 原子性刷盘
// 3. 刷盘成功后才更新目录索引

// MustStoreToDisk 的关键点:
// - 先写临时文件(如 small_001.tar.tmp)
// - 刷盘成功后,原子性 rename 到正式文件名
// - 如果进程崩溃,临时文件会被忽略,不会污染数据

// 这就是为什么 MergeSet 不需要 WAL:
// - InmemoryPart 每秒刷盘,数据最多丢失 1 秒
// - 刷盘后的数据已经是完整可用的 Part 文件
// - 重启时扫描目录即可恢复所有 Part

3.2 Part 文件结构:四文件合一

MergeSet 的 Part 文件采用独特的四文件结构,这在 lib/mergeset/part.go 中定义:

text 复制代码
  MergeSet Part 文件结构
┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                             │
│  Part.tar 文件内部结构:                                                     │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │  metaindex.bin                                                        │    │
│  │  ├── [MetaIndexRow 1] ← Block 1 的元信息(offset, size, min/max)    │    │
│  │  ├── [MetaIndexRow 2] ← Block 2 的元信息                              │    │
│  │  └── [MetaIndexRow N] ← Block N 的元信息                              │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │  index.bin                                                            │    │
│  │  ├── [IndexRow 1] ← MetricName → BlockID 映射                        │    │
│  │  ├── [IndexRow 2]                                                      │    │
│  │  └── [IndexRow N]                                                      │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │  items.bin                                                            │    │
│  │  ├── [Item 1] ← 时序数据点(Timestamp + Value)                       │    │
│  │  ├── [Item 2]                                                          │    │
│  │  └── [Item N]                                                          │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │  lens.bin                                                             │    │
│  │  └── 每行的长度信息(用于快速随机访问)                                  │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                                                             │
│  关键设计点:                                                               │
│  - metaindex.bin:Block 的索引,用于快速定位数据                            │
│  - index.bin:MetricName 倒排索引,用于标签查询                            │
│  - items.bin:实际数据,commonPrefix 压缩                                  │
│  - lens.bin:行长度,用于随机访问                                          │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

小贴士 --- 为什么 Part 文件是 .tar 格式?

.tar 格式最初用于将多个文件打包成一个便于传输。在 MergeSet 中,.tar 格式用于将 metaindex、index、items、lens 四个文件打包成一个 Part。.tar 本身不压缩,压缩发生在 items.bin 内部的 ZSTD 压缩。

3.3 commonPrefix 压缩:存储空间减少 30-50%

MergeSet 的另一大优化是 commonPrefix 压缩 ,在 lib/mergeset/block_header.go 中实现:

go 复制代码
 // lib/mergeset/block_header.go
// commonPrefix 压缩原理

// BlockHeader 结构:
type BlockHeader struct {
    // commonPrefix 长度:当前 Block 与前一个 Block 的公共前缀长度
    CommonPrefixLen uint64
    
    // 第一个 Item 的元信息
    FirstItemMeta uint64
    
    // 最后一个 Item 的元信息
    LastItemMeta uint64
    
    // Items 数量
    ItemsCount uint64
    
    // 压缩类型(NearestDelta / ZSTD / None)
    CompressionType uint64
}

// 压缩示例:
// 未压缩:[2024-01-01 10:00:00] cpu_usage{job="prometheus",instance="localhost:9090"} 95.5
// 压缩后:[2024-01-01 10:00:00] cpu_usage{job="prometheus",instance="localhost:9090"} 95.5
//           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 全部存储
//           ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
//           只存一次,后面的 Block 只存差异

// 实际效果:
// - 时序数据通常有很长的共同前缀(标签名+标签值模式固定)
// - commonPrefix 压缩可以将存储空间减少 30-50%
// - 同时保持解码速度(不需要解压,只需提取差异部分)

四、设计取舍与适用场景

设计精髓

MergeSet 的设计哲学是"用空间换时间,用简单换性能"。放弃 WAL 换来的是写入的极致简单;只合并不分层换来的是查询的可预测性。

4.1 MergeSet 的优势

优势 原因 实际效果
写入简单 不需要 WAL,不需要复杂的两阶段写入 写入延迟极低
查询稳定 扫描大文件而非多层小文件 P99 延迟可控
资源高效 commonPrefix + ZSTD 双重压缩 存储空间减少 50%+
运维简单 无分层,无复杂合并策略 调参少,易理解

4.2 MergeSet 的取舍

取舍 描述 影响
无 WAL 进程崩溃可能丢失最多 1 秒数据 不适用于数据零丢失的金融场景
Part 数量膨胀 高写入场景下,小 Part 产生速度快于合并 需要足够的 CPU 进行后台合并
查询全扫描 查询需要遍历所有 Part(虽然有 BloomFilter) 超多 Part 时查询变慢

4.3 适用场景对比

text 复制代码
  VictoriaMetrics MergeSet vs Prometheus LSM Tree vs InfluxDB TSM
┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                             │
│  场景                              │  Prometheus  │  InfluxDB  │    VM       │
│  ─────────────────────────────────┼─────────────┼────────────┼────────────│
│  超大规模 series (1000万+)        │      ⚠️     │     ⚠️     │     ✅      │
│  高写入吞吐 (100万 samples/s)     │      ⚠️     │     ⚠️     │     ✅      │
│  稳定 P99 查询延迟                │      ⚠️     │     ⚠️     │     ✅      │
│  低内存占用                       │      ⚠️     │     ⚠️     │     ✅      │
│  数据零丢失要求                   │      ✅     │     ✅     │     ⚠️      │
│  运维简单优先                     │      ⚠️     │     ⚠️     │     ✅      │
│  开源生态成熟                     │      ✅     │     ⚠️     │     ⚠️      │
│                                                                             │
│  ✅ 强烈推荐  ⚠️ 可用但非最优  ❌ 不推荐                                 │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

五、面试高频提问

面试问答 --- 本节精选面试高频问题,直接命中面试官想听到的答案

Q1:MergeSet 和 LSM Tree 的核心区别是什么?

**核心区别在于"是否分层"。**LSM Tree 将数据分成多层(L0, L1, L2...),每层大小递增,合并时需要逐层向上合并。MergeSet 不分层,所有 Part 文件在同一目录,小 Part 持续合并成大 Part,合并方向是单向的(Small → Big → Bigger)。这种设计让 MergeSet 的查询更稳定(只需扫描少量大文件),而 LSM Tree 的查询需要遍历多个层级。

Q2:MergeSet 为什么不需要 WAL?

因为 MergeSet 使用 InmemoryPart 的原子性刷盘代替 WAL。 在 lib/mergeset/inmemory_part.go 中,MustStoreToDisk() 函数先写临时文件,刷盘成功后原子性 rename 到正式文件名。进程崩溃时,临时文件会被忽略,不会污染数据。代价是最多丢失 1 秒数据。

Q3:MergeSet 的 Part 文件为什么是 .tar 格式?

**.tar 用于打包 metaindex、index、items、lens 四个文件。**每个 Part.tar 内部包含 4 个二进制文件:metaindex.bin(Block 索引)、index.bin(标签倒排索引)、items.bin(实际时序数据)、lens.bin(行长度)。.tar 本身不压缩,压缩发生在 items.bin 内部,通过 commonPrefix + ZSTD 实现 30-50% 的存储节省。

Q4:MergeSet 如何保证查询性能?

**通过 BloomFilter + 顺序读大文件 + commonPrefix 压缩。**查询时先通过 BloomFilter 快速判断某个 Part 是否可能包含目标数据,跳过不相关的 Part。对于可能包含数据的 Part,顺序读取大文件比 LSM Tree 的随机读多层小文件更高效。commonPrefix 压缩减少了解码数据量,进一步提升查询速度。
全篇必记口诀

MergeSet 的精髓是"只合并不分层":InmemoryPart 原子性刷盘替代 WAL,BloomFilter 过滤减少 IO,commonPrefix + ZSTD 双重压缩节省空间。记住这个口诀:"合并不分层,刷盘无 WAL,大文件顺序读"
VictoriaMetrics MergeSet LSM Tree 存储引擎 时序数据库