从内核到架构:解构VictoriaMetrics高性能时序存储引擎

本文面向寻求高性能、低成本监控解决方案的中高级工程师与架构师。我们将深入剖析 VictoriaMetrics,一个在时序数据库(TSDB)领域声名鹊起的开源项目。本文不止于介绍其 Prometheus 兼容性或简单的部署指南,而是从 LSM-Tree 的变体、列式存储的内核级优化,到其独特的分布式架构,层层剥茧,解构其如何在保持极低资源消耗的同时,实现业界领先的写入吞吐和查询性能。

现象与问题背景

在云原生和微服务时代,可观测性(Observability)从一个“加分项”变成了“生命线”。其中,Metrics(指标)作为可观测性的三大支柱之一,其数据量正以前所未有的速度爆炸式增长。一个中等规模的 Kubernetes 集群,加上应用层面的自定义指标,每秒产生的时序数据点(data points)可达数十万甚至上百万。这给传统的监控系统,尤其是以 Prometheus 为事实标准的生态,带来了严峻挑战:

  • 基数灾难(Cardinality Explosion): Kubernetes 的 Pod ID、容器 ID 等动态标签,以及微服务实例的频繁变更,导致时间序列的组合(即基数)急剧膨胀。高基数会使 Prometheus 的内存索引不堪重负,导致内存溢出(OOM)和查询性能断崖式下跌。
  • 单机瓶颈与存储扩展性: Prometheus 原生的 TSDB (v3) 被设计为单机存储,尽管性能优异,但其垂直扩展能力终有上限。当数据量超过单机磁盘容量或需要更长的保留周期时,维护和扩展变得极其复杂和脆弱。社区提供的 Thanos、Cortex 等方案虽然解决了扩展性问题,但引入了更高的架构复杂度和运维成本。
  • 高昂的存储与计算成本: 无论是内存还是磁盘,存储海量的时序数据都是一笔巨大的开销。同时,对长时间范围、高基数数据的复杂查询(如 PromQL)会消耗大量的 CPU 和内存资源,进一步推高运营成本。
  • 数据写入与乱序处理: 在大规模分布式环境中,网络延迟或节点故障可能导致指标数据乱序到达。一些TSDB对乱序数据的处理效率低下,甚至直接拒绝,这在需要数据回填(backfilling)或聚合复杂网络来源数据的场景中成为一个痛点。

正是在这样的背景下,VictoriaMetrics (VM) 应运而生。它并非对 Prometheus 的简单模仿或封装,而是在存储引擎层面进行了彻底的重新设计,旨在正面解决上述所有痛点。

关键原理拆解

VictoriaMetrics 的卓越性能根植于对计算机科学底层原理的深刻理解和极致运用。我们以一位大学教授的视角,来剖析其背后的核心理论。

存储模型:面向时序优化的 LSM-Tree 变体

现代数据库存储引擎主要分为两大流派:B/B+ Tree 和 Log-Structured Merge-Tree (LSM-Tree)。关系型数据库如 MySQL (InnoDB) 普遍采用 B+ Tree,它对读写操作进行了平衡,尤其擅长原地更新(in-place update)和范围查询。然而,对于时序数据这种“只增不删”(Append-only)且写操作远超读操作的场景,B+ Tree 的写放大(Write Amplification)问题会变得非常严重。每一次写入都可能触发树节点的分裂和合并,导致大量的随机 I/O。

LSM-Tree 则是为高吞吐写入场景而生的。其核心思想是将所有写入操作转化为顺序 I/O。数据首先被写入内存中的一个有序结构(MemTable),当 MemTable 达到阈值后,会被刷写(flush)到磁盘上,形成一个不可变的、有序的文件(在 LevelDB/RocksDB 中称为 SSTable,在 VM 中称为 `part`)。后台线程会定期将这些小文件合并(compaction)成更大的文件,以回收无效数据并优化读取性能。这种设计极大地提升了写入性能,因为顺序写磁盘的效率远高于随机写。

VictoriaMetrics 实现了一个高度定制化的 LSM-Tree 变体,名为 `MergeTree`。它不仅具备 LSM-Tree 的高写入吞吐优势,还针对时序数据特性做了深度优化,我们将在实现层进一步探讨。

