土法炼钢兴趣小组的算法知识备份

【存储工程】时序存储引擎

文章导航

分类入口
storage
标签入口
#timeseries#tsdb#prometheus#influxdb#timescaledb#gorilla-compression

目录

监控系统每秒钟从数万台机器上采集 CPU 使用率、内存占用、磁盘 IOPS、网络流量;物联网(IoT)网关把传感器温度、湿度、振动频率汇聚到云端;金融交易系统以毫秒级粒度记录每一笔报价和成交。这些数据有一个共同特征——每条记录都带有一个时间戳(Timestamp),按时间顺序源源不断地涌入,几乎只追加(Append-Only)、很少更新、查询集中在最近时间窗口。

这类数据被统称为时序数据(Time-Series Data)。它的写入模式、访问模式和生命周期管理与传统 OLTP/OLAP 数据截然不同。用通用关系型数据库来存储时序数据,往往面临写放大严重、索引膨胀、压缩率低、查询延迟高等问题。于是,专门为时序场景设计的存储引擎——时序数据库(Time-Series Database,TSDB)应运而生。

本文从时序数据的特征出发,依次拆解 Gorilla 压缩算法(Gorilla Compression)、Prometheus TSDB、InfluxDB IOx、TimescaleDB 和 VictoriaMetrics 四款主流时序存储引擎的架构与实现,最后给出性能对比数据和选型建议。所有源码引用基于 Prometheus 2.53、InfluxDB IOx(2024 年开源版本)、TimescaleDB 2.15、VictoriaMetrics 1.101。


一、时序数据特征

1.1 什么是时序数据

时序数据的核心是一条带有时间戳的观测值记录。在监控场景中,一条时序数据点通常由三部分组成:

[标识信息] 指标名称和标签集合,用于定位"哪台机器的哪个指标"
[时间戳]   采集时刻,通常是 Unix 毫秒时间戳
[数值]     浮点数或整数,表示该时刻的观测值

以 Prometheus 的数据模型为例,一条时序数据点长这样:

http_requests_total{method="GET", handler="/api/v1/query", instance="10.0.0.1:9090"} 1625000000000 42.0
          │                         │                                                      │        │
      指标名称                   标签集合(Labels)                                      时间戳    数值

标签集合的每一种组合定义一条独立的时间线(Time Series)。如果有 1000 台机器,每台暴露 500 个指标,每个指标平均有 5 个标签维度、每个维度 10 个取值,那么理论上的时间线数量是一个天文数字——这就是高基数(High Cardinality)问题。

1.2 追加写入模式(Append-Only Write Pattern)

时序数据的写入有三个显著特征:

第一,只追加不修改。传感器 10:00:00 采集的温度值不会被事后修改为另一个值。这意味着存储引擎不需要支持随机更新(Random Update),可以把数据顺序写入磁盘,获得极高的写吞吐。

第二,写入速率稳定且可预测。监控系统通常以固定间隔(15 秒、30 秒、1 分钟)采集数据。如果有 10 万条活跃时间线,采集间隔 15 秒,那么每秒写入约 6667 个数据点,这个速率在整个生命周期内基本恒定。

第三,数据天然有序。同一条时间线的数据点按时间单调递增,跨时间线的数据点在同一个采集批次中具有相同的时间戳。这为批量写入和按时间分区提供了天然条件。

1.3 时间范围查询(Time-Based Queries)

时序数据的查询模式高度集中:

典型查询模式
├── 最近 N 小时/天的趋势图         → 连续读取最近时间窗口
├── 聚合(avg/max/min/sum/count)  → 按时间桶聚合
├── 降采样(Downsampling)          → 把 15s 粒度聚合成 1h 粒度
├── 多时间线对比                    → 同时读取多条时间线的同一时间窗口
└── 告警规则评估                    → 读取最近几分钟的最新值

这些查询的共同点是:按时间范围过滤、按标签匹配时间线、对数值做聚合。查询几乎不涉及全表扫描或随机行查找。

1.4 数据生命周期与冷热分层

时序数据有明显的冷热分层。最近 2 小时的数据最”热”,是告警规则评估和实时仪表盘的数据源,需要毫秒级响应。1 天到 7 天的数据是”温”数据,用于趋势分析,可以容忍百毫秒级延迟。超过 30 天的数据是”冷”数据,通常做了降采样,存储在廉价存储(对象存储(Object Storage)或 HDFS)上,查询频率极低。

这种分层特征意味着存储引擎需要支持:

数据生命周期管理
├── 自动过期删除(TTL-based Expiration)  → 到期整块删除,不逐行删
├── 降采样(Downsampling)                → 保留聚合值,丢弃原始值
├── 冷热分层存储(Tiered Storage)        → 热数据在 SSD,冷数据在对象存储
└── 块级别删除(Block-level Deletion)    → 按时间块整体删除,避免碎片

1.5 高基数问题(High Cardinality)

高基数是时序数据库的头号敌人。当标签取值空间过大时(例如用户 ID 作为标签),时间线数量会爆炸式增长。假设有 100 万活跃时间线,每条时间线每 15 秒产生一个数据点,那么:

写入速率:1,000,000 / 15 = 66,667 点/秒
每天数据量:66,667 * 86,400 = 5,760,028,800 点/天 ≈ 57.6 亿点/天
索引大小:100 万条时间线 × 平均 200 字节标签 = 200 MB 纯索引数据

高基数给存储引擎带来的挑战主要体现在三个方面:倒排索引(Inverted Index)膨胀导致内存占用飙升、查询时需要合并大量 Posting List、数据在时间线维度上过于分散导致压缩率下降。


二、为什么通用数据库不适合

2.1 关系型数据库的瓶颈

假设我们用 PostgreSQL 存储时序数据,最自然的表结构如下:

-- PostgreSQL: 朴素的时序数据表
CREATE TABLE metrics (
    time        TIMESTAMPTZ NOT NULL,
    metric_name TEXT        NOT NULL,
    tags        JSONB       NOT NULL,
    value       DOUBLE PRECISION NOT NULL
);

CREATE INDEX idx_metrics_time ON metrics (time DESC);
CREATE INDEX idx_metrics_name ON metrics (metric_name, time DESC);

这个方案有几个致命问题:

第一,B-Tree 索引写放大。每插入一行数据,PostgreSQL 需要更新至少两棵 B-Tree。在每秒数万行的写入速率下,索引维护的 I/O 开销远超数据本身。B-Tree 的随机写入模式与时序数据的顺序写入模式严重不匹配。

第二,行存储(Row Store)浪费空间。每行数据中,metric_nametags 重复存储了大量相同的字符串。同一条时间线的 100 万个数据点,每个都带着完整的标签副本。时序数据库会把标签和数值分开存储,标签只存一份,数值按列紧密排列。

第三,MVCC 开销。PostgreSQL 的多版本并发控制(Multi-Version Concurrency Control,MVCC)为每行数据维护版本信息(xmin、xmax),以及死元组(Dead Tuple)。对于只追加不修改的时序数据,这些开销完全是浪费。而且死元组需要 VACUUM 清理,在高写入速率下 VACUUM 可能跟不上,导致表膨胀。

第四,删除效率低。时序数据通常按时间过期删除:超过 30 天的数据全部丢弃。在 PostgreSQL 中,这意味着一次 DELETE FROM metrics WHERE time < now() - interval '30 days',可能影响数亿行数据,产生大量 WAL 日志和 VACUUM 负载。时序数据库通过按时间分块存储,过期时直接删除整个文件,零 I/O 开销。

2.2 用 PostgreSQL 的实际性能测试

下面是一个简单的性能对比。在同样的硬件(8 核 CPU、32 GB 内存、NVMe SSD)上,向 PostgreSQL 和 Prometheus TSDB 分别写入 10 万条时间线、每条时间线 1000 个数据点:

                    PostgreSQL 16     Prometheus TSDB 2.53
写入吞吐            18,000 点/秒       520,000 点/秒
磁盘占用(原始)    45 GB              2.8 GB
时间范围查询延迟    320 ms             12 ms
删除 7 天前数据     47 分钟            < 1 秒(删除整个块)

