同样的数据,按列存比按行存压缩得小得多。这是“高压缩比”最重要的来源之一:让相似的值挨在一起。这篇把行式和列式、排列顺序、每列专用编码、分块大小、读一列的代价逐个量出来。
布局 原始字节 gzip-6 lz4 zstd-3 zstd-19 行式 JSON Lines 7214161 1484671 2314121 1331194 1047098 列式(JSON 数组) 4994174 937295 1716426 889278 752806 列式 zstd-3 比行式小 33.2%;列式 gzip 比行式小 36.9%;列式 lz4 比行式小 25.8%
压缩器靠找“前面出现过的相同内容”来省字节。行式里相邻的是同一条记录的不同字段(id、url、标题、价格……),彼此毫无关系;列式把同一个字段的值放在一起,url 列只剩公共前缀和递增的数字,currency 列只有两种取值,压缩器找得到很长的匹配。实验里 url 列压到 94.67×,id 列 18.01×。
列 原始字节 zstd-3 压缩比 占行式压缩后总量 id 160001 8886 18.01× 0.7% url 1135001 11989 94.67× 0.9% title 1223717 413803 2.96× 31.1% price 118460 49891 2.37× 3.7% currency 120001 6301 19.04× 0.5% stock 75643 31332 2.41× 2.4% rating 80001 20284 3.94× 1.5% tags 812005 123723 6.56× 9.3% category 451749 35091 12.87× 2.6% updated_at 460001 100143 4.59× 7.5% seller_id 77314 33137 2.33× 2.5% seller_name 280281 45325 6.18× 3.4% 各列压缩后相加 879905 字节;整个列式拼起来一起压缩 889278;行式 1331194
title 是随机拼的词,压缩后仍要 413803 字节,占行式压缩后总量的 31.1%;price、stock、seller_id 这类没有规律的数字只有 2.3–2.4×。列式能帮上忙的是有规律的列,对本来就没规律的内容无能为力。
原顺序(按 id) 行式 zstd-3 1331194;列式 zstd-3 889278 按 category 排序 行式 zstd-3 1297197;列式 zstd-3 972869 按 seller.id 排序 行式 zstd-3 1328172;列式 zstd-3 972163 按 title 排序 行式 zstd-3 1339855;列式 zstd-3 991309 随机打乱 行式 zstd-3 1350892;列式 zstd-3 1000771
按某个字段排序,让这个字段的相似值靠在一起,行式有时能小一点(按 category 排序小 2.6%),但会打乱别的规律(列式反而大 9.4%)。排序要看哪一列是大头。
列(编码方式) 编码后字节 再 zstd-3 id(差分 + varint) 20002 21 url(去掉公共前缀,只存 id 以外的部分) 334999 159 title(换行分隔的文本) 1183715 409620 price(整数分 varint) 41136 39571 currency(字典 2 项) 20008 3634 category(字典 30 项) 20616 13170 seller_name(字典 795 项) 46155 33606 stock(varint) 34856 26795 rating(整数 ×10) 20000 13560 tags(字典 60 项) 60652 45164 updated_at(Unix 秒 varint) 100000 70849 seller_id(varint) 36840 29011 合计 编码后 1918979 字节,逐列再 zstd-3 共 685160 字节;对比:行式 JSON Lines zstd-3 1331194、Protobuf 拼接 zstd-3 约 1378051(第 6 篇)、列式 JSON zstd-3 889278
给每列选合适的表示再压缩:id 存差分(20002 字节,压缩后 21 字节)、低基数字符串用字典、价格存整数分、时间存整数。合计 685160 字节,比行式 JSON Lines 小 48.5%。这就是列存格式(Parquet、ORC)做的事情的简化版。
每块条数 行式总字节 列式总字节 列式比行式小
100 1746783 1548566 11.3%
1000 1421724 1147083 19.3%
5000 1344906 925128 31.2%
20000 1331194 889278 33.2%列式的优势依赖一块里有足够多的重复:每块 100 条时只小 11.3%,5000 条时小 31.2%。要随机访问、频繁追加,块就小,列存的优势会缩水。
行式:解压全部 + 逐行 json.loads 读入压缩数据 1331194 字节,中位 57.7 ms(56.3–59.0),和 1105543.79 列式:只解压 price 列 + json.loads 读入压缩数据 49891 字节,中位 1.3 ms(1.3–1.4),和 1105543.79
列式只读 price 一列,读入 49891 字节、1.3 ms;行式要解压全部 1331194 字节再逐行解析,57.7 ms。代价是写入和按行读取、更新都更麻烦,不适合每条记录频繁变化的场景。