数据结构:列式存储与高效压缩

时序数据具有固定的结构:一个时间序列由唯一的标识符(metric name + labels)、一系列的时间戳和对应的值组成。传统的行式存储会将一个数据点(timestamp, value)作为一个单元存放在一起。而列式存储则将所有时间戳连续存储,所有值连续存储。

这种看似简单的转换,在计算机体系结构层面带来了巨大的性能优势:

  • 极致的压缩率: 同一列的数据类型相同,且通常具有高度的相似性。例如,连续的时间戳之间差值(delta)很小,连续的值也可能变化平缓。这使得专门的压缩算法能够大显身手。VM 借鉴并改进了 Facebook Gorilla TSDB 的思想,使用了如 delta-of-delta 编码、行程编码(Run-length encoding, RLE)和 zstd 等高效算法。高压缩率意味着更少的磁盘占用和更少的 I/O 带宽消耗。
  • CPU Cache 友好: 当执行一个聚合查询(如 `sum(rate(http_requests_total[5m]))`)时,查询引擎只需要读取 `value` 这一列的数据,而完全跳过 `timestamp` 列。由于 `value` 列的数据是连续存储的,CPU 可以有效地利用 Cache Line 预取(Prefetching)机制,将大量有效数据载入高速缓存,避免了昂贵的内存随机访问,从而极大提升了计算速度。这与行式存储需要将整个数据点(包括不需要的时间戳)读入内存,并污染 CPU Cache 的行为形成鲜明对比。

从根本上说,列式存储是利用了“数据局部性原理”(Locality of Reference),将计算所需的数据在物理上聚合在一起,这是其查询性能远超行式存储的关键所在。

系统架构总览

VictoriaMetrics 提供了单节点和集群两种部署模式。其集群架构设计充分体现了“关注点分离”和“无状态”的现代分布式系统设计哲学,使其具备了出色的水平扩展能力。

我们可以将 VictoriaMetrics 集群想象成由三个核心、可独立扩展的组件构成的系统:

  • vminsert (写入节点): 这是一个无状态的代理,负责接收所有传入的指标数据。它解析不同的写入协议(如 Prometheus remote_write, InfluxDB line protocol, Graphite, OpenTSDB 等),然后根据时间序列的标识符(metric name + labels)进行一致性哈希,将数据路由到正确的 `vmstorage` 节点。由于无状态,我们可以水平扩展 `vminsert` 实例,并通过一个负载均衡器(如 Nginx、HAProxy)将写入流量分发给它们,轻松应对写入洪峰。
  • vmstorage (存储节点): 这是系统的“有状态”核心,是真正存储和管理时序数据的地方。每个 `vmstorage` 节点都维护着一部分时间序列数据。它负责将数据写入磁盘上的 `MergeTree` 结构,执行后台数据合并,并响应来自 `vmselect` 的数据查询请求。数据的持久性和可用性主要由 `vmstorage` 层的设计来保障。
  • vmselect (查询节点): 这同样是一个无状态的组件,负责处理所有的查询请求(如 PromQL)。当一个查询请求到达时,`vmselect` 会解析查询语句,确定需要从哪些 `vmstorage` 节点获取数据。然后,它会将查询“下推”(push down)到所有相关的 `vmstorage` 节点并行执行。每个 `vmstorage` 节点在本地扫描其数据,并将原始数据点或部分聚合结果返回给 `vmselect`。最后,`vmselect` 负责将来自不同存储节点的结果进行最终的合并(merge)和计算,然后返回给客户端。与 `vminsert` 类似,`vmselect` 也可以通过增加实例数量来线性提升查询处理能力。

这种存算分离的架构,与 Prometheus 单机版将采集、存储、查询功能耦合在一起的设计截然不同。它允许我们根据业务负载的特点进行精细化扩展:如果写入压力大,就增加 `vminsert` 实例;如果查询复杂或并发高,就增加 `vmselect` 实例;如果数据量大或保留周期长,就增加 `vmstorage` 实例。这种灵活性是构建大规模、高性价比监控平台的基石。

