﻿---
title: "从 Prometheus 的三个限制说起：时序存储的架构、生态与市场"
description: "Prometheus 用久了会遇到三个限制：数据只留 15 天，加一个 label 内存就暴涨，单机挂了数据就断。顺着这三个现象往下追：时序数据为什么要专门存，一个样本怎么压到 1 字节多，写入路径为什么切成 2 小时的 block，高基数为什么难，长期存储生态和 Prometheus 是什么关系，最后看今天的时序存储市场。"
pubDate: 2026-10-14
tags: ["时序数据库", "Prometheus", "存储", "可观测性"]
clc: "TP311.13"
hero: "./hero.svg"
refs:
  - id: "1"
    title: "Writing a Time Series Database from Scratch"
    url: "https://fabxc.org/tsdb/"
    author: "Fabian Reinartz"
    date: "2017"
  - id: "2"
    title: "Storage Engine"
    url: "https://archive.docs.influxdata.com/influxdb/v0.9/concepts/storage_engine/"
    collect: "influxdata-influxdb-v0-9-concepts-storage-engine"
    site: "InfluxDB 0.9 文档"
  - id: "3"
    title: "Time-series compression algorithms, explained"
    url: "https://www.tigerdata.com/blog/time-series-compression-algorithms-explained"
    collect: "tigerdata-time-series-compression-algorithms-exp"
    site: "Tiger Data"
  - id: "4"
    title: "rrd-beginners(1)"
    url: "https://www.mankier.com/1/rrd-beginners"
    collect: "mankier-1-rrd-beginners"
  - id: "5"
    title: "Whisper"
    url: "https://graphite.readthedocs.io/en/1.1.5/whisper.html"
    collect: "readthedocs-1-1-5-whisper-html"
    site: "Graphite 文档"
  - id: "6"
    title: "HBase Schema"
    url: "https://opentsdb.net/docs/build/html/user_guide/backends/hbase.html"
    collect: "opentsdb-build-user-guide-backends-hbase-html"
    site: "OpenTSDB 文档"
  - id: "7"
    title: "Gorilla: A Fast, Scalable, In-Memory Time Series Database"
    url: "https://www.vldb.org/pvldb/vol8/p1816-teller.pdf"
    collect: "vldb-pvldb-vol8-p1816-teller-pdf"
    author: "Pelkonen T, et al."
    site: "PVLDB"
    date: "2015"
    note: "8(12):1816–1827"
  - id: "8"
    title: "Storage"
    url: "https://prometheus.io/docs/prometheus/latest/storage/"
    collect: "prometheus-prometheus-latest-storage"
    site: "Prometheus 文档"
  - id: "9"
    title: "Chimp: Efficient Lossless Floating Point Compression for Time Series Databases"
    author: "Liakos P, et al."
    site: "PVLDB"
    date: "2022"
  - id: "9b"
    title: "Elf: Erasing-based Lossless Floating-Point Compression"
    author: "Li R, et al."
    site: "PVLDB"
    date: "2023"
  - id: "9c"
    title: "ALP: Adaptive Lossless floating-Point Compression"
    author: "Afroozeh A, et al."
    site: "SIGMOD"
    date: "2024"
  - id: "10"
    title: "CHANGELOG"
    url: "https://github.com/prometheus/prometheus/blob/main/CHANGELOG.md"
    collect: "github-prometheus-prometheus-changelog-md"
    site: "Prometheus"
  - id: "10b"
    title: "CHANGELOG（release-3.15 分支）"
    url: "https://github.com/prometheus/prometheus/blob/release-3.15/CHANGELOG.md"
    collect: "github-prometheus-prometheus-release-3-15-change"
    site: "Prometheus"
    note: "3.15.0 / 2026-09-24：Stabilize the XOR2 float chunk encoding"
  - id: "11"
    title: "Storage"
    url: "https://prometheus.io/docs/prometheus/1.8/storage/"
    collect: "prometheus-prometheus-1-8-storage"
    site: "Prometheus 1.8 文档"
  - id: "12"
    title: "Prometheus 2.0"
    url: "https://lwn.net/Articles/744721/"
    collect: "lwn-744721"
    site: "LWN"
  - id: "13"
    title: "Prometheus TSDB: The Head Block"
    url: "https://ganeshvernekar.com/blog/prometheus-tsdb-the-head-block/"
    collect: "ganeshvernekar-prometheus-tsdb-the-head-block"
    author: "Ganesh Vernekar"
  - id: "14"
    title: "Announcing Prometheus 2.0"
    url: "https://prometheus.io/blog/2017/11/08/announcing-prometheus-2-0/"
    collect: "prometheus-2017-11-08-announcing-prometheus-2-0"
    site: "Prometheus Blog"
  - id: "15"
    title: "ClickHouse vs Prometheus: high cardinality, part 1"
    url: "https://clickhouse.com/blog/clickhouse-vs-promethous-high-cardinality-p1-understanding-the-problem"
    collect: "clickhouse-clickhouse-vs-promethous-high-cardina"
    site: "ClickHouse Blog"
  - id: "16"
    title: "How much RAM does Prometheus 2.x need for cardinality and ingestion?"
    url: "https://www.robustperception.io/how-much-ram-does-prometheus-2-x-need-for-cardinality-and-ingestion/"
    collect: "robustperception-how-much-ram-does-prometheus-2"
    site: "Robust Perception"
  - id: "17"
    title: "Prometheus TSDB: Persistent Block and its Index"
    url: "https://ganeshvernekar.com/blog/prometheus-tsdb-persistent-block-and-its-index"
    collect: "ganeshvernekar-prometheus-tsdb-persistent-block"
    author: "Ganesh Vernekar"
  - id: "18"
    title: "Path to 1 Billion Time Series: InfluxDB High Cardinality Indexing"
    url: "https://www.influxdata.com/blog/path-1-billion-time-series-influxdb-high-cardinality-indexing-ready-testing/"
    collect: "influxdata-path-1-billion-time-series-influxdb-h"
    site: "InfluxData"
  - id: "19"
    title: "Announcing InfluxDB IOx"
    url: "https://www.influxdata.com/blog/announcing-influxdb-iox/"
    collect: "influxdata-announcing-influxdb-iox"
    author: "Paul Dix"
  - id: "20"
    title: "High cardinality, part 2: cardinality in ClickHouse"
    url: "https://clickhouse.com/blog/clickhouse-vs-promethous-high-cardinality-part-2-cardinality-in-clickhouse"
    collect: "clickhouse-clickhouse-vs-promethous-high-cardina-2"
    site: "ClickHouse Blog"
  - id: "21"
    title: "Metric and label naming"
    url: "https://prometheus.io/docs/practices/naming/"
    collect: "prometheus-practices-naming"
    site: "Prometheus 文档"
  - id: "22"
    title: "Overview"
    url: "https://prometheus.io/docs/introduction/overview/"
    collect: "prometheus-introduction-overview"
    site: "Prometheus 文档"
  - id: "23"
    title: "Design"
    url: "https://thanos.io/tip/thanos/design.md/"
    collect: "thanos-tip-thanos-design"
    site: "Thanos 文档"
  - id: "24"
    title: "Architecture"
    url: "https://grafana.com/docs/mimir/latest/get-started/about-grafana-mimir-architecture/"
    collect: "grafana-mimir-latest-get-started-about-grafana-m"
    site: "Grafana Mimir 文档"
  - id: "24b"
    title: "Announcing Grafana Mimir"
    url: "https://grafana.com/blog/2022/03/30/announcing-grafana-mimir/"
    collect: "grafana-2022-03-30-announcing-grafana-mimir"
    site: "Grafana Labs Blog"
    date: "2022"
  - id: "24c"
    title: "Grafana Mimir ingester"
    url: "https://grafana.com/docs/mimir/latest/references/architecture/components/ingester/"
    collect: "grafana-docs-mimir-latest-references-architectur"
    site: "Grafana Mimir 文档"
  - id: "25"
    title: "Grafana Mimir 3.0"
    url: "https://grafana.com/blog/2025/11/03/grafana-mimir-3-0-release-all-the-latest-updates/"
    collect: "grafana-2025-11-03-grafana-mimir-3-0-release-all"
    site: "Grafana Blog"
  - id: "26"
    title: "Remote-Write 2.0 specification"
    url: "https://prometheus.io/docs/specs/prw/remote_write_spec_2_0/"
    collect: "prometheus-specs-prw-remote-write-spec-2-0"
    site: "Prometheus 文档"
  - id: "27"
    title: "How Prometheus remote write v2 can help cut network egress costs"
    url: "https://grafana.com/blog/how-prometheus-remote-write-v2-can-help-cut-network-egress-costs-by-as-much-as-50-"
    collect: "grafana-how-prometheus-remote-write-v2-can-help"
    site: "Grafana Blog"
  - id: "28"
    title: "Introducing Prometheus Agent Mode"
    url: "https://prometheus.io/blog/2021/11/16/agent/"
    collect: "prometheus-2021-11-16-agent"
    site: "Prometheus Blog"
  - id: "29"
    title: "VictoriaMetrics"
    url: "https://docs.victoriametrics.com/victoriametrics/"
    collect: "victoriametrics-victoriametrics"
    site: "VictoriaMetrics 文档"
  - id: "29b"
    title: "MetricsQL"
    url: "https://docs.victoriametrics.com/victoriametrics/metricsql/"
    collect: "victoriametrics-victoriametrics-metricsql"
    site: "VictoriaMetrics 文档"
  - id: "30"
    title: "Managed Service for Prometheus"
    url: "https://cloud.google.com/stackdriver/docs/managed-prometheus"
    collect: "google-stackdriver-managed-prometheus"
    site: "Google Cloud"
  - id: "31"
    title: "Introducing PromQL"
    url: "https://clickhouse.com/blog/introducing-promql"
    collect: "clickhouse-introducing-promql"
    site: "ClickHouse Blog"
  - id: "32"
    title: "PromQL"
    url: "https://docs.greptime.com/user-guide/query-data/promql"
    collect: "greptime-user-guide-query-data-promql"
    site: "GreptimeDB 文档"
  - id: "33"
    title: "OpenMetrics is archived, merged into Prometheus"
    url: "https://www.cncf.io/blog/2024/09/18/openmetrics-is-archived-merged-into-prometheus/"
    collect: "cncf-2024-09-18-openmetrics-is-archived-merged-i"
    site: "CNCF Blog"
  - id: "34"
    title: "Introducing the Prometheus Conformance Program"
    url: "https://prometheus.io/blog/2021/05/03/introducing-prometheus-conformance-program/"
    collect: "prometheus-2021-05-03-introducing-prometheus-con"
    site: "Prometheus Blog"
  - id: "35"
    title: "PromQL Compliance Tests"
    url: "https://promlabs.com/promql-compliance-tests/"
    collect: "promlabs-promql-compliance-tests"
    site: "PromLabs"
  - id: "36"
    title: "Announcing Prometheus 3.0"
    url: "https://prometheus.io/blog/2024/11/14/prometheus-3-0/"
    collect: "prometheus-2024-11-14-prometheus-3-0"
    site: "Prometheus Blog"
  - id: "37"
    title: "Ranking of Time Series DBMS"
    url: "https://db-engines.com/en/ranking/time+series+dbms"
    site: "DB-Engines"
  - id: "38"
    title: "Time Series DBMS, including secondary database models"
    url: "https://db-engines.com/en/ranking/time+series+dbms/all"
    site: "DB-Engines"
  - id: "39"
    title: "Timescale becomes TigerData"
    url: "https://www.tigerdata.com/blog/timescale-becomes-tigerdata"
    collect: "tigerdata-timescale-becomes-tigerdata"
    site: "Tiger Data"
  - id: "40"
    title: "Amazon Timestream for LiveAnalytics availability change"
    url: "https://docs.aws.amazon.com/timestream/latest/developerguide/AmazonTimestreamForLiveAnalytics-availability-change.html"
    collect: "amazon-timestream-latest-developerguide-amazonti"
    site: "AWS 文档"
  - id: "41"
    title: "InfluxDB 3 Core and Enterprise GA"
    url: "https://www.influxdata.com/blog/influxdata-announces-influxdb-3-oss-ga/"
    collect: "influxdata-influxdata-announces-influxdb-3-oss-g"
    site: "InfluxData"
  - id: "41b"
    title: "The future of Flux"
    url: "https://docs.influxdata.com/flux/v0/future-of-flux"
    collect: "influxdata-flux-v0-future-of-flux"
    site: "InfluxData"
  - id: "42"
    title: "Completes Chronosphere acquisition"
    url: "https://investors.paloaltonetworks.com/news-releases/news-release-details/palo-alto-networks-completes-chronosphere-acquisition-unifying"
    site: "Palo Alto Networks"
  - id: "43"
    title: "Flight, DataFusion, Arrow, and Parquet: Using the FDAP Architecture"
    url: "https://www.influxdata.com/blog/flight-datafusion-arrow-parquet-fdap-architecture-influxdb/"
    collect: "influxdata-flight-datafusion-arrow-parquet-fdap"
    author: "Andrew Lamb"
  - id: "44"
    title: "GreptimeDB v1.0 GA"
    url: "https://greptime.com/blogs/2026-04-14-greptimedb-v1-ga-release"
    collect: "greptime-blogs-2026-04-14-greptimedb-v1-ga-relea"
    site: "Greptime"
  - id: "45"
    title: "QuestDB 10"
    url: "https://questdb.com/blog/questdb-10-release/"
    collect: "questdb-questdb-10-release"
    site: "QuestDB"
  - id: "46"
    title: "ClickHouse acquires HyperDX"
    url: "https://clickhouse.com/blog/clickhouse-acquires-hyperdx-the-future-of-open-source-observability"
    collect: "clickhouse-clickhouse-acquires-hyperdx-the-futur"
    site: "ClickHouse Blog"
  - id: "46b"
    title: "ClickStack: A High-Performance OSS Observability Stack on ClickHouse"
    url: "https://clickhouse.com/blog/clickstack-a-high-performance-oss-observability-stack-on-clickhouse"
    collect: "clickhouse-clickstack-a-high-performance-oss-obs"
    site: "ClickHouse Blog"
    date: "2025"
  - id: "47"
    title: "2026 mid-year roundup"
    url: "https://victoriametrics.com/blog/victoriametrics-2026-mid-year-roundup/"
    collect: "victoriametrics-victoriametrics-2026-mid-year-ro"
    site: "VictoriaMetrics"
  - id: "48"
    title: "Architecture"
    url: "https://docs.tdengine.com/2.0/architecture"
    collect: "tdengine-2-0-architecture"
    site: "TDengine 文档"
  - id: "48b"
    title: "Data Model"
    url: "https://docs.tdengine.com/basic-features/data-model"
    site: "TDengine 文档"
  - id: "49"
    title: "TsFile Format"
    url: "https://cwiki.apache.org/confluence/display/IOTDB/TsFile+Format"
    collect: "apache-confluence-display-iotdb-tsfileformat"
    site: "Apache IoTDB"
  - id: "49b"
    title: "IoTDB Proposal"
    url: "https://cwiki.apache.org/confluence/display/incubator/IoTDBProposal"
    collect: "apache-confluence-display-incubator-iotdbproposa"
    site: "Apache Incubator Wiki"
  - id: "50"
    title: "KDB-X general availability"
    url: "https://www.businesswire.com/news/home/20260402375288/en/"
    site: "KX"