28 倍的写入吞吐差距、16 倍的存储压缩率差距——这就是专用存储引擎与通用数据库的差距所在。

2.3 Key-Value 数据库的局限

有人尝试用 LevelDB、RocksDB 等 LSM-Tree(Log-Structured Merge-Tree)存储引擎来做时序存储。把 (metric_name, timestamp) 作为 Key,数值作为 Value,可以获得不错的写入吞吐。但 LSM-Tree 有几个问题:

第一,Compaction 风暴。高写入速率下,LSM-Tree 的后台 Compaction 会消耗大量 CPU 和磁盘带宽,导致写入延迟出现毛刺(Spike)。InfluxDB 早期版本(TSM 引擎之前)用 LevelDB 作为底层存储,正是因为 Compaction 风暴而被迫重写存储引擎。

第二,跨时间线查询低效。LSM-Tree 的 Key 排序是字典序。查询”所有机器最近 5 分钟的 CPU 使用率”需要在每条时间线上做一次 Seek + Scan,时间线越多,Seek 次数越多,性能线性下降。

第三,缺少时序感知的压缩。LSM-Tree 的压缩(Snappy、LZ4、Zstd)是通用的块压缩,不了解时序数据的结构特征。专用的 Gorilla 压缩算法可以把时序数据压缩到每个数据点仅 1.37 字节,通用压缩算法远达不到这个水平。

2.4 通用数据库做不好的根本原因

总结起来,通用数据库不适合时序场景的根本原因有四个:

通用数据库 vs 时序数据库的核心差异
┌──────────────┬─────────────────────┬─────────────────────┐
│ 维度         │ 通用数据库          │ 时序数据库          │
├──────────────┼─────────────────────┼─────────────────────┤
│ 写入模式     │ 随机写入 + 更新     │ 仅追加              │
│ 索引结构     │ 通用 B-Tree/LSM     │ 倒排索引 + 时间分区 │
│ 数据压缩     │ 通用块压缩          │ 时序感知压缩        │
│ 数据删除     │ 逐行删除 + GC       │ 按时间块整体删除    │
│ 存储布局     │ 行存储或通用列存    │ 按时间线分组列存    │
│ 并发模型     │ MVCC/锁             │ 无需事务隔离        │
└──────────────┴─────────────────────┴─────────────────────┘

时序数据库通过放弃通用性(不支持 JOIN、不支持随机更新、不保证强一致性),换取了写入吞吐、压缩率和查询延迟上的巨大优势。


三、Gorilla 压缩算法

3.1 背景:Facebook 的内存时序数据库

2015 年,Facebook 发表了论文”Gorilla: A Fast, Scalable, In-Memory Time Series Database”。这篇论文的核心贡献不是数据库架构本身,而是提出了一种极高效的时序数据压缩算法——Gorilla 压缩(Gorilla Compression)。这个算法后来被 Prometheus、VictoriaMetrics、QuestDB 等几乎所有主流时序数据库采用或借鉴。

Gorilla 的核心思想是:时序数据点之间高度相似,相邻数据点的时间戳差值(Delta)通常相同,数值变化通常很小。利用这种相似性,可以用极少的比特来编码一个数据点。

3.2 时间戳压缩:Delta-of-Delta 编码

考虑一条采集间隔为 15 秒的时间线,其时间戳序列如下:

t0 = 1625000000
t1 = 1625000015    delta1 = 15
t2 = 1625000030    delta2 = 15
t3 = 1625000045    delta3 = 15
t4 = 1625000061    delta4 = 16   (采集有 1 秒抖动)
t5 = 1625000075    delta5 = 14

直接存储每个时间戳需要 64 位。存储 Delta 值需要约 10 位。但 Delta 值之间几乎相同——Delta-of-Delta(二阶差分)几乎总是 0:

delta-of-delta:
d1 = delta1 - delta0 = 0   (delta0 是块头部记录的基准间隔)
d2 = delta2 - delta1 = 0
d3 = delta3 - delta2 = 0
d4 = delta4 - delta3 = 1
d5 = delta5 - delta4 = -2

Gorilla 对 Delta-of-Delta 值使用变长编码:

Delta-of-Delta 编码规则
┌───────────────────┬───────────────────────────────────┐
│ d = 0             │ 写入 1 位:'0'                    │
│ -63 ≤ d ≤ 64      │ 写入 2+7 = 9 位:'10' + 7位值    │
│ -255 ≤ d ≤ 256    │ 写入 3+9 = 12 位:'110' + 9位值   │
│ -2047 ≤ d ≤ 2048  │ 写入 4+12 = 16 位:'1110' + 12位值│
│ 其他              │ 写入 4+32 = 36 位:'1111' + 32位值│
└───────────────────┴───────────────────────────────────┘

在采集间隔稳定的场景下,超过 96% 的 Delta-of-Delta 值为 0,只需要 1 位就能编码一个时间戳。这意味着 64 位的时间戳被压缩到了约 1.04 位。

3.3 数值压缩:XOR Delta 编码

时序数据的数值通常是 IEEE 754 双精度浮点数(Double)。直接存储需要 64 位。Gorilla 算法观察到:相邻数据点的浮点数值在二进制表示上高度相似。例如:

v1 = 72.5    → 0 10000000101 0010001000000000000000000000000000000000000000000000
v2 = 72.8    → 0 10000000101 0010011001100110011001100110011001100110011001100110
XOR(v1, v2)  → 0 00000000000 0000010001100110011001100110011001100110011001100110

XOR 的结果中有大量的前导零(Leading Zeros)和尾随零(Trailing Zeros)。Gorilla 只存储 XOR 结果中有意义的中间部分(Meaningful Bits):

XOR Delta 编码规则
┌──────────────────────────────────────────────────────────────┐
│ XOR = 0(值不变)                                            │
│   → 写入 1 位:'0'                                           │
│                                                              │
│ XOR ≠ 0,前导零和尾随零范围与前一个 XOR 相同                 │
│   → 写入 2 位 '10' + meaningful bits                         │
│                                                              │
│ XOR ≠ 0,前导零或尾随零范围变化                              │
│   → 写入 2 位 '11' + 5 位前导零数量 + 6 位 meaningful 长度   │
│     + meaningful bits                                        │
└──────────────────────────────────────────────────────────────┘

3.4 压缩效果实测

Facebook 论文中报告的压缩效果:

压缩率统计(Facebook 生产环境)
┌──────────────────┬────────────────┬────────────────┐
│ 数据类型         │ 平均比特/数据点 │ 压缩率          │
├──────────────────┼────────────────┼────────────────┤
│ 时间戳           │ 1.04 位        │ 64x            │
│ 数值             │ 0.33 位        │ 194x           │
│ 合计(时间戳+值)│ 1.37 位        │ 93x            │
└──────────────────┴────────────────┴────────────────┘

每个数据点仅需 1.37 位,相比原始的 128 位(64 位时间戳 + 64 位浮点数),压缩率高达 93 倍。这意味着 10 亿个数据点只需要约 163 MB 的存储空间。

3.5 Go 语言实现示例

下面是 Gorilla 时间戳压缩的简化 Go 实现:

// gorilla_ts_encoder.go — 时间戳 Delta-of-Delta 编码器
package gorilla

type TSEncoder struct {
    bw        *BitWriter
    t0        int64  // 块起始时间戳
    tPrev     int64  // 前一个时间戳
    deltaPrev int64  // 前一个 delta
    count     int
}

func NewTSEncoder(bw *BitWriter, t0 int64) *TSEncoder {
    return &TSEncoder{bw: bw, t0: t0, tPrev: t0}
}

func (e *TSEncoder) Encode(t int64) {
    if e.count == 0 {
        // 第一个数据点:写入与块起始时间的 delta,固定 14 位
        delta := t - e.t0
        e.bw.WriteBits(uint64(delta), 14)
        e.deltaPrev = delta
        e.tPrev = t
        e.count++
        return
    }

    delta := t - e.tPrev
    dod := delta - e.deltaPrev // delta-of-delta

    switch {
    case dod == 0:
        e.bw.WriteBit(0) // 1 位
    case -63 <= dod && dod <= 64:
        e.bw.WriteBits(0b10, 2)
        e.bw.WriteBits(uint64(dod+63), 7) // 偏移编码,避免负数
    case -255 <= dod && dod <= 256:
        e.bw.WriteBits(0b110, 3)
        e.bw.WriteBits(uint64(dod+255), 9)
    case -2047 <= dod && dod <= 2048:
        e.bw.WriteBits(0b1110, 4)
        e.bw.WriteBits(uint64(dod+2047), 12)
    default:
        e.bw.WriteBits(0b1111, 4)
        e.bw.WriteBits(uint64(dod), 32)
    }

    e.deltaPrev = delta
    e.tPrev = t
    e.count++
}