核心模块设计与实现

现在,让我们切换到极客工程师的视角,深入代码和实现细节,看看 VictoriaMetrics 是如何将理论转化为高性能软件的。

`vminsert`: 高效路由与反压

`vminsert` 的核心任务是“收数据、转数据、发数据”。这里的关键是效率和稳定性。它内部使用了一个高性能的 HTTP 服务器,并为每种注入协议都实现了高度优化的解析器。所有解析后的数据点都被放入一个内部的、有界的队列中。

为了防止下游 `vmstorage` 节点过载导致数据丢失,`vminsert` 实现了一套简单的反压机制。它会监控到每个 `vmstorage` 节点的连接状态和响应延迟。如果某个 `vmstorage` 节点处理变慢,`vminsert` 会暂时将数据缓存在内存中,或者在队列满时直接向客户端返回 `HTTP 503 Service Unavailable`,将压力传递给上游(如 `vmagent`),由上游进行重试或持久化到本地磁盘,从而保证整个系统的韧性。

其路由逻辑非常直接,基于时间序列的哈希值:


// 伪代码,展示 vminsert 的核心路由逻辑
type TimeSeries struct {
    MetricName string
    Labels     map[string]string
    // ...
}

func (ts *TimeSeries) GetRoutingHash() uint64 {
    // 对 MetricName 和排序后的 Labels 计算一个稳定的哈希值
    // 例如使用 xxhash64
    hasher := xxhash.New()
    hasher.WriteString(ts.MetricName)
    // 必须对 labels 排序以保证哈希稳定性
    sortedLabels := getSortedLabels(ts.Labels)
    for _, label := range sortedLabels {
        hasher.WriteString(label.Name)
        hasher.WriteString(label.Value)
    }
    return hasher.Sum64()
}

func routeToStorageNode(hash uint64, storageNodes []string) string {
    // 简单的一致性哈希或取模路由
    index := hash % uint64(len(storageNodes))
    return storageNodes[index]
}

这里的坑点在于,`Labels` 的哈希计算必须是稳定的。Go map 的遍历顺序是不确定的,因此必须先对 label key 进行排序,再进行哈希,否则同一个时间序列可能会被路由到不同的 `vmstorage` 节点,导致数据分裂。

`vmstorage`: `MergeTree` 的实现精髓

`vmstorage` 的心脏是其自研的 `MergeTree` 存储引擎。它没有直接使用 RocksDB 或 LevelDB,因为这些通用的 KV 存储引擎无法针对时序数据的特性做最深的优化。`vmstorage` 的数据存储路径大致如下:

  1. 数据点到达后,首先进入内存中的 `in-memory buffers`。
  2. 这些 buffer 会定期刷写到磁盘,形成小而新的 `part` 目录。每个 `part` 都是一个自包含的、不可变的数据块集合,包含索引数据和压缩后的列式数据块。
  3. 后台的合并进程(merger)会不断地选择一些 `parts`,将它们合并成一个更大的、新的 `part`。合并过程中,过期的数据会被清理,数据点会被重新排序和压缩。旧的 `parts` 在合并完成后会被删除。

VM 的一个关键优化在于其索引结构。它没有为每个数据点都建立索引,而是采用了稀疏索引(sparse index)。在一个数据块(block)内部,数据点按时间戳紧密排列。索引只记录每个数据块的起始时间戳、在文件中的偏移量以及一些元数据。查询时,VM 首先通过索引快速定位到可能包含目标时间范围的若干个数据块,然后只将这些块解压到内存中进行扫描。这极大地减少了索引大小和查询时需要扫描的数据量。

一个 `part` 在磁盘上的物理布局(简化版)可能如下所示:


// 磁盘上一个 part 目录的逻辑结构
// part-20230401-..../
// ├── metainfo.json      // part 的元信息,如时间范围、数据点数量等
// ├── index.bin          // 稀疏索引文件,映射 metricID -> data block offset
// ├── timestamps.bin     // 所有时间戳列,经过 delta-of-delta 和 zstd 压缩
// ├── values.bin         // 所有值列,经过 Gorilla-like 和 zstd 压缩
// └── len.bin            // 辅助文件,用于快速导航