---

<!-- TODO: 开头的三个现象目前是通用场景，最好换成作者自己遇到的真实经历和数字（比如当时的 series 数、内存、出事的那个 label）。 -->

用 Prometheus 监控一个 Kubernetes 集群，刚开始的体验通常很好：装上就能抓指标，PromQL 写起来也顺手。用上一段时间，就会陆续遇到这样几件事：

- 想对比三个月前的接口延迟，发现查不到。Prometheus 默认只保留 15 天的数据。
- 给一个指标加了 `user_id` 这个 label，想按用户看请求量。第二天 Prometheus 的内存涨了好几倍，最后被 OOM 杀掉。
- Prometheus 所在的节点宕机了一次，那段时间的监控数据全没了，告警也跟着停了。

去搜解决办法，会冒出一长串名字：Thanos、Mimir、VictoriaMetrics、InfluxDB、TimescaleDB、ClickHouse、GreptimeDB……它们有的说自己是 Prometheus 的长期存储，有的说自己是时序数据库，有的其实是通用的分析型数据库。

于是有了几个问题：

1. 时序数据有什么特别，为什么需要专门的存储？
2. Prometheus 自己的存储是怎么设计的？为什么默认只留 15 天、只做单机？
3. 为什么多加一个 label，内存就扛不住了？
4. 那一长串长期存储方案，和 Prometheus 是什么关系？
5. 跳出 Prometheus 来看，今天的时序存储市场是什么格局？

本文就顺着这几个问题，从「一个样本怎么存」讲起，一直讲到整个市场。

---

## 一、时序数据的特点

### 现象

先算一笔账。假设一个中等规模的集群有 1000 个抓取目标，每个目标暴露 1000 条时间序列，每 15 秒抓取一次：

- 每秒写入 1000 × 1000 ÷ 15 ≈ 6.7 万个样本；
- 每天写入约 57.6 亿个样本；
- 一个样本是 8 字节的时间戳加 8 字节的 float64，共 16 字节。不压缩的话，一天就是 92GB。

写入量这么大，查询却很集中。看板和告警绝大多数时候只看最近一小时、最近一天的数据，几个月前的数据偶尔才查一次。

### 数据长什么样

Prometheus 抓到的一个样本长这样：

```text
http_requests_total{job="api", path="/pay", code="500"}  @1728900000  1027
```

它由四部分组成：指标名（metric）、一组标签（label）、时间戳和值。**指标名加上一组确定的 label 取值，叫作一条时间序列（series）**。上面这一行属于 `http_requests_total{job="api", path="/pay", code="500"}` 这条 series，它每 15 秒多一个点。

这类数据的读写有几个鲜明的特点：

