---
name: parquet-01-understanding-columnar-storage
title: 深入理解 Parquet（一）：理解列式存储
date: 2026-01-28
---

本文是 Parquet 系列的第一篇，我们先不急着看 Parquet 本身，而是从更基础的问题出发：什么是列式存储？为什么在大数据分析场景下，列式存储比行式存储更有优势？理解了这些，后面学习 Parquet 的各种设计就会顺理成章。

## 从一个简单的问题开始

假设我们有一张用户表，存储了 1 亿条用户数据：

```sql
CREATE TABLE users (
    id          BIGINT,
    name        VARCHAR(100),
    age         INT,
    city        VARCHAR(50),
    salary      DECIMAL(10,2),
    create_time TIMESTAMP
);
```

现在有两种典型的查询场景：

**场景一：根据 ID 查询用户详情（OLTP）**

```sql
SELECT * FROM users WHERE id = 12345;
```

**场景二：统计各城市的平均薪资（OLAP）**

```sql
SELECT city, AVG(salary) FROM users GROUP BY city;
```

问题来了：这两种场景，对数据的存储方式有什么不同的要求？

## 行式存储：为 OLTP 而生

我们最熟悉的 MySQL、PostgreSQL 都是行式存储（Row-based Storage）。数据在磁盘上是按行连续存放的：

```
| id | name  | age | city     | salary  | create_time         |
|----|-------|-----|----------|---------|---------------------|
| 1  | 张三  | 28  | 北京     | 15000   | 2023-01-01 10:00:00 |
| 2  | 李四  | 35  | 上海     | 25000   | 2023-01-02 11:00:00 |
| 3  | 王五  | 42  | 北京     | 30000   | 2023-01-03 12:00:00 |
```

在磁盘上的物理布局是这样的（简化表示）：

```
[Row1: 1|张三|28|北京|15000|2023-01-01] [Row2: 2|李四|35|上海|25000|2023-01-02] [Row3: ...]
```

**对于场景一（按 ID 查询）**，这种存储方式非常高效：
1. 通过索引定位到 id=12345 的行
2. 一次磁盘 I/O 读取整行数据
3. 返回结果

这就是 OLTP 场景的特点：**读取少量行，但需要行的全部列**。

**但对于场景二（聚合分析）**，问题就来了：
1. 我们只需要 `city` 和 `salary` 两列
2. 却不得不把每一行的所有 6 列都读出来
3. 1 亿行数据，假设每行 200 字节，需要读取约 20GB 数据
4. 实际有用的数据可能只有 2GB（两列）

**这就是行式存储在分析场景下的核心问题：读放大（Read Amplification）**。

## 列式存储：换个思路

既然分析场景通常只需要少数几列，那我们把数据按列存储不就行了？

列式存储（Columnar Storage）的物理布局是这样的：

```
id 列:          [1, 2, 3, 4, 5, ...]
name 列:        [张三, 李四, 王五, ...]
age 列:         [28, 35, 42, ...]
city 列:        [北京, 上海, 北京, ...]
salary 列:      [15000, 25000, 30000, ...]
create_time 列: [2023-01-01, 2023-01-02, ...]
```

现在执行场景二的查询：
1. 只需要读取 `city` 列和 `salary` 列
2. 其他 4 列完全不用碰
3. I/O 量从 20GB 降到约 2GB

**这就是列式存储的第一个优势：列裁剪（Column Pruning）**。

你可能会问：如果查询带了过滤条件呢？

```sql
SELECT city, AVG(salary)
FROM users
WHERE age > 30 AND create_time > '2023-01-01'
GROUP BY city;
```

这时候需要读取 `city`、`salary`、`age`、`create_time` 共 4 列。

列裁剪的准确定义是：**只读取查询涉及的所有列**（包括 SELECT、WHERE、GROUP BY、ORDER BY 等子句中出现的列）。

即使如此，只要你的查询不是 `SELECT *`，列式存储通常都有优势。在真实的数据仓库中，表往往有几十甚至上百列，而单次查询通常只涉及 5-10 列，能节省大量的 IO 操作。

## 列式存储的压缩优势

列式存储还有一个杀手锏：**极高的压缩比**。

为什么？让我们看看 `city` 列的数据：

```
[北京, 上海, 北京, 广州, 北京, 上海, 北京, 深圳, 北京, 北京, ...]
```

你会发现，同一列的数据有两个重要特点：
1. **数据类型相同**：都是字符串
2. **数据值相似度高**：城市就那么几个，大量重复

