Skip to content

第6章:列存格式


元数据卡

维度
难度(进阶)
前置第1章 SQL 基础,第4章 B+树行存理解
关键词行存 vs 列存、Parquet、ORC、RLE、字典编码、Delta 编码、谓词下推、延迟物化、ClickHouse
代码语言SQL / 伪代码

你的进度

一天,老陈带你来到堡垒的另一翼——一间宽敞的分析大厅。这里的货架跟地下室完全不同:不是整排整排的文件夹,而是每面墙只放一种货物。'如果你只想查所有人的年龄总和,不需要把整排文件柜都搬出来,只需要年龄这一列。这就是列存。'

破局 · 溯源

行存的问题,在分析查询面前暴露了

先跑一个具体的实验。你用 MySQL InnoDB(行存引擎)建一张 1 亿行的表,跑一个简单的聚合查询:

sql
-- 行存中,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;

发生了什么(行存视角)

  1. 数据库读取每个数据页(16KB)
  2. 逐行解析整行——20 列全部读入内存
  3. 对每行检查 value 条件
  4. 符合条件的行提取 item_typevalue
  5. 其他 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。

列存还有两个更大的优势:

  1. 同类数据相邻 → 更高效的压缩(下面会讲)
  2. 只读需要的列 → 大幅减少 I/O

行存 vs 列存——老陈的对比清单

'各有所长。'老陈拿来两张清单,一张是地下室的文件柜(行存),一张是分析大厅的墙面(列存)。

维度行存列存
读取带宽读整行,带宽浪费大只读需要的列,带宽利用率高
压缩率差(不同类型混在一起)好(同类型、同语义数据连续)
插入/更新快(一次追加一行)慢(需要写所有列文件)
点查快(索引直接定位行)慢(需要从各列拼出行)
范围查询(全表扫描)快(只扫需要的列)
物化天然按行物化需要按列→行的重建
MVCC 实现成熟(PostgreSQL 的堆 + 死元组)复杂(列存上的版本链)
典型代表PostgreSQL MySQL SQLiteClickHouse 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。

python
# 用 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 Data

ORC 在 Stripe 级别保存每列的统计信息(min/max、Bloom Filter)。查询引擎可以在读取数据前就跳过不需要的 Stripe。

sql
-- 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 那面墙——他扫了一眼,满墙大半是'武器'。'连续相同的值可以压缩为 (值 重复次数)。'

pseudo
item_type 列(原始):武器 武器 武器 防具 防具 药水 药水 武器

RLE 编码后:
("武器" 3) ("防具" 2) ("药水" 2) ("武器" 1)

// 8 个值 → 4 个 (值 次数) 对
// 如果 item_type 有 200 万行且只有 6 个不同的类型
// 压缩率可以超过 90%

2. 字典编码(Dictionary Encoding)——给每个值编个号

'RLE 很聪明,但如果数据不连续重复呢?'老陈敲了敲墙壁,'比如这一排——武器、防具、武器、药水,没法连续压缩吧?'

他拿出另一张方法:建立值到整数 ID 的映射,然后只存 ID:

pseudo
item_type 列:武器 武器 防具 武器 药水 防具 药水

字典:
 武器 → 0
 防具 → 1
 药水 → 2

编码后的 ID 序列:0 0 1 0 2 1 2
ID 还可以进一步用 RLE 或位图压缩

字典编码特别适合中低基数的字符串列。Parquet 默认对字符串列使用字典编码。

pseudo
// Parquet 的编码策略
// 写到 Data Page 时,先统计该 Page 内的唯一值数量
// 如果唯一值数量少于 Page 总行数的某个比例 → 用字典编码
// 否则 → 用普通编码(Plain Encoding)
// 这叫 "Adaptive Dictionary Encoding"

3. Delta 编码(Delta Encoding)——存差值,省空间

'那数值列呢?'你指了指那排价值数字。

'它们的差通常很小,'老陈说。'比如 100、103、108——每个值单独存需要 4 字节,但存差值 '+3'、'+5' 可能只需要 1 字节。'

对于有序的数值(时间戳、自增 ID、序列号),存差值比存原始值节省空间:

pseudo
价值(原始):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 列的订单表:

pseudo
CSV 格式(行存文本): 约 250MB (100%)
行存数据库(InnoDB): 约 150MB (60%) ← 有页结构开销
Parquet(列存+压缩): 约 20MB (8%)

压缩率的主要来源:
 ① 25% → ZSTD/Snappy 的通用压缩
 ② 35% → 列存排除了不需要的列的存储
 ③ 40% → 字典/RLE/Delta 的语义级压缩

深入冒险

谓词下推(Predicate Pushdown)

上一章结尾我们提到,列存引擎能"在读取数据前就跳过不需要的部分"。这个机制叫谓词下推

核心思想:把 WHERE 条件从查询计划的"后面"推到"前面",在数据读取阶段就过滤

sql
-- 查询
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 的谓词下推是在文件读取层实现的:

python
# 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)。列存引擎需要把各列拼成行才能返回给用户。

延迟物化的意思是:尽可能晚地把列物化成行

pseudo
// 没有延迟物化(早物化):
// 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 的核心列存设计

sql
-- 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 快

  1. 列存 + 向量化执行:不只用列存格式,还用 SIMD 指令一次处理多个值
  2. 主键是排序键ORDER BY (item_type value) 决定了数据按 (item_type value) 排序存储。对 item_type 的 GROUP BY 天然高效。
  3. 分钟级别粒度索引:每个数据部分(Part)记录每列的最小值/最大值/总和,允许大范围跳过
  4. 数据按块(Block)处理:一次读取和处理 8192 行的一个列块
  5. 无事务开销:ClickHouse 设计上就是"批量写入、大量读取"的

在 ClickHouse 中看谓词下推的效果

sql
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 协议。

sql
-- 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. 谓词下推不总是生效

检查查询计划。如果谓词下推没有生效,查询引擎可能需要先读完整列数据再做过滤。

sql
-- 在 ClickHouse 中检查
EXPLAIN PIPELINE
SELECT * FROM vault_items_clickhouse WHERE func(value) > 10;
-- 如果 func() 不支持下推,那么整个列都要读取

4. 嵌套类型(Struct/List/Map)在列存中的挑战

Parquet 用 Repetition LevelDefinition Level 来编码嵌套数据,这比扁平列复杂得多。嵌套太深的 schema 会影响压缩率和读取性能。


通关挑战

** 热身(5 分钟,必做)**

  1. 用 PyArrow 生成一个 Parquet 文件并查看内部结构:
bash
pip install pyarrow
python
import 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}")
  1. 对比只读 2 列(item_type value)和读取全部列的时间差异。

** 挑战(30 分钟,选做)**

实现一个列存格式的最小版本:

python
# 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 文件的结构:

bash
# 安装 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)共同的选择:列存。

下一站预告

Built with VitePress | Software Systems Atlas