3. 压缩原理:列式存储如何利用数据局部性实现高压缩比

聊到列式存储,很多人第一反应是「查询快」。但为什么快?除了只读需要的列,另一个核心武器就是——高压缩比。

我刚开始接触列存时,有个困惑:行存也能压缩啊,凭什么列存就能压得更狠?后来亲手调过几个生产环境的表,才真正明白。说白了,列存能把「相似的数据」凑到一起,而相似的数据,天生就好压。

这一章,我们就来拆解三种最经典的列存压缩算法:字典编码、RLE(游程编码)、差分编码。我会结合项目里的真实案例,告诉你什么时候用哪种,以及——哪些坑我替你踩过了。

核心观点:列式存储的高压缩比,本质上是「数据局部性」的胜利。同一列的数据类型相同、值域集中、分布规律明显,压缩算法才能大显身手。
列式存储三大压缩算法对比 原始列数据(如:城市名、连续数值、递增ID) 字典编码 适用:低基数枚举型 如:城市、性别、状态码 压缩比:5x ~ 20x 游程编码(RLE) 适用:连续重复值 如:日志级别、状态切换 压缩比:10x ~ 100x 差分编码 适用:有序递增数值 如:时间戳、自增ID 压缩比:2x ~ 50x 共同基础:列内数据局部性 → 值域窄 / 重复多 / 变化小 → 高压缩比 实际存储引擎常组合使用:字典 + RLE、差分 + 位打包等

3.1 字典编码:把长字符串变成小整数

字典编码的思路特别朴素。你想想看,一列「城市」数据,可能有几百万行,但实际出现的城市名就几十个。那我何必每行都存一遍「北京市海淀区」这么长的字符串?

做法很简单:建一个字典表,把每个唯一值映射成一个整数ID。数据文件里只存ID,查询时再通过字典还原。

原始数据(城市列):
北京, 上海, 北京, 广州, 上海, 北京, 深圳, 广州, 北京, 上海

字典映射:
北京 → 0
上海 → 1
广州 → 2
深圳 → 3

编码后存储:
0, 1, 0, 2, 1, 0, 3, 2, 0, 1

你看,原来每个「北京市」要占 6 个字节(UTF-8),现在只存一个整数,可能 1 个字节甚至更少。这就是压缩的来源。

我的经验:字典编码最适合「基数低」的列。什么叫基数低?就是唯一值数量远小于总行数。比如性别(2个)、省份(34个)、HTTP状态码(几十个)。如果一列几乎每行都是新值(比如UUID),字典编码反而会膨胀——字典本身也要存。

我在一个电商项目里遇到过这种情况:订单表的「用户备注」列,几乎每个订单都不一样。当时新同事用了字典编码,结果压缩后比不压还大。嗯,这就是典型的「选错算法」。

3.2 游程编码(RLE):抓住连续重复的「块」

RLE 的思路更直接:与其重复存同一个值,不如只存「值 + 重复次数」。

举个例子。假设你有一列「日志级别」,按时间排序后可能是这样:

INFO, INFO, INFO, ERROR, ERROR, INFO, INFO, INFO, INFO, WARN

用 RLE 压缩后:

(INFO, 3), (ERROR, 2), (INFO, 4), (WARN, 1)

原来 10 个条目,现在变成 4 个「值+计数」对。如果连续重复的块越长,压缩比就越惊人。

我曾经处理过一个物联网时序表,传感器状态列只有「正常/异常」两种值。因为传感器大部分时间都正常,连续几万行都是「正常」。RLE 一压,压缩比直接干到 200:1。原来 1GB 的数据,变成 5MB 不到。

注意:RLE 最怕「交替频繁」的数据。比如男女交替出现:男, 女, 男, 女, 男, 女... 这种情况下 RLE 不但不压缩,还会膨胀(每个值都要额外存计数)。所以 RLE 通常要求数据先按某列排序,让重复值聚在一起。

3.3 差分编码:只存「变化量」

差分编码的思路是:不存原始值,只存当前值和前一个值的差值。

这招对「有序递增」的数据特别管用。比如时间戳列:

原始时间戳(秒):
1700000000, 1700000001, 1700000003, 1700000006, 1700000010

差分编码后:
1700000000, 1, 2, 3, 4
// 第一个值保留原始值,后续只存差值

你看,原始时间戳是 10 位的大整数,占 4 字节。但差值只有 1、2、3、4,用 1 个字节甚至更少就能存下。如果差值更小,还能用位打包进一步压缩。

我记得有一次做用户行为分析,事件时间戳列每秒可能有几百条记录。差分之后,大部分差值都是 0 或 1。配合位打包,压缩比从 4x 直接提升到 30x。

关键点:差分编码要求数据有序。列式存储天然适合——因为写入时通常按时间排序,读出来就是有序的。如果数据乱序,差分编码的效果会大打折扣。

3.4 组合使用:1+1 > 2

实际生产环境中,很少只用一种压缩算法。存储引擎通常会组合使用:

组合方式 适用场景 典型压缩比
字典 + RLE 低基数枚举列,且按值排序后连续重复 10x ~ 50x
差分 + 位打包 有序数值列,差值范围小 5x ~ 30x
字典 + 差分 字典ID后,ID序列有局部递增性 8x ~ 40x
RLE + 差分 时序数据,值变化缓慢 20x ~ 100x

我在调优 Parquet 表时,最常用的套路是:先字典编码把字符串转整数,再对整数列做 RLE 或差分。两步下来,数据体积能缩到原来的 5% 以下。

3.5 避坑指南:我踩过的三个坑

讲了这么多好处,也该说说坑了。毕竟压缩不是免费的午餐。

坑一:压缩和解压的 CPU 开销

我曾经在一个高并发查询系统里,对所有列都用了最高压缩级别的字典+RLE。结果查询延迟反而变高了——因为每次读取都要解压,CPU 成了瓶颈。后来我调整策略:高频查询的列用轻量压缩(差分),低频扫描的列用重度压缩(字典+RLE)。

坑二:字典编码的内存占用

字典本身要加载到内存才能解码。如果一列的基数很高(比如几十万个唯一值),字典可能占几百 MB。我遇到过因为字典太大导致 OOM 的情况。解决方案是:对高基数列不做字典编码,改用其他算法。

坑三:RLE 的更新代价

RLE 压缩后的数据,如果中间某行要更新,可能要把整个 RLE 块解压、修改、再重新压缩。这在 OLTP 场景下是灾难。所以列式存储通常只用于 OLAP(分析型)场景,写入后很少单行更新。

我的建议:选压缩算法前,先分析列的数据特征。基数多少?重复率多高?是否有序?查询频率如何?这些信息决定了你应该用哪种算法。别盲目追求最高压缩比,平衡压缩率和查询性能才是王道。

3.6 小结

三种压缩算法,各有各的脾气:

  • 字典编码:专治「值多行少」的枚举列,把字符串变整数
  • RLE:专治「连续重复」的数据列,把重复值变计数
  • 差分编码:专治「有序递增」的数值列,把大数变小数

它们能发挥威力的前提,都是列式存储带来的数据局部性。同一列的数据类型一致、值域集中、分布规律明显——这才是压缩算法真正的用武之地。