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

从一个简单的问题开始

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

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

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

场景一:根据 ID 查询用户详情(OLTP)

SELECT * FROM users WHERE id = 12345;

场景二:统计各城市的平均薪资(OLAP)

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. 我们只需要 citysalary 两列
  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)

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

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

这时候需要读取 citysalaryagecreate_time 共 4 列。

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

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

场景表列数查询涉及列行式读取列式读取I/O 节省
用户画像分析50 列8 列100%16%84%
行为日志查询30 列5 列100%17%83%

列式存储的压缩优势

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

为什么?让我们看看 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 对(值, 重复次数)。

实际压缩效果

我们用一组真实数据来感受一下:

数据类型原始大小压缩后大小压缩比适用编码
时间戳列(递增)800MB50MB16:1Delta Encoding
城市列(低基数)400MB15MB27:1Dictionary + RLE
ID 列(唯一值)800MB400MB2:1Delta + Bit Packing
金额列(随机)800MB300MB2.7:1通用压缩(Snappy/Zstd)

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

不同数据类型的压缩策略

不同特征的数据适合不同的编码方式,先简单了解一下,后续会展开介绍这些编码技术的细节:

数据特征推荐编码原理
低基数字符串(城市、状态)Dictionary Encoding建立字典,用整数 ID 代替原始值
排序后的重复值RLE(游程编码)存储(值, 重复次数)对
递增/递减的数值(时间戳、自增ID)Delta Encoding只存储与前一个值的差值
小范围整数Bit Packing用刚好够用的 bit 数存储,而非固定 32/64 bit
无明显规律的数据通用压缩(Snappy/Zstd/Gzip)基于 LZ77 等算法的通用压缩

实际使用中,这些编码方式经常组合使用。例如:先用 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, PostgreSQLParquet, 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 的文件结构,看看一个 Parquet 文件到底长什么样。