// 内存中的索引项(简化)
type IndexBlockHeader struct {
    MetricID      uint64 // 时间序列的内部唯一ID
    BlockOffset   uint64 // 数据块在 timestamps.bin/values.bin 中的起始偏移
    MinTimestamp  int64  // 该块的最小时间戳
    MaxTimestamp  int64  // 该块的最大时间戳
}

这种设计使得 VM 的索引非常紧凑,这也是它能够以远低于 Prometheus 的内存消耗来处理超高基数时间序列的秘密武器。

`vmselect`: 无锁并发与查询下推

`vmselect` 的高性能来自于其彻底的无状态和并发设计。它将一个 PromQL 查询分解成针对时间范围和 label matchers 的子任务。对于每个子任务,它会并发地向所有 `vmstorage` 节点发起请求。

在 `vmstorage` 内部,查询也是高度并发的。它会为查询扫描的每个 `part` 启动一个 goroutine。这些 goroutine 并行地读取和解压数据块,将符合条件的数据点发送回 `vmselect`。这种从查询层到存储层的全链路并发模型,最大限度地利用了多核 CPU 的处理能力。

`vmselect` 在聚合来自 `vmstorage` 的数据时,也尽可能地采用流式处理(streaming)的方式,避免将所有数据点一次性加载到内存中,从而能够处理时间跨度极长、数据点极多的查询。


// vmselect 查询扇出(fan-out)和聚合(gather)的伪代码
func (s *VMSelect) HandleQuery(query string, timeRange TimeRange) ([]Result, error) {
    storageNodes := s.getStorageNodes()
    resultsChan := make(chan []DataPoint, len(storageNodes))
    var wg sync.WaitGroup

    for _, node := range storageNodes {
        wg.Add(1)
        go func(nodeURL string) {
            defer wg.Done()
            // 向每个 vmstorage 发起并行的 RPC/HTTP 请求
            // vmstorage 会在本地进行数据扫描
            partialResult, err := fetchFromStorage(nodeURL, query, timeRange)
            if err != nil {
                // handle error
                return
            }
            resultsChan <- partialResult
        }(node)
    }

    wg.Wait()
    close(resultsChan)

    // 从 channel 中收集所有部分结果并进行最终的合并聚合
    finalResult := mergeAndAggregate(resultsChan)
    return finalResult, nil
}

这种无共享(shared-nothing)、大规模并行的处理方式,是 `vmselect` 能够快速响应复杂查询请求的根本原因。

性能优化与高可用设计

单纯的架构设计还不够,一线的工程实践充满了各种权衡和挑战。

对抗层:Trade-off 分析

  • 一致性 vs. 可用性 (CAP): 在分布式系统中,VictoriaMetrics 集群明显倾向于 AP (Availability, Partition Tolerance)。它不保证写入的强一致性。在网络分区或 `vmstorage` 节点短暂不可用时,`vminsert` 可能会暂时无法写入该节点负责的数据分片。对于监控指标这种允许少量数据点丢失的场景,这是一个完全可以接受的权衡。相比之下,Thanos 或 Cortex 为了实现更强的一致性和数据持久性,引入了 S3 对象存储和复杂的读写法定人数(quorum)机制,这增加了延迟和架构的复杂性。
  • 成本 vs. 性能: VM 的核心设计理念之一就是降低成本。通过高效的压缩和低内存占用的索引,它可以用更少的硬件资源处理同样的数据负载。尤其是在内存成本方面,相较于 Prometheus 或 InfluxDB,VM 的优势非常明显。这种对资源的极致利用,对于成本敏感的业务或需要进行大规模部署的场景,具有决定性的吸引力。
  • 查询延迟 vs. 数据新鲜度: VM 的数据在被查询到之前,需要经过 `in-memory buffer` -> `flush to disk part` -> `merge parts` 的过程。最新写入的数据可能还在内存中,查询可以直接访问。但更早的数据可能存在于多个未合并的 `part` 中,查询时需要扫描所有这些 `part`,这会增加查询延迟。后台的合并操作正是为了通过减少 `part` 的数量来优化查询性能。这是一个典型的“写入时整理”(compaction)与“读取时合并”(read-time merge)之间的权衡。VM 的策略是在后台持续不断地进行合并,以保证查询性能的稳定。