- **写入几乎全是追加。** 新样本的时间戳总是比旧的大，很少有人回头修改一个已经写入的样本。
- **删除是整段过期。** 不会删某一个点，而是「15 天以前的全部不要了」。
- **读取有两种方向。** 一种是「纵向」读：看一条 series 在过去一小时的走势。另一种是「横向」读：在同一个时刻，把几百条 series 加起来，比如所有实例的 QPS 之和。Prometheus TSDB 的作者 Fabian Reinartz 在设计文章里就是用这两个方向来分析问题的[^1]。

### 第一次尝试：放进关系数据库

最直觉的办法是建一张表，`(series_id, timestamp, value)` 做主键，一个样本一行。这在数据量小的时候没问题，量一上来就会遇到三个麻烦：

- **随机写。** 每秒 6.7 万个样本分属 100 万条 series，按主键插进 B-tree，几乎每次都落在不同的页上。InfluxDB 早期就试过基于 B+tree 的存储（BoltDB），数据库超过几 GB 之后，写入的 IOPS 开始出现尖峰[^2]。
- **行开销比数据还大。** PostgreSQL 每行约有 48 字节的额外开销[^3]，而一个样本本身只有 16 字节。
- **删除很贵。** 按保留期删数据，就是每天执行一次 `DELETE ... WHERE timestamp < ?`，要逐行删掉 57 亿行，再等存储引擎回收空间。

### 第二次尝试：固定大小的文件和分布式 KV

早期的监控系统走了两条路。

第一条是**固定大小的文件**。RRDtool 在创建数据库时就定好了大小，写满一圈后覆盖最旧的数据；降采样也在写入时完成，比如只保留每 5 分钟的平均值[^4]。Graphite 的 Whisper 思路类似，每个点固定占 12 字节[^5]。好处是文件大小永远不变，代价是原始精度在写入那一刻就丢了。而且一个指标一个文件，指标一多，磁盘就被大量的小文件随机写拖垮。

第二条是**借用分布式 KV**。OpenTSDB 把数据存在 HBase 里，rowkey 由「指标 ID + 按小时取整的时间 + 标签 ID」拼成，一行装一个小时的数据，再靠后台合并把一行里的多列拼成一列，以此减少 KV 本身的开销[^6]。这样能横向扩展，但每个点都要背着 HBase 的开销。

