---
name: parquet-03-parquet-encoding
title: 深入理解 Parquet（三）：编码技术
date: 2026-01-28
---

上一篇我们了解了 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 的处理流程是：**先编码，再压缩**。

为什么要这么设计？

1. **编码利用数据特征**：字典编码利用低基数、Delta 编码利用递增特性，这些是通用压缩算法无法有效利用的
2. **压缩处理剩余冗余**：编码后的数据可能还有冗余，通用压缩算法可以进一步压缩
3. **计算友好**：某些场景下可以直接在编码数据上计算，不需要完全解码（后面会讲）

## 从一个真实场景开始

假设我们有一张订单表，存了 1000 万条数据：

```sql
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 中主要用于：

1. **RLE/Bit-Packing 混合编码**：非重复值使用 Bit-Packing
2. **Definition Level / Repetition Level**：表示嵌套结构（后面文章会讲）
3. **Delta 编码的差值**：差值通常很小，Bit-Packing 效果好

## PLAIN 编码

最后说一下最简单的编码：**PLAIN**（也就是不编码）。

```
原始数据: [3.14, 2.71, 1.41, ...]
PLAIN 编码: [3.14, 2.71, 1.41, ...]  // 直接存储原始二进制
```

什么时候用 PLAIN？

1. **数据没有规律**：随机的浮点数、UUID 等
2. **字典超限后的 Fallback**：distinct 值太多时退化为 PLAIN
3. **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）支持**在编码数据上直接计算**，避免完全解码的开销。

### 字典编码的谓词下推

```sql
SELECT * FROM orders WHERE city = '北京';
```

传统做法：
```
1. 读取 city 列的所有 Data Page
2. 解码每个值
3. 比较是否等于 '北京'
```

向量化优化：
```
1. 读取 Dictionary Page，找到 '北京' 的编码值 = 0
2. 在编码数据上直接过滤 value == 0
3. 完全不需要解码字符串！
```

字符串比较变成了整数比较，效率提升数倍。

### 聚合计算优化

```sql
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 | 无规律数据 | 无 |

核心要点：

1. **编码 ≠ 压缩**：编码改变表示方式，压缩减小体积，两者叠加使用
2. **不同列用不同编码**：Parquet 会根据数据特征自动选择
3. **排序很重要**：排序后的数据，RLE 和 Delta 效果大增
4. **字典有限制**：超过 1MB 会 fallback 到 PLAIN
5. **可以在编码数据上计算**：现代引擎支持向量化执行优化

编码解决了"怎么存"的问题，但还有一个问题：怎么描述复杂的数据结构？

下一篇我们来看 [Parquet 的 Schema 和类型系统](/post/parquet-04-parquet-schema)，特别是它如何优雅地处理**嵌套结构**（Nested Structure）——这是 Parquet 设计中最精妙的部分之一。