高可用(HA)设计

VictoriaMetrics 的高可用策略非常务实:

  • 无状态组件(vminsert, vmselect): 直接运行多个实例,前面挂一个四层负载均衡器(L4 LB)即可。任何一个实例挂掉,LB 会自动把它摘除,对服务基本无影响。
  • 有状态组件(vmstorage): 官方推荐的 HA 方案是设置复制(Replication)。你可以在 `vminsert` 的启动参数中配置多个 `vmstorage` 地址,并设置复制因子(`-replicationFactor=N`)。这样,每一条数据都会被 `vminsert` 同时写入到 N 个不同的 `vmstorage` 节点。查询时,`vmselect` 会从 N 个副本中选择一个进行查询,如果某个副本失败,它会自动切换到其他副本。这种方案简单、可靠,且不需要共享存储,是典型的 Shared-Nothing 架构。
  • 替代方案(不推荐): 另一种方式是将 `vmstorage` 的数据目录置于高可用的网络存储上,如 Ceph、NFS 或云厂商提供的分布式文件系统。但这种方式会引入网络 I/O 的延迟和不稳定性,通常性能会差于本地 SSD,并且运维复杂度更高,因此除非有特殊需求,否则不建议采用。

架构演进与落地路径

对于一个已经在使用 Prometheus 的团队,迁移到 VictoriaMetrics 并不需要一步到位,可以采用平滑演进的策略。

  1. 第一阶段:作为 Prometheus 的远程存储 (Remote Storage)

    这是最简单、风险最低的接入方式。你不需要改变任何现有的 Prometheus 配置、采集任务或告警规则。只需在 Prometheus 的配置文件中,增加 `remote_write` 指向一个单节点的 VictoriaMetrics 实例。这样,Prometheus 在将数据写入本地 TSDB 的同时,也会将一份数据异步地发送给 VM。你可以立即获得长期、低成本的数据存储能力,并开始使用 VM 强大的查询能力来分析历史数据。这是验证 VM 价值和性能的最佳起点。

  2. 第二阶段:引入 `vmagent` 替代 Prometheus Scraper

    当对 VM 的稳定性建立信心后,可以开始使用 `vmagent`。`vmagent` 是一个轻量、高效的采集代理,可以完全兼容 Prometheus 的采集配置。你可以用 `vmagent` 集群替换掉原有的 Prometheus Server 集群。这样做的好处是:`vmagent` 资源消耗更低,支持更复杂的 relabeling 规则,并且可以将数据同时写入多个远程存储,非常适合构建高可用的数据采集链路。此时,查询和告警仍然可以由 Prometheus Server 负责,它通过 `remote_read` 从 VM 中读取数据。

  3. 第三阶段:全面迁移至 VictoriaMetrics 集群生态

    这是最终形态。使用 `vmagent` 负责数据采集,将数据写入 VictoriaMetrics 集群。使用 `vmselect` 结合 Grafana 进行数据查询和可视化。使用 `vmalert` 来执行告警和记录规则。至此,整个监控链路完全构建在 VictoriaMetrics 的技术栈之上,可以充分享受其水平扩展、高性能和低成本带来的全部优势。原有的 Prometheus Server 可以彻底下线,完成整个架构的演进。

总而言之,VictoriaMetrics 并非又一个简单的时序数据库轮子,它是对现代监控系统核心痛点的一次精准回应。通过回归计算机科学基础原理,在存储引擎、数据结构和分布式架构上进行大胆而务实的创新,它成功地在性能、成本和运维复杂度之间找到了一个绝佳的平衡点,为处理海量时序数据提供了一个值得所有架构师和SRE工程师认真评估的强大选项。

延伸阅读与相关资源

  • 想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
    交易系统整体解决方案
  • 如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
    产品与服务
    中关于交易系统搭建与定制开发的介绍。
  • 需要针对现有架构做评估、重构或从零规划,可以通过
    联系我们
    和架构顾问沟通细节,获取定制化的技术方案建议。
滚动至顶部