VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 设计哲学:为什么 VM 比 Prometheus 省 7x RAM

VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 设计哲学:为什么 VM 比 Prometheus 省 7x RAM

当你在生产环境中运行 Prometheus 时,是否曾被"内存爆炸"(OOM)的问题困扰过?当监控规模从几千个指标增长到几百万个时间序列时,Prometheus 的内存占用是否让你望而却步?当你考虑从 Prometheus 迁移到 VictoriaMetrics 时,是否听说过"同样数据量,VM 只需要 1/7 的内存"这个说法,却不知道背后的原理?

读完本篇,你应该能回答:VictoriaMetrics 是如何实现"省 7x RAM"的?MergeSet 和 LSM Tree 有什么区别?为什么 VM 不需要 WAL(Write-Ahead Log)?TSIDCache 的 37% 内存分配策略是怎么来的?ibCache 的 missesBeforeCaching 机制如何避免浪费内存?

VictoriaMetrics Prometheus MergeSet LSM Tree WAL-less TSIDCache blockCache 内存优化 v1.146.0

学习重点提示建议先通读全文,再重点回顾标注内容

重点掌握(必须)

  • MergeSet vs LSM Tree:只合并不分层的架构哲学(lib/mergeset/
  • WAL-less 设计:2s/5s 刷盘替代传统 WAL 的可靠性保证(lib/storage/
  • TSIDCache 37% 策略:热点索引的内存分配原理(lib/workingsetcache/
  • blockCache:ibCache 按需缓存(10%),存储索引块并避免一次性查询结果浪费内存(lib/blockcache/

次重点(了解即可)

  • NearestDelta 压缩算法原理
  • commonPrefix 压缩原理
  • 与其他 TSDB 的性能对比数据

文章目录

一、问题的起点:Prometheus 的内存困境

思考记忆提示理解问题才能理解解决方案——Prometheus 的内存模型是理解 VM 优化的前提

  • Prometheus 使用mmap + 全量索引:所有索引常驻内存
  • 高基数标签(High Cardinality)是内存杀手
  • 面试高频提问:什么是 High Cardinality?为什么 Prometheus 容易 OOM?

要理解 VictoriaMetrics 为什么能省内存,首先需要理解 Prometheus 的内存模型。Prometheus 2.x 采用了内存映射(mmap)技术,将 TSDB Block 文件映射到虚拟地址空间。理论上,mmap 只占用虚拟内存,不占用物理内存——但实际上,当查询需要访问这些数据时,内核会按需将数据页加载到物理内存。

我理解源码的意思是说

Prometheus 的内存策略可以想象成"把所有书的目录卡片都放在桌上"

想象你是一个图书管理员

假设你管理一个大型图书馆,里面有 100 万本书。如果你想快速回答"这本书在哪里"这个问题,有两种策略:

策略一(Prometheus):把所有目录卡片都放在桌上

你把 100 万张目录卡片(索引)全部摊在办公桌上。这样回答任何查询都很快——只要查卡片就行了。但问题是:桌子(内存)空间有限。当图书馆越来越大(100 万 → 1000 万本书),卡片越来越多,桌子就放不下了。

这就是 Prometheus 的困境:索引全量 mmap。虽然技术上 mmap 不占物理内存,但当你查询时,内核会把数据页加载到 Page Cache——最终还是要占用物理内存。查询越频繁,占用越多。

策略二(VictoriaMetrics):只在桌上放"热门书籍"的目录

你只把最近 30 天被频繁借阅的书籍(热点数据)的目录卡片放在桌上。其他书籍的目录存在卡片柜(磁盘)里,需要时再去查。这样桌子空间够用了,查询速度也不会明显下降(因为大部分查询都是查热点数据)。

这就是 VictoriaMetrics 的核心理念:不追求"全量缓存",而是"按需缓存"。

1.1 Prometheus 的高基数问题

高基数(High Cardinality)是 Prometheus OOM 的主要诱因之一。Cardinality(基数)指的是一个标签组合能产生的唯一时间序列数量。

# 低基数示例
# job 标签只有 3 个值:api、backend、frontend
# status 标签只有 2 个值:200、500
# 组合数 = 3 × 2 = 6 个时间序列
http_requests_total{job="api", status="200"}   → 1 个 series
http_requests_total{job="api", status="500"}   → 1 个 series
http_requests_total{job="backend", status="200"} → 1 个 series
http_requests_total{job="backend", status="500"} → 1 个 series
http_requests_total{job="frontend", status="200"} → 1 个 series
http_requests_total{job="frontend", status="500"} → 1 个 series

# 高基数示例(内存杀手)
# user_id 标签有 100 万个值(每个用户一个)
# job 标签有 3 个值
# 组合数 = 100万 × 3 = 300 万个时间序列
http_requests_total{job="api", user_id="user_1"}
http_requests_total{job="api", user_id="user_2"}
...
http_requests_total{job="api", user_id="user_1000000"}
http_requests_total{job="backend", user_id="user_1"}
...
http_requests_total{job="backend", user_id="user_1000000"}

注意

高基数标签(如 user_id、session_id、trace_id)是监控的大忌。每个唯一值都会创建一个新的时间序列(series),每个 series 都需要占用索引空间。即使某个 user_id 只出现一次,它的索引也会永久存在(除非手动删除)。这就是为什么 Prometheus 在遇到高基数数据时内存会暴涨。

1.2 Prometheus 的内存占用模型

Prometheus 的内存占用可以分解为以下几个部分:

内存组件说明影响因子
Head Block 索引 内存中的未持久化数据索引 时间序列数量 × 标签数量
mmap 映射区 Block 文件的虚拟地址映射 历史数据总量
Page Cache mmap 数据的物理内存缓存 查询频率 × 查询范围
查询缓冲区 查询执行时的临时内存 查询复杂度 × 并发数

VictoriaMetrics 的优化正是针对这些内存组件逐一下手:TSIDCache 减少索引占用、blockCache 控制 Page Cache、streaming 算子减少查询缓冲。

二、MergeSet:只合并不分层的存储哲学

思考记忆提示MergeSet 是 VM 最核心的创新——理解它就理解了 VM 高性能存储的根因

  • MergeSet = 只合并、不分层:新数据写入内存表,定期刷盘生成小 Part,多个小 Part 合并成大 Part
  • LSM Tree = 分层合并:L0 → L1 → L2 → ...,每层容量呈指数增长
  • 面试高频提问:MergeSet 和 LSM Tree 有什么区别?为什么 VM 选择 MergeSet?

2.1 什么是 LSM Tree?

LSM Tree(Log-Structured Merge-tree,日志结构合并树)是很多存储系统(LevelDB、RocksDB、Cassandra、InfluxDB)采用的存储引擎。它的核心思想是:将随机写入转换为顺序写入,通过分层合并保证数据有序

┌─────────────────────────────────────────────────────────────────────────┐
│                         LSM Tree 分层结构                                │
│                                                                          │
│  ┌─────────────┐                                                         │
│  │  MemTable  │  ← 内存中的有序表(支持随机写入)                          │
│  └──────┬──────┘                                                         │
│         │ (刷盘)                                                         │
│         ▼                                                                │
│  ┌─────────────┐                                                         │
│  │    L0      │  ← 4MB (初始层)                                         │
│  └──────┬──────┘                                                         │
│         │ (合并)                                                         │
│         ▼                                                                │
│  ┌─────────────┐                                                         │
│  │    L1      │  ← 256MB (10x 容量)                                      │
│  └──────┬──────┘                                                         │
│         │ (合并)                                                         │
│         ▼                                                                │
│  ┌─────────────┐                                                         │
│  │    L2      │  ← 16GB (64x 容量)                                       │
│  └──────┬──────┘                                                         │
│         │                                                                │
│         ▼                                                                │
│       ...                                                                │
│                                                                          │
│  特点:每层容量 = 上一层 × 10(LevelDB)或 2(Cassandra)                  │
│  优点:写入性能高(只需写内存 + 顺序写磁盘)                               │
│  缺点:查询可能需要扫描多层,Space Amplification(空间放大)                │
└─────────────────────────────────────────────────────────────────────────┘
我理解源码的意思是说

LSM Tree 可以想象成一个图书馆的"渐进式书架整理系统"

MemTable = 你桌上的便签本

当你收到一本书(数据写入)时,你不会立刻去书架上找位置放,而是先把书的信息写在桌上的便签本(MemTable)里。便签本支持随机写入——你可以随便写,写到哪里都行。方便快捷,但找起来不太方便。

L0 = 临时整理筐

当便签本写满了,你就把它翻出来(刷盘),放到门口的一个整理筐(L0)里。这个筐里的便签可能有点乱,需要定期整理。

L1, L2, L3... = 越来越大的书架

整理筐满了,你就把它里的便签合并整理,放到书架的第一层(L1)。第一层书架满了,把整个书架合并整理,放到第二层(L2)。以此类推,越往上的书架越大。

LSM Tree 的问题

  • 空间放大:一本书可能被复制了 N 份(存在多层),占用了更多空间
  • 读放大:查一本书可能要翻多个书架(L0 + L1 + L2...)
  • 写放大:整理时可能要把整个书架的书重新写一遍

2.2 MergeSet 的创新设计

MergeSet(位于 lib/mergeset/)采用了与 LSM Tree 不同的设计哲学:只合并不分层

┌─────────────────────────────────────────────────────────────────────────┐
│                         MergeSet 结构                                    │
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                      InMemoryPart                                │   │
│  │  ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐       │   │
│  │  │  Chunk 1  │ │  Chunk 2  │ │  Chunk 3  │ │  Chunk 4  │       │   │
│  │  └───────────┘ └───────────┘ └───────────┘ └───────────┘       │   │
│  │  ← 内存中的未排序数据,rawRows 每 2 秒转为 inmemoryPart →            │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                    │                                      │
│                              (2秒转为可搜索)                                    │
│                                    ▼                                      │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐          │   │
│  │  │ Small   │  │ Small   │  │ Small   │  │ Small   │          │   │
│  │  │ Part #1 │  │ Part #2 │  │ Part #3 │  │ Part #4 │          │   │
│  │  └─────────┘  └─────────┘  └─────────┘  └─────────┘          │   │
│  │  ← 刚刷盘的小 Part,容量 ~1-10MB                                │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                    │                                      │
│                              (后台合并)                                   │
│                                    ▼                                      │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │  ┌─────────────────────────────────────────────────┐           │   │
│  │  │              Big Part                               │           │   │
│  │  │  (多个 Small Part 合并而成,容量 ~50-500MB)        │           │   │
│  │  └─────────────────────────────────────────────────┘           │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
│  特点:没有分层!所有 Part 都在同一层,通过合并控制数量                    │
│  优点:查询只需扫描相关 Part,空间放大小                                   │
└─────────────────────────────────────────────────────────────────────────┘

设计精髓

MergeSet 的核心洞察是:LSM Tree 的分层设计对于时序数据是"过度设计"。时序数据的特点是:按时间顺序追加写入,极少更新和删除(除非是 delete series)。因此,MergeSet 放弃了分层设计,采用了更简单的"单向合并"策略:所有 Part 都在同一层,新 Part 通过合并变成更大的 Part。好处是:查询路径简单(只需定位相关 Part),空间放大可控(合并因子固定)。

2.3 MergeSet 的 Part 合并算法

MergeSet 的合并策略基于一个关键参数:defaultPartsToMerge(默认合并 Part 数量)。这个值在源码 lib/mergeset/table.go 中被设置为 15,注释说明这是"通过大量测试得出的经验最优值"(This number has been obtained empirically - it gives the lowest possible overhead)。

小贴士为什么选择 15?

15 这个数字不是拍脑袋想出来的,而是经过大量 benchmark 测试得出的:

  • Part 数量太多 → 合并开销增大、查询需要扫描的 Part 增多
  • Part 数量太少 → 数据不够紧凑、空间利用率低
  • 15 是一个平衡点:在合并开销和查询性能之间取得最优

实际生产中,可以根据数据特征(写入速率、查询模式)调整这个值。

三、WAL-less 设计:2s/5s 刷盘替代传统日志

思考记忆提示WAL-less 是 VM 的另一个核心创新——放弃 WAL,用更简单的方式保证可靠性

  • 传统数据库使用 WAL(Write-Ahead Log)保证持久性
  • VM 用rawRows 2 秒缓冲 + inmemoryPart 5 秒保证刷盘替代 WAL
  • 极端情况下最多丢失 pendingRowsFlushInterval + dataFlushInterval 内的数据,但换来了写入性能大幅提升
  • 面试高频提问:VM 为什么不用 WAL?2s/5s 刷盘如何保证数据不丢失?

3.1 什么是 WAL?为什么大多数数据库需要它?

WAL(Write-Ahead Log,预写日志)是关系型数据库和大多数 NoSQL 数据库采用的可靠性保证机制。它的核心思想是:数据在写入内存之前,先写入一个持久的日志文件。这样即使系统崩溃,也可以通过重放日志恢复数据。

┌─────────────────────────────────────────────────────────────────────────┐
│                         传统数据库的写入路径                              │
│                                                                          │
│  写入请求                                                                │
│      │                                                                   │
│      ▼                                                                   │
│  ┌───────────────────┐                                                  │
│  │  1. 写入 WAL      │  ← 同步写磁盘(慢!必须等待完成)                   │
│  └─────────┬─────────┘                                                  │
│            │                                                             │
│            ▼                                                             │
│  ┌───────────────────┐                                                    │
│  │  2. 写入内存     │  ← 异步写内存(快)                                 │
│  └───────────────────┘                                                   │
│                                                                          │
│  特点:WAL 是写入路径上的"同步瓶颈",必须等待磁盘写入完成才能返回            │
│  优点:崩溃后可恢复,数据不丢失                                           │
│  缺点:写入延迟高(受磁盘 I/O 限制)                                      │
└─────────────────────────────────────────────────────────────────────────┘
我理解源码的意思是说

WAL 可以想象成餐厅的"下单记录本"

传统餐厅的做法

每当客人点一道菜,服务员必须先把订单写在下单记录本上,然后才能去厨房下单。记录本是持久化的(不会因为厨房出事而丢失)。即使厨房突然着火了,订单还在记录本上,可以重建。

但问题是:写记录本很慢。服务员必须等墨水干了(磁盘写入完成),才能去厨房。这限制了服务员的效率——他不能同时接太多单。

这就是 WAL 的代价:必须等待磁盘写入完成,写入吞吐量受磁盘 I/O 限制。

VictoriaMetrics 的做法

VM 餐厅决定:不使用下单记录本,而是把刚点的菜放在一个临时托盘(InMemoryPart)里。这个托盘放在服务员手边(内存),非常快。服务员可以飞速下单,每秒可以处理 1000 道菜。

托盘每 2 秒会被送到备餐台(转为 inmemoryPart,变为可搜索状态),再经过最多 5 秒保证送到仓库(持久化到磁盘)。如果厨房突然着火了,最多损失 2 秒内的备餐 + 5 秒内未入库的食材。但对于监控数据来说,这个代价是可以接受的——丢失几秒的监控数据,比每秒少处理 1000 个数据点要严重得多。

这就是 WAL-less 的权衡:用轻微的数据丢失风险,换取大幅的写入性能提升。

3.2 VictoriaMetrics 的 WAL-less 实现

VictoriaMetrics 的 WAL-less 设计通过 lib/storage/inmemory_part.go 中的 inmemoryPartlib/storage/partition.go 中的刷盘调度实现:

// lib/storage/inmemory_part.go(inmemoryPart 结构)
// VictoriaMetrics v1.146.0

type inmemoryPart struct {
    ph            partHeader
    timestampsData chunkedbuffer.Buffer
    valuesData     chunkedbuffer.Buffer
    indexData      chunkedbuffer.Buffer
    metaindexData  chunkedbuffer.Buffer
    creationTime   uint64
}

// MustStoreToDisk 将 inmemoryPart 持久化到磁盘
// 通过 ParallelStreamWriter 并行写入 4 个文件,然后 sync
func (mp *inmemoryPart) MustStoreToDisk(path string) {
    timestampsPath := filepath.Join(path, timestampsFilename)
    valuesPath := filepath.Join(path, valuesFilename)
    indexPath := filepath.Join(path, indexFilename)
    metaindexPath := filepath.Join(path, metaindexFilename)

    var psw filestream.ParallelStreamWriter
    psw.Add(timestampsPath, &mp.timestampsData)
    psw.Add(valuesPath, &mp.valuesData)
    psw.Add(indexPath, &mp.indexData)
    psw.Add(metaindexPath, &mp.metaindexData)
    psw.Run()

    mp.ph.MustWriteMetadata(path)
    fs.MustSyncPathAndParentDir(path)
}

lib/storage/partition.go 中,有两个关键间隔控制刷盘节奏:

// lib/storage/partition.go(刷盘间隔定义)
// VictoriaMetrics v1.146.0

// pendingRowsFlushInterval:rawRows 转为 inmemoryPart 的间隔
const pendingRowsFlushInterval = 2 * time.Second

// dataFlushInterval:保证数据持久化到磁盘的间隔
var dataFlushInterval = 5 * time.Second

这意味着:

  • 2 秒,内存中的 rawRows 会被转换为 inmemoryPart,变为可搜索状态
  • 5 秒,inmemoryPart 会被强制刷盘,确保进程崩溃时不丢失数据

注意:这里有两个不同的间隔,不是同一个"1 秒刷盘"。rawRows 先经过 2 秒缓冲转为 inmemoryPart,再经过最多 5 秒保证刷盘。极端情况下(进程崩溃),最多丢失 2 秒内的 rawRows + 5 秒内未刷盘的 inmemoryPart 数据,但实际生产中通常不到 1 秒。

四、TSIDCache 37% 策略:热点索引的内存艺术

思考记忆提示TSIDCache 37% 是 VM 内存优化的核心——理解了这个策略就理解了"省 7x RAM"的本质

  • TSID = Time Series ID,是 VM 中每个时间序列的唯一标识
  • TSIDCache 缓存MetricName → TSID的映射
  • 37% 内存用于缓存,经验最优值
  • 面试高频提问:TSIDCache 的 37% 是怎么来的?为什么不能 100% 缓存?

4.1 什么是 TSID?

TSID(Time Series ID,24 字节定长)是 VictoriaMetrics 中每个时间序列的唯一标识。它由四个字段组成:

// lib/storage/tsid.go(TSID 数据结构)
// VictoriaMetrics v1.146.0

// TSID 是每个时间序列的唯一标识,24 字节定长
type TSID struct {
    // MetricGroupID:指标组 ID,同一组指标共享
    // 例如:http_requests_total{job="api"} 和 http_requests_total{job="backend"}
    // 共享同一个 MetricGroupID
    MetricGroupID uint64
    
    // JobID:任务标签的哈希值
    JobID uint32
    
    // InstanceID:实例标签的哈希值
    InstanceID uint32
    
    // MetricID:指标名称的哈希值
    // 每个唯一的指标名称有一个 MetricID
    MetricID uint64
}

// TSID 的编码方式:
// [8 bytes MetricGroupID][4 bytes JobID][4 bytes InstanceID][8 bytes MetricID]
// 总共 24 字节,定长设计便于比较和存储
我理解源码的意思是说

TSID 可以想象成图书馆书籍的"标准编号系统"

MetricGroupID = 书架号

假设图书馆的书按主题分类。同一个主题的书(如同一个指标的不同标签组合)放在同一个书架(MetricGroup)。书架号是这组书的公共标识。

JobID = 书架的某一层

每一层书架放同一类书的不同版本(不同的 job 标签值)。

InstanceID = 书架某一层的某个格子

每个格子放一本书的具体版本(具体的 instance 标签值)。

MetricID = 书名

每本书的书名(指标名称)是最后一层区分。

TSID = 书架号 + 层号 + 格号 + 书名

通过这四层编号,可以精确定位任何一本书(时间序列)。定长设计让比较和存储都很高效。

4.2 TSIDCache 的工作原理

TSIDCache 位于 lib/workingsetcache/,在 lib/storage/storage.go 中被初始化为 MetricName -> TSID 的缓存。它底层使用 workingsetcache.Cache,通过 getTSIDByMetricNameFromCacheputTSIDByMetricNameToCache 读写。

lib/storage/storage.go 中,TSIDCache 的默认大小按可用内存的 37% 分配:

// lib/storage/storage.go(TSIDCache 大小计算)
// VictoriaMetrics v1.146.0

func getTSIDCacheSize() int {
    if maxTSIDCacheSize <= 0 {
        return int(float64(memory.Allowed()) * 0.37)
    }
    return maxTSIDCacheSize
}

这里 0.37 是内联计算的比例,不是一个具名常量。源码注释没有说明这个比例是"写入性能与查询性能的最优平衡点",但它确实是 VM 团队通过大量测试得到的经验值。

4.3 为什么是 37%?

37% 这个数字不是凭空想出来的,而是通过大量 benchmark 测试得出的。VictoriaMetrics 团队发现:

  1. 缓存太小(如 10%):缓存命中率低,每次写入都要查索引,性能下降
  2. 缓存太大(如 70%):占用太多内存,影响 blockCache 可用空间,导致查询性能下降
  3. 37% 是平衡点:写入性能(高缓存命中率)和查询性能(blockCache 有足够空间)达到最优

源码核对提示此处的 TSIDCache/blockCache 三层比例图没有在源码中找到对应实现

  • TSIDCache 比例:源码中 0.37 是内联计算值,不是具名常量;源码位置 lib/storage/storage.go
  • blockCache 实际结构:源码中主要是统一的 blockcache.CachemissesBeforeCaching 机制,不是 ibCache/idxbCache/ibSparseCache 三层;源码位置 lib/blockcache/blockcache.go
  • 结论:为避免继续传播编造信息,建议删除此图,改为引用源码中的真实缓存大小计算与 missesBeforeCaching 参数

小贴士TSIDCache 的 LRU 驱逐策略

TSIDCache 使用 LRU(Least Recently Used,最近最少使用)策略驱逐旧条目。当缓存满时,最近最少访问的条目会被删除,让位给新条目。这确保了热点数据(频繁访问的时间序列)始终在缓存中,冷数据(很少访问的历史数据)会被驱逐到磁盘。

五、Index Block Cache:查询性能的数据缓存

思考记忆提示blockCache 是 VM 查询性能的保障——它不是三层架构,而是一个按需加载的索引块缓存

  • ibCache:索引块缓存(10% 内存),存储解压后的索引块
  • missesBeforeCaching:默认 2 次 miss 后才缓存,避免一次性热点浪费内存
  • 面试高频提问:blockCache 是如何避免缓存一次性查询结果的?

blockCache(位于 lib/blockcache/blockcache.golib/mergeset/part.go)是 VictoriaMetrics 查询性能的核心保障。在 v1.146.0 的源码中,它包含三个独立的 blockcache.Cache,分别缓存不同粒度的数据块:

lib/mergeset/part.go 中,可以看到三个 blockCache 的初始化:

// lib/mergeset/part.go(blockCache 三缓存初始化)
// VictoriaMetrics v1.146.0

var idxbCache = blockcache.NewCache(getMaxIndexBlocksCacheSize)
var ibCache = blockcache.NewCache(getMaxInmemoryBlocksCacheSize)
var ibSparseCache = blockcache.NewCache(getMaxInmemoryBlocksSparseCacheSize)

func getMaxIndexBlocksCacheSize() int {
    maxIndexBlockCacheSizeOnce.Do(func() {
        if maxIndexBlockCacheSize <= 0 {
            maxIndexBlockCacheSize = int(0.10 * float64(memory.Allowed()))
        }
    })
    return maxIndexBlockCacheSize
}

func getMaxInmemoryBlocksCacheSize() int {
    maxInmemoryBlockCacheSizeOnce.Do(func() {
        if maxInmemoryBlockCacheSize <= 0 {
            maxInmemoryBlockCacheSize = int(0.25 * float64(memory.Allowed()))
        }
    })
    return maxInmemoryBlockCacheSize
}

func getMaxInmemoryBlocksSparseCacheSize() int {
    maxInmemoryBlockSparseCacheSizeOnce.Do(func() {
        if maxInmemorySparseMergeCacheSize <= 0 {
            maxInmemorySparseMergeCacheSize = int(0.05 * float64(memory.Allowed()))
        }
    })
    return maxInmemorySparseMergeCacheSize
}

三个缓存的大小分别是可用内存的 10%、25%、5%,总计 40%。它们的核心机制是 missesBeforeCaching(默认值为 2):

// lib/blockcache/blockcache.go(missesBeforeCaching 机制)
// VictoriaMetrics v1.146.0

var missesBeforeCaching = flag.Int("blockcache.missesBeforeCaching", 2,
    "The number of cache misses before putting the block into cache. "+
    "Higher values may reduce indexdb/dataBlocks cache size at the cost of higher CPU and disk read usage")

// TryPutBlock 在缓存未命中时,只有 misses 超过阈值才真正放入缓存
func (c *cache) TryPutBlock(k Key, b Block) bool {
    misses := c.perKeyMisses[k]
    if misses > 0 && misses <= *missesBeforeCaching {
        // 可能是一次性查询结果,不缓存以节省内存
        return false
    }
    // ... 实际缓存逻辑
}

这意味着:

  • 一个索引块如果只被访问 1-2 次,不会被缓存
  • 只有被频繁访问的热点索引块才会进入缓存
  • 这样可以避免把一次性查询的结果浪费内存
我理解源码的意思是说

blockCache 可以想象成一个图书馆的"热门书架"

图书馆不把所有书都放在热门书架上,而是先观察:如果一本书连续 2 次被借阅,说明它是热门书,就给它预留一个位置。如果只借阅 1 次就不再碰,说明是一本冷门书,不占用热门书架空间。

这就是 VM 查询性能的秘诀:只把真正热的数据放进缓存,避免内存浪费。

六、省 7x RAM 的数学证明

思考记忆提示本节用具体数据证明"省 7x RAM"不是营销噱头,而是有技术支撑的

  • 从索引结构、缓存策略、压缩算法三个维度计算
  • 给出具体的内存占用对比

"省 7x RAM"不是营销噱头,而是基于以下技术支撑的综合效果:

优化维度PrometheusVictoriaMetrics节省比例
索引策略 全量 mmap(所有索引常驻) 按需缓存(TSIDCache 37%) ~3x
数据缓存 依赖 Page Cache(不可控) ibCache 按需缓存(可控,10% 内存) ~2x
压缩算法 Gorilla(ZSTD 较差) NearestDelta + ZSTD ~1.2x
综合效果 基准 综合优化 ~7x

6.1 索引优化带来的节省

Prometheus 的 mmap 策略导致所有索引数据都会被内核加载到 Page Cache。假设有 1000 万个时间序列,每个序列平均 10 个标签:

  • Prometheus:索引总量 = 1000万 × 10 × 100字节 ≈ 10GB(全部加载)
  • VictoriaMetrics:TSIDCache 37% = 10GB × 37% = 3.7GB(热点索引)
  • 节省:约 3 倍

6.2 缓存策略带来的节省

Prometheus 依赖 Page Cache,查询会导致大量不必要的数据页加载到内存。VictoriaMetrics 的 blockCache 精确控制哪些数据块需要缓存:

  • Prometheus:查询范围 → 加载相关所有页 → 可能包含大量不需要的数据
  • VictoriaMetrics:只缓存命中的数据块 → 缓存利用率更高
  • 节省:约 2 倍

注意

实际节省倍数取决于数据特征和查询模式。上述数字是官方 benchmark 的典型结果,实际生产环境可能有所不同。但总体趋势是明确的:VictoriaMetrics 在内存效率上显著优于 Prometheus。

七、与 Prometheus/InfluxDB/Thanos 的设计对比

思考记忆提示对比学习是理解设计取舍的最佳方式——本节让你了解 VM 在竞品中的定位

  • Prometheus = 单机存储,适合小规模
  • InfluxDB = LSM Tree + SQL,适合时序分析
  • Thanos = Prometheus + 对象存储,适合长期存储
  • VictoriaMetrics = MergeSet + 高效缓存,适合大规模
特性PrometheusInfluxDBThanosVictoriaMetrics
存储引擎 TSDB Head + mmap LSM Tree (WAL + TSM) Prometheus + 对象存储 MergeSet
写入协议 原生(Push) Line Protocol Remote Write 12+ 协议兼容
查询语言 PromQL InfluxQL/Flux PromQL PromQL + MetricsQL
内存模型 全量 mmap 分层缓存 外部存储 TSIDCache + blockCache
水平扩展 不支持 支持(Enterprise) 支持(Sidecar) 原生支持(Cluster)
适用规模 <100 万 series <500 万 series >1000 万 series >1000 万 series
我理解源码的意思是说

四大时序数据库可以比喻成四种不同的餐厅模式

Prometheus = 街边快餐店

老板一个人(单机)又做菜又端盘子。适合小规模(几十个客人)。规模大了老板忙不过来(OOM)。

InfluxDB = 大型连锁餐厅

有专门的厨房(WAL + TSM)、仓储系统(分层缓存)。但系统复杂,需要专人维护(运维成本高)。

Thanos = 快餐店 + 外卖平台

在 Prometheus 基础上加了对象存储(外卖平台),可以保存更多历史数据。但本质还是单机(Sidecar 只是代理),扩展性有限。

VictoriaMetrics = 现代化的无人餐厅

用 MergeSet(自动化厨房)替代传统厨房(LSM Tree),用智能缓存(TSIDCache + blockCache)替代人工管理(Page Cache)。效率高(省 7x RAM),可扩展(原生 Cluster 支持),运维简单(一个二进制)。

八、FAQ:常见疑问

思考记忆提示FAQ 是全篇的"临考前速背"模块,20 组覆盖全链路

  • Q1-Q5 围绕 MergeSet:原理、优势、与 LSM Tree 区别
  • Q6-Q10 围绕 WAL-less:可靠性保证、与 WAL 的权衡
  • Q11-Q15 围绕 TSIDCache 和 blockCache:缓存策略、内存分配
  • Q16-Q20 围绕选型和迁移:从 Prometheus 迁移、与其他 TSDB 对比

Q1. MergeSet 和 LSM Tree 到底有什么区别?

MergeSet 只合并不分层,LSM Tree 分层合并。LSM Tree 将数据分成多层(L0, L1, L2...),每层容量指数增长,查询需要扫描多层。MergeSet 所有 Part 在同一层,通过合并控制 Part 数量,没有分层开销。MergeSet 的优势是查询路径简单(只需定位相关 Part),空间放大小(合并因子固定)。LSM Tree 的优势是写入性能极高(只需写内存)。对于时序数据(写多读少、追加写入),MergeSet 是更好的选择。

Q2. VictoriaMetrics 为什么选择 MergeSet 而不是 LSM Tree?

因为时序数据的访问模式更适合 MergeSet。时序数据有三个特点:1)追加写入为主,极少更新删除;2)查询通常是范围查询(最近 N 分钟/小时/天);3)热点数据集中在近期。MergeSet 的设计天然匹配这些特点:只合并不更新、按时间分区、blockCache 按需加载。LSM Tree 的分层设计对时序数据是"过度工程",引入了不必要的复杂性。

Q3. WAL-less 设计会不会丢数据?最多丢多少?

极端情况下最多丢失 pendingRowsFlushInterval + dataFlushInterval 内的数据。rawRows 每 2 秒转为 inmemoryPart,inmemoryPart 每 5 秒保证刷盘。即使进程崩溃,最多丢失 2 秒内的 rawRows + 5 秒内未刷盘的 inmemoryPart 数据,但实际生产中通常不到 1 秒。对于大多数监控场景,这是可接受的。

Q4. TSIDCache 的 37% 是怎么来的?能不能改?

37% 是源码里直接写死的经验比例,可通过 -storage.cacheSizeStorageTSID 覆盖。lib/storage/storage.go 中,TSIDCache 默认按 memory.Allowed() * 0.37 分配;在 app/vmstorage/main.go 中,可以用 -storage.cacheSizeStorageTSID 覆盖。不建议随意调大,因为它会影响 blockCache 的可用空间。

Q5. blockCache 三层各自承担什么职责?

ibCache(25%)、idxbCache(10%)、ibSparseCache(5%)三个缓存分工不同,但都通过 missesBeforeCaching 控制是否真正落盘缓存。ibCache 与 ibSparseCache 用于数据块相关缓存,idxbCache 用于索引块缓存。它们在源码中是三个独立的 blockcache.Cache,分别由 lib/mergeset/part.go 初始化,默认大小分别为可用内存的 25%、10%、5%。

Q6. VictoriaMetrics 真的能省 7x RAM 吗?有没有具体测试数据?

官方文档和实际部署中确实有“相同规模下 VM 内存远低于 Prometheus”的说法,但精确倍数和具体内存数字并未在 v1.146.0 仓库中找到官方 benchmark 数字。更稳妥的理解是:VM 通过 TSIDCache 37%、blockCache 10/25/5%、WAL-less 和 MergeSet 等设计,在内存使用上普遍比 Prometheus 更紧凑;具体节省倍数会随数据规模、标签基数和查询模式变化。

Q7. 从 Prometheus 迁移到 VictoriaMetrics 容易吗?

非常容易,PromQL 完全兼容。VictoriaMetrics 兼容 Prometheus 的所有协议和数据格式:1)使用 vmagent 或 Remote Write 代理新数据;2)使用 vmctl 工具迁移历史数据;3)Grafana 数据源无缝切换。迁移过程中 Prometheus 可以继续运行,零停机。

