惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

L
LangChain Blog
V
V2EX
爱范儿
爱范儿
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Martin Fowler
Martin Fowler
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Apple Machine Learning Research
Apple Machine Learning Research
WordPress大学
WordPress大学
有赞技术团队
有赞技术团队
宝玉的分享
宝玉的分享
Last Week in AI
Last Week in AI
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
罗磊的独立博客
小众软件
小众软件
Vercel News
Vercel News
博客园 - 司徒正美
阮一峰的网络日志
阮一峰的网络日志
V
Visual Studio Blog
J
Java Code Geeks
P
Proofpoint News Feed
MongoDB | Blog
MongoDB | Blog
B
Blog
美团技术团队
量子位

博客园_首页

Plist 二进制格式 Milvus 和 PGVector,哪个更好? OpenClaw 已过时?在 VS Code 中运行 Hermes Agent! 第30篇文章:一个大三计科生的自白 Manim如何在数学公式中完美显示中文? Docker 部署 RocketMQ 5 并发编程核心概念辨析 C#事务处理最佳实践:别再让“主表存了、明细丢了”的破事发生 CLI 是什么?为什么大厂突然集体卷命令行? 【从0到1构建一个ClaudeAgent】协作-自主Agent UIImageView 设置图片不生效的原因排查 最小二乘问题详解20:无先验约束下的增量式SFM自由网平差 痞子衡嵌入式:大话双核i.MXRT1180之XIP应用里借助MU实现可靠Flash IAP的方法 AI Chat 封装, SemanticKerne.AiProvider.Unified 已发布 Windows下右键编辑js文件无法打开记事本——在注册表中使用环境变量 在后台服务中使用 Scoped 服务,为什么总是报错? H200 安装驱动并使用sglang启动模型 wireshark 抓包Trap上报告警内容 我用 AI 辅助开发了一系列小工具(2):图片压缩工具 [A Primer On MC and CC] 2.1 Memory Consistency 1 - 指令重排序和 SC 模型 Oracle数据库SCN推进技术详解与实践指南 玩转控件:封装个带图片的Label控件 Claude Code 4.7 真正该升级的不是模型,而是你的工作流 前端小白一句话,AI 帮我做了个颜值拉满的桌面媒体播放器。当代码不再是门槛,一句话编程就是现实。 5. WorkBuddy: 小龙虾的灵魂三件套,让你的小龙虾不只是工具 SQLite 分片方案实战:三种分片策略的深度对比 告别简陋 UI!一款基于 Fluent Design 和基于 WinUI 的开源免费、现代化的 Avalonia UI 控件库 关于二进制排列组合枚举的总结 AI开发-python-LangGraph框架(3-27-LangGraph从零实现大模型智能决策工作流) ElasticSearch主分片和副本分片概念详解
VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 架构演进:...
左扬 · 2026-06-29 · via 博客园_首页

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

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

一、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,可能影响查询性能
  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 的设计理念被清晰定义:

 // 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 对比

  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.goscheduleMerges() 函数中实现:

  • 默认配置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 中实现:

 // 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 中定义:

  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 中实现:

 // 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 适用场景对比

  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,大文件顺序读"