第6章:列存格式
元数据卡
维度 值 难度 (进阶) 前置 第1章 SQL 基础,第4章 B+树行存理解 关键词 行存 vs 列存、Parquet、ORC、RLE、字典编码、Delta 编码、谓词下推、延迟物化、ClickHouse 代码语言 SQL / 伪代码
你的进度
一天,老陈带你来到堡垒的另一翼——一间宽敞的分析大厅。这里的货架跟地下室完全不同:不是整排整排的文件夹,而是每面墙只放一种货物。'如果你只想查所有人的年龄总和,不需要把整排文件柜都搬出来,只需要年龄这一列。这就是列存。'
破局 · 溯源
行存的问题,在分析查询面前暴露了
先跑一个具体的实验。你用 MySQL InnoDB(行存引擎)建一张 1 亿行的表,跑一个简单的聚合查询:
-- 行存中,2000 万件藏品 × 20 列
CREATE TABLE vault_items (
item_id BIGINT PRIMARY KEY
name VARCHAR(100)
item_type VARCHAR(20)
value INT
origin VARCHAR(30)
-- ... 还有 15 列其他属性
);
-- 一个典型的分析查询
SELECT item_type SUM(value) AS total_value
FROM vault_items
WHERE value > 1000
GROUP BY item_type;发生了什么(行存视角):
- 数据库读取每个数据页(16KB)
- 逐行解析整行——20 列全部读入内存
- 对每行检查
value条件 - 符合条件的行提取
item_type和value - 其他 17 列被浪费地读取和解码
假设表占 40GB 磁盘空间,查询要扫描约 40GB。即使只用了 2 列(约 2GB 的有效数据),你读的是 40GB。
浪费比例 ≈ 95%。
老陈叹了口气:'行存引擎不是为这种盘点设计的。'
列存的基本思想——按列存,不是按行存
列存引擎把"同一列的所有值"连续存在一起:
行存(Row-Oriented):
item_id item_type name value
1 武器 龙骨盾 5000
2 防具 锁子甲 3200
3 药水 治愈药水 2100
... ... ... ...
磁盘布局: [1 武器 龙骨盾 5000] [2 防具 锁子甲 3200] ...
列存(Column-Oriented):
item_id: 1 2 3 ...
item_type 武器 防具 药水...
name 龙骨盾 锁子甲 治愈药水...
value 5000 3200 2100...
磁盘布局: [1 2 3 ...] [武器 防具 药水 ...] [龙骨盾 锁子甲 治愈药水 ...] [5000 3200 2100 ...]回到刚才的查询:列存引擎只用读取 date region amount 三列的数据。2 列(item_type value)≈ 500MB 而不是 40GB。
列存还有两个更大的优势:
- 同类数据相邻 → 更高效的压缩(下面会讲)
- 只读需要的列 → 大幅减少 I/O
行存 vs 列存——老陈的对比清单
'各有所长。'老陈拿来两张清单,一张是地下室的文件柜(行存),一张是分析大厅的墙面(列存)。
| 维度 | 行存 | 列存 |
|---|---|---|
| 读取带宽 | 读整行,带宽浪费大 | 只读需要的列,带宽利用率高 |
| 压缩率 | 差(不同类型混在一起) | 好(同类型、同语义数据连续) |
| 插入/更新 | 快(一次追加一行) | 慢(需要写所有列文件) |
| 点查 | 快(索引直接定位行) | 慢(需要从各列拼出行) |
| 范围查询(全表扫描) | 慢 | 快(只扫需要的列) |
| 物化 | 天然按行物化 | 需要按列→行的重建 |
| MVCC 实现 | 成熟(PostgreSQL 的堆 + 死元组) | 复杂(列存上的版本链) |
| 典型代表 | PostgreSQL MySQL SQLite | ClickHouse DuckDB Parquet |
关键结论:
- 行存是 OLTP(Online Transaction Processing) 的答案——大量点查、频繁写入
- 列存是 OLAP(Online Analytical Processing) 的答案——大量聚合、少量写入、宽表
Parquet 和 ORC——两种通用的列存文件格式
行存引擎把自己的存储结构藏起来了——你只能通过 SQL 和它打交道。但列存不太一样。'你把文件拖出来看看,'老陈说。'Parquet 和 ORC 都是开放的列存文件格式,你可以在文件系统上直接查看。Spark 用 Parquet,Hive 用 ORC。'
直接用 parquet-tools 就能看到文件内部长什么样。
Apache Parquet
Parquet 是 Cloudera/Twitter 开发的列存格式,目前是 Hadoop/Spark 生态的事实标准。老陈说它就像'预制好的分析报告——打开就能用。'
结构:
Magic Bytes: "PAR1" (4 bytes)
Row Group 0
Column Chunk: item_type
Data Page 0 (武器 防具...)
Data Page 1 (药水 武器...)
...
Column Chunk: value
Data Page 0 (5000 3200...)
...
Column Chunk: name
Row Group 1
...
Footer
Schema(数据的列类型定义)
Row Group 元数据
Column Chunk 信息(偏移、压缩)
Page 索引
Key-Value 元数据
Footer Length (4 bytes) + "PAR1"'为什么要分三层?'你问。
老陈从 Parquet 文件的结构图里抽出一个 Row Group 展示给你看。'你要理解,每一层解决一个问题。'Row Group → Column Chunk → Page 三级的作用是平衡读取粒度和压缩效率:
- Row Group(典型 128MB-1GB):决定了你能跳过数据的最小单位。一个 Row Group 是一批行的所有列。
- Column Chunk:每个 Row Group 中一列的全部数据。压缩时能看到这一列的完整分布。
- Data Page(典型 64KB-1MB):是 Parquet 的最小 I/O 单元。不解压整个 Column Chunk 就能读取一小部分。
读取时,查询引擎首先读取 Footer,找到需要的列在哪些 Column Chunk 中,然后只读取这些 Chunk 的 Data Page。
# 用 PyArrow 读取 Parquet(只读需要的列)
import pyarrow.parquet as pq
# 只读 item_type 和 value 列,不会读所有 20 列
table = pq.read_table("vault_items.parquet"
columns=["item_type" "value"])
print(table.num_rows table.num_columns)Apache ORC——带迷你目录的分析报告
'Parquet 像预制报告,'老陈又拿起另一本册子,'ORC 更像每页都自带迷你目录。'
ORC(Optimized Row Columnar)是 Hive 早期列存优化版的继承者,现在也是 Apache 项目。
ORC 的结构和 Parquet 类似,但有一个重要的区别:Stripe 级别的索引。
File Tail
Metadata
Footer
PostScript
Stripe 0
Index Data ← 每列的最小值、最大值、布隆过滤器
region Index
amount Index
date Index ← date 在 2024-01-01 到 2024-06-30 之间
Row Data
region Data
amount Data
date Data
Stripe Footer
Stripe 1
Index Data ← date 在 2024-07-01 到 2024-12-31 之间
Row DataORC 在 Stripe 级别保存每列的统计信息(min/max、Bloom Filter)。查询引擎可以在读取数据前就跳过不需要的 Stripe。
-- Hive 使用 ORC 时的谓词下推
SELECT region SUM(amount)
FROM vault_items_orc
WHERE date = '2024-06-15';
-- ORC 读取流程:
-- 1. 读 File Tail(获取 Stripe 信息)
-- 2. 对每个 Stripe,检查 Index Data 中 date 列的 min/max
-- 3. 如果 2024-06-15 不在某个 Stripe 的 date 范围内 → 跳过整个 Stripe
-- 4. 只读取包含目标日期的 Stripe 中 region 和 amount 列数据压缩技术——同样的数据排在一起才好压
'你看,'老陈走到一面墙前——那种只放 item_type 标签的墙,'同一列的数据有相同的语义和类型,压缩效率远超行存。'
他拿出一块海绵挤了挤:'行存的数据像杂货堆,软硬不一,怎么挤都有空隙。列存像一摞同样大小的砖——码得整整齐齐,压下去多实在。'
同一列的数据具有相同的语义和类型,压缩效率远超行存。主要有三种方法。
1. 游程编码(Run-Length Encoding RLE)——重复多的用这个
老陈指着 item_type 那面墙——他扫了一眼,满墙大半是'武器'。'连续相同的值可以压缩为 (值 重复次数)。'
item_type 列(原始):武器 武器 武器 防具 防具 药水 药水 武器
RLE 编码后:
("武器" 3) ("防具" 2) ("药水" 2) ("武器" 1)
// 8 个值 → 4 个 (值 次数) 对
// 如果 item_type 有 200 万行且只有 6 个不同的类型
// 压缩率可以超过 90%2. 字典编码(Dictionary Encoding)——给每个值编个号
'RLE 很聪明,但如果数据不连续重复呢?'老陈敲了敲墙壁,'比如这一排——武器、防具、武器、药水,没法连续压缩吧?'
他拿出另一张方法:建立值到整数 ID 的映射,然后只存 ID:
item_type 列:武器 武器 防具 武器 药水 防具 药水
字典:
武器 → 0
防具 → 1
药水 → 2
编码后的 ID 序列:0 0 1 0 2 1 2
ID 还可以进一步用 RLE 或位图压缩字典编码特别适合中低基数的字符串列。Parquet 默认对字符串列使用字典编码。
// Parquet 的编码策略
// 写到 Data Page 时,先统计该 Page 内的唯一值数量
// 如果唯一值数量少于 Page 总行数的某个比例 → 用字典编码
// 否则 → 用普通编码(Plain Encoding)
// 这叫 "Adaptive Dictionary Encoding"3. Delta 编码(Delta Encoding)——存差值,省空间
'那数值列呢?'你指了指那排价值数字。
'它们的差通常很小,'老陈说。'比如 100、103、108——每个值单独存需要 4 字节,但存差值 '+3'、'+5' 可能只需要 1 字节。'
对于有序的数值(时间戳、自增 ID、序列号),存差值比存原始值节省空间:
价值(原始):100 103 108 115 120 130 141 150
Delta 编码后:100 +3 +5 +7 +5 +10 +11 +9
// 或者:100 3 5 7 5 10 11 9
// 这些差值可以用更少的比特存储
// 第1个值存完整值,后续存差值对于有序的数值列——相邻值的差通常很小,Delta 编码非常有效。
组合起来效果惊人
'这三种方法不是单独用的,'老陈把三张纸叠在一起,'列存格式会把它们组合起来。'
以 Parquet 文件为例,对一个 50 列的订单表:
CSV 格式(行存文本): 约 250MB (100%)
行存数据库(InnoDB): 约 150MB (60%) ← 有页结构开销
Parquet(列存+压缩): 约 20MB (8%)
压缩率的主要来源:
① 25% → ZSTD/Snappy 的通用压缩
② 35% → 列存排除了不需要的列的存储
③ 40% → 字典/RLE/Delta 的语义级压缩深入冒险
谓词下推(Predicate Pushdown)
上一章结尾我们提到,列存引擎能"在读取数据前就跳过不需要的部分"。这个机制叫谓词下推。
核心思想:把 WHERE 条件从查询计划的"后面"推到"前面",在数据读取阶段就过滤。
-- 查询
SELECT item_type SUM(value) FROM vault_items
WHERE value > 500
AND item_type = '武器';
-- 没有谓词下推:
-- 1. 读取 item_type value name 三列的所有数据
-- 2. 然后在内存中过滤
-- 有谓词下推:
-- 1. 先读 value 列 → 只保留命中的行索引(Row Index)
-- 2. 用命中的行索引读 item_type 列 → 进一步筛选
-- 3. 根据筛选结果读 name 列
↓
不用读的列从一开始就不碰Parquet 和 ORC 的谓词下推是在文件读取层实现的:
# PyArrow + Parquet 的谓词下推
import pyarrow.dataset as ds
dataset = ds.dataset("vault_items.parquet" format="parquet")
# 这个查询在执行前就会:
# 1. 读取每个 Row Group 的 value 列统计信息(min/max)
# 2. 跳过不含 value > 500 范围的 Row Group
# 3. 只读需要的 Row Group
table = dataset.to_table(
columns=["item_type" "value"]
filter=(
(ds.field("value") > ds.scalar(500))
& (ds.field("item_type") == ds.scalar("武器"))
)
)谓词下推在列存中的三个层次:
1. 文件级别 ← 整个文件包含的数据范围(footer 中的统计信息)
2. Stripe/Row Group 级别 ← ORC 的 Stripe Index / Parquet 的 Row Group 统计
3. Page 级别 ← 每个 Data Page 的统计信息(ORC Bloom Filter)
每一层都可以"跳过"(Skip),不需要读盘。延迟物化(Late Materialization)——能不拼行就别拼
'有一个问题,'你看着列存的流程,'各列数据是分开存的,最后怎么拼回去?'
老陈走到一台织布机前。'行存读出来就是布——一行就是一块布。列存读出来是一根根线,你需要织成布。但这个'织布'操作很贵,所以能拖就拖。'
行存引擎从磁盘读到数据时,数据已经是"行"的形式了——叫物化(Materialization)。列存引擎需要把各列拼成行才能返回给用户。
延迟物化的意思是:尽可能晚地把列物化成行。
// 没有延迟物化(早物化):
// 1. 读 item_type 列
// 2. 读 value 列
// 3. 读 name 列(等等,name 列可能不需要!!)
// 4. 把三列拼成行(物化)
// 5. 过滤
// 6. 聚合
// 有延迟物化:
// 1. 读 value 列 → 获取命中的行索引 [42 153 789 ...]
// 2. 读 item_type 列 → 只读行索引 42 153 789... 的值
// 3. 进一步过滤 → 行索引 [42 789]
// 4. 读 name 列 → 只读行索引 42 789 的值
// 5. 聚合
// 6. 物化(此时才拼成行)延迟物化的好处:
- 减少内存占用——不是所有列都物化
- 减少 CPU 开销——早期阶段处理的是紧凑的列数据
- 减少 I/O——后续列的读取可以基于前一步的过滤结果跳读
ClickHouse 列存实践
ClickHouse 是列存引擎的标杆——它用一套自研的列存格式(Mergetree),在单机上就能做到数百 GB/s 的扫描速度。
ClickHouse 的核心列存设计:
-- ClickHouse 建表(列存)
CREATE TABLE vault_items_clickhouse (
item_id UInt64
item_type String
value UInt32
name String
) ENGINE = MergeTree()
ORDER BY (item_type value);
-- 这个查询几乎瞬间返回
SELECT item_type SUM(value)
FROM vault_items_clickhouse
WHERE value > 1000
GROUP BY item_type;为什么 ClickHouse 快:
- 列存 + 向量化执行:不只用列存格式,还用 SIMD 指令一次处理多个值
- 主键是排序键:ORDER BY (item_type value) 决定了数据按 (item_type value) 排序存储。对
item_type的 GROUP BY 天然高效。 - 分钟级别粒度索引:每个数据部分(Part)记录每列的最小值/最大值/总和,允许大范围跳过
- 数据按块(Block)处理:一次读取和处理 8192 行的一个列块
- 无事务开销:ClickHouse 设计上就是"批量写入、大量读取"的
在 ClickHouse 中看谓词下推的效果:
EXPLAIN PLAN
SELECT item_type SUM(value)
FROM vault_items_clickhouse
WHERE value > 5000;
-- 输出会显示:
-- Expression (计算)
-- Aggregating (聚合)
-- Expression (额外的计算)
-- ReadFromMergeTree (读取数据)
-- Where: (value > 5000) ← 下推的谓词
-- Granules: 4/1200 ← 只读了 4 个颗粒DuckDB 另辟蹊径
DuckDB 是另一个列存 OLAP 引擎。它不用 ClickHouse 的排序主键,而是使用Zone Map(每个 Row Group 的 min/max)和数据压缩字典的组合策略。DuckDB 的优势是嵌入式、无服务器、支持标准的 PostgreSQL 协议。
-- DuckDB 直接在文件系统上查 Parquet
SELECT item_type SUM(value)
FROM 'vault_items.parquet'
WHERE value > 1000
GROUP BY item_type;DuckDB 不需要一个长期运行的服务器——你在命令行上直接跑查询,查询完了就退出。
常见陷阱
1. 列存不适合 OLTP
不要因为列存查询快就把它当 OLTP 用。插入一行数据到列存引擎意味着:
- 写所有列文件(重新压缩 Column Chunk)
- 更新索引
- 维护列到行的映射
ClickHouse 的 INSERT 性能是批量的(每秒几十万行),但单行插入的性能远不如 MySQL。
2. 列数不是越多越好
宽表(1000+ 列)对列存是挑战。Parquet 对超过 1000 列的 schema 支持不完善,不同引擎的嵌套类型处理方式不同。
3. 谓词下推不总是生效
检查查询计划。如果谓词下推没有生效,查询引擎可能需要先读完整列数据再做过滤。
-- 在 ClickHouse 中检查
EXPLAIN PIPELINE
SELECT * FROM vault_items_clickhouse WHERE func(value) > 10;
-- 如果 func() 不支持下推,那么整个列都要读取4. 嵌套类型(Struct/List/Map)在列存中的挑战
Parquet 用 Repetition Level 和 Definition Level 来编码嵌套数据,这比扁平列复杂得多。嵌套太深的 schema 会影响压缩率和读取性能。
通关挑战
** 热身(5 分钟,必做)**
- 用 PyArrow 生成一个 Parquet 文件并查看内部结构:
pip install pyarrowimport pyarrow as pa
import pyarrow.parquet as pq
import pandas as pd
# 创建示例数据——老陈的宝库清单
df = pd.DataFrame({
'item_type': ['武器' '武器' '防具' '药水' '武器' '防具'] * 10000
'value': [5000 3200 2100 1800 4500 2800] * 10000
'name': ['龙骨盾' '魔剑' '锁子甲' '治愈药水' '龙牙刀' '铁甲'] * 10000
})
table = pa.Table.from_pandas(df)
pq.write_table(table 'vault_items.parquet')
# 查看 Parquet 文件元数据
pf = pq.ParquetFile('vault_items.parquet')
print("Num Row Groups:" pf.metadata.num_row_groups)
print("Schema:" pf.schema)
# 查看每个 Row Group 的统计信息
for i in range(pf.metadata.num_row_groups):
rg = pf.metadata.row_group(i)
for j in range(rg.num_columns):
col = rg.column(j)
print(f" Col {j}: min={col.statistics.min} max={col.statistics.max}")- 对比只读 2 列(item_type value)和读取全部列的时间差异。
** 挑战(30 分钟,选做)**
实现一个列存格式的最小版本:
# min_columnar.py
import struct
from pathlib import Path
class SimpleColumnarStore:
"""
实现一个简单的列存文件格式:
- 每个列一个文件(.col)
- footer 存每列的元数据(偏移、计数、min/max)
- 支持 RLE 和字典编码
- 支持谓词过滤后只读需要的列
"""
def __init__(self dir_path):
self.dir_path = Path(dir_path)
self.dir_path.mkdir(exist_ok=True)
def write_table(self data: dict):
"""写入一个完整表的列存文件
data = {"col_name": [values...] ...}
"""
# 你的实现在这里
pass
def read_columns(self columns: list predicate=None):
"""读取指定列,支持谓词过滤
predicate = ("col_name" ">=" value)
"""
# 你的实现在这里
pass
def compress_rle(self values):
"""RLE 压缩"""
# 你的实现在这里
pass
def compress_dictionary(self values):
"""字典编码"""
# 你的实现在这里
pass
# 验证
store = SimpleColumnarStore("/tmp/test_col_store")
store.write_table({
"item_type": ["武器" "武器" "防具" "药水"] * 10
"value": [5000 3200 2100 1800] * 10
"name": ["龙骨盾" "魔剑" "锁子甲" "治愈药水"] * 10
})
# 只读 item_type 和 value
result = store.read_columns(["item_type" "value"])
print(result)
# 带谓词过滤
result = store.read_columns(["item_type" "value"]
predicate=("value" ">=" 3000))
print(result)** 观察**:用 parquet-tools 观察真实 Parquet 文件的结构:
# 安装 parquet-tools (Java)
# 或者用 Python 的内省工具:
python -c "
import pyarrow.parquet as pq
pf = pq.ParquetFile('vault_items.parquet')
print(pf.metadata)
print(pf.schema)
for col in range(pf.schema.num_columns - 1):
print(pf.read_column_chunk(col))
"验收标准
读完本章后,你应该能:
- [x] 解释行存和列存在磁盘布局上的本质区别
- [x] 描述 Parquet 的三层结构:Row Group → Column Chunk → Data Page
- [x] 说出三种列存压缩技术(RLE、字典编码、Delta 编码)各自适用的场景
- [x] 解释谓词下推如何在文件/Stripe/Page 三级跳过不必要的数据
- [x] 解释延迟物化为什么减少 I/O 和内存占用
- [x] 给出 ClickHouse 列存查询快的三个原因
- [x] 判断一种工作负载适合行存还是列存
常见卡点
| 卡点 | 原因 | 解决 |
|---|---|---|
| "列存的插入为什么比行存慢?" | 因为一行数据要分散写入多个列文件,每个列文件重新压缩 | 列存引擎设计上就是批量写入的 |
| "Parquet 和 ORC 到底选哪个?" | 功能上几乎等价。Parquet 生态更好(Spark、Presto 原生支持),ORC 在 Hive 中使用更多 | 新项目优先选 Parquet |
| "列存能处理嵌套类型吗?" | 可以,Parquet 用 Repetition Level/Definition Level 编码嵌套 | 嵌套类型会增加数据膨胀和读取开销。能用扁平列尽量用 |
| "ClickHouse 的 ORDER BY 和 MySQL 的索引有什么区别?" | ClickHouse 的 ORDER BY 决定数据在磁盘上的物理排序,相当于"全局聚簇索引" | 一个表只有一个 ORDER BY 排序键,选择合适的排序键是优化关键 |
现在不需要理解
- 列存的并行扫描策略:如何分割 Row Group 让多个 CPU 核并行处理
- 微分区(Micro-partition):Snowflake 的列存单元
- 列存上的 JOIN 实现:Distributed Join、Broadcast Join
- 列存上的 UPDATE/DELETE:Merge-on-Read vs Copy-on-Write
旅人笔记
列存对 OLAP 的加速是量级级的:谓词下推跳过不需要的数据块、同类数据让压缩率飙升、延迟物化让 CPU 和内存各司其职。行存和列存没有对错——OLTP 用行存让写入更快,OLAP 用列存让分析更快。现代数仓(Snowflake、Redshift、ClickHouse)共同的选择:列存。