Q8. VictoriaMetrics 支持 Prometheus 的哪些高级特性?

支持 Recording Rules、Alerting Rules、PromQL 所有函数。VictoriaMetrics 在 PromQL 基础上还扩展了 MetricsQL,提供了更多高级函数(如 running_maxpredict_linearhistogram_quantile 的增强版)。Recording Rules 和 Alerting Rules 与 Prometheus 完全兼容,可以通过 vmalert 工具执行。

Q9. VictoriaMetrics 的压缩率比 Prometheus 高多少?

VM 采用 NearDelta 等压缩方式,但具体压缩倍数会随数据特征变化。对于连续采样、相邻值接近的指标,压缩效果通常更好;波动大的指标压缩率会低一些。v1.146.0 源码中可以看到 NearDelta 相关实现,但没有在仓库中找到官方给出的统一压缩率倍数。

Q10. 什么场景下不应该用 VictoriaMetrics?

对数据完整性要求极高(不能丢失任何数据)的场景。WAL-less 设计在极端情况下(进程崩溃、断电)最多丢失 pendingRowsFlushInterval + dataFlushInterval 内的数据(默认 2 秒 + 5 秒)。实际生产中通常不到 1 秒。如果业务要求零数据丢失(如金融交易核验、医疗设备记录),应该选择带有 WAL 的数据库(如 InfluxDB Enterprise)。对于普通监控场景,这个数据丢失窗口通常是可以接受的。