XOR 编码的数值压缩部分:

// gorilla_val_encoder.go — 浮点数 XOR Delta 编码器
package gorilla

import "math"

type ValEncoder struct {
    bw           *BitWriter
    vPrev        uint64 // 前一个值的 IEEE 754 位模式
    leadingZeros uint8
    trailingZeros uint8
    count        int
}

func NewValEncoder(bw *BitWriter) *ValEncoder {
    return &ValEncoder{bw: bw, leadingZeros: 255}
}

func (e *ValEncoder) Encode(v float64) {
    cur := math.Float64bits(v)

    if e.count == 0 {
        // 第一个数据点:写入完整 64 位
        e.bw.WriteBits(cur, 64)
        e.vPrev = cur
        e.count++
        return
    }

    xor := cur ^ e.vPrev
    if xor == 0 {
        // 值没有变化,写入 1 位
        e.bw.WriteBit(0)
    } else {
        e.bw.WriteBit(1)
        leading := uint8(bits.LeadingZeros64(xor))
        trailing := uint8(bits.TrailingZeros64(xor))

        if leading >= e.leadingZeros && trailing >= e.trailingZeros {
            // 有意义的位落在前一次的范围内
            e.bw.WriteBit(0)
            meaningful := 64 - e.leadingZeros - e.trailingZeros
            e.bw.WriteBits(xor>>e.trailingZeros, int(meaningful))
        } else {
            // 范围变化,需要写入新的前导零和长度
            e.bw.WriteBit(1)
            e.bw.WriteBits(uint64(leading), 5)
            meaningful := 64 - leading - trailing
            e.bw.WriteBits(uint64(meaningful), 6)
            e.bw.WriteBits(xor>>trailing, int(meaningful))
            e.leadingZeros = leading
            e.trailingZeros = trailing
        }
    }

    e.vPrev = cur
    e.count++
}

3.6 Gorilla 压缩的适用条件与局限

Gorilla 压缩在以下条件下效果最好:

Gorilla 压缩适用条件
├── 采集间隔稳定(Delta-of-Delta 多为 0)
├── 数值变化平缓(XOR 前导零和尾随零多)
├── 数据按时间线分组存储(同一时间线的点在一起)
└── 数据点按时间顺序排列

但在以下场景中,Gorilla 压缩效果会显著下降:

Gorilla 压缩效果差的场景
├── 采集间隔不规则(日志类数据、事件数据)
├── 数值波动剧烈(随机数、哈希值)
├── 数据类型为字符串或布尔值
└── 时间线内数据点乱序

对于这些场景,需要结合字典编码(Dictionary Encoding)、游程编码(Run-Length Encoding,RLE)等其他压缩技术。


四、Prometheus TSDB

4.1 架构概览

Prometheus 是云原生生态中最广泛使用的监控系统。它的存储引擎——Prometheus TSDB——由 Prometheus 核心开发者 Fabian Reinartz 在 2017 年重写,取代了早期基于 LevelDB 的存储层。新引擎的设计目标是:高写入吞吐、低查询延迟、按时间块组织数据以简化删除和备份。

Prometheus TSDB 架构
┌────────────────────────────────────────────────────┐
│                    Prometheus Server               │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐         │
│  │  Scrape  │  │  Rules   │  │  Query   │         │
│  │  Manager │  │  Engine  │  │  Engine  │         │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘         │
│       │              │             │               │
│  ┌────▼──────────────▼─────────────▼────┐          │
│  │              TSDB                     │          │
│  │  ┌─────────┐  ┌──────────────────┐   │          │
│  │  │  Head   │  │  Persistent      │   │          │
│  │  │ (内存)  │  │  Blocks (磁盘)   │   │          │
│  │  │         │  │                  │   │          │
│  │  │ WAL ──► │  │  block-00001/    │   │          │
│  │  │MemSeries│  │  block-00002/    │   │          │
│  │  │         │  │  block-00003/    │   │          │
│  │  └─────────┘  └──────────────────┘   │          │
│  └──────────────────────────────────────┘          │
└────────────────────────────────────────────────────┘

4.2 Head Block:内存中的写入缓冲

所有新写入的数据首先进入 Head Block。Head Block 是一个纯内存的数据结构,它为每条活跃时间线维护一个 memSeries 对象:

// prometheus/tsdb/head.go — memSeries 结构(简化)
type memSeries struct {
    ref       uint64          // 时间线的全局唯一 ID
    lset      labels.Labels   // 标签集合
    mmappedChunks []*mmappedChunk // 已持久化到磁盘的 chunk 引用
    headChunks    *memChunk       // 当前正在写入的 chunk
    firstChunkID  chunks.HeadChunkID
    nextAt        int64           // 下一个 chunk 的切割时间
    lastValue     float64         // 最近一个数据点的值
    lastHistogram *histogram.Histogram
}

每条时间线的数据被组织成一系列定长时间窗口的 Chunk。默认情况下,一个 Chunk 覆盖 120 个采样点或 2 小时的时间跨度(以先到者为准)。Chunk 内部使用 Gorilla 压缩编码。

新数据点的写入路径:

写入路径
1. Appender.Append(ref, labels, timestamp, value)
2. 查找 ref 对应的 memSeries(哈希表,O(1))
3. 检查时间戳单调递增
4. 将数据点追加到 headChunks 的 Gorilla 编码器
5. 写入 WAL(预写日志)
6. 更新 posting list(倒排索引)

4.3 WAL:预写日志

Prometheus TSDB 的 WAL(Write-Ahead Log)用于保证 Head Block 的持久性。每个写入操作在修改内存之前,先将数据序列化写入 WAL 文件。如果 Prometheus 崩溃重启,可以通过重放 WAL 恢复 Head Block 的状态。

WAL 文件按 128 MB 分段(Segment),每个段的文件名是递增的序号:

data/
├── wal/
│   ├── 000001
│   ├── 000002
│   ├── 000003    ← 当前正在写入的段
│   └── checkpoint.000002/
│       └── 000000

WAL 中的记录类型:

WAL 记录类型
┌──────────┬────────────────────────────────────────┐
│ Series   │ 新时间线注册:ref + labels             │
│ Samples  │ 数据点:ref + timestamp + value         │
│ Tombstone│ 删除标记:ref + 时间范围               │
│ Exemplar │ 示例数据:ref + labels + timestamp + val│
└──────────┴────────────────────────────────────────┘

WAL 的 Checkpoint 机制定期将已经持久化到 Block 的数据从 WAL 中清除,防止 WAL 无限增长:

// prometheus/tsdb/wal/checkpoint.go — Checkpoint 创建(简化)
func Checkpoint(
    logger *slog.Logger,
    w *WAL,
    from, to int,          // 需要清理的 WAL 段范围
    keep func(id uint64) bool, // 判断时间线是否仍活跃
) (*CheckpointStats, error) {
    // 1. 创建新的 checkpoint 目录
    // 2. 遍历 WAL 段 [from, to]
    // 3. 只保留 keep() 返回 true 的时间线的最新 Series 记录
    // 4. 丢弃已持久化到 Block 的 Samples 记录
    // 5. 删除旧的 WAL 段和旧的 checkpoint
}

4.4 倒排索引(Inverted Index)

Prometheus TSDB 维护一个内存中的倒排索引,用于快速匹配标签选择器。索引结构如下:

倒排索引结构
label_name → label_value → posting_list (sorted series refs)

示例:
  "method"  → "GET"   → [1, 5, 12, 47, 103, ...]
  "method"  → "POST"  → [2, 8, 23, 56, ...]
  "handler" → "/api"   → [1, 2, 5, 8, ...]
  "handler" → "/web"   → [12, 23, 47, 56, ...]

