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

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

作者 David更新于 第 21 篇(共 52 篇)

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

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

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

核心洞见:存差值的差值

回想时序数据的第一个先验:抓取是等间隔的。于是时间戳序列长这样:

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

存一串 0 几乎不要钱。 这就是 delta-of-delta(dod)编码:不存时间戳本身,不存差值,而是存“差值的变化量”——而这个量在理想情况下恒为 0。

真实实现:dod == 0 只花 1 个 bit

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

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
// ... 更大的范围用更长的前缀
}

这是一个变长前缀编码**(和霍夫曼编码同一个思想):越常见的情况,编码越短。**

dod 的范围前缀总位数什么时候
dod == 001 bit抓取准时(绝大多数)
能用 14 位表示1016 bit有点抖动
能用 17 位表示11020 bit抖动较大
更大1110…更长罕见(重启、乱序)

理想情况下,一个时间戳只花 1 个 bit —— 相比原始的 64 bit,压缩率 64 倍。而这正是常态,因为抓取循环用哈希打散保证了它非常准时。

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

看它跑起来

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

动画加载中…

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

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

结论仍然成立(抖动确实要花钱),只是量级要诚实:时间戳段十几倍,整体约 2 倍。 具体倍数取决于你的值有多能压——值越平稳(越接近 1 bit),时间戳的膨胀占比就越大、总代价倍数也越高。

源码里还留着一条有意思的注释(xor.go:189):

// TODO(beorn7): This seems to needlessly jump to large bit sizes...

连维护者自己都觉得这个分档还能再优化。 这提醒我们:读源码时看到 TODO 别忽略,它们往往标记着“已知的、但还没人来得及做的改进”——这是给贡献者的路标。

值:XOR 编码

时间戳解决了,值怎么办?靠第二个先验:相邻值极其相似。

把相邻两个 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):

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

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

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

位流:连字节对齐都不要

这套编码里,一个值可能占 1 bit、3 bit、17 bit……根本不对齐字节。这就需要 bstream.go(bit stream,位流):

bstream 提供“往流里写 N 个 bit / 读 N 个 bit”的能力,让编码可以精确到位**,不浪费任何一个 bit 去做对齐。**

这是block 不可变带来的红利之一:因为数据永不修改,就可以紧密打包到位级。可变的结构做不到——你没法在一串位里“原地改”一个值。不可变 → 位级打包 → 极致压缩,这条因果链是 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 没有从零发明这个,而是站在 Gorilla 论文和一个已有 Go 实现的肩膀上。好的工程是知道什么时候该借鉴。

chunk:压缩的单位

这些位流被组织进 chunk:

  • 一个 chunk 属于一条 series,装它在一段时间内的采样点。
  • 默认最多 120 个采样点(或达到 chunkRange 时长)就切一个新的。
  • chunk 是压缩、存储、读取的最小单位——查询时哪怕只要一个点,也得解压整个 chunk。

为什么是 120 个点?这是个压缩率 vs 读放大的权衡:

120 个点 × 15 秒 = 30 分钟——这是个经验平衡点。注意它和 chunkRange(2 小时)是两个不同的阈值:先到哪个算哪个。

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

压缩率到底多少

Gorilla 论文和 Prometheus 的实践都指向同一个数字:平均每个采样点约 1.3 字节(相比原始 16 字节,约 12 倍压缩)。

拆开看:

  • 时间戳 —— 理想情况 1 bit(dod=0),现实中平均远小于 1 字节。
  • 值 —— 取决于数据性格:稳定的 gauge 接近 1 bit;剧烈波动的值(比如随机数)几乎压不动,接近 64 bit。

所以“1.3 字节”是个统计平均,你的实际数字取决于:抓取有多准时(影响时间戳)、指标有多平稳(影响值)。

一个实用推论:容量规划时,用“每采样点 1.5~2 字节”估算比较稳妥(留点余量)。

存储 ≈ series 数 × (86400 / 抓取间隔秒数) × 天数 × 2 字节
# 例:100 万 series,15s 间隔,保留 15 天
#   = 1e6 × 5760 × 15 × 2 ≈ 173 GB

这个公式配合第 2 部分的基数乘法公式,能让你在上线前就算出监控要花多少钱。

小结与下一章

Gorilla 压缩把 16 字节的采样点压到平均 ~1.3 字节(约 12 倍),靠两个先验:

编码精确到位、不做字节对齐(bstream)——这是block 不可变带来的红利:不可变 → 位级打包 → 极致压缩。chunk 是压缩 / 存储 / 读取的最小单位(默认 120 点或 chunkRange 先到为准),120 这个数是压缩率 vs 读放大的权衡。容量规划可用 每点 1.5~2 字节估算。

数据压好了,怎么快速找到要的那条 series?下一章看倒排索引。👉 索引格式。

参考资料

  • [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] 关联时序三先验、抓取的哈希打散。

本页目录