这简直是为压缩算法量身定做的！

### 字典编码（Dictionary Encoding）

```
原始数据: [北京, 上海, 北京, 广州, 北京, 上海, ...]

字典:    {0: 北京, 1: 上海, 2: 广州, 3: 深圳}
编码后:  [0, 1, 0, 2, 0, 1, ...]
```

一个城市名从占用 6-12 字节变成只需要 2-3 bit！

### 游程编码（Run-Length Encoding, RLE）

如果数据是排序的，效果更惊人：

```
排序后:   [北京, 北京, 北京, 北京, 上海, 上海, 上海, 广州, 广州, ...]
RLE 编码: [(北京, 4), (上海, 3), (广州, 2), ...]
```

原来 9 个值，现在只需要存 3 对（值, 重复次数）。

可以看到，**数据越规整、重复度越高，压缩效果越好**。这也是为什么在数据仓库中，通常会对数据进行排序后再存储。

实际使用中，这些编码方式经常**组合使用**。比如先用 Dictionary 编码把字符串变成整数，再用 RLE 压缩重复的整数，最后套一层 Snappy 通用压缩。后面还会有专门一篇文章详细介绍各个编码技术。

## 列式存储的代价

任何架构选择都是 trade-off。列式存储的优势说了这么多，它有什么缺点呢？

### 1. 写入成本高

行式存储插入一行：直接追加到文件末尾，一次 I/O

但是，列式存储插入一行：需要同时更新 N 个列文件（N = 列数），N 次 I/O，很多时候 N 可能是比较大的

所以列式存储通常采用**批量写入**的方式，积攒一批数据后一次性写入。

### 2. 单点查询效率低

比如要根据 ID 查询一行完整数据：

行式存储：定位到行，一次读取，完事

列式存储：从 N 个列文件中各读取一个值，然后拼装成一行
### 3. 更新困难

行式存储的原地更新很简单，而列式存储的更新则非常麻烦。为什么？

**行式存储更新一行**：

假设要把 id=100 的用户薪资从 15000 改成 18000：
```
定位到该行 → 原地修改 salary 字段 → 完成
```
因为一行数据是连续存储的，字段位置固定，直接覆盖即可。（当然，我们没有考虑复杂的原来空间不够存储新值的情况，此时可能会发生行迁移、页分裂等，但是总体来说也不复杂）

**列式存储更新一行**：

同样的操作，列式存储面临的问题：

1. 数据分散：salary 列和其他列物理上不在一起，需要定位多个位置

2. 编码依赖：如果 salary 列使用了 Delta 编码（存储差值），改了一个值可能影响后续所有值

3. 压缩块问题：数据通常按块压缩存储，改一个值需要：解压整个块 → 修改 → 重新压缩 → 写回

所以很多列式存储（包括 Parquet）干脆设计成不可变的，根本不支持原地更新，而是采用以下策略处理更新：

- **Copy-On-Write**：写入新版本数据，标记旧数据删除
- **Merge-On-Read**：维护一个增量更新层，读取时合并
- **定期 Compaction**：后台定期合并，清理旧数据

## 行式存储 和 列式存储 简单对比

| 特征 | 行式存储 | 列式存储 |
|------|---------|---------|
| 典型场景 | OLTP（交易处理） | OLAP（分析处理） |
| 查询模式 | 少量行，全部列 | 大量行，少数列 |
| 写入模式 | 频繁小批量写入 | 低频大批量写入 |
| 代表产品 | MySQL, PostgreSQL | Parquet, ORC, ClickHouse |
| 压缩效果 | 一般（2-4倍） | 优秀（5-20倍） |

## 那 Parquet 做了什么？

理解了列式存储的基本原理，我们再来看 Parquet 就清晰多了。

Parquet 是一种**列式存储的文件格式**，它在列式存储的基础上做了很多精妙的设计：

1. **Row Group**：将数据水平切分，兼顾列式存储和数据局部性
2. **丰富的编码方式**：RLE、Dictionary、Delta、Bit Packing 等，针对不同数据特点选择最优编码
3. **嵌套数据支持**：通过 Repetition Level 和 Definition Level 支持复杂的嵌套结构
4. **丰富的元数据**：支持谓词下推、统计信息等高级优化

这些内容，我们在后续文章中会一一深入剖析。下一篇文章，我们会深入 [Parquet 的文件结构](/post/parquet-02-parquet-file-structure)，看看一个 Parquet 文件到底长什么样。
