VictoriaMetrics 存储机制逻辑分析

VictoriaMetrics 存储机制逻辑分析

VictoriaMetrics 是后出的一款面向可观测指标的时序数据存储和查询引擎, 传闻比prometheus 查询性能强,存储成本还低,我们也有做指标数据平台,特来学习下

以下分析基于 v1.45.x 版本

1. 核心结论

VictoriaMetrics 的存储设计可以概括为以下几点:

  • 使用 vminsert、vmstorage 和 vmselect 分离写入、存储与查询职责;
  • 采用 Prometheus 风格的单值时序模型,每个数据点由时间戳和浮点数值组成;
  • 根据指标名和标签生成时间序列标识 TSID;
  • 将标签索引与原始时序数据分开存储;
  • 原始数据按 TSID 聚合,并将时间戳和值分别进行列式压缩;
  • 数据先写入内存,刷盘后形成不可变的 Part,再通过后台 Merge 合并;
  • 按月建立数据分区,通过 small 和 big 两级目录平衡近期数据的读取速度与历史数据的压缩率;
  • 索引和数据均采用分层文件结构,通过元索引缩小读取范围;
  • 不依赖传统 WAL,在异常退出时可能丢失尚未刷盘的数据。

2. 集群架构

VictoriaMetrics 集群由三个核心组件组成。

组件 主要职责
vminsert 接收写入请求,通过一致性哈希将数据分发到不同的 vmstorage 节点
vmstorage 保存标签索引和时序数据,并执行本地检索
vmselect 接收查询请求,从一个或多个 vmstorage 节点读取数据并汇总结果

三个组件可以分别扩容。vmstorage 采用 Shared-Nothing 架构:节点之间不共享数据,也不需要互相通信。这种结构降低了节点间协调成本,使扩容和故障隔离更简单。

Cluster-VictoriaMetrics-components

2.1 多租户

集群以 accountID 或 accountID:projectID 标识租户,租户编号位于请求路径中。首次写入数据时会自动创建对应租户。

VictoriaMetrics 主要负责租户数据隔离;身份认证、租户名称、配额和计费等信息通常由前置服务管理。不支持在一个查询请求中同时查询多个租户。

3. 数据模型

https://docs.victoriametrics.com/victoriametrics/keyconcepts/#structure-of-a-metric

VictoriaMetrics 采用单值时序模型。一个时间序列由指标名称和标签集合唯一确定,其数据点由时间戳与浮点数值组成:

1
2
MetricName = 指标名称 + 标签集合
Sample = 时间戳 + 浮点数值

例如:

1
2
3
4
5
6
7
----------------------------------------------------------------------
| <time series> | <value> | <timestamp> |
| requests_total{path="/health", code="200"} | 1 | 1676297640 |
| requests_total{path="/health", code="200"} | 2 | 1676297670 |
| requests_total{path="/health", code="200"} | 3 | 1676297700 |
| requests_total{path="/health", code="200"} | 4 | 1676297730 |
....

同一时间序列的所有数据点共享相同的指标名称和标签,仅时间戳和值不断变化。value只存储浮点型指标值,不直接支持字符串、布尔值或字节数组等字段类型。

4. 写入时的数据拆分

VictoriaMetrics 收到数据后,先根据指标名称和标签集合识别时间序列,并生成内部标识 TSID。随后将信息拆分为两类:

1
2
3
4
5
6
指标名称 + 标签集合
│
▼
TSID
├── 索引:指标名称、标签、MetricID 与 TSID 的映射
└── 数据:TSID + Timestamp + Value

这样做的目的,是避免在每个数据点中重复保存较长的指标名称和标签。标签查询先通过索引获得 TSID,随后再使用 TSID 定位原始数据。

5. TSID 与索引映射

上文所述 TSID 包含以下字段:

  • MetricGroupID:由指标名称计算得到;
  • JobID:由约定位置的 job 标签计算得到;
  • InstanceID:由约定位置的 instance 标签计算得到;
  • MetricID:用于唯一标识时间序列。

其中 MetricID 是始终有效的核心标识。写入新时间序列时,会建立多种映射关系:

