# 索引格式 · 倒排索引与符号表

> 几百万条 series 里怎么瞬间挑出匹配的？这一章读官方的 index 磁盘格式规范：符号表怎么靠去重把索引压小、postings（倒排表）为什么要单调递增，以及多个标签匹配器如何靠交集求解。

- 作者：David（道雾轩）
- 专栏：指标采集与观测（https://daiw.org/manual/observability.md）
- 最后更新：2026-07-15
- 原文：https://daiw.org/manual/observability/index-format
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 索引格式 · 倒排索引与符号表

[压缩](https://daiw.org/manual/observability/chunk-encoding)解决了“存得下”。现在是第二个难题：**查得快**。

当你写下 `{job="api", status="500"}`，TSDB 要在**几百万条 series** 里瞬间挑出匹配的那些。答案是**倒排索引**——和搜索引擎同源的技术。这一章读它的**官方磁盘格式规范**（`tsdb/docs/format/index.md`）。

## 整体布局

规范开头就给了全景图（真实文档）：

```
┌────────────────────────────┬─────────────────────┐
│ magic(0xBAAAD700) <4b>     │ version(1) <1 byte> │
├────────────────────────────┴─────────────────────┤
│ ┌──────────────────────────────────────────────┐ │
│ │                 Symbol Table                 │ │  ← 去重的字符串池
│ ├──────────────────────────────────────────────┤ │
│ │                    Series                    │ │  ← 每条 series 的标签 + chunk 位置
│ ├──────────────────────────────────────────────┤ │
│ │                 Label Index 1..N             │ │
│ ├──────────────────────────────────────────────┤ │
│ │                   Postings 1..N              │ │  ← 倒排表(核心!)
│ ├──────────────────────────────────────────────┤ │
│ │               Label Offset Table             │ │
│ ├──────────────────────────────────────────────┤ │
│ │             Postings Offset Table            │ │  ← 怎么找到某个倒排表
│ ├──────────────────────────────────────────────┤ │
│ │                      TOC                     │ │  ← 目录,入口点
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
```

<Callout type="info">
  **注意最后那个 **TOC（Table of Contents）**，规范说它“**serves as an entry point into the index**”——而且它在**文件末尾**。**

  为什么目录放最后？因为**索引是顺序写出来的**：写的时候你并不知道各个段最终有多长、在什么偏移。**写完了才知道**，所以把“各段在哪”的目录**追加在最后**。读的时候先跳到文件尾读 TOC，再按图索骥。

  **这是“一次顺序写”格式的经典手法**（ZIP 文件的中央目录也在末尾，同一个道理）——它让写入永远是**顺序追加**，不用回头改已写的部分。又一次呼应[不可变 + 顺序写](https://daiw.org/manual/observability/tsdb-overview)这条主线。
</Callout>

## 符号表：把重复的字符串抽出来

<Callout type="info">
  **规范原文：**

  > The symbol table holds a **sorted list of deduplicated strings** that occur in label pairs of the stored series. They can be **referenced from subsequent sections** and **significantly reduce the total index size**.

  **想想为什么这招这么有效：** 一个 block 里有 100 万条 series，它们的标签里，`job="api-server"` 这个字符串可能**出现 50 万次**、`__name__="http_requests_total"` 出现 10 万次。如果每条 series 都存一份完整字符串，**索引会被重复字符串撑爆**。

  符号表的做法：**把所有出现过的字符串去重、排序、存一份**，然后各处**只存“第几个字符串”这个整数引用**。

  ```
  符号表:  [0]="__name__"  [1]="api-server"  [2]="http_requests_total"  [3]="job" ...
  series:  { 3→1, 0→2 }   ← 只存整数,不存字符串!
           (job="api-server", __name__="http_requests_total")
  ```

  **这和[第 1 部分那个 `dedupelabels` 的 Labels 实现](https://daiw.org/manual/observability/data-model)是同一个思想在两个层面的体现**：内存里去重共享标签、磁盘上用符号表去重字符串。**“标签字符串大量重复”这个观察，被 Prometheus 在每一层都利用到了。**

  为什么要**排序**？因为排序后可以**二分查找**，而且[相邻字符串常有公共前缀](https://daiw.org/manual/observability/index-format)，利于进一步压缩。
</Callout>

## Postings：倒排表本体

这是索引的心脏。规范说：

> Postings sections store **monotonically increasing lists of series references** that contain a given label pair associated with the list.

```
每个"标签对 → series 列表"是一个 postings:

job="api-server"     → [1, 5, 7, 12, 19, 23, ...]   ← series 引用,单调递增
status="500"         → [5, 12, 88, 91, ...]
__name__="http_..."  → [1, 5, 7, 12, ...]
```

<Callout type="info">
  **“倒排”的意思是：**正着**是“series → 有哪些标签”，**倒过来**是“标签 → 有哪些 series”。查询要的正是后者。**

  于是 `{job="api-server", status="500"}` 的求解变成：

  ```
  postings(job="api-server") = [1, 5, 7, 12, 19, 23]
  postings(status="500")     = [5, 12, 88, 91]
  ────────────────────────────────────────────────
  交集(AND)                  = [5, 12]      ← 答案
  ```

  **多个匹配器 = 多个 postings 求交集。** 这就是为什么 [PromQL 里加一个标签筛选通常会让查询更快](https://daiw.org/manual/observability/promql-performance)——交集只会更小。

  **而“单调递增”这个约束是关键**：两个**有序**列表求交集，可以用**归并**的方式**线性**扫过去（两个指针，谁小谁前进），而不需要哈希表或排序。**这是倒排索引能快的核心原因**——所有的集合运算（交、并）都退化成有序列表的归并。

  更进一步：有序还允许 **跳跃（skip）** 优化——如果一个列表当前是 5、另一个是 88，可以直接**跳过**第一个列表里所有 < 88 的项，不用逐个比。这在[列表长度差异悬殊时](https://daiw.org/manual/observability/promql-performance)（比如 `job="api"` 有 50 万条、`status="500"` 只有 10 条）能带来巨大加速。
</Callout>

## Postings Offset Table：索引的索引

有了一堆 postings，怎么知道 `job="api-server"` 的那个在文件哪个偏移？这就是 **Postings Offset Table**:

```
("job", "api-server")   → 偏移 0x1A2B
("status", "500")       → 偏移 0x3C4D
...
```

<Callout type="info">
  **注意这是个**两级查找**：先查 offset table 找到 postings 的位置，再去读 postings 本身。**

  为什么不把它们放一起？因为 offset table **小得多**（每个标签对一条记录），可以**整个加载进内存**或轻松二分；而 postings 本身**很大**（一个热门标签对的 series 列表可能有几十万个 uint32），按需读取更合理。

  **“小的元数据常驻，大的数据按需读”——这是几乎所有存储引擎的通用套路。**
</Callout>

## 一次查询的完整索引路径

```mermaid
flowchart TD
    Q["查询 {job='api', status='500'}"] --> TOC["① 读文件末尾的 TOC<br/>知道各段在哪"]
    TOC --> SYM["② 查符号表<br/>'job'→#3, 'api'→#1, ..."]
    SYM --> OT["③ 查 Postings Offset Table<br/>找到两个 postings 的偏移"]
    OT --> P1["④ 读 postings(job=api)<br/>[1,5,7,12,19,23]"]
    OT --> P2["④ 读 postings(status=500)<br/>[5,12,88,91]"]
    P1 --> AND["⑤ 归并求交集(有序!)<br/>→ [5,12]"]
    P2 --> AND
    AND --> SER["⑥ 读 Series 段<br/>拿到每条的 chunk 位置"]
    SER --> CH["⑦ 去 chunks/ 读数据<br/>解压(第 3 章)"]
    style AND fill:#22c55e,color:#fff
```

## 实战：为什么正则匹配可能很慢

<Callout type="warn">
  **倒排索引对“**等值匹配**”（`job="api"`）极快——直接查表拿 postings。但对“**正则匹配**”（`job=~"api.*"`）就没那么美好了。**

  因为倒排索引是按“**具体的标签对**”建的，`job=~"api.*"` 没有现成的 postings。TSDB 必须：
  1. **遍历 `job` 这个标签的所有取值**，逐个用正则测试。
  2. 把所有匹配上的取值的 postings **求并集**。

  **如果 `job` 有 10 万种取值，这就是 10 万次正则匹配 + 一次大规模并集。** 这也是为什么：
  - **能用等值就别用正则** —— `{job="api"}` 远快于 `{job=~"api"}`（后者虽然结果一样，但走的是正则路径）。
  - **锚定的正则更快** —— Prometheus 会对 `=~"api.*"` 这类做优化（提取公共前缀），但 `=~".*api.*"` 这种**两头开放**的几乎必然全表扫。
  - **高基数标签上做正则 = 灾难** —— 又一次，[基数](https://daiw.org/manual/observability/defining-metrics)是万恶之源。

  源码里 `model/labels/regexp.go` 专门做正则优化（比如识别出“其实是个等值匹配”、提取字面量前缀），但**它只能优化，不能变魔术**。[第 4 部分讲 PromQL 性能](https://daiw.org/manual/observability/promql-performance)时会再碰这个话题。
</Callout>

## 小结与下一章

TSDB 用**倒排索引**解决“查得快”。磁盘格式（`tsdb/docs/format/index.md`，magic `0xBAAAD700`）：**TOC 在文件末尾**（[因为索引是一次顺序写，写完才知道各段偏移——ZIP 也这么干](https://daiw.org/manual/observability/tsdb-overview)）；**符号表**把[重复出现几十万次的标签字符串去重成一份、各处只存整数引用](https://daiw.org/manual/observability/index-format)，排序后还能二分——[和内存里的 `dedupelabels` 是同一思想在两层的体现](https://daiw.org/manual/observability/data-model)；**postings** 是“标签对 → **单调递增**的 series 列表”，多个匹配器**求交集**即可——**有序是核心**，让交并集退化成**线性归并**（还能跳跃加速）；**Postings Offset Table** 是“索引的索引”（[小元数据常驻、大数据按需读](https://daiw.org/manual/observability/index-format)）。实战：**等值匹配极快，正则匹配可能很慢**（[要遍历该标签所有取值逐个正则再求并集；两头开放的 `.*x.*` 几乎必然全扫](https://daiw.org/manual/observability/promql-performance)）——**能用等值别用正则**。

数据和索引都在 block 里了。block 越攒越多怎么办？下一章看压实。👉 [块与压实](https://daiw.org/manual/observability/blocks-compaction)。

## 参考资料

- [1] Prometheus 源码 `tsdb/docs/format/index.md`（完整磁盘格式规范）、`tsdb/index/index.go`。
- [2] Prometheus 源码 `model/labels/regexp.go`（正则匹配优化）、`tsdb/index/postings.go`（归并求交）。
- [3] 关联[数据模型的三套 Labels](https://daiw.org/manual/observability/data-model)、[基数](https://daiw.org/manual/observability/defining-metrics)、[PromQL 性能](https://daiw.org/manual/observability/promql-performance)。
