列式存储的数据结构:Page、Row Group、Column Chunk 详解
说实话,很多搞大数据的人用了好几年 Parquet,问他数据到底怎么存的,支支吾吾说不清楚。我面试过不少候选人,能把「列式存储比行式快」挂在嘴边,但一问到 Page 和 Row Group 的区别,就卡住了。
这一节,咱们就把这三个核心概念掰开揉碎。你搞懂了它们,后面调优才有底气。
从文件到数据:三层结构概览
列式存储文件不是一坨二进制乱码。它像一栋楼,分三层:Row Group 是楼层,Column Chunk 是每层的房间,Page 是房间里的柜子。
核心关系:
- 一个文件 = 多个 Row Group(水平切分)
- 一个 Row Group = 多个 Column Chunk(垂直切分)
- 一个 Column Chunk = 多个 Page(数据存储单元)
我刚开始接触 Parquet 源码时,最困惑的就是:为什么非要套三层?直接存列数据不行吗?后来踩了坑才明白——每一层解决一个特定问题。
Row Group:数据的水平切片
Row Group 是什么?说白了,就是把一个大表切成若干块,每块包含固定行数的所有列数据。比如一张表有 1 亿行,你设 Row Group 大小为 100 万行,那就切成 100 个 Row Group。
我见过有人把 Row Group 设得特别大,一个 Group 塞 500 万行。结果查询只扫几行数据,却要加载整个 Group,I/O 浪费严重。反过来,设太小也不行——元数据膨胀,小文件太多。
我的经验值:
OLAP 场景下,Row Group 大小建议 64K ~ 256K 行。太小了压缩率上不去,太大了随机查询吃亏。你可以在写入时通过参数调整,比如 Parquet 的 parquet.block.size。
Column Chunk:列数据的垂直切分
每个 Row Group 内部,按列切分成 Column Chunk。举个例子:Row Group 0 里有 10 万行数据,包含 id、name、age 三列,那就生成三个 Column Chunk——id 的 10 万行、name 的 10 万行、age 的 10 万行。
Column Chunk 是列式存储的精髓。查询时只读取需要的列,其他列直接跳过。比如你只查 age 的平均值,那就只加载 age 对应的 Column Chunk,id 和 name 碰都不碰。
| 特性 | Row Group | Column Chunk |
|---|---|---|
| 切分方向 | 水平(按行) | 垂直(按列) |
| 包含数据 | 所有列的部分行 | 单列的部分行 |
| 主要作用 | 控制并行度、I/O 粒度 | 实现列裁剪、减少读取量 |
| 元数据存储 | 行数、偏移量 | 统计信息(min/max/null count) |
Page:真正的数据存储单元
Column Chunk 再往下拆,就是 Page。Page 是列式存储里最底层的存储单元,也是压缩和编码的直接对象。
Parquet 里 Page 分三种类型:
- Data Page:存实际数据值。比如 age 列的一堆整数。
- Dictionary Page:存字典映射。如果某列基数低(比如性别只有男/女),用字典编码能省大量空间。
- Index Page:存索引信息,用于快速定位数据位置。
我记得有一次调优,发现某个表存储膨胀了 3 倍。查了半天,原来是 Page 大小设得太小,导致压缩率上不去。后来把 Page 从 8KB 调到 64KB,存储直接降了 40%。
避坑指南:
我曾经遇到过一个生产事故——Page 大小设了 1MB,结果查询小范围数据时,每次都要读一个 1MB 的 Page,I/O 延迟飙升。后来改成 128KB,问题解决。Page 不是越大越好,也不是越小越好,要根据查询模式来调。
Page 内部的编码与压缩
Page 里存的数据不是裸值。为了省空间、提速度,会先编码再压缩。常见的编码方式有:
- Plain 编码:不编码,直接存原始值。简单但空间利用率低。
- 字典编码:建一个字典,数据存字典索引。适合低基数列。
- RLE(游程编码):连续重复值只存一次加计数。适合排序后的列。
- Delta 编码:存相邻值的差值。适合递增序列,比如时间戳。
编码之后,再套一层压缩算法(Snappy、Zstd、Gzip 等)。我个人习惯是:编码优先,压缩兜底。编码能直接减少数据量,压缩是锦上添花。
一个典型 Page 的存储流程:
- 原始数据进入 Page
- 选择编码方式(字典 / RLE / Delta)
- 编码后的数据压缩(Snappy / Zstd)
- 写入文件,记录元数据(偏移量、大小、编码类型)
三层结构如何协同工作
你想想看,一次查询请求进来,系统是怎么利用这三层结构的?
- 文件级别:读取文件尾部的元数据,拿到所有 Row Group 的列表和统计信息。
- Row Group 级别:根据查询条件(比如 WHERE age > 30),利用 min/max 统计信息,跳过不满足条件的 Row Group。
- Column Chunk 级别:只加载查询涉及的列对应的 Chunk,其他列直接忽略。
- Page 级别:在 Column Chunk 内部,只读取包含目标数据的 Page,跳过无关 Page。
这一套组合拳打下来,可能 1TB 的数据,实际只读了几 MB。这就是列式存储快的原因——不是算得快,而是读得少。
性能调优的关键参数
搞清楚了三层结构,调优就有方向了。下面几个参数是我在实际项目中反复调过的:
| 参数 | 影响层级 | 建议值 | 调优方向 |
|---|---|---|---|
| Row Group 大小 | Row Group | 64K ~ 256K 行 | 大则压缩好,小则随机查询快 |
| Page 大小 | Page | 8KB ~ 128KB | 大则压缩率高,小则 I/O 粒度细 |
| 编码方式 | Page | 字典 / RLE / Delta | 根据列数据特征选择 |
| 压缩算法 | Page | Snappy / Zstd | Snappy 快,Zstd 压缩率高 |
一个小技巧:
如果你不确定怎么调,先跑一次全量数据,看看每个 Column Chunk 的统计信息。如果某个列的 min/max 范围特别大,说明数据分布散,可以考虑增大 Page 或换编码方式。我一般用 parquet-tools 或者 Spark 的 df.printSchema() 来观察。
嗯,三层结构就讲到这里。你记住一句话:Row Group 管行数,Column Chunk 管列裁剪,Page 管编码压缩。搞明白这个,后面讲谓词下推、延迟物化的时候,你就知道它们是在哪一层干活了。