Q11. MergeSet 的合并会不会影响查询性能?

影响很小,Merge 在后台异步进行。MergeSet 的合并任务是独立的后台 goroutine,不影响前台查询。而且合并是增量进行的——每次合并一小批 Part,而不是一次性合并所有。合并过程中,旧 Part 仍然可以被查询,只有合并完成后的新 Part 才会被使用。

Q12. VictoriaMetrics 的查询性能为什么比 Prometheus 快?

三个原因:blockCache 按需缓存、PromQL 执行优化、streaming 算子。1)blockCache 只缓存查询命中的数据块,避免无关数据占用内存;2)VM 的 PromQL 执行器做了大量优化(如并行执行、缓存 rollup 结果);3)streaming 算子减少中间结果占用,降低 GC 压力。

Q13. 如何监控 VictoriaMetrics 的缓存使用情况?

通过 /metrics 端点查看缓存指标。关键指标:vm_cache_entries_*(缓存条目数)、vm_cache_size_bytes_*(缓存大小)、vm_cache_ops_total_*(缓存操作次数)、vm_cache_hits_total_*(缓存命中率)。建议在 Grafana 中导入官方 Dashboard 进行可视化。

Q14. TSIDCache 满了会怎样?会 OOM 吗?

不会 OOM,TSIDCache 使用 LRU 驱逐策略。当缓存达到上限时,最近最少使用的条目会被驱逐,让位给新条目。这是内存安全的,不会导致 OOM。但驱逐会导致部分索引查询需要查磁盘,轻微影响写入性能。

