bigywater.tech
数据编码与压缩 · 8

行式与列式布局:同样的数据换个排列为什么压缩得更小

同样的数据,按列存比按行存压缩得小得多。这是“高压缩比”最重要的来源之一:让相似的值挨在一起。这篇把行式和列式、排列顺序、每列专用编码、分块大小、读一列的代价逐个量出来。

33.2%
2 万条 products 列式比行式(JSON Lines)zstd-3 压缩后小的比例
48.5%
再给每列配专用编码(字典、差分、整数分)后比行式小的比例(685160 对 1331194 字节)
1.3 对 57.7 ms
只读 price 一列:列式只解压一列 与 行式解压全部再逐行解析

为什么列式压得更小

实测 · 本机执行输出
  布局                          原始字节    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。代价是写入和按行读取、更新都更麻烦,不适合每条记录频繁变化的场景。

分步演示

行式与列式
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8(系统 Python,自带 compression.zstd)/ zstandard 0.25.0 / lz4 4.4.5 / zstd、lz4、brotli、protoc 命令行(Homebrew);msgpack、protobuf、snappy 等第三方库临时装在临时目录里,用完删除;所有数据都是本机脚本生成的合成数据(固定随机种子),没有使用任何真实网站或用户的数据;计时用 time.perf_counter,取多次中位数,数字每次运行会有小幅抖动。数据集见第 1 篇;列存的专用编码是我手写的简化版(字典、差分 varint、整数分、Unix 秒),不是 Parquet 的实现,没有页、统计信息和嵌套支持;第六节是 7 次中位数,其余是确定性字节数
    • 没有验证:真实的 Parquet / ORC / Arrow 文件的体积和读取速度;不同 zstd 级别下列式的优势变化(只看了 3 和 19 的整体);嵌套和可空字段的列存表示;更新和删除的代价;列式在 sqlite 或 Redis 里的使用方式
    京ICP备2026067502号-1京公网安备11010502063487号