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 中的 inmemoryPart 和 lib/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,通过 getTSIDByMetricNameFromCache 和 putTSIDByMetricNameToCache 读写。
在 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 团队发现:
- 缓存太小(如 10%):缓存命中率低,每次写入都要查索引,性能下降
- 缓存太大(如 70%):占用太多内存,影响 blockCache 可用空间,导致查询性能下降
- 37% 是平衡点:写入性能(高缓存命中率)和查询性能(blockCache 有足够空间)达到最优
源码核对提示 — 此处的 TSIDCache/blockCache 三层比例图没有在源码中找到对应实现
- TSIDCache 比例:源码中
0.37是内联计算值,不是具名常量;源码位置 lib/storage/storage.go - blockCache 实际结构:源码中主要是统一的
blockcache.Cache与missesBeforeCaching机制,不是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.go 和 lib/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"不是营销噱头,而是基于以下技术支撑的综合效果:
| 优化维度 | Prometheus | VictoriaMetrics | 节省比例 |
|---|---|---|---|
| 索引策略 | 全量 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 + 高效缓存,适合大规模
| 特性 | Prometheus | InfluxDB | Thanos | VictoriaMetrics |
|---|---|---|---|---|
| 存储引擎 | 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_max、predict_linear、histogram_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

浙公网安备 33010602011771号