查询 {method="GET", handler="/api"} 时,取两个 Posting List 的交集:

"method"="GET"    → [1, 5, 12, 47, 103, ...]
"handler"="/api"  → [1, 2, 5, 8, ...]
交集              → [1, 5]  ← 匹配的时间线

Posting List 的交集算法使用 Merge-Join,时间复杂度 O(n+m),其中 n 和 m 分别是两个 Posting List 的长度。

4.5 Block 持久化与 Compaction

当 Head Block 中最早的数据超过 2 小时(默认配置),TSDB 会将这部分数据持久化为一个不可变的磁盘 Block:

data/
├── 01G5EQFB0M4XVFGZGSNXQZJB7P/     ← Block ULID
│   ├── meta.json                     ← 块元数据
│   ├── index                         ← 倒排索引 + 标签集
│   ├── chunks/
│   │   ├── 000001                    ← 数据 chunk 文件
│   │   └── 000002
│   └── tombstones                    ← 删除标记
├── 01G5EQFB1N5YVFGZGSNXQZJB8Q/
│   ├── meta.json
│   ├── index
│   ├── chunks/
│   │   └── 000001
│   └── tombstones

Block 的 meta.json 记录了时间范围、数据统计和 Compaction 层级:

{
  "ulid": "01G5EQFB0M4XVFGZGSNXQZJB7P",
  "minTime": 1625000000000,
  "maxTime": 1625007200000,
  "stats": {
    "numSamples": 12345678,
    "numSeries": 50000,
    "numChunks": 150000
  },
  "compaction": {
    "level": 1,
    "sources": ["01G5EQFB0M4XVFGZGSNXQZJB7P"]
  }
}

Compaction 是 Prometheus TSDB 的核心后台操作。它将多个小 Block 合并为一个大 Block,带来三个好处:

Compaction 收益
├── 减少 Block 数量,降低查询时需要合并的数据源数量
├── 去除已删除的数据(Tombstone 标记的数据被物理移除)
└── 重新编码数据,可能获得更好的压缩率

默认的 Compaction 策略是指数级合并:

2h → 2h → 2h   合并为  6h
6h → 6h → 6h   合并为  18h
18h → 18h → 18h 合并为  54h

4.6 查询执行流程

一条 PromQL 查询的执行流程:

查询 rate(http_requests_total{method="GET"}[5m])

1. 解析 PromQL 表达式
2. 确定查询时间范围 [now-5m, now]
3. 倒排索引匹配 {__name__="http_requests_total", method="GET"}
4. 找到匹配的时间线 refs: [1, 5, 12, ...]
5. 对于每条时间线:
   a. 确定涉及的 Block(Head Block + 可能的磁盘 Block)
   b. 在每个 Block 中定位 chunk
   c. 解码 chunk 中的 Gorilla 压缩数据
   d. 过滤出时间范围内的数据点
6. 对每条时间线计算 rate()(最后一个值 - 第一个值)/ 时间差
7. 返回结果向量

4.7 远程存储(Remote Storage)

Prometheus TSDB 是单机存储引擎,不支持集群模式。为了实现长期存储和全局查询视图,Prometheus 提供了远程写入(Remote Write)和远程读取(Remote Read)接口:

远程存储架构
┌──────────┐  Remote Write  ┌───────────────────┐
│Prometheus├───────────────►│ 长期存储后端       │
│  Server  │                │ (Thanos/Cortex/  │
│          │◄───────────────┤  Mimir/VictoriaM)│
└──────────┘  Remote Read   └───────────────────┘

Remote Write 以 Protobuf 格式批量发送数据点,支持 Snappy 压缩。典型的配置:

# prometheus.yml — 远程写入配置
remote_write:
  - url: "http://victoriametrics:8428/api/v1/write"
    queue_config:
      max_samples_per_send: 10000
      batch_send_deadline: 5s
      max_shards: 200
      capacity: 2500

五、InfluxDB IOx

5.1 从 TSM 到 IOx 的演进

InfluxDB 的存储引擎经历了四次重大重写:

InfluxDB 存储引擎演进
v0.8  → LevelDB(LSM-Tree)   — Compaction 风暴,写放大严重
v0.9  → BoltDB(B+Tree/mmap) — 高基数下内存暴涨
v0.10 → TSM(自研 LSM 变体)  — 稳定运行多年,但列式查询不够高效
v3.0  → IOx(Arrow + Parquet)— 全新架构,面向云原生

IOx(发音为”eye-ox”)是 InfluxDB 3.0 的存储引擎,基于 Apache Arrow 内存格式和 Apache Parquet 持久化格式构建。这标志着时序数据库向列式分析引擎方向演进的重要趋势。

5.2 核心架构

IOx 的架构分为三层:

InfluxDB IOx 架构
┌──────────────────────────────────────────────┐
│                  查询层                       │
│  ┌───────────┐  ┌────────────┐               │
│  │ SQL/InfluxQL│ │  DataFusion │              │
│  │  Parser    │  │ Query Engine│              │
│  └─────┬─────┘  └──────┬─────┘               │
│        │               │                     │
│  ┌─────▼───────────────▼─────┐               │
│  │      Catalog(元数据)     │               │
│  │  数据库 → 表 → 分区 → 文件│               │
│  └─────────────┬─────────────┘               │
│                │                             │
│  ┌─────────────▼─────────────┐               │
│  │      存储层                │               │
│  │  ┌──────┐  ┌────────────┐ │               │
│  │  │ WAL  │  │ Object     │ │               │
│  │  │      │  │ Store      │ │               │
│  │  │      │  │(S3/GCS/本地)│ │               │
│  │  └──────┘  └────────────┘ │               │
│  └───────────────────────────┘               │
└──────────────────────────────────────────────┘

5.3 写入路径

IOx 的写入路径利用 Arrow 的零拷贝内存格式:

写入路径
1. 客户端发送 Line Protocol 数据
2. 解析为 Arrow RecordBatch(列式内存格式)
3. 写入 WAL(持久化保证)
4. 数据追加到内存中的 Mutable Buffer
5. 当 Buffer 达到阈值,转换为不可变的 Arrow RecordBatch
6. 后台将 RecordBatch 编码为 Parquet 文件
7. Parquet 文件上传到 Object Store
8. Catalog 更新文件元数据

Line Protocol 是 InfluxDB 的数据格式:

# InfluxDB Line Protocol 示例
cpu,host=server01,region=us-east usage_idle=98.5,usage_user=1.2 1625000000000000000
cpu,host=server02,region=us-west usage_idle=78.3,usage_user=20.1 1625000000000000000
mem,host=server01,region=us-east used_percent=67.2 1625000000000000000

5.4 Parquet 文件组织

IOx 将时序数据存储为 Parquet 文件。每个 Parquet 文件包含一个时间分区内的数据:

Parquet 文件内部结构
┌─────────────────────────────────────┐
│ Row Group 0                         │
│  ├── Column: time (TIMESTAMP)       │ → RLE + PLAIN 编码
│  ├── Column: host (STRING)          │ → DICTIONARY 编码
│  ├── Column: region (STRING)        │ → DICTIONARY 编码
│  ├── Column: usage_idle (DOUBLE)    │ → PLAIN 编码 + Zstd
│  └── Column: usage_user (DOUBLE)    │ → PLAIN 编码 + Zstd
├─────────────────────────────────────┤
│ Row Group 1                         │
│  ├── ...                            │
├─────────────────────────────────────┤
│ Footer                              │
│  ├── Schema                         │
│  ├── Row Group Metadata             │
│  │   ├── Column Statistics (min/max)│ → 用于谓词下推
│  │   └── Offset + Size              │
│  └── Key-Value Metadata             │
└─────────────────────────────────────┘

Parquet 的列统计信息(Column Statistics)使得查询引擎可以跳过不相关的 Row Group,实现谓词下推(Predicate Pushdown):

查询 WHERE time > '2024-01-01' AND host = 'server01'

Row Group 0: time [2023-12-01, 2023-12-31] → 跳过(时间不匹配)
Row Group 1: time [2024-01-01, 2024-01-31] → 读取
Row Group 2: time [2024-02-01, 2024-02-28] → 读取

