类型与编解码器 — 把同样的值存得更小
一句话总结
在列式存储中,决定磁盘大小的旋钮有三个——排序键、类型、编解码器。类型决定压缩前的大小,编解码器则分两步(改写值的预处理 → 通用压缩)缩小这些字节。哪个会赢取决于数据,所以不要猜,要用同样的数据测量后再选。
为什么需要它
在第一个模块中我们看到,各列的压缩率差别很大。只有五个站点名称的列几乎是免费的,而不断增大的时间列用默认编解码器(LZ4)几乎压缩不下来。用本实验的数据测量,间隔 10 秒的一百万个时间值(4,000,000 字节)经过 LZ4 之后是 4,017,298 字节——反而略微变大。LZ4 是寻找“相同字节块是否再次出现”的算法,而不断增大的整数每次的字节都不一样,所以找不到可压缩的东西。
不过这一列有明显的结构。相邻值的差始终是 10。只要把这个结构改写成 LZ4 能认出来的形态(10、10、10 ……)就行了。官方文档(Compression in ClickHouse)把影响压缩的三个要素列为排序键、数据类型和编解码器,并说“都由 schema 决定”,原因就在这里。把 schema 选对一次,以后每天进来的数据就全都能小这么多。
工作原理
编解码器是一条链。 CODEC(Delta, ZSTD(1)) 从左到右依次应用。官方文档(CODEC)把编解码器分为两类。通用编解码器(LZ4、LZ4HC、ZSTD)在字节层面压缩,专用编解码器则利用数据的性质来改写值。
| 编解码器 | 做什么 | 适用的数据 |
|---|---|---|
| Delta | 改写成与相邻值的差 | 单调递增的整数、时间 |
| DoubleDelta | 把差的差压缩成紧凑的位 | 间隔恒定的时间序列 |
| T64 | 每 64 个一组,裁掉没有用到的高位 | 取值范围很窄的整数 |
| Gorilla | 与前一个浮点数做 XOR | 缓慢变化的 gauge 指标 |
Delta 和 DoubleDelta 在文档中被写作“data preparation codec”——单独使用什么也压缩不了。服务器也知道这一点。Float64 CODEC(Delta) 会因“does not compress anything”错误被拒绝,把顺序反过来写成 CODEC(ZSTD(1), Delta) 则会报“meaningless”错误。因为在通用压缩之后再做转换是没有意义的。
用本实验的数据测量同一个时间列,结果如下(压缩后,约):LZ4 4.0MB · ZSTD 2.9MB · Delta+ZSTD 5KB · DoubleDelta+ZSTD 5KB · 单独 DoubleDelta 128KB。预处理把结构暴露出来之后,通用编解码器几乎能把剩下的全部抹掉。文档建议的“默认用 ZSTD,整数、日期序列用 Delta”完全正确。
但是专用编解码器并不总是赢。 在同一个实验中,给 CPU 使用率(Float64)列加上 Gorilla,结果比只用 ZSTD 的列更大(3.0MB → 4.6MB)。精确到小数点后第二位的值,在二进制浮点数中有很多位不同,所以 XOR 并不会变小。反过来,范围 0–1999 的延迟时间(UInt32),T64 裁掉了没用的高位,比单独用 ZSTD 更小。编解码器的选择不是规则,而是测量。
类型先于编解码器。 只有四种取值的 status,如果用 String,压缩前是 10MB,压缩后约 1.07MB。LowCardinality(String) 把每个值在字典里只记一次,每行只存一个小编号,所以压缩前就缩小到 1MB,压缩后约 0.30MB。Enum8 每行也是 1 字节,差不多,但取值列表写死在表定义里,来了新值就需要 ALTER。文档(LowCardinality)建议对字符串先考虑 LowCardinality 而不是 Enum,并指出不同取值在 1 万个以下时效率好,超过 10 万个反而可能变差。
Nullable 并不是免费的。 Nullable(UInt16) 会在值文件旁边多放一个每行 1 字节的 null 映射(null map)。所以压缩前的大小不是每行 2 字节,而是 3 字节。WHERE err IS NULL 只读 null 映射(每行 1 字节),而 sum(err) 要把两个文件都读。含义也不同——avg() 计算平均值时会去掉 NULL,但会计入 0。把 NULL 填成 0 的 UInt16 列,大部分都是 0,所以这个服务器用稀疏序列化(sparse serialization,只记录不是默认值的行的方式)来存储,在 system.parts_columns 的 substreams 中能看到 err_zero.sparse.idx。
在现场相遇的样子
最常见的是“先用 String,先用 Nullable”搬过来的表。把源数据库的 schema 原样搬过来,所有列都成了 Nullable,状态、国家、套餐这样的列都成了 String。在本实验中,这样的表(plain)和精心选择类型、编解码器的表(tuned),压缩后大小约为 18.5MB 对 7.7MB。打磨 schema 要先于修改查询,而且一次就对整张表见效。
第二种是修改运行中的表的编解码器。ALTER TABLE ... MODIFY COLUMN ts CODEC(...) 只改元数据。已经写出的数据片段仍是旧的编解码器,新编解码器只会应用于新数据片段,以及通过合并重新写出的数据片段。实验中 ALTER 之后大小一个字节都没有变,用 OPTIMIZE ... FINAL 把数据片段重写之后才变小。相反,修改类型的 MODIFY COLUMN host LowCardinality(String) 是要重写数据片段的变形(mutation),在大表上很重(变形会在最后一个模块讲)。
第三种是自动选择。文档指出,打开表设置 enable_adaptive_codec_selection,对使用默认编解码器的列,合并时会为每个块选出最小的编解码器。在这个 Pod 的 26.8 服务器上,默认值是 0(关闭)。不管开不开,判断依据都一样——system.columns 的 data_compressed_bytes。
下一项实验要做什么
向默认类型、默认编解码器的 codecs.plain 写入 100 万行,并把数据片段固定为一个。把同一个 status 放进 String、LowCardinality、Enum8 三列,把同一个时间放进 LZ4、ZSTD、Delta、DoubleDelta 四列,把计数器、延迟时间、CPU 放进“只用 ZSTD”和“专用编解码器 + ZSTD”的配对里,比较压缩后的字节数,找出专用编解码器反而吃亏的列。用读取的字节数确认 Nullable 的 null 映射,再测量汇集了获胜选择的表的总大小,最后用 ALTER 修改在线表的编解码器,记录大小何时变化。