把同一批样本分别放进关系表、Whisper 和 OpenTSDB，三种存法的差别就很直观了：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 486" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="tsdb-layout-title tsdb-layout-desc">
<title id="tsdb-layout-title">同一批样本的三种存法</title>
<desc id="tsdb-layout-desc">12:00:15 抓到 http_requests_total 的 3 个样本。关系表把它们作为 3 行插进 3 个不同的 B-tree 页；Whisper 给每个指标一个固定大小的文件，样本写进 15 秒精度归档的一个槽，粗精度归档只存平均值；OpenTSDB 用指标、整点时间和标签拼出 rowkey，一行装一小时，样本按秒偏移存成一列。</desc>
<rect class="dd-frame" width="800" height="486" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="128" rx="8"/>
<text class="t-zone" x="32" y="44">12:00:15 抓到的 3 个样本 · http_requests_total</text>
<rect class="dd-zone" x="16" y="160" width="240" height="310" rx="8"/>
<text class="t-zone" x="32" y="188">关系表 · 一行一个样本</text>
<rect class="dd-zone" x="272" y="160" width="256" height="310" rx="8"/>
<text class="t-zone" x="288" y="188">Whisper · 一个指标一个文件</text>
<rect class="dd-zone" x="544" y="160" width="240" height="310" rx="8"/>
<text class="t-zone" x="560" y="188">OpenTSDB · 一行装一小时</text>
<rect class="dd-card" x="32" y="60" width="232" height="36" rx="6"/>
<text class="t-mono" x="48" y="83">path="/login",code="200"</text>
<text class="t-val" x="248" y="83" text-anchor="end">88</text>
<rect class="dd-card" x="284" y="60" width="232" height="36" rx="6"/>
<text class="t-mono" x="300" y="83">path="/login",code="500"</text>
<text class="t-val" x="500" y="83" text-anchor="end">4</text>
<rect class="dd-card" x="536" y="60" width="232" height="36" rx="6"/>
<text class="t-mono" x="552" y="83">path="/pay",code="500"</text>
<text class="t-val" x="752" y="83" text-anchor="end"><tspan class="t-acc">1027</tspan></text>
<text class="t-note" x="32" y="124">下面三栏把这 3 个样本分别存进三种结构，跟着 1027 看它落在哪里。</text>
<rect class="dd-card" x="32" y="204" width="208" height="58" rx="6"/>
<path class="dd-card-head" d="M32.6 236 V210 Q32.6 204.6 38 204.6 H234 Q239.4 204.6 239.4 210 V236 Z"/>
<line class="dd-edge" x1="32" y1="236" x2="240" y2="236"/>
<text class="t-mono" x="48" y="225">page 17</text>
<text class="t-note" x="224" y="225" text-anchor="end">B-tree 叶子页</text>
<text class="t-val" x="48" y="253">(3, 1728900015, <tspan class="t-acc">1027</tspan>)</text>
<rect class="dd-card" x="32" y="274" width="208" height="58" rx="6"/>
<path class="dd-card-head" d="M32.6 306 V280 Q32.6 274.6 38 274.6 H234 Q239.4 274.6 239.4 280 V306 Z"/>
<line class="dd-edge" x1="32" y1="306" x2="240" y2="306"/>
<text class="t-mono" x="48" y="295">page 52</text>
<text class="t-note" x="224" y="295" text-anchor="end">B-tree 叶子页</text>
<text class="t-val" x="48" y="323">(1, 1728900015, 88)</text>
<rect class="dd-card" x="32" y="344" width="208" height="58" rx="6"/>
<path class="dd-card-head" d="M32.6 376 V350 Q32.6 344.6 38 344.6 H234 Q239.4 344.6 239.4 350 V376 Z"/>
<line class="dd-edge" x1="32" y1="376" x2="240" y2="376"/>
<text class="t-mono" x="48" y="365">page 90</text>
<text class="t-note" x="224" y="365" text-anchor="end">B-tree 叶子页</text>
<text class="t-val" x="48" y="393">(2, 1728900015, 4)</text>
<text class="t-say" x="32" y="430">3 个样本落在 3 个不同的页，</text>
<text class="t-say" x="32" y="450">每行还要另带约 48 字节。</text>
<rect class="dd-card" x="288" y="204" width="224" height="136" rx="6"/>
<path class="dd-card-head" d="M288.6 236 V210 Q288.6 204.6 294 204.6 H506 Q511.4 204.6 511.4 210 V236 Z"/>
<line class="dd-edge" x1="288" y1="236" x2="512" y2="236"/>
<text class="t-mono" x="304" y="225">api/pay/500.wsp</text>
<text class="t-note" x="496" y="225" text-anchor="end">大小固定</text>
<line class="dd-hair" x1="304" y1="262" x2="496" y2="262"/>
<line class="dd-hair" x1="304" y1="288" x2="496" y2="288"/>
<line class="dd-hair" x1="304" y1="314" x2="496" y2="314"/>
<text class="t-mono" x="304" y="253">archive 0</text>
<text class="t-val" x="384" y="253">15s · 1 天</text>
<text class="t-mono" x="304" y="279">archive 1</text>
<text class="t-val" x="384" y="279">5m 平均 · 30 天</text>
<text class="t-mono" x="304" y="305">archive 2</text>
<text class="t-val" x="384" y="305">1h 平均 · 1 年</text>
<text class="t-mono" x="304" y="331">本次写入</text>
<text class="t-val" x="384" y="331">槽 2881 ← <tspan class="t-acc">1027</tspan></text>
<rect class="dd-card" x="288" y="352" width="224" height="36" rx="6"/>
<text class="t-mono" x="304" y="375">api/login/*.wsp</text>
<text class="t-note" x="496" y="375" text-anchor="end">另 2 个文件</text>
<text class="t-say" x="288" y="430">文件大小在创建时就定了，</text>
<text class="t-say" x="288" y="450">粗精度的归档只存平均值。</text>
<rect class="dd-card" x="560" y="204" width="208" height="136" rx="6"/>
<path class="dd-card-head" d="M560.6 236 V210 Q560.6 204.6 566 204.6 H762 Q767.4 204.6 767.4 210 V236 Z"/>
<line class="dd-edge" x1="560" y1="236" x2="768" y2="236"/>
<text class="t-mono" x="576" y="225">rowkey</text>
<text class="t-note" x="752" y="225" text-anchor="end">HBase 行</text>
<line class="dd-hair" x1="576" y1="262" x2="752" y2="262"/>
<line class="dd-hair" x1="576" y1="288" x2="752" y2="288"/>
<line class="dd-hair" x1="576" y1="314" x2="752" y2="314"/>
<text class="t-mono" x="576" y="253">metric</text>
<text class="t-val" x="632" y="253">000001</text>
<text class="t-mono" x="576" y="279">小时</text>
<text class="t-val" x="632" y="279">670CEBA0</text>
<text class="t-note" x="752" y="279" text-anchor="end">12:00</text>
<text class="t-mono" x="576" y="305">path</text>
<text class="t-val" x="632" y="305">000002:000007</text>
<text class="t-mono" x="576" y="331">code</text>
<text class="t-val" x="632" y="331">000003:000009</text>
<rect class="dd-card" x="560" y="352" width="208" height="84" rx="6"/>
<path class="dd-card-head" d="M560.6 384 V358 Q560.6 352.6 566 352.6 H762 Q767.4 352.6 767.4 358 V384 Z"/>
<line class="dd-edge" x1="560" y1="384" x2="768" y2="384"/>
<text class="t-name" x="576" y="373">列</text>
<text class="t-note" x="752" y="373" text-anchor="end">按秒偏移</text>
<line class="dd-hair" x1="576" y1="410" x2="752" y2="410"/>
<text class="t-mono" x="576" y="401">+0</text>
<text class="t-val" x="632" y="401">1020</text>
<text class="t-mono" x="576" y="427">+15</text>
<text class="t-val" x="632" y="427"><tspan class="t-acc">1027</tspan></text>
<text class="t-say" x="560" y="458">一行最多 240 列，后台再合并。</text>
</svg>
</div>
<figcaption>图 1 · 同一批样本：关系表把它们插进不同的页，Whisper 预先分好固定的槽，OpenTSDB 按小时把一条 series 装进一行</figcaption>
</figure>

这两条路暴露出时序存储真正要解决的两件事：**一个样本要存得足够小，写入路径要对追加友好、删除要便宜。** 先看第一件：**一个 16 字节的样本，最少能压到多小？**

---

## 二、样本的压缩

### 分析：相邻样本非常像

把一条 series 的样本按时间排好，会发现相邻的两个样本几乎一样：

- 时间戳每 15 秒一个，间隔几乎是常数，偶尔因为抓取抖动差一两秒；
- 值要么不变（比如一个温度、一个配置项），要么变化很小。

Facebook 在 2015 年的 Gorilla 论文里，就是利用这两个规律，把内存里的时序数据压缩了十倍以上[^7]。

### 时间戳：差值的差值

假设一条 series 的时间戳是：

```text
1728900000, 1728900015, 1728900030, 1728900045, 1728900061
```

相邻两个的差值是 15、15、15、16，再对差值做一次差，得到 0、0、1。Gorilla 的做法是：第一个时间戳原样存，第二个存差值，从第三个开始只存「差值的差值」（delta-of-delta），并且按大小分档编码：

- 等于 0，只写 1 个 bit：`0`；
- 落在 [-63, 64] 之间，写 `10` 再加 7 个 bit；
- 更大的值用更长的前缀和位数。

上面这串时间戳，后三个分别只占 1、1、9 个 bit。论文统计，Facebook 的生产数据里约 96% 的时间戳只需要 1 个 bit。

### 数值：和前一个值做异或

浮点数没法直接做差，Gorilla 改为和前一个值做按位异或（XOR）。比如一个温度传感器连续上报 22.5、22.5、22.75：

```text
22.5   = 0x4036800000000000
22.5   = 0x4036800000000000    XOR = 0                  → 写 1 个 bit
22.75  = 0x4036C00000000000    XOR = 0x0000400000000000 → 前导 0 有 17 个，有效位只有 1 个
```

值不变时异或结果为 0，只写 1 个 bit。值变了，异或结果的前后都是大段的 0，只需要记下「前面有几个 0、中间有效位有多长、有效位是什么」。22.5 变成 22.75 这一下，只需要 14 个 bit：2 个控制位、5 个 bit 的前导零个数、6 个 bit 的有效位长度，再加 1 个有效位。

### 效果

Gorilla 的论文给出的结果是平均 **1.37 字节一个样本**，比原始的 16 字节小了约 12 倍[^7]。Prometheus 沿用了同样的思路，官方文档给出的数字是平均每个样本 1–2 字节[^8]。

回到开头那笔账：一天 92GB 的原始数据，按每个样本 1.5 字节算，压缩后约 8.6GB。保留 15 天大约 130GB，一块普通的 SSD 就放得下。

浮点压缩此后还在持续改进：Chimp（2022）、Elf（2023）和 ALP（2024）都在 Gorilla 的基础上进一步提高了压缩率[^9][^9b][^9c]。Prometheus 3.15（2026 年 9 月）也把新的 XOR2 浮点编码转成了稳定特性[^10b]。

不过，这种编码有一个前提：**同一条 series 的样本要挨在一起，才能和「前一个值」做差。**可样本是按时间到达的，每 15 秒，100 万条 series 各送来一个点，彼此交错在一起。**怎么才能既按 series 连续地存，又不至于随机写盘？**

---

## 三、写入路径的设计

### 第一次尝试：一条 series 一个文件

最直接的做法是每条 series 一个文件，来一个点就追加到对应的文件末尾。Prometheus 1.x 就是这样设计的：每条 series 一个文件，数据切成 1KB 的 chunk，索引存在 LevelDB 里[^11]。

问题出在 Kubernetes 上。在 Kubernetes 里，pod 不断被销毁和重建，每个新 pod 都带着新的 `pod` label，也就带来一批新的 series，旧的 series 随之停止写入。这种现象叫 **series churn**。LWN 报道 Prometheus 2.0 时提到，它的测试场景是每 10 分钟替换一半的 pod[^12]。series 一多，「一条 series 一个文件」就意味着：

- 磁盘上有上百万个文件，每 15 秒要随机追加一遍；
- 大量短命的 series 留下大量只有几个点的小文件；
- 过期删除要逐个文件去处理。

InfluxDB 也在这里走过弯路。它先用 LevelDB（一种 LSM 树），写入很快，但删除要先写墓碑标记再等后台合并，代价很高。改成按时间分片、每个分片一个 LevelDB 之后，过期可以直接删文件了，可小文件又多到把文件句柄耗光。换成 BoltDB，句柄问题解决了，又遇到前面说的写入 IOPS 尖峰。最后才自己设计了 TSM 引擎：先写 WAL 和内存缓存，再批量写成不可变的文件，并且按时间分片[^2]。

### 解决：Prometheus 2.0 的 block 设计

2017 年，Fabian Reinartz 为 Prometheus 2.0 重写了存储引擎[^1]。核心思路是**先按时间切，再在每段时间里按 series 组织**：

- **最近的数据放在内存里**，叫 Head block。每条 series 在 Head 里有自己的 chunk，新样本追加到 chunk 末尾，用上一节的方法压缩。一个 chunk 最多装 120 个样本（按 15 秒的抓取间隔，约 30 分钟），写满的 chunk 会刷到磁盘并用 mmap 映射回来[^13]。
- **每个样本先写 WAL。** 内存里的数据断电就没了，所以每个样本先追加到预写日志（WAL）里。进程崩溃后重放 WAL，就能恢复 Head。追加写是顺序 IO，很便宜。
- **每满 2 小时，切成一个不可变的 block。** Head 覆盖的时间达到 3 小时，就把最老的 2 小时写成磁盘上的一个 block 目录，然后截断 WAL[^13]。block 里的样本按 series 排好，查询时可以顺序读取。
- **小 block 在后台合并成大 block**，最大到保留期的 10% 或 31 天（取较小者）[^8]。
- **过期就是删除整个目录。** 一个 block 覆盖一段确定的时间，过了保留期直接删掉，不需要逐条删除。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 440" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="tsdb-block-title tsdb-block-desc">
<title id="tsdb-block-title">Prometheus TSDB 的写入路径和磁盘布局</title>
<desc id="tsdb-block-desc">最近的样本在内存的 Head block 里，同时追加到 data/wal/；Head 覆盖满 3 小时后，把最老的 10:00 到 12:00 写成一个不可变的 block 目录，里面有 meta.json、chunks、index 和 tombstones；更老的 block 过期时整个目录删除。</desc>
<defs>
<marker id="tsdb-block-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="tsdb-block-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="440" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="296" height="408" rx="8"/>
<text class="t-zone" x="32" y="44">内存</text>
<rect class="dd-zone" x="328" y="16" width="456" height="408" rx="8"/>
<text class="t-zone" x="344" y="44">磁盘 · data/</text>
<path class="dd-edge" d="M296 96 H342" marker-end="url(#tsdb-block-arrow)"/>
<path class="dd-edge-acc" d="M296 169 H312 Q320 169 320 177 V266 Q320 274 328 274 H342" marker-end="url(#tsdb-block-arrow-acc)"/>
<rect class="dd-card" x="32" y="72" width="264" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 104 V78 Q32.6 72.6 38 72.6 H290 Q295.4 72.6 295.4 78 V104 Z"/>
<line class="dd-edge" x1="32" y1="104" x2="296" y2="104"/>
<text class="t-name" x="48" y="93">Head block</text>
<text class="t-note" x="280" y="93" text-anchor="end">当前时间 12:43</text>
<line class="dd-hair" x1="48" y1="130" x2="280" y2="130"/>
<line class="dd-hair" x1="48" y1="156" x2="280" y2="156"/>
<line class="dd-hair" x1="48" y1="182" x2="280" y2="182"/>
<text class="t-mono" x="48" y="121">series</text>
<text class="t-val" x="120" y="121">1,000,000 条</text>
<text class="t-mono" x="48" y="147">覆盖</text>
<text class="t-val" x="120" y="147">10:00 – 12:43</text>
<text class="t-mono" x="48" y="173">最老 2h</text>
<text class="t-val" x="120" y="173"><tspan class="t-acc">10:00 – 12:00</tspan></text>
<text class="t-note" x="280" y="173" text-anchor="end">待切出</text>
<text class="t-mono" x="48" y="199">postings</text>
<text class="t-val" x="120" y="199">job="api" → 7, 9, 12 …</text>
<text class="t-say" x="32" y="244">Head 覆盖满 3 小时，</text>
<text class="t-say" x="32" y="264">就把最老的 2 小时写成 block，</text>
<text class="t-say" x="32" y="284">然后截断 WAL。</text>
<text class="t-note" x="32" y="324">每条 series 的 chunk 最多 120 个样本，</text>
<text class="t-note" x="32" y="344">写满后刷到 chunks_head/ 并 mmap 回来。</text>
<rect class="dd-card" x="344" y="72" width="424" height="110" rx="6"/>
<path class="dd-card-head" d="M344.6 104 V78 Q344.6 72.6 350 72.6 H762 Q767.4 72.6 767.4 78 V104 Z"/>
<line class="dd-edge" x1="344" y1="104" x2="768" y2="104"/>
<text class="t-mono" x="360" y="93">wal/</text>
<text class="t-note" x="752" y="93" text-anchor="end">每个样本先追加到这里，崩溃后重放</text>
<line class="dd-hair" x1="360" y1="130" x2="752" y2="130"/>
<line class="dd-hair" x1="360" y1="156" x2="752" y2="156"/>
<text class="t-mono" x="360" y="121">00000041</text>
<text class="t-val" x="528" y="121">128MB，已写满</text>
<text class="t-mono" x="360" y="147">00000042</text>
<text class="t-val" x="528" y="147">写入中</text>
<text class="t-mono" x="360" y="173">checkpoint.00000040</text>
<text class="t-val" x="528" y="173">截断前留下的快照</text>
<rect class="dd-card" x="344" y="206" width="424" height="136" rx="6"/>
<path class="dd-card-head" d="M344.6 238 V212 Q344.6 206.6 350 206.6 H762 Q767.4 206.6 767.4 212 V238 Z"/>
<line class="dd-edge" x1="344" y1="238" x2="768" y2="238"/>
<text class="t-mono" x="360" y="227">01JA3K7X…/</text>
<text class="t-note" x="752" y="227" text-anchor="end">不可变的 block</text>
<line class="dd-hair" x1="360" y1="264" x2="752" y2="264"/>
<line class="dd-hair" x1="360" y1="290" x2="752" y2="290"/>
<line class="dd-hair" x1="360" y1="316" x2="752" y2="316"/>
<text class="t-mono" x="360" y="255">meta.json</text>
<text class="t-val" x="488" y="255">minTime <tspan class="t-acc">10:00</tspan>  maxTime <tspan class="t-acc">12:00</tspan></text>
<text class="t-mono" x="360" y="281">chunks/000001</text>
<text class="t-val" x="488" y="281">按 series 排好的压缩样本</text>
<text class="t-mono" x="360" y="307">index</text>
<text class="t-val" x="488" y="307">符号表 + postings 倒排</text>
<text class="t-mono" x="360" y="333">tombstones</text>
<text class="t-val" x="488" y="333">删除标记，不改 chunk</text>
<rect class="dd-card" x="344" y="358" width="424" height="36" rx="6"/>
<text class="t-mono" x="360" y="381">01JA2Q9M…/</text>
<text class="t-val" x="488" y="381">08:00 – 10:00</text>
<text class="t-note" x="752" y="381" text-anchor="end">过期时整个目录删除</text>
</svg>
</div>
<figcaption>图 2 · 新样本先进内存和 WAL，每满 2 小时切出一个不可变的 block，过期时整块删除</figcaption>
</figure>

这个设计把三件事同时理顺了：写入是顺序的（追加 WAL、追加内存 chunk），读取是按 series 连续的（block 里排好了序），删除是整块的（删目录）。2.0 正式发布时，官方给出的对比数据是：和 1.8 相比，CPU 占用降到原来的 20%–40%，磁盘空间降到 33%–50%，查询负载不高时，磁盘 IO 通常在 1% 以下[^14]。

### 回到「只留 15 天」

现在可以回答开头的第一个现象了。15 天只是 `--storage.tsdb.retention.time` 的默认值，可以改成 90 天甚至一年。官方文档给出的磁盘估算公式是[^8]：

$$
D = T \times R \times B
$$

其中 $D$ 是磁盘空间，$T$ 是保留的秒数，$R$ 是每秒写入的样本数，$B$ 是每个样本的字节数。

按前面的例子，保留一年大约需要 3TB，单盘也放得下。真正的问题不在容量，而在官方对本地存储的定位。文档原话是：本地存储 "not clustered or replicated"，面对磁盘或节点故障时 "not arbitrarily scalable or durable"[^8]。数据只有一份，存得越久，丢的时候损失越大。这一点留到第五节再展开。

block 设计解决了写入和删除的问题，但 Head 里还藏着一样东西：每条 series 在内存里都有一个对象，索引里也有它的一项。**series 的数量一多，会发生什么？**

---

## 四、高基数问题

### 现象

回到开头的第二个现象。假设 `http_requests_total` 有这些 label：

- `instance`：100 个实例；
- `path`：50 个接口；
- `code`：5 种状态码。

最多是 100 × 50 × 5 = 25000 条 series。为了按用户看请求量，加上一个 `user_id`。如果有 1 万个活跃用户，series 数就变成 25000 × 10000 = 2.5 亿。样本数没变，还是每个请求累加一次；变的是 series 的数量。label 取值组合的个数叫作**基数**（cardinality），这就是常说的「高基数问题」。

### 原因

为什么 series 数比样本数更要命？看 Head 里一条 series 要占用什么：

- **一个内存对象。** Prometheus 用一个 `memSeries` 结构表示一条 series，光结构本身约 200 字节，还没算 label 字符串和正在写的 chunk[^15]。Brian Brazil 按最坏情况估算，Prometheus 2.x 每条 series 约 732 字节，label 另算，而且因为 Go 的垃圾回收，这些全部还要再翻一倍；算下来一百万条 series 光这部分就要约 2GiB 内存[^16]。
- **倒排索引里的若干项。** 查询 `{path="/pay", code="500"}` 时，Prometheus 不会扫描所有 series，而是查倒排索引（postings）：每个 `label=value` 对应一个排好序的 series ID 列表，把两个列表求交集，就得到要读的 series[^17]。每多一条 series，它的每个 label 值对应的列表里都要多一项。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="tsdb-card-title tsdb-card-desc">
<title id="tsdb-card-title">倒排索引与基数</title>
<desc id="tsdb-card-desc">http_requests_total 有 4 条 series，倒排索引把每个 label 值映射到 series ID 列表；查询 path="/pay" 且 code="500" 时，对 {3,4} 和 {2,4} 求交集得到 series 4。如果再加一个有 1 万个取值的 user_id，series 数变成 4 万条，每条都要一个内存对象和若干索引项。</desc>
<defs>
<marker id="tsdb-card-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="tsdb-card-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="400" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="280" height="368" rx="8"/>
<text class="t-zone" x="32" y="44">series</text>
<rect class="dd-zone" x="312" y="16" width="224" height="368" rx="8"/>
<text class="t-zone" x="328" y="44">倒排索引（postings）</text>
<rect class="dd-zone" x="552" y="16" width="232" height="368" rx="8"/>
<text class="t-zone" x="568" y="44">再加一个 user_id</text>
<path class="dd-edge-acc" d="M280 195 H326" marker-end="url(#tsdb-card-arrow-acc)"/>
<path class="dd-edge dd-dash" d="M520 88 H566" marker-end="url(#tsdb-card-arrow)"/>
<rect class="dd-card" x="32" y="72" width="248" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 104 V78 Q32.6 72.6 38 72.6 H274 Q279.4 72.6 279.4 78 V104 Z"/>
<line class="dd-edge" x1="32" y1="104" x2="280" y2="104"/>
<text class="t-mono" x="48" y="93">http_requests_total</text>
<line class="dd-hair" x1="48" y1="130" x2="264" y2="130"/>
<line class="dd-hair" x1="48" y1="156" x2="264" y2="156"/>
<line class="dd-hair" x1="48" y1="182" x2="264" y2="182"/>
<text class="t-mono" x="48" y="121">1</text>
<text class="t-val" x="68" y="121">path="/login",code="200"</text>
<text class="t-mono" x="48" y="147">2</text>
<text class="t-val" x="68" y="147">path="/login",code="500"</text>
<text class="t-mono" x="48" y="173">3</text>
<text class="t-val" x="68" y="173">path="/pay",code="200"</text>
<text class="t-mono" x="48" y="199"><tspan class="t-acc">4</tspan></text>
<text class="t-val" x="68" y="199">path="/pay",code="500"</text>
<text class="t-say" x="32" y="244">每条 series 在 Head 里</text>
<text class="t-say" x="32" y="264">都有一个 memSeries 对象。</text>
<text class="t-note" x="32" y="304">Brian Brazil 的估算：</text>
<text class="t-note" x="32" y="324">每条约 732 字节，GC 还要再翻倍</text>
<rect class="dd-card" x="328" y="72" width="192" height="136" rx="6"/>
<path class="dd-card-head" d="M328.6 104 V78 Q328.6 72.6 334 72.6 H514 Q519.4 72.6 519.4 78 V104 Z"/>
<line class="dd-edge" x1="328" y1="104" x2="520" y2="104"/>
<text class="t-mono" x="344" y="93">index</text>
<text class="t-note" x="504" y="93" text-anchor="end">label=value → ID</text>
<line class="dd-hair" x1="344" y1="130" x2="504" y2="130"/>
<line class="dd-hair" x1="344" y1="156" x2="504" y2="156"/>
<line class="dd-hair" x1="344" y1="182" x2="504" y2="182"/>
<text class="t-mono" x="344" y="121">path="/login"</text>
<text class="t-val" x="504" y="121" text-anchor="end">1, 2</text>
<text class="t-mono" x="344" y="147">path="/pay"</text>
<text class="t-val" x="504" y="147" text-anchor="end">3, <tspan class="t-acc">4</tspan></text>
<text class="t-mono" x="344" y="173">code="200"</text>
<text class="t-val" x="504" y="173" text-anchor="end">1, 3</text>
<text class="t-mono" x="344" y="199">code="500"</text>
<text class="t-val" x="504" y="199" text-anchor="end">2, <tspan class="t-acc">4</tspan></text>
<rect class="dd-card" x="328" y="232" width="192" height="84" rx="6"/>
<path class="dd-card-head" d="M328.6 264 V238 Q328.6 232.6 334 232.6 H514 Q519.4 232.6 519.4 238 V264 Z"/>
<line class="dd-edge" x1="328" y1="264" x2="520" y2="264"/>
<text class="t-name" x="344" y="253">查询</text>
<text class="t-note" x="504" y="253" text-anchor="end">/pay 且 500</text>
<line class="dd-hair" x1="344" y1="290" x2="504" y2="290"/>
<text class="t-val" x="344" y="281">{3, 4} ∩ {2, 4}</text>
<text class="t-val" x="344" y="307">= {<tspan class="t-acc">4</tspan>}</text>
<text class="t-note" x="504" y="307" text-anchor="end">只读这一条</text>
<rect class="dd-derived" x="568" y="72" width="200" height="164" rx="6"/>
<text class="t-mono" x="584" y="100">user_id="u0001"</text>
<text class="t-mono" x="584" y="124">user_id="u0002"</text>
<text class="t-note" x="584" y="148">…</text>
<text class="t-mono" x="584" y="172">user_id="u9999"</text>
<line class="dd-hair" x1="584" y1="190" x2="752" y2="190"/>
<text class="t-val" x="584" y="214">4 × 10,000 = 40,000 条</text>
<text class="t-say" x="568" y="272">虚线：还没出现的 series。</text>
<text class="t-say" x="568" y="292">每来一个新用户，就多出</text>
<text class="t-say" x="568" y="312">4 条 series 和一批索引项。</text>
</svg>
</div>
<figcaption>图 3 · 倒排索引让查询只读命中的 series，但每条 series 都要占内存，label 取值一多就成倍增长</figcaption>
</figure>

按一百万条约 2GiB 估算，2.5 亿条 series 光这一项就需要约 500GiB 内存。这还没算上 series churn：短命的 series 不再写入以后，也要等 Head 下一次切 block（2 到 3 小时）才会从内存里清掉[^15]。

所以高基数的根源是：**存储的成本和 series 的数量成正比，而 series 的数量是各个 label 取值个数的乘积。**

### 第一次尝试：把索引搬到磁盘

InfluxDB 也遇到过同样的问题。它早期的索引全部放在内存里，短命的 series 一多，内存就不断上涨，而且启动时要把整个索引加载到堆上。2017 年的 1.3 版推出了 TSI，把索引做成磁盘上的 LSM 结构，用 mmap 访问，目标是支持 10 亿条 series[^18]。

这缓解了内存压力，但没有改变本质：索引的大小仍然和 series 数成正比，有时候索引比数据本身还大。InfluxData 的创始人 Paul Dix 后来总结，InfluxDB 实际上是「倒排索引」和「时间序列数据」两个数据库拼在一起，这种割裂没法通过渐进修改来解决[^19]。

### 换个思路：不要 series 索引

2020 年，InfluxData 宣布用 Rust 重写存储引擎（项目代号 IOx，后来成为 InfluxDB 3）。新设计里，**tag 就是表里普通的一列**，数据按列存储、按排序键排好，查询靠每个数据块的最小值和最大值来跳过不相关的部分[^19]。新出现一个设备，只是在列里多写几个值，不会产生新的索引项，所以它宣称对基数没有上限。

ClickHouse 走的是同一个方向。它在一篇对比 Prometheus 的文章里说：在 ClickHouse 里，一个新的 label 组合只是多一行，不会产生新的结构对象；稀疏索引大约每 8000 行才有一项。所以成本取决于查询命中了多少行，而不是一共有多少条 series[^20]。

但基数并没有凭空消失。同一篇文章也承认，按一个高基数的列做大范围的 `GROUP BY` 仍然很贵：示例里按 `request_path` 分组聚合一天的数据，耗时 9.4 秒，峰值内存 22.8GB[^20]。列存把高基数的成本从「写入时一直占着内存」挪到了「查询时临时计算」。对于偶尔查一次的分析需求，这很划算；对于每 15 秒刷新一次的看板，就未必了。

### 在 Prometheus 里能做什么

如果后端还是 Prometheus，能做的主要是控制基数：

- **不把无界的值放进 label。** 官方的命名规范明确说，不要用 label 存用户 ID、邮箱这类取值没有上限的维度[^21]。「按用户看请求量」更适合交给日志或链路追踪，或者一个列式数据库。
- **用 native histogram 代替传统直方图。** 传统直方图的每个 bucket 都是一条单独的 series，native histogram 把整个分布放进一条 series。它在 3.8 版（2025 年 11 月）成为稳定特性[^10]。

到这里，前两个现象都有了答案。还剩第三个：Prometheus 宕机，数据就没了。**单机的边界怎么突破？**

---

## 五、Prometheus 与长期存储

### 原因：Prometheus 本来就没打算做数据库

要理解 Prometheus 为什么只做单机，得先看它是什么。官方文档的定位是一个**监控和告警系统**：用拉模式定期抓取各个目标的 `/metrics`，每台 Prometheus 服务器独立运行，只依赖本地磁盘，不依赖网络存储。这样，在其他基础设施都出了问题的时候，监控还能正常工作[^22]。

本地的 TSDB 是为这个目标服务的一个部件，所以它有意保持简单：单机、不复制，默认只存最近的数据。需要长期保存、高可用和全局视图时，官方文档指向的是「远程存储集成」（remote storage integrations）[^8]。

### 第一种办法：把 block 原样搬到对象存储

既然 Prometheus 每 2 小时会切出一个不可变的 block，最省事的办法就是把这些 block 传到 S3 这类对象存储上。

**Thanos** 就是这么做的。它在每台 Prometheus 旁边部署一个 sidecar，把新生成的 block 原样上传到对象存储；再由 Store Gateway 从对象存储里读 block 提供查询，Compactor 负责合并和降采样，Querier 把一个 PromQL 查询分发到各处，再把多个副本的结果去重。Thanos 的设计文档说，Prometheus 2.0 的存储格式是它所有组件的基础[^23]。

**Cortex** 和从它分叉出来的 **Grafana Mimir**（2022 年，AGPL 许可）[^24b]改用推送的方式：Prometheus 通过 remote write 把样本推给 Mimir，Mimir 的 ingester 在内存里跑同样的 TSDB，默认写三个副本，每 2 小时把 block 传到对象存储[^24][^24c]。2025 年 11 月发布的 Mimir 3.0 又在写入和查询之间加了一层 Kafka，把读写两条路径彻底拆开[^25]。

这条路线的好处是，存储格式直接沿用 Prometheus 的 block，行为和单机 Prometheus 一致。代价是 block 格式里的倒排索引也一起搬了过去，高基数问题依然存在，只是可以通过分片分摊到更多机器上。

### 第二种办法：只对接协议

另一条路线不复用 Prometheus 的存储格式，只实现它的两个接口：写入用 **remote write**，查询用 **PromQL**。

remote write 是 Prometheus 把样本推送到远端的 HTTP 协议，消息体是用 snappy 压缩的 protobuf。1.0 版在 2023 年成为稳定规范；2.0 版加入了字符串表（把重复的 label 字符串换成索引）、指标元数据等改进，目前还处于实验阶段[^26]。Grafana 报告说，内部切换到 2.0 之后，网络出口流量下降了一半以上[^27]。

2021 年 Prometheus 还推出了 Agent 模式：关掉查询、告警和本地存储，只保留服务发现、抓取和 remote write[^28]。这一步很能说明问题：**Prometheus 自己也承认，「抓指标」和「存指标」是可以分开的两件事。**

只要实现了这两个接口，后端用什么存储都可以：

- **VictoriaMetrics** 用自研的存储格式，数据按月分区，查询语言 MetricsQL 基本兼容 PromQL[^29][^29b]；
- **Google Cloud Managed Service for Prometheus** 的后端是 Google 内部的监控存储 Monarch[^30]；
- **ClickHouse** 在 2026 年 9 月推出了 `TimeSeries` 表引擎和服务端 PromQL，目前处于 private preview[^31]；
- **GreptimeDB** 用 Rust 重新实现了 PromQL，数据以 Parquet 格式放在对象存储上[^32][^44]。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 468" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="tsdb-eco-title tsdb-eco-desc">
<title id="tsdb-eco-title">Prometheus 和长期存储的两种连接方式</title>
<desc id="tsdb-eco-desc">Prometheus 本地只存最近的数据。Thanos 由 sidecar 读取本地 data/ 目录，Mimir 通过 remote write 接收样本，两者都把 2 小时的 block 存进 S3。VictoriaMetrics、ClickHouse、GreptimeDB 只实现 remote write 和 PromQL，存储格式各不相同。</desc>
<defs>
<marker id="tsdb-eco-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="tsdb-eco-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="468" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="224" height="436" rx="8"/>
<text class="t-zone" x="32" y="44">Prometheus 服务器</text>
<rect class="dd-zone" x="256" y="16" width="528" height="208" rx="8"/>
<text class="t-zone" x="272" y="44">复用 Prometheus 的 block 格式</text>
<rect class="dd-zone" x="256" y="240" width="528" height="212" rx="8"/>
<text class="t-zone" x="272" y="268">自研存储，只对接协议</text>
<path class="dd-edge-acc" d="M224 195 H240 Q248 195 248 187 V125 Q248 117 256 117 H270" marker-end="url(#tsdb-eco-arrow-acc)"/>
<path class="dd-edge-acc" d="M240 195 Q248 195 248 203 V276 Q248 284 256 284 H678 Q686 284 686 292 V298" marker-end="url(#tsdb-eco-arrow-acc)"/>
<path class="dd-edge-acc" d="M350 284 V298" marker-end="url(#tsdb-eco-arrow-acc)"/>
<path class="dd-edge-acc" d="M518 284 V298" marker-end="url(#tsdb-eco-arrow-acc)"/>
<path class="dd-edge" d="M392 156 V170" marker-end="url(#tsdb-eco-arrow)"/>
<path class="dd-edge" d="M648 156 V170" marker-end="url(#tsdb-eco-arrow)"/>
<rect class="dd-card" x="32" y="72" width="192" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 104 V78 Q32.6 72.6 38 72.6 H218 Q223.4 72.6 223.4 78 V104 Z"/>
<line class="dd-edge" x1="32" y1="104" x2="224" y2="104"/>
<text class="t-mono" x="48" y="93">prometheus</text>
<text class="t-note" x="208" y="93" text-anchor="end">单机</text>
<line class="dd-hair" x1="48" y1="130" x2="208" y2="130"/>
<line class="dd-hair" x1="48" y1="156" x2="208" y2="156"/>
<line class="dd-hair" x1="48" y1="182" x2="208" y2="182"/>
<text class="t-mono" x="48" y="121">抓取</text>
<text class="t-val" x="104" y="121">每 15s 拉取</text>
<text class="t-mono" x="48" y="147">存储</text>
<text class="t-val" x="104" y="147">data/ · 15d</text>
<text class="t-mono" x="48" y="173">复制</text>
<text class="t-val" x="104" y="173">无</text>
<text class="t-mono" x="48" y="199">推送</text>
<text class="t-val" x="104" y="199"><tspan class="t-acc">remote write</tspan></text>
<text class="t-say" x="32" y="244">本地只存最近的数据，</text>
<text class="t-say" x="32" y="264">不复制，挂了就断。</text>
<text class="t-say" x="32" y="284">长期存储交给右边。</text>
<rect class="dd-card" x="32" y="388" width="192" height="36" rx="6"/>
<text class="t-name" x="48" y="411">Grafana</text>
<text class="t-mono" x="208" y="411" text-anchor="end">PromQL</text>
<text class="t-note" x="32" y="372">右边每个后端都能用它查</text>
<rect class="dd-card" x="272" y="72" width="240" height="84" rx="6"/>
<path class="dd-card-head" d="M272.6 104 V78 Q272.6 72.6 278 72.6 H506 Q511.4 72.6 511.4 78 V104 Z"/>
<line class="dd-edge" x1="272" y1="104" x2="512" y2="104"/>
<text class="t-name" x="288" y="93">Grafana Mimir</text>
<text class="t-note" x="496" y="93" text-anchor="end">Cortex 分叉</text>
<line class="dd-hair" x1="288" y1="130" x2="496" y2="130"/>
<text class="t-mono" x="288" y="121">写入</text>
<text class="t-val" x="336" y="121"><tspan class="t-acc">remote write</tspan></text>
<text class="t-mono" x="288" y="147">存储</text>
<text class="t-val" x="336" y="147">2h block，三副本</text>
<rect class="dd-card" x="528" y="72" width="240" height="84" rx="6"/>
<path class="dd-card-head" d="M528.6 104 V78 Q528.6 72.6 534 72.6 H762 Q767.4 72.6 767.4 78 V104 Z"/>
<line class="dd-edge" x1="528" y1="104" x2="768" y2="104"/>
<text class="t-name" x="544" y="93">Thanos</text>
<text class="t-note" x="752" y="93" text-anchor="end">sidecar</text>
<line class="dd-hair" x1="544" y1="130" x2="752" y2="130"/>
<text class="t-mono" x="544" y="121">读取</text>
<text class="t-val" x="592" y="121">同机的 data/ 目录</text>
<text class="t-mono" x="544" y="147">上传</text>
<text class="t-val" x="592" y="147">2h block，原样</text>
<rect class="dd-card" x="272" y="172" width="496" height="36" rx="6"/>
<text class="t-name" x="288" y="195">对象存储</text>
<text class="t-mono" x="360" y="195">s3://metrics/01JA3K7X…/{chunks,index,meta.json}</text>
<rect class="dd-card" x="272" y="300" width="156" height="84" rx="6"/>
<path class="dd-card-head" d="M272.6 332 V306 Q272.6 300.6 278 300.6 H422 Q427.4 300.6 427.4 306 V332 Z"/>
<line class="dd-edge" x1="272" y1="332" x2="428" y2="332"/>
<text class="t-name" x="288" y="321">VictoriaMetrics</text>
<line class="dd-hair" x1="288" y1="358" x2="412" y2="358"/>
<text class="t-mono" x="288" y="349">查询</text>
<text class="t-val" x="328" y="349">MetricsQL</text>
<text class="t-mono" x="288" y="375">存储</text>
<text class="t-val" x="328" y="375">按月分区</text>
<rect class="dd-card" x="440" y="300" width="156" height="84" rx="6"/>
<path class="dd-card-head" d="M440.6 332 V306 Q440.6 300.6 446 300.6 H590 Q595.4 300.6 595.4 306 V332 Z"/>
<line class="dd-edge" x1="440" y1="332" x2="596" y2="332"/>
<text class="t-name" x="456" y="321">ClickHouse</text>
<line class="dd-hair" x1="456" y1="358" x2="580" y2="358"/>
<text class="t-mono" x="456" y="349">查询</text>
<text class="t-val" x="488" y="349">PromQL</text>
<text class="t-mono" x="456" y="375">存储</text>
<text class="t-val" x="488" y="375">TimeSeries 表</text>
<rect class="dd-card" x="608" y="300" width="156" height="84" rx="6"/>
<path class="dd-card-head" d="M608.6 332 V306 Q608.6 300.6 614 300.6 H758 Q763.4 300.6 763.4 306 V332 Z"/>
<line class="dd-edge" x1="608" y1="332" x2="764" y2="332"/>
<text class="t-name" x="624" y="321">GreptimeDB</text>
<line class="dd-hair" x1="624" y1="358" x2="748" y2="358"/>
<text class="t-mono" x="624" y="349">查询</text>
<text class="t-val" x="664" y="349">PromQL + SQL</text>
<text class="t-mono" x="624" y="375">存储</text>
<text class="t-val" x="664" y="375">Parquet + S3</text>
<text class="t-note" x="272" y="412">还有 M3、Google Monarch 和各家云厂商的托管服务 …</text>
<text class="t-say" x="272" y="436">它们只认 remote write 和 PromQL，不关心 Prometheus 怎么存。</text>
</svg>
</div>
<figcaption>图 4 · 长期存储有两种接法：沿用 Prometheus 的 block，或者只实现 remote write 和 PromQL</figcaption>
</figure>

### 分析：Prometheus 更像一套标准

把这两条路线放在一起看，会发现一件有意思的事：在各家实现之间真正通用的，并不是 Prometheus 的存储引擎，而是下面四样东西：

1. **数据模型**：指标名加一组 label；
2. **暴露格式**：`/metrics` 的文本格式。曾经独立出去的 OpenMetrics 项目，2024 年又并回了 Prometheus[^33]；
3. **写入协议**：remote write；
4. **查询语言**：PromQL 和它的 HTTP API。

Prometheus 和 CNCF 在 2021 年推出的兼容性认证（Prometheus Conformance Program），认证的也正是这些接口，而不是存储[^34]。PromLabs 的 PromQL 兼容性测试（最近一轮公开结果停在 2022 年）里，Cortex、Thanos、Grafana Cloud 和 AWS 的托管服务都是 100% 通过；VictoriaMetrics 只有 74%，因为 MetricsQL 有意修改了 `rate`、`increase` 这类函数的外推方式[^35]。

所以我的看法是：**今天的 Prometheus，作为一套标准的分量，已经超过了它作为一个数据库的分量。** 这是我对现象的归纳，不是官方的说法。连 ClickHouse 这样的通用数据库都在补 PromQL，就是一个佐证。Prometheus 3.x 原生接收 OpenTelemetry 的 OTLP 数据、默认支持 UTF-8 的指标名[^36]，也在把这套标准往 OpenTelemetry 那边靠：OpenTelemetry 负责埋点和传输，Prometheus 负责查询和告警的语义。

### 回到开头的三个现象

现在可以把开头的三个现象放在一起回答了：

1. **只留 15 天**：15 天只是默认值。真正的限制是本地存储单机、不复制，所以长期数据应该交给对象存储上的后端。
2. **加一个 label 就 OOM**：存储成本和 series 数成正比，而 series 数是 label 取值个数的乘积。在 Prometheus 里要控制基数；真正需要高基数分析，就交给列式存储。
3. **节点宕机数据就断**：Prometheus 本来就是按「单机自治」设计的，高可用要靠多副本加查询端去重，或者 remote write 到一个多副本的后端。

不过，长期存储只是时序存储的一个分支。**跳出 Prometheus，整个时序存储市场今天是什么样子？**

---

## 六、时序存储的市场格局

### 现象：排名里的老面孔和新动向

DB-Engines 的时序数据库排名，是观察这个市场最常用的参考。下表是我能拿到的最新数据（2026 年 3 月）[^37]：

<!-- TODO: 发文前到 db-engines.com 换成最新一期的排名 -->

| 排名 | 产品 | 分数 | 和一年前比 |
|---|---|---|---|
| 1 | InfluxDB | 20.74 | −0.76 |
| 2 | Prometheus | 8.71 | +2.33 |
| 3 | kdb | 7.41 | +0.32 |
| 4 | TimescaleDB | 5.42 | +1.93 |
| 5 | DolphinDB | 4.58 | +2.28 |
| 6 | Graphite | 4.51 | −0.06 |
| 7 | QuestDB | 3.66 | +0.57 |
| 8 | Apache Druid | 3.50 | +0.71 |
| 9 | TDengine | 2.27 | +0.51 |
| 10 | VictoriaMetrics | 1.84 | +0.46 |

InfluxDB 仍然排第一，但分数在缓慢下降；Prometheus、TimescaleDB 和 DolphinDB 涨得最快。如果把「把时序当作次要模型」的数据库也算进来，ClickHouse 能排到第 5，排在大多数专用时序数据库前面[^38]。

排名之外，过去两年还有几件事值得注意：

- Timescale 在 2025 年 6 月改名 **Tiger Data**，理由是它云上的大部分负载根本不是时序数据，公司转而定位为「最快的 PostgreSQL」[^39]。
- AWS 自研的 **Timestream for LiveAnalytics** 从 2025 年 6 月起不再接受新客户，AWS 转而推荐托管版的 InfluxDB[^40]。
- InfluxDB 3 在 2025 年 4 月正式发布，用 Rust 完全重写，并且**放弃了 2.x 主推的 Flux 查询语言**，改回 SQL 和 InfluxQL[^41][^41b]。
- 可观测性平台 Chronosphere（创始人来自 Uber 的 M3 团队）在 2026 年 1 月被 Palo Alto Networks 以 33.5 亿美元收购[^42]。

### 分析：「时序数据库」这个品类正在被拆散

这些事情背后，我看到三股力量。下面是我对现象的归纳。

**第一股力量是新一代架构：列式存储加对象存储。** 第四节讲过，用列存代替 series 索引，可以绕开高基数问题；第五节讲过，用对象存储代替本地盘，可以绕开单机的限制。新一代引擎把这两件事合在了一起，并且尽量复用开源组件，而不是从头写：InfluxDB 3 用 Arrow 作内存格式、DataFusion 作查询引擎、Parquet 作文件格式（InfluxData 称之为 FDAP 架构）[^43]；GreptimeDB 在 2026 年 4 月发布 1.0，同样把 Parquet 放在对象存储上[^44]；QuestDB 10.0 把 Parquet 作为表的存储格式，并兼容 Iceberg[^45]。存储格式一旦变成通用的列式格式，查询引擎也就自然变成了通用的 SQL 引擎。

**第二股力量是通用数据库吃掉了时序场景。** 当时序数据可以用「按时间排序的列存」来存时，通用的分析型数据库天然就能做。Tiger Data 改名、AWS 停掉自研的时序库、ClickHouse 补上 PromQL，说的都是同一件事。

**第三股力量是可观测性的融合。** 指标、日志、链路追踪原本各存一套，现在越来越多的产品想把它们放进同一个库：GreptimeDB 1.0 的定位就是「可观测性数据库」；ClickHouse 在 2025 年收购 HyperDX[^46]，随后推出了 ClickStack[^46b]；VictoriaMetrics 也陆续推出了 VictoriaLogs 和 VictoriaTraces[^47]。驱动力主要是成本：三套存储变成一套，运维和存储都更便宜。

### 专用时序数据库守住的场景

在这种局面下，专用时序数据库靠的是对某个垂直场景的深度优化：

- **工业物联网。** 设备多、测点固定、写入极其规律。TDengine 采用「一个设备一张表」的模型，每张表的数据连续存放，新数据只需追加，表与表之间不竞争锁；再用「超级表」统一同类设备的结构[^48][^48b]。Apache IoTDB 最早是清华大学的研究项目[^49b]，有自己的 TsFile 列式文件格式[^49]。两者都在往「AI 原生」上重新定位，比如 TDengine 的 TDgpt 可以在 SQL 里直接做预测和异常检测。
- **金融和量化。** kdb+ 长期是这个领域的标准，2026 年推出了新一代产品 KDB-X，并开放免费的社区版[^50]；国内的 DolphinDB 在 DB-Engines 上涨得很快；QuestDB 也主打金融行情数据。
- **Prometheus 规模的指标存储。** VictoriaMetrics 和 Mimir 专门为 PromQL 和大规模指标优化，是第五节里那两条路线的主力。

### 怎么选

把前面的问题链串起来，选型时可以依次问自己几个问题：

1. **数据是不是 Prometheus 生态里的指标？** 如果是，先确认是不是真的需要长期存储和高可用。如果需要，在「复用 block」（Thanos、Mimir）和「只对接协议」（VictoriaMetrics 等）之间选。前者和原生 Prometheus 的行为最一致；后者通常更省资源，但要接受查询语义上的细微差异。
2. **是不是需要按高基数的维度分析？** 比如按用户、按请求 ID。如果是，这部分数据更适合放进列式数据库（ClickHouse、InfluxDB 3、GreptimeDB），而不是硬塞进 Prometheus 的 label。
3. **团队是不是已经在用 PostgreSQL 或 ClickHouse？** 如果是，TimescaleDB 或 ClickHouse 往往比再引入一个专用时序库更省事。
4. **是不是工业物联网或金融场景？** 这时专用产品（TDengine、IoTDB、kdb+、DolphinDB、QuestDB）在数据模型和性能上的优势最明显。

---

## 小结

回头看这一路，每一步都是被一个具体的问题推着走的：

1. **时序数据的特点**：写入量大、几乎全是追加、只看最近的数据、删除是整段过期。关系数据库的随机写和逐行删除扛不住；固定大小的文件丢精度，借用 KV 又太重。
2. **样本的压缩**：相邻的样本非常像。Gorilla 用「差值的差值」压时间戳，用异或压数值，把 16 字节的样本压到 1.37 字节。
3. **写入路径的设计**：压缩要求同一条 series 的数据挨在一起，样本却按时间交错到达。Prometheus 2.0 先写 WAL 和内存，每 2 小时切出一个不可变的 block，过期时整块删除。
4. **高基数问题**：存储成本和 series 数成正比，而 series 数是 label 取值个数的乘积。把索引搬到磁盘只能缓解；列式存储干脆不要 series 索引，把成本挪到了查询时。
5. **Prometheus 与长期存储**：Prometheus 本来就只想做单机自治的监控。长期存储有两种接法：沿用它的 block 格式，或者只实现 remote write 和 PromQL。真正通用的是这套标准，而不是它的存储引擎。
6. **市场格局**：列存加对象存储成了新一代架构的默认选择，通用数据库和可观测性平台正在吃掉时序场景，专用时序数据库守住工业物联网、金融和大规模指标这几个垂直场景。

如果你正被开头那三个现象困扰，最小的改动是：先用 `prometheus_tsdb_head_series` 这个指标看一看当前的 series 数，找出基数最大的那几个 label；再决定哪些数据该留在 Prometheus，哪些该交给长期存储或列式数据库。

---

## 参考资料
