压缩策略调优:不同数据类型的压缩参数选择
压缩这事儿,说起来简单——不就是把数据变小嘛。但真到了生产环境,你会发现整型、浮点、字符串这三类数据的脾气完全不一样。我刚开始做时序存储的时候,一套压缩参数打天下,结果整型数据压缩比漂亮得很,浮点数据却膨胀得吓人。后来才明白,每种数据类型都得单独伺候。
整型数据的压缩:Delta-of-Delta 才是王道
时序数据里,整型最常见的就是计数器、状态码、时间戳。这类数据有个特点:相邻值变化很小。你想想看,CPU使用率从45%跳到46%,差值只有1。这就是整型压缩的突破口。
我个人习惯用 Delta-of-Delta + 变长编码 的组合。说白了,就是先算差值,再算差值的差值。为什么这么做?因为时序数据的时间戳通常是均匀的,第一次差值可能是个固定值,第二次差值就变成0了。
// 伪代码:Delta-of-Delta 编码
func compressInt64(values []int64) []byte {
var result []byte
prev := int64(0)
prevDelta := int64(0)
for _, v := range values {
delta := v - prev
deltaOfDelta := delta - prevDelta
// 根据 deltaOfDelta 的值选择编码方式
if deltaOfDelta == 0 {
// 只写一个控制位 0
result = append(result, 0b0)
} else if deltaOfDelta >= -63 && deltaOfDelta <= 64 {
// 控制位 10 + 7位有符号整数
result = append(result, 0b10, byte(deltaOfDelta))
} else {
// 控制位 11 + 完整64位
result = append(result, 0b11)
result = append(result, int64ToBytes(deltaOfDelta)...)
}
prev = v
prevDelta = delta
}
return result
}
关键参数调优:
- 块大小(Block Size):我建议整型数据用 1024 个点为一个块。太小了压缩率上不去,太大了内存占用高。我在项目中试过 512 和 2048,1024 是性价比最高的。
- 编码阈值:Delta-of-Delta 的阈值设置很关键。上面代码里我用了 7 位和 64 位两档,实际生产中可以加一档 16 位或 32 位,看你的数据波动范围。
- 游程编码(RLE)开关:如果连续多个值相同(比如状态码),RLE 能压到极致。我建议当连续相同值超过 4 个时启用 RLE。
浮点数据的压缩:XOR 才是真功夫
浮点数据是压缩的噩梦。为什么?因为 3.14 和 3.15 在 IEEE 754 标准下,二进制表示可能天差地别。你算差值?没用,浮点数的差值本身也是浮点,精度问题一堆。
我踩过这个坑。有一次监控系统的 CPU 指标,整型压缩比能做到 10:1,浮点数据连 2:1 都到不了。后来换了 Facebook 的 Gorilla 压缩算法,才把浮点压缩比拉到 5:1 以上。
Gorilla 的核心思路是 XOR 相邻值。浮点数虽然数值变化小,但二进制表示变化也小?不一定。但 XOR 之后,前导0和后导0的数量会很多,这就有了压缩空间。
// 伪代码:Gorilla 浮点压缩
func compressFloat64(values []float64) []byte {
var result []byte
prev := float64(0)
prevLeading := uint8(0)
prevTrailing := uint8(0)
for _, v := range values {
bits := math.Float64bits(v)
prevBits := math.Float64bits(prev)
xor := bits ^ prevBits
if xor == 0 {
// 值没变,写一个 0 位
result = append(result, 0b0)
} else {
// 值变了,写控制位 1
leading := uint8(bits.LeadingZeros64())
trailing := uint8(bits.TrailingZeros64())
if leading >= prevLeading && trailing >= prevTrailing {
// 前导0和后导0范围没变,只存中间的 XOR 值
result = append(result, 0b10)
// 存 (64 - prevLeading - prevTrailing) 位
meaningfulBits := xor >> prevTrailing
result = append(result, encodeBits(meaningfulBits, 64-prevLeading-prevTrailing)...)
} else {
// 范围变了,存完整的前导0、后导0和 XOR 值
result = append(result, 0b11)
result = append(result, leading, trailing)
meaningfulBits := xor >> trailing
result = append(result, encodeBits(meaningfulBits, 64-leading-trailing)...)
prevLeading = leading
prevTrailing = trailing
}
}
prev = v
}
return result
}
我的经验:浮点压缩的块大小建议用 480 或 960。为什么是这两个数?因为 Gorilla 算法在块内维护前导0和后导0的上下文,块太大上下文会失效,块太小又浪费头部开销。480 是我在多个项目里试出来的甜点值。
字符串数据的压缩:字典压缩 + LZ4
字符串在时序数据里通常是什么?主机名、标签键值对、日志级别。这类数据重复率极高。你想想,100 万条数据里,主机名可能只有 100 个不同的值。
我建议用 两阶段压缩:先做字典压缩,再做 LZ4 或 Snappy。字典压缩把重复字符串映射成整数 ID,LZ4 再对 ID 序列做二次压缩。
| 压缩阶段 | 方法 | 压缩比(典型值) | CPU 开销 |
|---|---|---|---|
| 第一阶段 | 字典映射(字符串→整数ID) | 5:1 ~ 20:1 | 低 |
| 第二阶段 | LZ4 快速压缩 | 1.5:1 ~ 3:1 | 极低 |
| 整体 | 字典 + LZ4 | 8:1 ~ 60:1 | 低 |
字典大小怎么设?我一般用 65536 条。为什么是这个数?因为 16 位整数刚好能表示,而且大多数场景下标签值的种类不会超过这个数。如果超过,可以用 LRU 淘汰策略。
我曾经踩过的坑:字典压缩有个大坑——字典膨胀。如果字符串种类太多(比如每条数据都有唯一 UUID),字典本身会比数据还大。解决办法是设置字典大小上限,超过上限就清空重建。我一般设 10 万条为上限,或者每 1 小时重建一次字典。
参数选择决策树
说了这么多,到底怎么选?我画了一张决策图,帮你快速定位。
混合类型的处理策略
实际场景中,时序数据往往是混合的。一条数据里既有整型(时间戳)、浮点(指标值)、字符串(标签)。怎么办?
我建议 按列分开压缩。每一列独立选择压缩算法和参数。这样做的好处是:
- 整型列用 Delta-of-Delta,压缩比高
- 浮点列用 Gorilla,不拖累其他列
- 字符串列用字典压缩,去重效果好
嗯,这里要注意:按列压缩会增加元数据开销。每一列都要存压缩类型、块大小、字典信息。我一般把元数据放在块头部,每块额外开销控制在 64 字节以内。
实战参数速查表:
| 数据类型 | 压缩算法 | 块大小 | 字典大小 | 典型压缩比 |
|---|---|---|---|---|
| 整型(计数器) | Delta-of-Delta + 变长编码 | 1024 | — | 8:1 ~ 15:1 |
| 整型(状态码) | RLE + 变长编码 | 1024 | — | 20:1 ~ 50:1 |
| 浮点(普通) | Gorilla XOR | 480 | — | 4:1 ~ 8:1 |
| 浮点(低精度) | 缩放转整型 + Delta | 1024 | — | 6:1 ~ 12:1 |
| 字符串(标签) | 字典 + LZ4 | 4096 | 65536 | 10:1 ~ 60:1 |
| 字符串(日志) | 字典 + Snappy | 8192 | 65536 | 5:1 ~ 20:1 |
最后说一句,压缩参数不是设一次就完事了。我建议上线后跑一周数据,看看实际压缩比和 CPU 开销。如果压缩比低于预期,先检查数据分布,再调参数。别一上来就追求极致压缩比,CPU 开销和查询性能也得权衡。