本文是 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 查询),这种存储方式非常高效:
- 通过索引定位到 id=12345 的行
- 一次磁盘 I/O 读取整行数据
- 返回结果
这就是 OLTP 场景的特点:读取少量行,但需要行的全部列。
但对于场景二(聚合分析),问题就来了:
- 我们只需要
city和salary两列 - 却不得不把每一行的所有 6 列都读出来
- 1 亿行数据,假设每行 200 字节,需要读取约 20GB 数据
- 实际有用的数据可能只有 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, ...]
现在执行场景二的查询:
- 只需要读取
city列和salary列 - 其他 4 列完全不用碰
- I/O 量从 20GB 降到约 2GB
这就是列式存储的第一个优势:列裁剪(Column Pruning)。
你可能会问:如果查询带了过滤条件呢?
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 列:
| 场景 | 表列数 | 查询涉及列 | 行式读取 | 列式读取 | I/O 节省 |
|---|---|---|---|---|---|
| 用户画像分析 | 50 列 | 8 列 | 100% | 16% | 84% |
| 行为日志查询 | 30 列 | 5 列 | 100% | 17% | 83% |
列式存储的压缩优势
列式存储还有一个杀手锏:极高的压缩比。
为什么?让我们看看 city 列的数据:
[北京, 上海, 北京, 广州, 北京, 上海, 北京, 深圳, 北京, 北京, ...]
你会发现,同一列的数据有两个重要特点:
- 数据类型相同:都是字符串
- 数据值相似度高:城市就那么几个,大量重复
这简直是为压缩算法量身定做的!
字典编码(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 对(值, 重复次数)。
实际压缩效果
我们用一组真实数据来感受一下:
| 数据类型 | 原始大小 | 压缩后大小 | 压缩比 | 适用编码 |
|---|---|---|---|---|
| 时间戳列(递增) | 800MB | 50MB | 16:1 | Delta Encoding |
| 城市列(低基数) | 400MB | 15MB | 27:1 | Dictionary + RLE |
| ID 列(唯一值) | 800MB | 400MB | 2:1 | Delta + Bit Packing |
| 金额列(随机) | 800MB | 300MB | 2.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 字段 → 完成
因为一行数据是连续存储的,字段位置固定,直接覆盖即可。(当然,我们没有考虑复杂的原来空间不够存储新值的情况,此时可能会发生行迁移、页分裂等,但是总体来说也不复杂)
列式存储更新一行:
同样的操作,列式存储面临的问题:
-
数据分散:salary 列和其他列物理上不在一起,需要定位多个位置
-
编码依赖:如果 salary 列使用了 Delta 编码(存储差值),改了一个值可能影响后续所有值
-
压缩块问题:数据通常按块压缩存储,改一个值需要:解压整个块 → 修改 → 重新压缩 → 写回
所以很多列式存储(包括 Parquet)干脆设计成不可变的,根本不支持原地更新,而是采用以下策略处理更新:
- Copy-On-Write:写入新版本数据,标记旧数据删除
- Merge-On-Read:维护一个增量更新层,读取时合并
- 定期 Compaction:后台定期合并,清理旧数据
行式存储 和 列式存储 简单对比
| 特征 | 行式存储 | 列式存储 |
|---|---|---|
| 典型场景 | OLTP(交易处理) | OLAP(分析处理) |
| 查询模式 | 少量行,全部列 | 大量行,少数列 |
| 写入模式 | 频繁小批量写入 | 低频大批量写入 |
| 代表产品 | MySQL, PostgreSQL | Parquet, ORC, ClickHouse |
| 压缩效果 | 一般(2-4倍) | 优秀(5-20倍) |
那 Parquet 做了什么?
理解了列式存储的基本原理,我们再来看 Parquet 就清晰多了。
Parquet 是一种列式存储的文件格式,它在列式存储的基础上做了很多精妙的设计:
- Row Group:将数据水平切分,兼顾列式存储和数据局部性
- 丰富的编码方式:RLE、Dictionary、Delta、Bit Packing 等,针对不同数据特点选择最优编码
- 嵌套数据支持:通过 Repetition Level 和 Definition Level 支持复杂的嵌套结构
- 丰富的元数据:支持谓词下推、统计信息等高级优化
这些内容,我们在后续文章中会一一深入剖析。下一篇文章,我们会深入 Parquet 的文件结构,看看一个 Parquet 文件到底长什么样。
0 条评论