1
2
3
4
5
MetricName          -> TSID
MetricID -> MetricName
MetricID -> TSID
标签键值对 -> MetricID
指标名称 -> MetricID

例如查询:

1
http_requests_total{status="200", method="GET"}

索引查询的大致过程为:

  1. 分别查出指标名称、status="200" 和 method="GET" 对应的 MetricID 集合;
  2. 对多个集合求交集;
  3. 根据命中的 MetricID 查出 TSID;
  4. 使用 TSID 和时间范围查询数据文件;
  5. 根据 MetricID 还原原始指标名称与标签。

VictoriaMetrics 的 TSID 的结构如下所示,包含 MetricGroupID、JobID、InstanceID、MetricID
等几个字段,其中除了 MetricID 外,其他字段都是可选的。这个几个 ID 的生成方法如下:

  • MetricGroupID:是根据 MetricName中的 MetricGroup使用 xxhash的 sum64 算法生成。
  • JobID和 InstanceID分别由 MetricName中的第一个 tag 和第二个 tag 使用 xxhash的 sum64 算法生成。为什么使用第一个 tag 和第二个 tag?这是因为 VictoriaMetrics 在写入时,将写入请求中的 JobID 和 InstanceID 放在了 Tag 数组的第一个和第二个位置。
  • MetricID,使用 VictoriaMetrics 进程启动时的系统纳秒时间戳自增生成。

因为 TSID 中除了 MetricID 外,其他字段都是可选的,因此 TSID 中可以始终作为有效信息的只有 MetricID,因此 VictoriaMetrics 的在构建 tag 到 TSID 的字典过程中,是直接存储的 tag 到 MetricID 的字典。

  • metricName -> TSID, 即 http_requests_total{status="200", method="GET"} -> {metricGroupID=0, jobID=0, instanceID=0, metricID=51106185174286}
  • metricID -> metricName,即 51106185174286 -> http_requests_total{status="200", method="GET"}
  • metricID -> TSID,即 51106185174286 -> {metricGroupID=0, jobID=0, instanceID=0, metricID=51106185174286}
  • tag -> metricID,即 status="200" -> 51106185174286、method="GET" -> 51106185174286、"__name__" = http_requests_total -> 51106185174286

img

集群以 accountID 或 accountID:projectID 标识租户,租户编号位于请求路径中。首次写入数据时会自动创建对应租户。

6. 磁盘目录

存储根目录的核心内容包括:

1
2
3
4
storageDataPath/
├── data/ # 原始时序数据
├── indexdb/ # 标签与时间序列索引
└── flock.lock # 防止多个进程同时修改存储目录的文件锁

6.1 数据目录

data 下主要包含 small 和 big 两类目录,两者都按月份组织分区:

1
2
3
4
5
6
7
8
9
data/
├── small/
│ └── YYYY_MM/
│ ├── Part 目录
│ ├── tmp/
│ └── txn/
└── big/
└── YYYY_MM/
└── Part 目录

分区以月为单位,例如 2020_11 表示 2020 年 11 月的数据。内存数据每次刷盘都会在相应月份分区中生成一个新的 Part。

Part 目录名编码了以下信息:

  • 数据点数量;
  • 数据块数量;
  • 最小时间戳;
  • 最大时间戳;
  • 用于保证目录名唯一的标识。

6.2 small 与 big

新刷盘的数据首先进入 small。后台 Compaction 会持续选择若干较小的 Part,将其合并成更大的 Part。

当合并结果超过 small Part 的规模阈值时,输出进入 big。因此,big 中的数据主要来自 small 中 Part 的后台合并,而不是直接写入。

两级目录使用不同强度的压缩策略:

  • small 面向近期、经常查询的数据,使用较低级别的通用压缩,以降低解压成本;
  • big 面向较老、较少查询的数据,使用较高级别的压缩,以节省磁盘空间。

这种设计在近期数据查询性能与长期存储压缩率之间取得平衡。

6.3 索引目录

indexdb 保存指标名称、标签、MetricID 和 TSID 之间的映射。索引同样采用 Table、Part 和后台 Merge 的组织方式。