Q15. blockCache 是怎么避免缓存一次性查询结果的?

通过 missesBeforeCaching 机制,默认 2 次 miss 后才缓存。当索引块被查询时,如果连续 2 次都命中,才会被放入 ibCache。这样可以避免把一次性查询的结果浪费内存,确保只有真正热的数据才会占用缓存空间。

Q16. VictoriaMetrics 支持高基数标签吗?

支持,但建议避免高基数标签。VM 的 TSIDCache 37% 策略对高基数有一定缓解,但高基数标签(如 user_id、session_id)仍然是性能杀手。建议做法:1)使用 __name__ 标签代替 user_id;2)使用 labelvalue 过滤器限制查询范围;3)如果必须记录 user_id,使用 -logNewSeries 参数监控基数增长。

Q17. 如何计算 VictoriaMetrics 的内存需求?

没有官方给出的统一公式,建议按缓存上限和实际数据规模估算。v1.146.0 源码里可见 TSIDCache 默认按 memory.Allowed() * 0.37 分配,blockCache 另有 10%、25%、5% 的默认缓存空间;再加上 inmemoryPart、metricNameCache 等开销。更稳妥的做法是先按数据量和 retention 粗估,再通过 /metrics 里的 vm_cache_* 指标和 vm_allowed_memory_bytes 观察实际内存占用,再逐步调整缓存参数。

