chunk 编码 · Gorilla 压缩的位级魔法
16 字节怎么压成 1~2 字节?这一章读 chunkenc/xor.go 的真实位级实现——delta-of-delta 编码时间戳(等间距时只花 1 个 bit!)、XOR 编码浮点值,以及这套算法为什么如此贴合时序数据的性格。
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 == 0 | 0 | 1 bit | 抓取准时(绝大多数) |
| 能用 14 位表示 | 10 | 16 bit | 有点抖动 |
| 能用 17 位表示 | 110 | 20 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 读放大的权衡:
- chunk 越大 → 压缩率越好(前面的点为后面的点提供“上下文”,摊薄了头部开销),但读一个点要解压更多东西(读放大)。
- chunk 越小 → 读得快,但压缩率差、chunk 数量多(索引里的引用也变多)。
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 倍),靠两个先验:
- 时间戳用 delta-of-delta —— 等间距抓取让 dod 恒为 0,而
dod == 0只花 1 个 bit(变长前缀编码,和霍夫曼同源:越常见编码越短)。这里呼应了抓取的哈希打散:一个设计,两处收益(rate()准 + 时间戳压缩率高 64 倍);反过来抖动会让时间戳段膨胀十几倍、整体存储约翻一倍(下面的演示可实测)。 - 值用 XOR —— 相邻浮点数高位相同,XOR 后只存 leading 零数 + 有效位;值不变时也只花 1 bit(极常见)。
编码精确到位、不做字节对齐(bstream)——这是block 不可变带来的红利:不可变 → 位级打包 → 极致压缩。chunk 是压缩 / 存储 / 读取的最小单位(默认 120 点或 chunkRange 先到为准),120 这个数是压缩率 vs 读放大的权衡。容量规划可用 每点 1.5~2 字节估算。
数据压好了,怎么快速找到要的那条 series?下一章看倒排索引。👉 索引格式。