会结合数据保留周期滚动索引 Table。旧索引到期后,系统切换到新的索引目录进行写入。

7. Part 与后台 Merge

Part 是数据和索引的基本持久化单元。它具有两个重要特征:

  1. 刷盘或合并会生成新的 Part;
  2. 已生成的 Part 基本保持不可变。

后台 Merge 的主要作用包括:

  • 减少 Part 和文件数量;
  • 降低查询时需要扫描的文件数量;
  • 将小块数据整理为连续的大块数据;
  • 提高压缩效果;
  • 将满足条件的数据从 small 整理到 big。

这种模式类似 LSM Tree 的追加写入与后台归并:前台写入避免频繁随机修改已有文件,后台再以顺序读写的方式完成整理。

8. 索引文件格式

一次索引刷盘或合并产生的 Part 主要包含四类文件:

文件 作用
metaindex.bin 保存索引块的概要信息,用于快速定位 index.bin 中的范围
index.bin 保存各数据块的头部及其位置
items.bin 保存真正的索引 Item
lens.bin 保存 items.bin 中各 Item 的长度信息

它们形成分层定位关系:

1
2
3
4
5
6
metaindex.bin
│
▼
index.bin
├──────► lens.bin
└──────► items.bin

8.1 metaindex.bin

metaindex.bin 保存一组按首个 Item 字典序排列的元索引记录。每条记录包含:

  • 当前范围的最小 Item;
  • 对应索引块的位置和大小;
  • 索引块所含 Block Header 的数量。

文件会被压缩,并在打开 Part 时加载到内存。查询可先对元索引进行二分查找,从而减少需要从磁盘读取的 index.bin 范围。

8.2 index.bin

index.bin 包含多个索引块,每个索引块又包含多个 Block Header。Block Header 记录:

  • Item 的公共前缀;
  • 当前块的最小 Item;
  • Item 数量;
  • Item 数据的位置;
  • 序列化或压缩类型。

公共前缀只保存一次,items.bin 中的 Item 会去掉这部分前缀,从而减少冗余。

8.3 items.bin 与 lens.bin

items.bin 保存编码后的索引 Item。系统会根据 Item 数量、数据长度和压缩收益决定是否压缩,避免对很小或压缩收益很低的数据强行压缩。

lens.bin 保存每个 Item 的长度,使系统能够从 items.bin 中切分并解析变长 Item。长度数据会先使用面向整数序列的编码,再视情况进行通用压缩。

9. 数据文件格式

一次数据刷盘或合并产生的 Part 主要包含:

文件 作用
metaindex.bin 按 TSID 和时间范围粗粒度定位索引块
index.bin 保存每个数据块的 TSID、时间范围和数据偏移
timestamps.bin 保存压缩后的时间戳序列
values.bin 保存压缩后的指标值序列

分层关系如下:

1
2
3
4
5
6
metaindex.bin
│
▼
index.bin
├──────► timestamps.bin
└──────► values.bin

9.1 数据组织

数据首先按 TSID 分组;同一 TSID 下的数据点再按时间组织。每个数据块只保存一条时间序列的一段数据。

这种组织方式具有明显的局部性:查询某条时间序列时,可以快速跳过其他 TSID 的数据;查询指定时间范围时,也可以通过块的最小、最大时间戳排除无关块。

9.2 列式存储

时间戳和值分别写入 timestamps.bin 和 values.bin。两者的数据特征不同,分开存储后可以使用不同的编码方式:

  • 时间戳通常具有固定或近似固定的采样间隔,适合 Delta、Delta-of-Delta 等编码;
  • 浮点值在相邻采样之间往往只有少量二进制位变化,适合 XOR 类编码;
  • 整数差值可结合 ZigZag 等方式编码;
  • 完成时序特征编码后,再根据收益决定是否追加 ZSTD 压缩。

这种“专用时序编码 + 通用压缩”的组合,是 VictoriaMetrics 获得高压缩比的关键。

10. 查询路径

一个典型的标签查询可分为两个阶段。