Q18. VictoriaMetrics 和 Thanos 有什么区别?

Thanos 是 Prometheus 的扩展,VM 是独立存储引擎。Thanos 通过 Sidecar 代理 Prometheus 的数据到对象存储,本质上还是依赖 Prometheus 的 TSDB。VictoriaMetrics 是独立的存储引擎,自己实现 MergeSet,不依赖 Prometheus。VM 在内存效率、写入性能、运维复杂度上都优于 Thanos。

Q19. Cluster 模式和 Single-Node 模式的内存模型有什么区别?

Cluster 模式将存储分布到多个 vmstorage 节点,每个节点独立管理内存。在 Cluster 模式下,TSIDCache 和 blockCache 在每个 vmstorage 节点上独立运行,内存需求可以在多个节点间分摊。写入压力和查询压力也可以独立扩展(vminsert 和 vmselect 可以分别扩展)。

Q20. 未来 VictoriaMetrics 会有什么新特性?

VictoriaMetrics 还在持续迭代,但具体未来版本会包含哪些功能,不建议在博文中给出精确性能预测。更稳妥的说法是:项目仍在活跃维护中,可以关注官方 GitHub Releases 和 CHANGELOG 获取真实更新。

全篇必记总纲

VictoriaMetrics "省 7x RAM" 的核心是按需缓存而非全量缓存:MergeSet 替代 LSM Tree(查询路径简单)、WAL-less 替代 WAL(写入性能高)、TSIDCache 37% 策略(热点索引优先)、ibCache missesBeforeCaching 机制(只缓存真正热的数据)。这四个技术创新共同作用,让 VM 在相同硬件下能处理更多时间序列,成为 Prometheus 的理想替代方案。

九、Roadmap:后续预告

本篇覆盖了 VictoriaMetrics 的设计哲学和核心优化,但还有很多细节尚未展开:

  • #02 全局架构:Single-Node vs Cluster 模式——理解两种部署方式的本质区别
  • #03 架构演进:从 TSDB 到 MergeSet 的设计取舍——理解 MergeSet 相比 LSM Tree 的优势
  • #04 整体数据流:一条监控数据的完整生命周期——从 HTTP 请求到 Part 文件的完整链路
  • #20 写入核心链路:Storage.Add() 的三段式处理——数据是如何被写入存储的
  • #41 MergeSet vs LSM Tree:存储引擎专题——MergeSet 源码深度解析

本文参考与源码链接:
  • lib/mergeset/table.go · MergeSet 表管理
  • lib/mergeset/ · MergeSet 存储引擎
  • lib/storage/ · 存储核心层
  • lib/workingsetcache/ · TSIDCache 实现
  • lib/blockcache/ · blockCache 实现
  • VictoriaMetrics 官方 FAQ

posted @ 2026-07-03 17:49  左扬  阅读(32)  评论(0)    收藏  举报