VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 架构演进:从 TSDB 到 MergeSet 的设计取舍
一、TSDB 存储引擎演进史思考记忆提示— 理解 TSDB 存储引擎的演进才能理解 MergeSet 为什么会这样设计第一代 TSDB基于 B-Tree如 InfluxDB 1.x第二代 TSDB基于 LSM Tree如 Prometheus 2.x、Cassandra第三代 TSDBMergeSetVictoriaMetrics独创面试高频提问MergeSet 和 LSM Tree 的核心区别是什么1.1 传统 TSDB 的存储架构在讨论 MergeSet 之前我们需要了解传统 TSDB 的存储架构。主流的 TSDB如 Prometheus 2.x采用LSM TreeLog-Structured Merge Tree作为底层存储引擎。LSM Tree 的核心思想是写入时数据先写入内存中的 MemTable类似 WAL达到阈值后刷盘生成 SSTable合并时多个 SSTable 按层次合并小表合并成大表这就是分层的概念查询时需要读取多个层次的 SSTable可能影响查询性能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 万 seriesLSM Tree 的分层合并策略会成为性能瓶颈。二、MergeSet 核心设计只合并不分层思考记忆提示— MergeSet 的精髓在于只合并不分层——这是它与 LSM Tree 的本质区别MergeSet 不分层所有 Part 文件在同一层级合并策略小型 Part 合并成大型 Part永远变大的单向合并设计优势查询只需扫描少量大文件IO 更高效2.1 MergeSet 的核心概念MergeSet 是 VictoriaMetrics 独创的存储架构其核心设计哲学可以用一句话概括只合并不分层。这与 LSM Tree 的分层合并形成鲜明对比。在 lib/mergeset/table.go 中MergeSet 的设计理念被清晰定义// lib/mergeset/table.go // MergeSet 核心设计只合并不分层 // MergeSet 与 LSM Tree 的本质区别 // - LSM Tree: 分层合并Level N 合并到 Level N1 // - 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 对比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 BloomFilterIO 模式大量小文件随机读少量大文件顺序读写放大严重多层重复写轻量只写一次查询延迟不稳定P99 难控制稳定可预测源码视角MergeSet 合并调度MergeSet 的合并调度逻辑在 lib/mergeset/table.go 的 scheduleMerges() 函数中实现默认配置defaultPartsToMerge15每次合并最多 15 个小 Part合并策略优先合并最老的小 Part避免大量小文件堆积并行合并通过 rawItemsShards 实现 CPU 级别的并行合并ZSTD 压缩合并时自动选择压缩级别getCompressLevel() 根据数据量动态选择三、源码解析MergeSet vs LSM Tree思考记忆提示— 源码是理解 MergeSet 设计取舍的最佳途径lib/mergeset/ 是 MergeSet 的核心实现lib/storage/ 中的 Table/Partition 对接 MergeSet面试高频提问MergeSet 为什么不需要 WAL3.1 InmemoryPart1秒刷盘的原子性保证MergeSet 不使用 WALWrite-Ahead Log而是通过InmemoryPart的原子性刷盘实现数据可靠性。这在 lib/mergeset/inmemory_part.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 文件 // - 重启时扫描目录即可恢复所有 Part3.2 Part 文件结构四文件合一MergeSet 的 Part 文件采用独特的四文件结构这在 lib/mergeset/part.go 中定义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.binBlock 的索引用于快速定位数据 │ │ - index.binMetricName 倒排索引用于标签查询 │ │ - 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 中实现// 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{jobprometheus,instancelocalhost:9090} 95.5 // 压缩后[2024-01-01 10:00:00] cpu_usage{jobprometheus,instancelocalhost: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 适用场景对比VictoriaMetrics MergeSet vs Prometheus LSM Tree vs InfluxDB TSM ┌─────────────────────────────────────────────────────────────────────────────┐ │ │ │ 场景 │ Prometheus │ InfluxDB │ VM │ │ ─────────────────────────────────┼─────────────┼────────────┼────────────│ │ 超大规模 series (1000万) │ ⚠️ │ ⚠️ │ ✅ │ │ 高写入吞吐 (100万 samples/s) │ ⚠️ │ ⚠️ │ ✅ │ │ 稳定 P99 查询延迟 │ ⚠️ │ ⚠️ │ ✅ │ │ 低内存占用 │ ⚠️ │ ⚠️ │ ✅ │ │ 数据零丢失要求 │ ✅ │ ✅ │ ⚠️ │ │ 运维简单优先 │ ⚠️ │ ⚠️ │ ✅ │ │ 开源生态成熟 │ ✅ │ ⚠️ │ ⚠️ │ │ │ │ ✅ 强烈推荐 ⚠️ 可用但非最优 ❌ 不推荐 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘五、面试高频提问