上一篇我们了解了 Parquet 的文件结构,知道数据存储在 Column Chunk 和 Page 中。但 Page 里的数据是怎么存的?直接存原始值吗?当然不是。Parquet 使用了多种精妙的编码技术,让数据在保持可读的同时尽可能”瘦身”。这篇文章,我们就来深入这些编码技术。
编码 vs 压缩:先把概念分清楚
在开始之前,先澄清一个容易混淆的概念:编码(Encoding) 和 压缩(Compression) 是两回事。
编码:改变数据的表示方式,但数据本身是”明文”的,可以直接参与计算
原始数据: [北京, 上海, 北京, 北京, 广州]
字典编码: 字典 = {0:北京, 1:上海, 2:广州}
数据 = [0, 1, 0, 0, 2]
压缩:对数据进行压缩,必须解压后才能使用
编码后的数据: [0, 1, 0, 0, 2]
Snappy 压缩: 一串二进制 blob,人类不可读
Parquet 的处理流程是:先编码,再压缩。
为什么要这么设计?
- 编码利用数据特征:字典编码利用低基数、Delta 编码利用递增特性,这些是通用压缩算法无法有效利用的
- 压缩处理剩余冗余:编码后的数据可能还有冗余,通用压缩算法可以进一步压缩
- 计算友好:某些场景下可以直接在编码数据上计算,不需要完全解码(后面会讲)
从一个真实场景开始
假设我们有一张订单表,存了 1000 万条数据:
CREATE TABLE orders (
order_id BIGINT, -- 订单ID,从 1 递增
user_id BIGINT, -- 用户ID
amount DECIMAL(10,2), -- 金额
status VARCHAR(20), -- 状态:待支付/已支付/已发货/已完成/已取消
city VARCHAR(50), -- 城市
create_time TIMESTAMP -- 创建时间
);
不同列有不同的数据特征:
| 列 | 数据特征 | 值的示例 |
|---|---|---|
| order_id | 递增 | 1, 2, 3, 4, … |
| user_id | 随机整数 | 10086, 23333, 66666, … |
| amount | 随机小数 | 99.00, 1234.56, 8.88, … |
| status | 低基数(只有 5 种值) | 待支付, 已支付, 已发货, … |
| city | 中低基数(几百种值) | 北京, 上海, 广州, … |
| create_time | 递增/相近 | 2024-01-01 10:00:00, 10:00:01, … |
问题来了:用同一种方式存储这些列,合适吗?
显然不合适。status 只有 5 种值,存完整字符串太浪费;order_id 是递增的,存完整数字也太浪费。
这就是为什么 Parquet 支持多种编码方式,并且每列可以独立选择最适合的编码。
字典编码(Dictionary Encoding)
字典编码是 Parquet 中最常用的编码方式,特别适合低基数列(distinct 值较少的列)。
原理
原始 status 列数据:
[待支付, 已支付, 已发货, 待支付, 已完成, 已取消, 待支付, 已支付, ...]
建立字典:
{0: 待支付, 1: 已支付, 2: 已发货, 3: 已完成, 4: 已取消}
编码后的数据:
[0, 1, 2, 0, 3, 4, 0, 1, ...]
空间节省有多大?
原始数据:
- "待支付" 占 9 字节(UTF-8 中文 3 字节/字)
- 1000 万条数据,假设平均长度 8 字节
- 总大小:80 MB
字典编码后:
- 字典:5 个值,约 40 字节
- 数据:每个值只需要 3 bit(5 种值,2^3=8 足够)
- 总大小:1000 万 × 3 bit ÷ 8 ≈ 3.75 MB
压缩比:80 MB → 3.75 MB,约 21 倍!
字典编码在 Parquet 中的实现
在 Parquet 文件中,字典编码的列会有两种 Page:
Column Chunk (status):
+-------------------+------------------+------------------+
| Dictionary Page | Data Page 1 | Data Page 2 |
| {0:待支付,...} | [0,1,2,0,3,...]| [4,0,1,0,2,...]|
+-------------------+------------------+------------------+
Dictionary Page 存储字典映射关系,整个 Column Chunk 只有一个。
Data Page 存储编码后的整数索引。
字典编码的限制
字典编码不是万能的,有几个限制:
1. 字典大小限制
Parquet 的字典大小默认限制为 1MB。如果列的 distinct 值太多,字典会超限。
city 列:全国几百个城市,字典可能只有几 KB,OK
user_id 列:几百万个不同用户,字典会爆炸,不适合
2. 超限后的 Fallback
当字典超过大小限制时,Parquet 会怎么处理?
写入过程:
1. 开始写入,使用字典编码
2. 遇到新的 distinct 值,添加到字典
3. 字典超过 1MB 限制
4. 当前 Page 写完后,后续 Page 切换到其他编码(如 PLAIN)
这意味着同一个 Column Chunk 中,前面的 Data Page 可能是字典编码,后面的 Data Page 可能是 PLAIN 编码。
3. 不利于字典中不存在的谓词过滤
如果查询条件的值不在字典中,需要扫描所有数据才能确定没有匹配。某些查询引擎会对字典进行谓词过滤优化。
什么列适合字典编码?
| 适合 | 不适合 |
|---|---|
| 状态、枚举类型 | 主键、唯一标识 |
| 城市、国家、省份 | 时间戳(精确到毫秒) |
| 用户等级(VIP1-VIP6) | 金额(随机小数) |
| 性别、年龄段 | 日志内容、描述文本 |
经验法则:distinct 值不超过 10 万的列,字典编码通常很有效。
游程编码(Run-Length Encoding, RLE)
RLE 是另一种经典的编码方式,特别适合连续重复值。
原理
原始数据(已排序):
[北京, 北京, 北京, 北京, 上海, 上海, 上海, 广州, 广州, ...]
RLE 编码:
[(北京, 4), (上海, 3), (广州, 2), ...]
含义:北京连续出现 4 次,上海连续出现 3 次,广州连续出现 2 次
可以看到,RLE 的效果强烈依赖数据是否排序。
未排序的数据:
[北京, 上海, 北京, 广州, 北京, 上海, 北京, 深圳, 北京, 北京, ...]
RLE 编码(几乎没有压缩效果):
[(北京,1), (上海,1), (北京,1), (广州,1), (北京,1), (上海,1), ...]
Parquet 中的 RLE/Bit-Packing 混合编码
Parquet 并不单独使用 RLE,而是使用 RLE/Bit-Packing 混合编码。
为什么要混合?因为:
- 连续重复值多时,RLE 效果好
- 值交替变化时,RLE 反而有开销,Bit-Packing 更合适
Bit-Packing 是什么?就是用刚好够用的 bit 数来存储值。
假设字典有 5 个值(0-4),实际仅需要 3 bit 表示一个值。
原始数据(用 32 bit int 存储):
[0, 1, 2, 0, 3, 4, 0, 1]
每个值 32 bit,共 256 bit
Bit-Packing 后(用 3 bit 存储):
[000, 001, 010, 000, 011, 100, 000, 001]
每个值 3 bit,共 24 bit
压缩比:256 / 24 ≈ 10.7 倍
混合编码的工作方式
Parquet 会根据数据特点在 RLE 和 Bit-Packing 之间切换:
数据流:
[0,0,0,0,0,0,0,0, 1,2,0,3,1,2,0,1, 4,4,4,4,4,4,4,4]
└──────────────┘ └──────────────┘ └──────────────┘
8 个 0 交替变化 8 个 4
使用 RLE 使用 Bit-Packing 使用 RLE
编码结果:
RLE(0, 8) | BitPacked[1,2,0,3,1,2,0,1] | RLE(4, 8)
具体来说,Parquet 的 RLE/Bit-Packing 混合编码格式是:
run = 一段连续使用“同一种编码方式”的数据块
一个 run 包含 header + payload
每个 run 以一个 header 开始:
- 如果 header 的最低位是 0:表示这是 RLE 编码的 run
- 如果 header 的最低位是 1:表示这是 Bit-Packed 的 run
RLE Run:
[header (varint)] [value (bit-width bits)]
header = (count << 1) | 0
表示 payload 中的 value 会重复 count 次,这非常好理解
Bit-Packed Run:
[header (varint)] [values...]
header = (num_groups << 1) | 1
一个 group 是 8 个值
所以这里表示后面跟着 num_groups × 8 个 bit-packed 的值,每个值用 bit-width 位存储
与字典编码的配合
在 Parquet 中,字典编码和 RLE/Bit-Packing 经常组合使用:
原始数据 → 字典编码 → RLE/Bit-Packing 编码
↓
字符串变成整数 ID → 整数 ID 进一步压缩
例如:
原始 city 列: [北京, 北京, 北京, 上海, 上海, 广州, 广州, 广州, 广州]
Step 1 - 字典编码:
字典: {0: 北京, 1: 上海, 2: 广州}
数据: [0, 0, 0, 1, 1, 2, 2, 2, 2]
Step 2 - RLE 编码:
[(0, 3), (1, 2), (2, 4)]
最终存储:
- Dictionary Page: {0: 北京, 1: 上海, 2: 广州}
- Data Page: RLE encoded [(0,3), (1,2), (2,4)]
这就是为什么 Parquet 在低基数列上能达到惊人的压缩比。
Delta 编码(Delta Encoding)
Delta 编码特别适合递增或递减的数值列,比如自增 ID、时间戳。
原理
原始 order_id 列:
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Delta 编码:
第一个值: 1
后续存储差值: [1, 1, 1, 1, 1, 1, 1, 1, 1, ...]
原始数据需要存储: 1, 2, 3, 4, ... 10 (值越来越大)
Delta 后只需存储: 1, 1, 1, 1, ... 1 (全是 1)
差值全是 1,配合 Bit-Packing 或 RLE,压缩效果极佳:
差值 [1, 1, 1, 1, ...] 用 RLE:
(1, 10000000) — 1 重复 1000 万次
原本需要存 1000 万个 64-bit 整数 = 80 MB
现在只需要存一个 RLE pair = 几个字节!
时间戳的 Delta 编码
时间戳是 Delta 编码的最佳应用场景:
原始 create_time 列(毫秒时间戳):
[1704067200000, 1704067200100, 1704067200200, 1704067200350, ...]
(2024-01-01 00:00:00, 00:00:00.100, 00:00:00.200, 00:00:00.350)
Delta 编码:
第一个值: 1704067200000
差值: [100, 100, 150, ...]
时间戳的差值通常都很小(毫秒级),从 13 位数字变成 2-3 位数字,压缩效果显著。
Parquet 的 Delta 编码变种
Parquet 实际使用的是 DELTA_BINARY_PACKED 编码,比简单的 Delta 编码更复杂:
1. 数据按 block 分组(默认 128 个值一个 block)
2. 每个 block 内计算差值
3. 差值再用 Bit-Packing 压缩
4. 每个 block 记录最小差值(min_delta),实际存储 (delta - min_delta)
Block 结构:
[block_size] [num_mini_blocks] [total_value_count] [first_value]
[min_delta] [bit_widths...] [mini_block_1] [mini_block_2] ...
这种设计的好处:
- 差值减去最小差值后,数值更小,bit width 更低
- 分 block 处理,支持随机访问
- 即使差值不完全相同,也能有效压缩
什么列适合 Delta 编码?
适合:自增 ID,时间戳,序列号
不适合:随机 ID(UUID),金额(无规律),状态码(非递增),哈希值
关键特征:值与值之间有明确的递增/递减规律。
Bit-Packing 详解
前面提到了 Bit-Packing,这里单独详细解释一下。
核心思想
标准数据类型都是固定位宽的:
- INT32:32 bit
- INT64:64 bit
但实际数据往往不需要这么多位。比如年龄(0-150),用 8 bit 就够了,但存成 INT32 浪费了 24 bit。
Bit-Packing 的思想就是:根据实际数据范围,用刚好够用的位数存储。
计算 Bit Width
数据范围 需要的 bit 数 节省
0-1 1 bit 31/32 = 97%
0-3 2 bit 30/32 = 94%
0-7 3 bit 29/32 = 91%
0-15 4 bit 28/32 = 88%
0-255 8 bit 24/32 = 75%
0-65535 16 bit 16/32 = 50%
公式:bit_width = ceil(log2(max_value + 1))
实际存储布局
Bit-Packing 后的数据是紧密排列的:
假设 bit_width = 3,存储 8 个值 [5, 3, 7, 2, 1, 4, 6, 0]
二进制:
5=101, 3=011, 7=111, 2=010, 1=001, 4=100, 6=110, 0=000
紧密排列(3 bytes = 24 bits):
byte 0: [101][011][11] = 0xAF (10101111)
byte 1: [1][010][001][1] = 0x51 (01010001)
byte 2: [00][110][000] = 0x30 (00110000)
8 个 INT32 (32 bytes) → 3 bytes,压缩 10.7 倍
Parquet 中的应用
Bit-Packing 在 Parquet 中主要用于:
- RLE/Bit-Packing 混合编码:非重复值使用 Bit-Packing
- Definition Level / Repetition Level:表示嵌套结构(后面文章会讲)
- Delta 编码的差值:差值通常很小,Bit-Packing 效果好
PLAIN 编码
最后说一下最简单的编码:PLAIN(也就是不编码)。
原始数据: [3.14, 2.71, 1.41, ...]
PLAIN 编码: [3.14, 2.71, 1.41, ...] // 直接存储原始二进制
什么时候用 PLAIN?
- 数据没有规律:随机的浮点数、UUID 等
- 字典超限后的 Fallback:distinct 值太多时退化为 PLAIN
- Boolean 类型:只有 true/false,没必要复杂编码
Parquet 如何选择编码?
Parquet 会根据数据类型和写入时的统计信息自动选择编码:
| 数据类型 | 默认编码策略 |
|---|---|
| BOOLEAN | RLE |
| INT32 / INT64 | 尝试 DELTA,fallback 到 PLAIN |
| FLOAT / DOUBLE | PLAIN(浮点数通常无规律) |
| BINARY / STRING | 尝试 DICTIONARY,fallback 到 PLAIN |
| FIXED_LEN_BYTE_ARRAY | 尝试 DICTIONARY,fallback 到 PLAIN |
当然,很多框架实现,也允许我们手动指定编码,但不管怎么说,Parquet 文件内容肯定是自解释的,不管是用哪个语言哪个框架写的 Parquet 文件,在另一个语言或者另一个实现上,都是可以被完美还原的。
编码 + 压缩的叠加效果
我们前面说过,Parquet 是先编码,再压缩。那叠加效果如何?
原始 status 列: 100 MB
↓ 字典 + RLE 编码
编码后: 4 MB
↓ Snappy 压缩
最终: 1.5 MB
总压缩比: 100 / 1.5 ≈ 67:1
不同压缩算法的特点:
| 压缩算法 | 压缩比 | 压缩速度 | 解压速度 | 适用场景 |
|---|---|---|---|---|
| Snappy | 中等(2-4x) | 很快 | 很快 | 默认选择,性能敏感 |
| Zstd | 高(3-8x) | 中等 | 快 | 存储成本敏感 |
| Gzip | 高(3-6x) | 慢 | 中等 | 兼容性要求高 |
| LZ4 | 中等(2-3x) | 极快 | 极快 | 实时分析 |
| 不压缩 | 1x | - | - | CPU 敏感场景 |
通常推荐:
- 查询性能优先:Snappy 或 LZ4
- 存储成本优先:Zstd
向量化执行:编码数据的计算优化
现代查询引擎(如 ClickHouse、DuckDB、Spark 3.x)支持在编码数据上直接计算,避免完全解码的开销。
字典编码的谓词下推
SELECT * FROM orders WHERE city = '北京';
传统做法:
1. 读取 city 列的所有 Data Page
2. 解码每个值
3. 比较是否等于 '北京'
向量化优化:
1. 读取 Dictionary Page,找到 '北京' 的编码值 = 0
2. 在编码数据上直接过滤 value == 0
3. 完全不需要解码字符串!
字符串比较变成了整数比较,效率提升数倍。
聚合计算优化
SELECT city, COUNT(*) FROM orders GROUP BY city;
使用字典编码后:
原本:对字符串做 GROUP BY,需要字符串哈希
优化:直接对字典 ID 做 GROUP BY,整数哈希更快
最后:用字典把 ID 翻译回字符串
总结
这篇文章我们深入了解了 Parquet 的编码技术:
| 编码方式 | 适用场景 | 压缩效果 |
|---|---|---|
| Dictionary | 低基数列(状态、城市) | 极佳(10-50x) |
| RLE | 排序后的重复值 | 极佳(依赖排序) |
| Delta | 递增/递减数值(ID、时间戳) | 极佳(10-100x) |
| Bit-Packing | 值范围小的整数 | 良好(2-10x) |
| PLAIN | 无规律数据 | 无 |
核心要点:
- 编码 ≠ 压缩:编码改变表示方式,压缩减小体积,两者叠加使用
- 不同列用不同编码:Parquet 会根据数据特征自动选择
- 排序很重要:排序后的数据,RLE 和 Delta 效果大增
- 字典有限制:超过 1MB 会 fallback 到 PLAIN
- 可以在编码数据上计算:现代引擎支持向量化执行优化
编码解决了”怎么存”的问题,但还有一个问题:怎么描述复杂的数据结构?
下一篇我们来看 Parquet 的 Schema 和类型系统,特别是它如何优雅地处理嵌套结构(Nested Structure)——这是 Parquet 设计中最精妙的部分之一。
0 条评论