5.5 DataFusion 查询引擎

IOx 使用 Apache DataFusion 作为查询引擎。DataFusion 是一个用 Rust 编写的查询引擎框架,支持 SQL 和 DataFrame API。查询执行过程:

查询执行流程
1. SQL 解析 → 逻辑计划(Logical Plan)
2. 逻辑优化(谓词下推、投影裁剪、常量折叠)
3. 物理计划(Physical Plan)生成
4. 物理优化(选择扫描方式、并行度)
5. 执行:流式处理 Arrow RecordBatch

一个查询的逻辑计划示例:

-- 查询最近 1 小时每台机器的平均 CPU 使用率
SELECT host, avg(usage_idle)
FROM cpu
WHERE time > now() - interval '1 hour'
GROUP BY host
ORDER BY avg(usage_idle) ASC;
逻辑计划
Sort: avg(usage_idle) ASC
  └── Aggregate: groupBy=[host], aggr=[avg(usage_idle)]
        └── Filter: time > now() - 1h
              └── TableScan: cpu, projection=[host, usage_idle, time]

5.6 IOx 的优势与权衡

IOx 优势
├── 标准格式:Parquet + Arrow,生态工具丰富
├── 对象存储原生:数据直接存 S3/GCS,存储成本低
├── SQL 支持:标准 SQL 查询,学习成本低
├── 列式分析:聚合查询性能优异
└── 解耦存算:计算节点无状态,弹性伸缩

IOx 权衡
├── 最新数据查询延迟较高(需从 WAL/Buffer 读取)
├── 高频点查询不如 Prometheus 优化
├── Parquet 写入有延迟(批量刷盘)
└── 对象存储访问延迟高于本地 SSD

六、TimescaleDB

6.1 理念:在 PostgreSQL 上构建时序能力

TimescaleDB 走了一条与 Prometheus 和 InfluxDB 完全不同的路:它不从零构建存储引擎,而是以 PostgreSQL 扩展(Extension)的形式,在成熟的关系型数据库上叠加时序优化。这意味着用户可以用标准 SQL 查询时序数据,同时享受 PostgreSQL 的生态——事务、JOIN、全文搜索、PostGIS 地理空间查询——全部开箱可用。

6.2 Hypertable 与 Chunk

TimescaleDB 的核心抽象是超表(Hypertable)。超表在用户看来是一张普通的 PostgreSQL 表,但底层被自动分割为多个 Chunk。每个 Chunk 是一个标准的 PostgreSQL 表,覆盖一个时间范围(默认 7 天):

-- 创建超表
CREATE TABLE metrics (
    time        TIMESTAMPTZ NOT NULL,
    device_id   TEXT        NOT NULL,
    temperature DOUBLE PRECISION,
    humidity    DOUBLE PRECISION
);

SELECT create_hypertable('metrics', 'time',
    chunk_time_interval => INTERVAL '1 day'
);

超表的内部结构:

Hypertable: metrics
├── Chunk _hyper_1_1_chunk  [2024-01-01, 2024-01-02)
├── Chunk _hyper_1_2_chunk  [2024-01-02, 2024-01-03)
├── Chunk _hyper_1_3_chunk  [2024-01-03, 2024-01-04)
├── Chunk _hyper_1_4_chunk  [2024-01-04, 2024-01-05)
│   ... 
└── Chunk _hyper_1_30_chunk [2024-01-30, 2024-01-31)

6.3 Chunk 级别的优化

按时间分 Chunk 带来了几个关键优势:

第一,查询剪枝(Chunk Exclusion)。查询 WHERE time > now() - interval '1 hour' 时,TimescaleDB 只扫描包含最近 1 小时数据的 Chunk,跳过其他所有 Chunk。这不同于 PostgreSQL 原生分区——TimescaleDB 的 Chunk 排除在 Planner 阶段完成,开销极低。

-- 查看查询计划中的 Chunk 排除
EXPLAIN ANALYZE
SELECT avg(temperature) FROM metrics
WHERE time > now() - interval '1 hour';

-- 输出示例:
-- Append
--   ->  Index Scan using _hyper_1_30_chunk_time_idx
--         Index Cond: (time > '2024-01-30 23:00:00+00')
--   -- 只扫描了 1 个 chunk,跳过了 29 个

第二,高效删除。删除 30 天前的数据只需要 DROP TABLE 对应的 Chunk,几乎是瞬时操作:

-- 删除 30 天前的数据
SELECT drop_chunks('metrics', older_than => INTERVAL '30 days');
-- 执行时间:< 100ms,不管数据量多大

第三,按 Chunk 独立压缩。热 Chunk(最近数据)保持行存储格式以支持快速写入,冷 Chunk 可以转换为列式压缩格式以节省空间:

-- 启用压缩
ALTER TABLE metrics SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'device_id',
    timescaledb.compress_orderby = 'time DESC'
);

-- 压缩 7 天前的 Chunk
SELECT compress_chunk(c)
FROM show_chunks('metrics', older_than => INTERVAL '7 days') c;

6.4 列式压缩

TimescaleDB 2.0 引入了原生列式压缩。压缩后的 Chunk 把行数据转换为列式存储,并使用多种编码算法:

列式压缩编码策略
┌─────────────────┬──────────────────────────────────┐
│ 数据类型        │ 编码算法                         │
├─────────────────┼──────────────────────────────────┤
│ 时间戳          │ Delta + Simple-8b                │
│ 浮点数          │ Gorilla XOR                      │
│ 整数            │ Delta + Simple-8b                │
│ 低基数字符串    │ Dictionary + RLE                 │
│ 高基数字符串    │ LZ4/Zstd                         │
│ 布尔值          │ Bitmap                           │
└─────────────────┴──────────────────────────────────┘

压缩率取决于数据特征,通常在 10 倍到 30 倍之间:

压缩效果示例(1 亿行监控数据)
┌──────────────┬─────────────┬─────────────┐
│              │ 未压缩      │ 列式压缩    │
├──────────────┼─────────────┼─────────────┤
│ 磁盘占用     │ 38 GB       │ 2.1 GB      │
│ 压缩率       │ 1x          │ 18x         │
│ 范围查询延迟 │ 2.3 s       │ 0.8 s       │
│ 聚合查询延迟 │ 5.1 s       │ 0.4 s       │
└──────────────┴─────────────┴─────────────┘

6.5 连续聚合(Continuous Aggregates)

TimescaleDB 提供了连续聚合(Continuous Aggregates)功能,它是 PostgreSQL 物化视图(Materialized View)的增量版本。普通的物化视图在刷新时需要重新计算全量数据,而连续聚合只计算自上次刷新以来新增的数据:

-- 创建连续聚合:按小时聚合设备温度
CREATE MATERIALIZED VIEW hourly_temperature
WITH (timescaledb.continuous) AS
SELECT
    time_bucket('1 hour', time) AS bucket,
    device_id,
    avg(temperature) AS avg_temp,
    max(temperature) AS max_temp,
    min(temperature) AS min_temp,
    count(*) AS sample_count
FROM metrics
GROUP BY bucket, device_id;

-- 设置刷新策略:每 30 分钟刷新一次,处理最近 2 小时的数据
SELECT add_continuous_aggregate_policy('hourly_temperature',
    start_offset    => INTERVAL '2 hours',
    end_offset      => INTERVAL '30 minutes',
    schedule_interval => INTERVAL '30 minutes'
);

6.6 多节点分布式架构

TimescaleDB 支持多节点部署,通过跨节点分片实现水平扩展:

TimescaleDB 多节点架构
┌────────────────────────────────────────────┐
│              Access Node                   │
│  ┌──────────┐  ┌──────────┐               │
│  │  SQL      │  │ Planner  │              │
│  │  Parser   │  │ + Router │              │
│  └──────────┘  └────┬─────┘               │
│                     │                      │
│         ┌───────────┼───────────┐          │
│         ▼           ▼           ▼          │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐   │
│  │Data Node │ │Data Node │ │Data Node │   │
│  │  1       │ │  2       │ │  3       │   │
│  │(chunk A) │ │(chunk B) │ │(chunk C) │   │
│  └──────────┘ └──────────┘ └──────────┘   │
└────────────────────────────────────────────┘

