# chunk 编码 · Gorilla 压缩的位级魔法

> 16 字节怎么压成 1~2 字节？这一章读 chunkenc/xor.go 的真实位级实现——delta-of-delta 编码时间戳（等间距时只花 1 个 bit！）、XOR 编码浮点值，以及这套算法为什么如此贴合时序数据的性格。

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

# chunk 编码 · Gorilla 压缩的位级魔法

一个采样点原始是 **16 字节**（int64 时间戳 + float64 值）。TSDB 把它压到平均 **1~2 字节**。这一章看这个魔法怎么变的——它是整个 Prometheus **最漂亮的一段代码**。

算法来自 Facebook 的 **Gorilla 论文**，Prometheus 的实现在 `tsdb/chunkenc/xor.go`，配套的位流工具在 `bstream.go`。

## 核心洞见：存差值的差值

<Callout type="info">
  **回想[时序数据的第一个先验](https://daiw.org/manual/observability/tsdb-overview)：[抓取是等间隔的](https://daiw.org/manual/observability/scrape-loop)。于是时间戳序列长这样：**

  ```
  时间戳:  1000  1015  1030  1045  1060   (每 15 秒)
  delta:        15    15    15    15      ← 一阶差:恒定!
  delta-of-delta:  0     0     0          ← 二阶差:全是 0!!
  ```

  **存一串 0 几乎不要钱。** 这就是 **delta-of-delta（dod）编码**：不存时间戳本身，不存差值，而是**存“差值的变化量”**——而这个量在理想情况下**恒为 0**。
</Callout>

## 真实实现：dod == 0 只花 1 个 bit

看 `chunkenc/xor.go:184` 起的真实代码：

```go
dod := int64(tDelta - a.tDelta)     // 差值的差值
// ...
switch {
case dod == 0:
    a.b.writeBit(zero)                                    // ← 就 1 个 bit!
case bitRange(dod, 14):
    a.b.writeByte(0b10<<6 | (uint8(dod>>8) & (1<<6 - 1))) // 0b10 前缀 + 6 位
    a.b.writeByte(uint8(dod))                             // 再 8 位 → 共 2+14 bit
case bitRange(dod, 17):
    a.b.writeBits(0b110, 3)                               // 0b110 前缀
    a.b.writeBits(uint64(dod), 17)                        // → 共 3+17 bit
// ... 更大的范围用更长的前缀
}
```

<Callout type="info">
  **这是一个**变长前缀编码**（和霍夫曼编码同一个思想）：**越常见的情况，编码越短**。**

  | dod 的范围 | 前缀 | 总位数 | 什么时候 |
  | --- | --- | --- | --- |
  | **`dod == 0`** | `0` | **1 bit** | **抓取准时（绝大多数）** |
  | 能用 14 位表示 | `10` | 16 bit | 有点抖动 |
  | 能用 17 位表示 | `110` | 20 bit | 抖动较大 |
  | 更大 | `1110`… | 更长 | 罕见（重启、[乱序](https://daiw.org/manual/observability/head-and-wal)） |

  **理想情况下，一个时间戳只花 1 个 bit** —— 相比原始的 64 bit，**压缩率 64 倍**。而这正是常态，因为[抓取循环用哈希打散保证了它非常准时](https://daiw.org/manual/observability/scrape-loop)。

  **注意这里的呼应**：[第 1 部分讲 `Target.offset` 用哈希算固定偏移](https://daiw.org/manual/observability/scrape-loop)，当时说“确定性让采样点等间距，这对 `rate()` 很重要”。现在你看到了**第二个理由**：**等间距直接让压缩率提升几十倍**。一个设计，两处收益——这就是好架构的样子。
</Callout>

### 看它跑起来

下面是真实的编码逻辑在跑（`dod` 分档和 XOR 都按 `xor.go` 实现）。**勾上「抓取抖动」，看时间戳柱从绿变红、压缩率应声下跌**——这就是上面那句话的代价：

<GorillaBits />

<Callout type="warn">
  **反过来，这也解释了一个现实问题：**抖动会让存储变贵**。** 如果你的抓取时间不稳（目标响应慢、网络抖、Prometheus 自己 GC 卡顿），dod 就不是 0 了，**每个时间戳从 1 bit 涨到 16~20 bit**——单看时间戳这一段就是**十几倍**的膨胀。

  **但要说清楚：整体存储不会跟着翻十几倍。** 因为一个采样点 = 时间戳 + 值，而**值那半边通常才是大头**（它不受抓取抖动影响）。拿下面那个演示实测：稳定 gauge 在准时抓取下约 **1.5 字节/点**，开了抖动后约 **3.4 字节/点**——**整体约翻一倍**。

  **结论仍然成立（抖动确实要花钱），只是量级要诚实：时间戳段十几倍，整体约 2 倍。** 具体倍数取决于你的值有多能压——[值越平稳（越接近 1 bit），时间戳的膨胀占比就越大、总代价倍数也越高](https://daiw.org/manual/observability/chunk-encoding)。

  源码里还留着一条有意思的注释（`xor.go:189`）：
  > `// TODO(beorn7): This seems to needlessly jump to large bit sizes...`

  **连维护者自己都觉得这个分档还能再优化。** 这提醒我们：[读源码时看到 TODO 别忽略](https://daiw.org/manual/observability/reading-the-source)，它们往往标记着“**已知的、但还没人来得及做的改进**”——这是给贡献者的路标。
</Callout>

## 值：XOR 编码

时间戳解决了，**值**怎么办？靠[第二个先验：相邻值极其相似](https://daiw.org/manual/observability/tsdb-overview)。

<Callout type="info">
  **把相邻两个 float64 做 **XOR（异或）**。相似的浮点数，二进制表示的高位大量相同，XOR 之后**高位全是 0**，只有中间一小段是有效信息。**

  ```
  v1 = 42.3   →  0100000001000101 0010011001100110 0110011001100110 0110011001100110
  v2 = 42.5   →  0100000001000101 0100000000000000 0000000000000000 0000000000000000
  v1 XOR v2   →  0000000000000000 0110011001100110 0110011001100110 0110011001100110
                 └─ leading zeros ─┘└──── 有效位(meaningful bits)────────────────┘
  ```

  于是只需要存：**前导零的个数（leading）+ 有效位的个数 + 那几个有效位**。源码里那两个字段就是干这个的（`xor.go:157`）：

  ```go
  type xorAppender struct {
      leading  uint8   // 前导零个数
      trailing uint8   // 尾部零个数
      // ...
  }
  ```

  **更妙的优化**：如果这次的有效位区间**和上次一样**（相邻值变化模式相似时很常见），就**连 leading/trailing 都不用重存**，只写一个标志位 + 有效位本身。

  **极端情况：值完全没变**（比如一个稳定的 gauge）→ XOR = 0 → **只花 1 个 bit**。这在现实中极其常见：很多指标在大部分时间是不变的。
</Callout>

## 位流：连字节对齐都不要

这套编码里，一个值可能占 1 bit、3 bit、17 bit……**根本不对齐字节**。这就需要 `bstream.go`（bit stream，位流）：

<Callout type="info">
  **`bstream` 提供“往流里写 N 个 bit / 读 N 个 bit”的能力，让编码可以**精确到位**，不浪费任何一个 bit 去做对齐。**

  这是[block 不可变](https://daiw.org/manual/observability/tsdb-overview)带来的红利之一：**因为数据永不修改，就可以紧密打包到位级**。可变的结构做不到——你没法在一串位里“原地改”一个值。**不可变 → 位级打包 → 极致压缩**，这条因果链是 TSDB 设计的核心。

  `tsdb/docs/bstream.md` 专门解释了这块（尤其是 dod 的**二进制补码**处理：负数的 dod 怎么用尽量少的位表示）。文档开头很坦诚：
  > This doc is incomplete. For more background, see the Gorilla TSDB white paper or the original go-tsz implementation, which this code is based on.

  **“基于 go-tsz”**——[Prometheus 没有从零发明这个](https://daiw.org/manual/observability/reading-the-source)，而是站在 Gorilla 论文和一个已有 Go 实现的肩膀上。**好的工程是知道什么时候该借鉴。**
</Callout>

## chunk：压缩的单位

这些位流被组织进 **chunk**：

- **一个 chunk 属于一条 series**，装它在一段时间内的采样点。
- **默认最多 120 个采样点**（或达到 [chunkRange 时长](https://daiw.org/manual/observability/head-and-wal)）就切一个新的。
- chunk 是**压缩、存储、读取的最小单位**——查询时哪怕只要一个点，也得**解压整个 chunk**。

<Callout type="info">
  **为什么是 120 个点？这是个**压缩率 vs 读放大**的权衡：**

  - **chunk 越大** → 压缩率越好（[前面的点为后面的点提供“上下文”](https://daiw.org/manual/observability/chunk-encoding)，摊薄了头部开销），但**读一个点要解压更多东西**（读放大）。
  - **chunk 越小** → 读得快，但压缩率差、chunk 数量多（[索引里的引用也变多](https://daiw.org/manual/observability/index-format)）。

  **120 个点 × 15 秒 = 30 分钟**——这是个经验平衡点。注意它和 [chunkRange（2 小时）](https://daiw.org/manual/observability/head-and-wal)是两个不同的阈值：**先到哪个算哪个**。

  **实战含义**：如果你的抓取间隔很长（比如 5 分钟一次），120 个点 = 10 小时，那么 chunkRange 会先触发。反之如果抓取极密（1 秒一次），120 个点 = 2 分钟，chunk 会切得很碎——**这时你会发现存储效率变差**，因为每个 chunk 的固定开销被摊薄得不够。
</Callout>

## 压缩率到底多少

<Callout type="info">
  **Gorilla 论文和 Prometheus 的实践都指向同一个数字：**平均每个采样点约 1.3 字节**（相比原始 16 字节，约 **12 倍**压缩）。**

  拆开看：
  - **时间戳** —— 理想情况 **1 bit**（dod=0），现实中平均**远小于 1 字节**。
  - **值** —— 取决于数据性格：**稳定的 gauge 接近 1 bit**；**剧烈波动的值**（比如随机数）几乎压不动，接近 64 bit。

  **所以“1.3 字节”是个统计平均**，你的实际数字取决于：[抓取有多准时](https://daiw.org/manual/observability/scrape-loop)（影响时间戳）、[指标有多平稳](https://daiw.org/manual/observability/metric-types)（影响值）。

  **一个实用推论：容量规划时，用“每采样点 1.5~2 字节”估算比较稳妥**（留点余量）。
  ```
  存储 ≈ series 数 × (86400 / 抓取间隔秒数) × 天数 × 2 字节
  # 例:100 万 series,15s 间隔,保留 15 天
  #   = 1e6 × 5760 × 15 × 2 ≈ 173 GB
  ```
  **这个公式配合[第 2 部分的基数乘法公式](https://daiw.org/manual/observability/defining-metrics)，能让你在上线前就算出监控要花多少钱。**
</Callout>

## 小结与下一章

Gorilla 压缩把 16 字节的采样点压到平均 **~1.3 字节**（约 12 倍），靠两个先验：

- **时间戳用 [delta-of-delta](https://daiw.org/manual/observability/chunk-encoding)** —— [等间距抓取让 dod 恒为 0，而 `dod == 0` 只花 **1 个 bit**](https://daiw.org/manual/observability/chunk-encoding)（变长前缀编码，和霍夫曼同源：越常见编码越短）。**这里呼应了[抓取的哈希打散](https://daiw.org/manual/observability/scrape-loop)**：一个设计，两处收益（`rate()` 准 + 时间戳压缩率高 64 倍）；反过来**抖动会让时间戳段膨胀十几倍、整体存储约翻一倍**（[下面的演示可实测](https://daiw.org/manual/observability/chunk-encoding)）。
- **值用 [XOR](https://daiw.org/manual/observability/chunk-encoding)** —— [相邻浮点数高位相同，XOR 后只存 leading 零数 + 有效位；值不变时也只花 1 bit](https://daiw.org/manual/observability/chunk-encoding)（极常见）。

编码精确到位、不做字节对齐（`bstream`）——这是[**block 不可变**带来的红利](https://daiw.org/manual/observability/tsdb-overview)：**不可变 → 位级打包 → 极致压缩**。**chunk** 是压缩 / 存储 / 读取的最小单位（默认 120 点或 [chunkRange](https://daiw.org/manual/observability/head-and-wal) 先到为准），120 这个数是[**压缩率 vs 读放大**的权衡](https://daiw.org/manual/observability/chunk-encoding)。容量规划可用 **每点 1.5~2 字节**估算。

数据压好了，怎么**快速找到**要的那条 series？下一章看倒排索引。👉 [索引格式](https://daiw.org/manual/observability/index-format)。

## 参考资料

- [1] Prometheus 源码 `tsdb/chunkenc/xor.go`（`xorAppender:157`、dod 编码 `:184` 起）、`bstream.go`、`varbit.go`；`tsdb/docs/bstream.md`。
- [2] Facebook「Gorilla： A Fast， Scalable, In-Memory Time Series Database」（VLDB 2015）；dgryski/go-tsz（实现来源）。
- [3] 关联[时序三先验](https://daiw.org/manual/observability/tsdb-overview)、[抓取的哈希打散](https://daiw.org/manual/observability/scrape-loop)。
