压缩策略调优:不同数据类型的压缩参数选择

压缩这事儿,说起来简单——不就是把数据变小嘛。但真到了生产环境,你会发现整型、浮点、字符串这三类数据的脾气完全不一样。我刚开始做时序存储的时候,一套压缩参数打天下,结果整型数据压缩比漂亮得很,浮点数据却膨胀得吓人。后来才明白,每种数据类型都得单独伺候。

整型数据的压缩: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 + 变长编码 块大小: 1024 浮点数据 推荐算法 Gorilla XOR 压缩 块大小: 480 或 960 字符串数据 推荐算法 字典压缩 + LZ4 字典大小: 65536 参数调优建议 • 整型:波动小用 RLE,波动大用 Delta-of-Delta • 浮点:先看精度要求,允许降精度时用缩放转整型 • 字符串:先做字典去重,再做通用压缩 • 混合类型:按列分开压缩,不要混在一起

混合类型的处理策略

实际场景中,时序数据往往是混合的。一条数据里既有整型(时间戳)、浮点(指标值)、字符串(标签)。怎么办?

我建议 按列分开压缩。每一列独立选择压缩算法和参数。这样做的好处是:

  • 整型列用 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 开销和查询性能也得权衡。