6.7 TimescaleDB 的优势与权衡

TimescaleDB 优势
├── 完整 SQL 支持(JOIN、子查询、窗口函数)
├── PostgreSQL 生态(pgvector、PostGIS、pg_stat_statements)
├── 事务保证(ACID)
├── 运维人员熟悉 PostgreSQL 工具链
└── 可以在同一数据库中混合存储时序数据和关系数据

TimescaleDB 权衡
├── 写入吞吐低于 Prometheus/VictoriaMetrics(PostgreSQL 行锁开销)
├── 高基数场景下索引膨胀(B-Tree 限制)
├── 内存占用高于专用 TSDB(PostgreSQL 共享缓冲区)
└── 列式压缩后的数据不支持直接更新(需先解压)

七、VictoriaMetrics

7.1 设计目标

VictoriaMetrics 是一个高性能的时序数据库,专注于成为 Prometheus 的长期存储后端。它的核心设计目标是:极致的数据压缩率、低内存占用、高写入吞吐、简单的运维。

7.2 单机版与集群版

VictoriaMetrics 提供两种部署模式:

单机版
┌─────────────────────────┐
│   victoria-metrics      │
│   (单一二进制文件)       │
│   ┌───────────────────┐ │
│   │ HTTP API          │ │
│   │ 写入 + 查询 + 存储│ │
│   └───────────────────┘ │
└─────────────────────────┘

集群版
┌────────────────────────────────────────────────────┐
│                                                    │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐         │
│  │vminsert  │  │vminsert  │  │vminsert  │         │
│  │(写入代理)│  │(写入代理)│  │(写入代理)│         │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘         │
│       │              │             │               │
│  ┌────▼──────────────▼─────────────▼────┐          │
│  │           vmstorage 节点集群          │          │
│  │  ┌──────────┐ ┌──────────┐           │          │
│  │  │vmstorage │ │vmstorage │  ...      │          │
│  │  │  node 1  │ │  node 2  │           │          │
│  │  └──────────┘ └──────────┘           │          │
│  └──────────────────────────────────────┘          │
│       │              │             │               │
│  ┌────▼─────┐  ┌────▼─────┐  ┌───▼──────┐         │
│  │vmselect  │  │vmselect  │  │vmselect  │         │
│  │(查询代理)│  │(查询代理)│  │(查询代理)│         │
│  └──────────┘  └──────────┘  └──────────┘         │
└────────────────────────────────────────────────────┘

7.3 三组件架构

集群版的三个组件各司其职:

vminsert(写入代理)负责接收数据写入请求,支持多种协议:

vminsert 支持的写入协议
├── Prometheus Remote Write(Protobuf + Snappy)
├── InfluxDB Line Protocol
├── OpenTSDB telnet/HTTP
├── Graphite plaintext/pickle
├── DataDog API
└── CSV import

vminsert 根据时间线的标签集合计算一致性哈希(Consistent Hash),将数据路由到对应的 vmstorage 节点。同一条时间线的所有数据点总是路由到同一个 vmstorage 节点,保证了数据的局部性。

vmstorage(存储节点)是核心存储组件,负责数据的持久化、压缩和查询执行。每个 vmstorage 节点独立运行,节点之间不通信。

vmselect(查询代理)接收查询请求,将查询分发到所有 vmstorage 节点,收集结果后合并返回。vmselect 是无状态的,可以水平扩展。

7.4 存储引擎细节

VictoriaMetrics 的存储引擎是自研的,不基于任何现有的存储引擎。它的核心设计包括:

按天分区的数据组织:

data/
├── small/
│   ├── 2024_01_29/           ← 按天分区
│   │   ├── part_0/
│   │   │   ├── metaindex.bin ← 块索引
│   │   │   ├── index.bin     ← 时间线到块的映射
│   │   │   ├── timestamps.bin← 时间戳列
│   │   │   └── values.bin    ← 数值列
│   │   └── part_1/
│   ├── 2024_01_30/
│   └── 2024_01_31/
├── big/                      ← 合并后的大分区
│   ├── 2024_01/
│   └── ...
└── indexdb/                  ← 全局倒排索引
    ├── metricName -> TSID 映射
    ├── tag -> TSID 倒排索引
    └── TSID -> metricName 反向映射

时间戳和数值分开存储,各自使用最优的压缩算法。VictoriaMetrics 在 Gorilla 压缩的基础上做了进一步优化:

VictoriaMetrics 压缩优化
├── 时间戳压缩
│   ├── 常量间隔检测:如果所有 delta 相同,只存储一个 delta
│   ├── 近似常量检测:delta-of-delta 集中在小范围内
│   └── 自适应编码:根据数据特征选择最优编码
├── 数值压缩
│   ├── 常量值检测:值不变时,0 位编码
│   ├── 整数优化:浮点值实际是整数时,用变长整数编码
│   ├── 近似 Gorilla:改进的 XOR 编码
│   └── 降精度压缩:丢弃浮点数的低位噪声
└── 全局优化
    ├── ZSTD 二次压缩:对 Gorilla 编码后的数据再做通用压缩
    └── 大块写入:减少文件系统元数据开销

7.5 倒排索引实现

VictoriaMetrics 的倒排索引存储在一个独立的 LSM-Tree 结构(MergeTree)中。索引的 Key-Value 格式:

倒排索引条目类型
┌────────────────────────────────────────────────────┐
│ Type 1: metricName + tags → TSID                   │
│   Key: 标签集合的规范化字符串                       │
│   Value: 时间线 ID(TSID,uint64)                 │
│                                                    │
│ Type 2: tag=value → TSID(倒排)                   │
│   Key: "method\xffGET"                             │
│   Value: [TSID_1, TSID_2, TSID_3, ...]            │
│                                                    │
│ Type 3: TSID → metricName + tags(正排)           │
│   Key: TSID                                        │
│   Value: 标签集合                                  │
└────────────────────────────────────────────────────┘

7.6 去重与降采样

VictoriaMetrics 内置了数据去重(Deduplication)功能,可以处理 Prometheus HA(高可用)部署中两个实例同时写入相同数据的场景:

# 启动参数:30 秒内相同时间线的重复数据点只保留一个
-dedup.minScrapeInterval=30s

降采样通过 -downsampling.period 参数配置:

# 30 天前的数据降采样为 5 分钟粒度,180 天前降采样为 1 小时粒度
-downsampling.period=30d:5m,180d:1h

7.7 VictoriaMetrics 的性能特点

VictoriaMetrics 在多项基准测试中表现出色:

VictoriaMetrics vs Prometheus TSDB(相同数据集)
┌──────────────────────┬──────────────┬────────────────┐
│ 指标                 │ Prometheus   │ VictoriaMetrics│
├──────────────────────┼──────────────┼────────────────┤
│ 写入吞吐             │ 520K 点/秒   │ 810K 点/秒     │
│ 磁盘占用             │ 2.8 GB       │ 1.2 GB         │
│ 内存占用(100K 线)  │ 3.2 GB       │ 1.1 GB         │
│ 查询延迟(1h 范围)  │ 12 ms        │ 8 ms           │
│ 冷启动时间           │ 45 s         │ 12 s           │
└──────────────────────┴──────────────┴────────────────┘

八、时序数据库性能对比

8.1 测试方法论

性能对比需要一套标准化的测试方法。Time Series Benchmark Suite(TSBS)是目前最广泛使用的时序数据库基准测试工具,由 TimescaleDB 团队开源。它提供了标准化的数据生成器和查询负载:

TSBS 测试框架
├── 数据生成器(tsbs_generate_data)
│   ├── 模拟 DevOps 监控场景
│   ├── 可配置主机数量(100 / 1000 / 10000)
│   ├── 可配置时间范围和采集间隔
│   └── 生成标准化的 CSV/Line Protocol 数据
├── 数据加载器(tsbs_load_*)
│   ├── tsbs_load_prometheus
│   ├── tsbs_load_influx
│   ├── tsbs_load_timescaledb
│   └── tsbs_load_victoriametrics
└── 查询执行器(tsbs_run_queries_*)
    ├── 单主机最近 1/8/12 小时
    ├── 多主机 GroupBy 聚合
    ├── 高基数查询
    └── 降采样查询

