
传统 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{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 的取舍