10.1 索引阶段

  1. 将查询中的指标名称和标签条件编码为索引 Key;
  2. 在 indexdb 中分别获得对应的 MetricID 集合;
  3. 对集合执行交集或其他过滤;
  4. 将命中的 MetricID 转换为 TSID。

10.2 数据阶段

  1. 根据时间范围选择相关月份分区;
  2. 在分区内检查相关 Part;
  3. 使用 metaindex.bin 粗定位;
  4. 使用 index.bin 找到匹配 TSID 和时间范围的数据块;
  5. 从 timestamps.bin 和 values.bin 读取并解压数据;
  6. 合并多个 Part 的结果并返回。

11. 设计优势

VictoriaMetrics 的优势并非来自某个单独优化,而是写入、压缩、查询和扩展机制共同作用的结果。

设计维度 采用的机制 带来的收益
写入 内存追加,刷盘生成新的不可变 Part,避免频繁随机修改历史文件 缩短前台写入路径,适合持续、高吞吐的时序写入
存储 按 TSID 聚合数据,将时间戳和值分列编码,再结合 ZSTD 与后台 Merge 利用时序数据的相似性提高压缩率,减少长期磁盘占用
查询 标签索引先筛选 TSID,元索引再根据 TSID 和时间范围排除无关数据块 形成逐层剪枝,避免直接扫描全部索引和原始数据
扩展 vminsert、vmstorage、vmselect 职责分离,存储节点采用 Shared-Nothing 架构 写入、存储和查询可以独立扩容,减少存储节点之间的协调成本
生命周期 数据按月分区,并通过 small、big 使用不同强度的压缩策略 便于执行保留策略,同时兼顾近期数据的读取速度和历史数据的压缩率

整体来看,这套机制尤其适合写多读多、数据保留时间较长,并且需要按标签筛选时间序列的监控指标场景。

12. 存在的的限制

这些优势也伴随着相应的取舍。主要限制(基于 v1.45.0 )如下:

限制 形成原因 可能影响 使用时的关注点
高基数标签的索引成本 一个标签可能对应大量 MetricID,多条件查询还需要读取多个列表并求交集 增加索引读取、集合运算和后续数据扫描成本 控制动态标签和标签组合数量,重点评估时间序列基数及标签变化率
索引查询链路较长 查询依次经历标签到 MetricID、MetricID 到 TSID、TSID 到数据块的定位过程 缓存未命中或命中序列很多时,磁盘 I/O 与查询延迟可能上升 结合典型查询跨度、命中序列数量和缓存命中率进行压测
未刷盘数据存在可靠性窗口 不使用传统 WAL,数据先进入内存再周期性持久化 进程或主机在刷盘前异常退出时,尚未持久化的数据可能丢失 根据业务可接受的数据丢失窗口设计采集重试、复制和基础设施持久性方案
数据类型有限 存储模型主要围绕数值型监控指标设计 不适合直接保存字符串、布尔值或复杂事件对象 将日志、事件和复杂对象交给更合适的存储系统,避免强行映射为指标

因此,VictoriaMetrics 的设计重点是以较低资源成本保存和查询大规模数值指标,而不是提供通用数据类型、强事务语义或任意维度分析。选型时需要用实际基数、查询模式和可靠性目标验证这些取舍是否能够接受。

13. 总结

VictoriaMetrics 的核心思路是:

使用标签索引找到时间序列,再使用 TSID 定位按列压缩的时间戳和值;通过不可变 Part、按月分区和后台 Merge,实现高吞吐写入、高压缩率及可扩展查询。

从存储引擎视角看,它主要围绕四个目标进行权衡:

  1. 通过追加写和后台合并提升写入吞吐;
  2. 通过索引与数据分离支持标签检索;
  3. 通过时序专用编码和分层压缩降低长期存储成本;
  4. 通过组件解耦和 Shared-Nothing 架构实现水平扩展。

相应代价是后台合并带来的资源消耗、高基数场景的索引压力,以及未刷盘数据的可靠性窗口。评估 VictoriaMetrics 时,应结合时间序列基数、标签变化率、查询跨度、磁盘余量和可接受的数据丢失窗口进行容量规划。

Ps , 单样本能压缩到 1 byte 以下, 真的厉害~

img


Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×