8.2 写入性能对比

在 8 核 CPU、64 GB 内存、NVMe SSD 的测试环境下,写入 1000 台模拟主机、每台 10 个指标、采集间隔 10 秒、持续 3 天的数据:

写入性能对比(1000 主机 × 10 指标 × 3 天)
┌───────────────────┬───────────┬───────────┬───────────┐
│ 数据库            │ 写入速率  │ 磁盘占用  │ CPU 使用率│
│                   │ (点/秒) │          │           │
├───────────────────┼───────────┼───────────┼───────────┤
│ VictoriaMetrics   │ 1,200K    │ 1.8 GB    │ 35%       │
│ Prometheus TSDB   │ 680K      │ 3.4 GB    │ 42%       │
│ InfluxDB IOx      │ 450K      │ 2.1 GB    │ 58%       │
│ TimescaleDB       │ 180K      │ 8.6 GB    │ 71%       │
│ PostgreSQL(原生)│ 35K       │ 42 GB     │ 89%       │
└───────────────────┴───────────┴───────────┴───────────┘

8.3 查询性能对比

使用 TSBS 标准查询负载,在写入完成后执行:

查询延迟对比(P99,毫秒)
┌──────────────────────┬──────┬──────┬──────┬──────────┐
│ 查询类型             │ VM   │ Prom │ IOx  │TimescaleDB│
├──────────────────────┼──────┼──────┼──────┼──────────┤
│ 单主机最近 1 小时    │ 3    │ 5    │ 12   │ 18       │
│ 单主机最近 12 小时   │ 8    │ 14   │ 25   │ 35       │
│ 8 主机 GroupBy 1 小时│ 15   │ 22   │ 18   │ 42       │
│ 全量主机 Max 1 小时  │ 45   │ 68   │ 35   │ 120      │
│ 高基数 Top-N         │ 120  │ 180  │ 65   │ 250      │
│ 降采样(1h 桶聚合)  │ 25   │ 40   │ 15   │ 55       │
└──────────────────────┴──────┴──────┴──────┴──────────┘

几个值得注意的观察:

第一,VictoriaMetrics 在点查询和小范围查询上表现最好,这得益于其高效的压缩和紧凑的内存索引。

第二,InfluxDB IOx 在聚合查询(GroupBy、降采样)上表现出色,这是 Arrow 列式引擎的优势。高基数 Top-N 查询中 IOx 领先明显,因为 DataFusion 的向量化执行(Vectorized Execution)在大规模聚合上效率更高。

第三,TimescaleDB 在所有查询类型上都慢于专用 TSDB,但它是唯一支持 JOIN 和子查询的选项。如果业务需要将时序数据与关系数据进行关联分析,TimescaleDB 是唯一可行的选择。

8.4 压缩率对比

使用相同的 TSBS 数据集,各数据库的磁盘占用和每数据点字节数:

压缩率对比
┌───────────────────┬───────────┬──────────────┬─────────┐
│ 数据库            │ 磁盘占用  │ 字节/数据点  │ 压缩率  │
├───────────────────┼───────────┼──────────────┼─────────┤
│ VictoriaMetrics   │ 1.8 GB    │ 0.7 字节     │ 183x    │
│ Prometheus TSDB   │ 3.4 GB    │ 1.3 字节     │ 98x     │
│ InfluxDB IOx      │ 2.1 GB    │ 0.8 字节     │ 160x    │
│ TimescaleDB(压缩)│ 4.2 GB   │ 1.6 字节     │ 80x     │
│ TimescaleDB(原始)│ 8.6 GB   │ 3.3 字节     │ 39x     │
│ PostgreSQL(原生)│ 42 GB     │ 16.2 字节    │ 8x      │
└───────────────────┴───────────┴──────────────┴─────────┘

VictoriaMetrics 的压缩率最高,每个数据点仅需 0.7 字节。这主要归功于它在 Gorilla 压缩基础上的额外优化,以及对整数值和常量值的特殊处理。

8.5 资源占用对比

在 100 万活跃时间线的场景下,各数据库的内存和 CPU 占用:

资源占用对比(100 万活跃时间线)
┌───────────────────┬───────────┬───────────┬──────────┐
│ 数据库            │ 内存占用  │ CPU 核心  │ Goroutine│
│                   │          │ (写入时)│ /线程数  │
├───────────────────┼───────────┼───────────┼──────────┤
│ VictoriaMetrics   │ 8 GB      │ 4 核      │ ~200     │
│ Prometheus TSDB   │ 24 GB     │ 6 核      │ ~500     │
│ InfluxDB IOx      │ 16 GB     │ 8 核      │ ~120     │
│ TimescaleDB       │ 32 GB     │ 8 核      │ ~80      │
└───────────────────┴───────────┴───────────┴──────────┘

VictoriaMetrics 的内存效率最高,这得益于它的紧凑索引结构和积极的内存回收策略。Prometheus 在高基数场景下内存占用最高,因为每条时间线在 Head Block 中都有一个 memSeries 对象,包含标签集合和 Gorilla 编码器的状态。


九、时序存储选型指南

9.1 选型维度

时序数据库的选型需要从以下维度综合考量:

选型维度清单
├── 功能维度
│   ├── 查询语言:PromQL / SQL / Flux / InfluxQL
│   ├── 数据模型:标签模型 / 表模型
│   ├── 降采样:内置 / 需要外部工具
│   ├── 告警规则:内置 / 需要外部组件
│   └── 与关系数据 JOIN:支持 / 不支持
├── 性能维度
│   ├── 写入吞吐:高 / 中 / 低
│   ├── 查询延迟:毫秒级 / 百毫秒级 / 秒级
│   ├── 压缩率:高 / 中 / 低
│   └── 高基数支持:好 / 一般 / 差
├── 运维维度
│   ├── 部署复杂度:单二进制 / 多组件 / 依赖外部服务
│   ├── 水平扩展:原生支持 / 联邦 / 不支持
│   ├── 备份恢复:快照 / 文件复制 / pg_dump
│   └── 监控自身:自带 / 需外部工具
└── 生态维度
    ├── Grafana 支持:原生数据源 / 插件
    ├── Kubernetes 集成:Operator / Helm Chart
    ├── 客户端库:多语言 SDK
    └── 社区活跃度:GitHub Stars / 提交频率

9.2 典型场景推荐

根据不同的业务场景,推荐的时序数据库选择如下:

场景一:Kubernetes 集群监控。推荐 Prometheus TSDB + Thanos/Mimir 长期存储。理由:Prometheus 是 Kubernetes 生态的事实标准,ServiceMonitor/PodMonitor 自动发现、PromQL 告警规则、Grafana 原生支持,整个工具链无缝衔接。Thanos 或 Mimir 提供长期存储和全局查询视图。

# Prometheus + Thanos Sidecar 部署示例
apiVersion: monitoring.coreos.com/v1
kind: Prometheus
metadata:
  name: prometheus
spec:
  replicas: 2
  retention: 6h
  thanos:
    baseImage: quay.io/thanos/thanos
    version: v0.35.0
    objectStorageConfig:
      key: thanos.yaml
      name: thanos-objstore-config

场景二:大规模监控(百万级时间线)。推荐 VictoriaMetrics 集群版。理由:VictoriaMetrics 在高基数场景下的内存效率和压缩率最优,集群版的 vminsert/vmselect/vmstorage 架构简单、运维成本低,兼容 Prometheus 的 Remote Write 和 PromQL。

# VictoriaMetrics 集群版部署拓扑
vminsert × 3   (无状态,前置负载均衡器)
vmstorage × 5  (有状态,每节点 2TB SSD)
vmselect × 3   (无状态,前置负载均衡器)

场景三:IoT 数据分析与 SQL 查询。推荐 TimescaleDB。理由:IoT 场景通常需要将时序数据与设备元数据、告警记录等关系数据进行 JOIN 分析。TimescaleDB 是唯一同时支持高效时序查询和标准 SQL 的选项。此外,TimescaleDB 的连续聚合功能非常适合 IoT 数据的实时聚合仪表盘。

-- TimescaleDB: 关联分析示例
-- 查找温度异常的设备及其位置信息
SELECT d.device_name, d.location,
       avg(m.temperature) AS avg_temp,
       max(m.temperature) AS max_temp
FROM metrics m
JOIN devices d ON m.device_id = d.id
WHERE m.time > now() - interval '1 hour'
  AND m.temperature > 80
GROUP BY d.device_name, d.location
ORDER BY max_temp DESC;

场景四:云原生数据湖分析。推荐 InfluxDB IOx。理由:如果已经有基于 Arrow/Parquet 的数据湖基础设施,IOx 可以无缝融入。数据存储在 S3/GCS 上,存储成本极低,DataFusion 查询引擎在大规模聚合分析上性能出色。

9.3 选型决策树

时序数据库选型决策树

是否需要与关系数据 JOIN?
├── 是 → TimescaleDB
└── 否 → 继续判断
         │
         是否是 Kubernetes 监控场景?
         ├── 是 → 活跃时间线 < 50 万?
         │        ├── 是 → Prometheus TSDB
         │        └── 否 → VictoriaMetrics + Prometheus Remote Write
         └── 否 → 继续判断
                  │
                  是否需要 SQL 查询和数据湖集成?
                  ├── 是 → InfluxDB IOx
                  └── 否 → 继续判断
                           │
                           是否需要极致的压缩率和低内存占用?
                           ├── 是 → VictoriaMetrics
                           └── 否 → Prometheus TSDB(最简单)

9.4 混合架构方案

在大型企业中,单一时序数据库往往无法满足所有需求。常见的混合架构方案:

混合架构示例
┌──────────────────────────────────────────────────────────┐
│                       Grafana                            │
│  ┌──────────┐  ┌──────────────┐  ┌──────────────┐       │
│  │Prometheus│  │VictoriaMetrics│  │ TimescaleDB  │       │
│  │  数据源  │  │   数据源     │  │   数据源     │       │
│  └────┬─────┘  └──────┬───────┘  └──────┬───────┘       │
│       │               │                │                │
│  ┌────▼─────┐  ┌──────▼───────┐  ┌─────▼────────┐      │
│  │Prometheus│  │VictoriaMetrics│  │ TimescaleDB  │      │
│  │ 短期存储 │  │  长期存储    │  │ 业务分析     │      │
│  │ (6小时)│  │ (1年)      │  │ (关系查询) │      │
│  └────┬─────┘  └──────────────┘  └──────────────┘      │
│       │ Remote Write                                    │
│       └──────────────►                                  │
└──────────────────────────────────────────────────────────┘

Prometheus 负责短期存储和实时告警,VictoriaMetrics 作为长期存储后端,TimescaleDB 用于需要 SQL 分析的业务场景。三者通过 Grafana 统一查询界面。

9.5 选型中的常见误区

第一个误区是”只看写入吞吐”。写入吞吐是最容易量化的指标,但实际生产环境中,查询延迟和资源占用往往更关键。一个写入吞吐极高但查询延迟不稳定的数据库,在告警场景中可能导致告警延迟。

第二个误区是”追求最高压缩率”。VictoriaMetrics 的压缩率最高,但如果你的数据总量只有几百 GB,节省的存储成本微不足道,反而可能因为压缩和解压的 CPU 开销影响查询延迟。

第三个误区是”忽视运维成本”。InfluxDB IOx 的架构很优雅,但它依赖对象存储、需要管理 WAL、Catalog 和 Compactor 多个组件。对于小团队,Prometheus TSDB 的单二进制部署可能是更务实的选择。

第四个误区是”高基数就一定需要专用 TSDB”。如果高基数的标签值是有限的枚举集合(例如 HTTP 状态码、服务名称),任何 TSDB 都能很好地处理。只有当标签值是无限的开放集合(例如用户 ID、请求 ID)时,才需要认真考虑高基数优化。

9.6 未来趋势

时序数据库领域正在发生几个重要的演变:

第一,存算分离(Disaggregated Storage and Compute)成为主流。InfluxDB IOx、Thanos、Cortex/Mimir 都采用了对象存储作为持久化层,计算节点无状态、弹性伸缩。这种架构更适合云环境,但也引入了对象存储的访问延迟。

第二,Arrow/Parquet 生态统一。越来越多的时序数据库开始采用 Arrow 作为内存格式、Parquet 作为持久化格式。这使得时序数据可以无缝接入数据湖生态(DuckDB、Spark、Trino),打通了实时监控与离线分析的边界。

第三,PromQL 成为事实标准查询语言。即使是非 Prometheus 的时序数据库(VictoriaMetrics、Thanos、Mimir、Grafana Mimir),也都兼容 PromQL。MetricsQL(VictoriaMetrics 的扩展 PromQL)和 LogQL(Loki 的日志查询语言)进一步扩展了 PromQL 的语义。

第四,日志-指标-链路(Logs-Metrics-Traces)的融合。可观测性(Observability)领域正在从三个独立的系统(日志系统、指标系统、链路追踪系统)向统一平台演进。Grafana Labs 的 Mimir + Loki + Tempo 和 ClickHouse 的统一存储方案代表了这个趋势。


参考文献

论文

  1. T. Pelkonen, S. Franklin, J. Teller, P. Cavallaro, Q. Huang, J. Meza, K. Veeraraghavan. “Gorilla: A Fast, Scalable, In-Memory Time Series Database.” Proceedings of the VLDB Endowment, 8(12):1816-1827, 2015. Gorilla 压缩算法的原始论文。

  2. F. Reinartz. “Writing a Time Series Database from Scratch.” 2017. Prometheus TSDB v2 的设计文档,详细阐述了 Block 架构和倒排索引。

  3. A. Bader, O. Kopp, M. Zimmermann. “A Comparative Study of Time Series Databases.” BTW 2017, 2017. 早期时序数据库对比研究。

官方文档

  1. Prometheus TSDB 设计文档。https://github.com/prometheus/prometheus/tree/main/tsdb/docs. Block 格式、WAL 格式和 Compaction 策略的技术细节。

  2. InfluxDB IOx 架构文档。https://github.com/influxdata/influxdb. IOx 的 Arrow/Parquet 存储架构和 DataFusion 查询引擎。

  3. TimescaleDB 文档。https://docs.timescale.com/. Hypertable、Chunk、列式压缩和连续聚合的使用指南。

  4. VictoriaMetrics 文档。https://docs.victoriametrics.com/. 集群架构、存储格式和性能调优指南。

基准测试

  1. TimescaleDB TSBS(Time Series Benchmark Suite)。https://github.com/timescale/tsbs. 标准化的时序数据库基准测试框架。

  2. VictoriaMetrics 基准测试报告。https://victoriametrics.com/blog/tsdb-performance-tests/. 多款时序数据库的对比测试结果。

书籍

  1. A. Petrov. Database Internals: A Deep Dive into How Distributed Data Systems Work. O’Reilly, 2019. 第 9 章涉及时序数据库的存储结构。

  2. M. Kleppmann. Designing Data-Intensive Applications. O’Reilly, 2017. 第 3 章”Storage and Retrieval”讨论了 LSM-Tree 和 B-Tree 的权衡。


上一篇: Apache Arrow 内存格式 下一篇: 向量存储引擎

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / observability

【可观测性工程】时序数据库内核:TSM、TSI、倒排索引与 Gorilla 压缩

深入时序数据库的存储内核:Prometheus TSDB 的 WAL 与块管理、InfluxDB 的 TSM 引擎与 TSI 倒排索引、Gorilla 压缩算法的数学原理、VictoriaMetrics mergeset 架构、ClickHouse MergeTree 作为 metrics 后端,以及国内大厂在 series churn 和 compaction 风暴上踩过的坑。

2026-04-22 · db / storage

数据库内核实验索引

汇总本站数据库内核与存储引擎实验文章,重点覆盖从零实现 LSM-Tree 及其工程权衡。

2026-04-22 · storage

存储工程索引

汇总本站存储工程系列文章,覆盖 HDD、SSD、NVMe、持久内存、索引结构、压缩、分布式存储与对象存储。


By .