一条典型的分析查询只访问表中数百列里的三四列,行式存储却把整行数据从磁盘搬进内存,绝大多数字节在读入后立刻被丢弃。列式存储(Columnar Storage)把同一列的值连续存放,查询只需要读取涉及到的列,I/O 量可以降低一到两个数量级。但 I/O 减少只是故事的一半——列式布局还为压缩、向量化执行(Vectorized Execution)和延迟物化(Late Materialization)创造了条件,这些因素叠加在一起,才使得分析查询在列式存储上跑出数倍乃至数十倍的加速。
本文从最底层的 I/O 模型讲起,逐层拆解列式存储的压缩优势、向量化执行原理、写入挑战、混合布局方案,再到 ClickHouse、DuckDB、Redshift 的工程实现和 TiDB TiFlash 的行列混合架构,最后给出性能实测数据和选型建议。
一、行式与列式的 I/O 差异
1.1 行式存储的数据布局
行式存储(Row
Store)把一行记录的所有列连续存放在同一个磁盘页中。以一张包含
id、name、age、city、salary
五列的员工表为例,行式布局在磁盘上的排列方式如下:
┌─── 磁盘页 0 ──────────────────────────────────────────────┐
│ Row 0: [id=1, name="张三", age=28, city="北京", salary=15000] │
│ Row 1: [id=2, name="李四", age=35, city="上海", salary=22000] │
│ Row 2: [id=3, name="王五", age=31, city="深圳", salary=18000] │
│ Row 3: [id=4, name="赵六", age=42, city="杭州", salary=25000] │
└───────────────────────────────────────────────────────────┘
┌─── 磁盘页 1 ──────────────────────────────────────────────┐
│ Row 4: [id=5, name="钱七", age=26, city="成都", salary=13000] │
│ Row 5: [id=6, name="孙八", age=38, city="广州", salary=20000] │
│ ... │
└───────────────────────────────────────────────────────────┘
假设每行占 200 字节,一个 8KB 的磁盘页可以存放约 40 行。如果表有 1 亿行,需要约 250 万个磁盘页,总数据量约 20GB。
1.2 列式存储的数据布局
列式存储(Column Store)把同一列的所有值连续存放。同样的员工表,列式布局在磁盘上的排列方式完全不同:
┌─── id 列 ──────────────────────────────────────────────────┐
│ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...] │
└────────────────────────────────────────────────────────────┘
┌─── name 列 ────────────────────────────────────────────────┐
│ ["张三", "李四", "王五", "赵六", "钱七", "孙八", ...] │
└────────────────────────────────────────────────────────────┘
┌─── age 列 ─────────────────────────────────────────────────┐
│ [28, 35, 31, 42, 26, 38, ...] │
└────────────────────────────────────────────────────────────┘
┌─── city 列 ────────────────────────────────────────────────┐
│ ["北京", "上海", "深圳", "杭州", "成都", "广州", ...] │
└────────────────────────────────────────────────────────────┘
┌─── salary 列 ──────────────────────────────────────────────┐
│ [15000, 22000, 18000, 25000, 13000, 20000, ...] │
└────────────────────────────────────────────────────────────┘
每列独立存储,age 列只包含 4 字节整数,1
亿行的 age 列总共约 400MB;salary
列也是 4 字节整数,约 400MB。
1.3 分析查询的 I/O 对比
考虑一个典型的分析查询:
-- 统计各城市的平均薪资
SELECT city, AVG(salary)
FROM employees
GROUP BY city;这条查询只涉及 city 和 salary
两列,我们来对比两种存储布局的 I/O 开销。
行式存储的 I/O 路径:
需要读取:全部 250 万个磁盘页(约 20GB)
实际使用:每行 200 字节中的约 30 字节(city + salary)
I/O 利用率:30 / 200 = 15%
浪费的 I/O:85%
行式存储必须把整行读入内存,即使查询只需要两列。每个磁盘页中,85%
的字节是
id、name、age
等不需要的列,这些字节被读入后立刻丢弃。
列式存储的 I/O 路径:
需要读取:city 列 + salary 列
city 列大小:约 1.2GB(假设平均每个城市名 12 字节)
salary 列大小:约 400MB
总 I/O:约 1.6GB
I/O 利用率:接近 100%
列式存储只读取 city 和 salary
两列的数据,完全不碰
id、name、age 列。I/O
量从 20GB 降到 1.6GB,减少了约 12 倍。
1.4 I/O 差异的量化模型
用一个简单的数学模型来量化两种布局的 I/O 差异。假设表有 \(N\) 行、\(C\) 列,每列平均宽度为 \(W\) 字节,查询涉及 \(Q\) 列(\(Q \leq C\)):
行式存储 I/O = N × C × W (读取全部数据)
列式存储 I/O = N × Q × W (只读取涉及的列)
I/O 加速比 = C / Q
当表有 100 列、查询涉及 5 列时,列式存储的 I/O 量是行式存储的 5/100 = 5%,加速比为 20 倍。实际场景中,分析型数据仓库的表通常有几十到几百列,而单条查询通常只涉及 3-10 列,这正是列式存储的最佳场景。
1.5 磁盘顺序读取的优势
列式存储还有一个被低估的 I/O 优势:同一列的数据在磁盘上是连续的,读取一列的全部值是纯粹的顺序 I/O(Sequential I/O)。
行式存储读取 salary 列:
Page 0 → 跳过 id/name/age → 读 salary → 跳过 city
Page 1 → 跳过 id/name/age → 读 salary → 跳过 city
...
每个页内都有随机偏移
列式存储读取 salary 列:
salary 文件 → 顺序读取连续的 4 字节整数块
完全顺序 I/O,无跳跃
对于 HDD(Hard Disk Drive),顺序读取的吞吐量可以达到 150-200 MB/s,而随机读取只有 1-2 MB/s,差距达到两个数量级。即使在 SSD(Solid State Drive)上,顺序读取的吞吐量也显著高于随机读取——NVMe SSD 顺序读取可以达到 3-7 GB/s,而 4KB 随机读取在高队列深度下通常在 1-2 GB/s。
1.6 行式存储的优势场景
列式存储在分析查询上的优势不代表它在所有场景下都更好。行式存储在以下场景下仍然占优:
点查询(Point Query)和 OLTP(Online Transaction Processing)负载:
-- 按主键查一行,需要全部列
SELECT * FROM employees WHERE id = 42;行式存储只需要一次 I/O 就能读取整行数据。列式存储需要分别从每一列的文件中读取对应位置的值,涉及 \(C\) 次 I/O,反而更慢。
高频小范围写入:
行式存储插入一行只需要追加到一个位置。列式存储插入一行需要分别写入 \(C\) 个列文件,写放大(Write Amplification)显著。
I/O 模型的总结如下表:
| 维度 | 行式存储(Row Store) | 列式存储(Column Store) |
|---|---|---|
| 全表扫描、少量列 | 读取大量无关数据 | 只读涉及的列,I/O 大幅减少 |
| 点查询、全部列 | 一次 I/O 读整行 | 需要 C 次 I/O 拼装整行 |
| 顺序 I/O | 页内混合多列,局部性一般 | 同列连续存放,顺序 I/O |
| 写入 | 追加一行到一个位置 | 追加一行需要写 C 个列文件 |
| 典型负载 | OLTP | OLAP |
二、列式压缩优势
2.1 为什么列式数据更容易压缩
列式存储的压缩率通常比行式存储高 5-10 倍,这不是偶然的。同一列的值具有三个对压缩友好的特性:
- 类型一致性:同一列的所有值类型相同——全是
INT32、全是VARCHAR、全是TIMESTAMP。通用压缩算法(如 LZ4、Zstd)在处理类型一致的数据时效率更高。 - 值域集中:同一列的值通常来自相同的值域。例如
age列的值集中在 18-65,status列可能只有active、inactive、pending三个值。值域越集中,熵(Entropy)越低,可压缩性越强。 - 局部有序性:如果数据按时间插入,时间戳列天然有序;如果有主键排序,相关列也呈现局部有序。有序数据的压缩效果远好于随机数据。
行式存储中,一个页内混合了不同类型的列——INT、VARCHAR、FLOAT、DATE
交替排列,数据的统计特性不一致,压缩算法很难找到有效的模式。
2.2 列式存储常用的编码技术
列式存储不只依赖通用压缩算法,还大量使用针对特定数据模式的编码(Encoding)技术。这些编码技术在压缩的同时,可以直接在压缩数据上执行查询,避免解压开销。
2.2.1 游程编码(Run-Length Encoding, RLE)
游程编码(Run-Length
Encoding)适用于排序后连续重复值较多的列。编码方式是把连续的相同值替换为
(值, 重复次数) 对:
原始数据(city 列,按 city 排序后):
["北京", "北京", "北京", "北京", "上海", "上海", "上海", "深圳", "深圳"]
RLE 编码后:
[("北京", 4), ("上海", 3), ("深圳", 2)]
9 个字符串被压缩为 3 个元组,压缩率约 3:1。如果
city 列有 1 亿行但只有 300 个不同城市,且数据按
city 排序,RLE 可以把 1.2GB 的数据压缩到几十
KB。
RLE 的另一个优势是可以直接在编码数据上执行聚合操作。例如
COUNT(*) GROUP BY city:
直接读取 RLE 编码:
"北京" → count = 4
"上海" → count = 3
"深圳" → count = 2
不需要逐行扫描
2.2.2 字典编码(Dictionary Encoding)
字典编码(Dictionary Encoding)适用于基数(Cardinality)较低的列——即不同值的数量远小于总行数。编码方式是建立一个字典,把每个值映射到一个整数编码,然后用整数编码替代原始值:
原始数据(city 列):
["北京", "上海", "深圳", "杭州", "北京", "上海", "成都", "北京", "广州"]
字典:
0 → "北京"
1 → "上海"
2 → "深圳"
3 → "杭州"
4 → "成都"
5 → "广州"
编码后的数据:
[0, 1, 2, 3, 0, 1, 4, 0, 5]
原来每个城市名占 6-12 字节(UTF-8 编码的中文),现在每个值只需要 1 字节(如果不同值不超过 256 个)甚至更少。字典本身只需要存一份,占用极小的空间。
字典编码可以和 RLE 组合使用:先做字典编码,再对整数序列做 RLE,进一步提高压缩率。
2.2.3 位图编码(Bitmap Encoding)
位图编码(Bitmap Encoding)为每个不同值建立一个位图(Bitmap),位图中的每一位对应一行,如果该行的值等于这个值则为 1,否则为 0:
原始数据(status 列):
["active", "inactive", "active", "pending", "active", "inactive", "active", "active"]
位图:
active: [1, 0, 1, 0, 1, 0, 1, 1]
inactive: [0, 1, 0, 0, 0, 1, 0, 0]
pending: [0, 0, 0, 1, 0, 0, 0, 0]
过滤条件 WHERE status = 'active' 只需要读取
active 的位图,用位运算(Bitwise
Operation)快速定位符合条件的行。位图天然支持 AND、OR、NOT
等逻辑运算,多个过滤条件可以通过位图的交集、并集来计算。
对于基数较高的列(例如
user_id),每个值的位图都非常稀疏(几乎全是
0),此时需要使用压缩位图(如 Roaring
Bitmap)来避免空间浪费。
2.2.4 差分编码(Delta Encoding)
差分编码(Delta Encoding)适用于有序或近似有序的数值列,特别是时间戳列。编码方式是存储第一个值和后续值与前一个值的差值:
原始数据(timestamp 列,毫秒级时间戳):
[1694563200000, 1694563201000, 1694563201500, 1694563202000, 1694563205000]
Delta 编码后:
基础值: 1694563200000
差值序列: [0, 1000, 500, 500, 3000]
原始值需要 8 字节(INT64),差值通常只需要 1-2 字节。对于毫秒级时间戳,差值通常在几百到几千的范围内,可以进一步用变长整数编码(Variable-Length Integer Encoding)或帧参考(Frame of Reference, FOR)压缩。
2.2.5 帧参考编码(Frame of Reference, FOR)
帧参考编码(Frame of Reference)把一组值减去它们的最小值,得到一组较小的非负整数,然后用尽可能少的位数来存储这些整数:
原始数据(age 列):
[28, 35, 31, 42, 26, 38, 29, 33]
最小值: 26
偏移后: [2, 9, 5, 16, 0, 12, 3, 7]
最大偏移值: 16,需要 5 位
FOR 编码:每个值用 5 位存储
存储空间: 8 × 5 = 40 位 = 5 字节
原始空间: 8 × 4 = 32 字节
压缩率: 约 6.4:1
FOR 编码的关键优势在于解码速度极快——批量位解包(Bit Unpacking)可以利用 SIMD(Single Instruction, Multiple Data)指令并行处理,每个时钟周期解码多个值。
2.3 压缩率对比
以下是一组典型的压缩率对比数据,基于 TPC-H 的
lineitem 表(约 6 亿行,16 列):
| 编码方式 | 适用列类型 | 典型压缩率 | 可否在压缩态查询 |
|---|---|---|---|
| 游程编码(RLE) | 排序后重复值多的列 | 10:1 - 1000:1 | 是 |
| 字典编码(Dictionary) | 低基数列 | 3:1 - 20:1 | 是 |
| 位图编码(Bitmap) | 低基数列 | 2:1 - 10:1 | 是(位运算) |
| 差分编码(Delta) | 有序数值列、时间戳 | 4:1 - 20:1 | 部分支持 |
| 帧参考(FOR) | 值域集中的整数列 | 3:1 - 8:1 | 是(SIMD 解码) |
| LZ4 通用压缩 | 任意列 | 2:1 - 4:1 | 否 |
| Zstd 通用压缩 | 任意列 | 3:1 - 8:1 | 否 |
列式存储通常采用分层压缩策略(Layered Compression):先用轻量级编码(字典、RLE、Delta、FOR),再用通用压缩算法(LZ4 或 Zstd)做最后一层压缩。这种方式可以在压缩率和解码速度之间取得平衡。
2.4 压缩对查询性能的影响
压缩不只是节省磁盘空间。在现代硬件上,内存带宽和磁盘 I/O 往往是分析查询的瓶颈,而 CPU 能力相对过剩。压缩通过减少数据量,实际上加速了查询:
未压缩:
磁盘读取 10GB → 内存传输 10GB → CPU 处理
总时间 ≈ 磁盘 I/O 时间(瓶颈)
压缩后(5:1 压缩率):
磁盘读取 2GB → 解压到 10GB → CPU 处理
总时间 ≈ 磁盘 I/O 时间(大幅减少)+ 解压时间(CPU 密集,通常很快)
LZ4 的解压速度可以达到 4-5 GB/s,Zstd 也能达到 1-2 GB/s,远超磁盘或网络的传输速度。压缩后的数据在磁盘和内存之间传输更快,解压的 CPU 开销可以被 I/O 节省所覆盖。
我认为,压缩率是列式存储相对于行式存储最被低估的优势。很多人只关注”只读需要的列”带来的 I/O 减少,却忽略了列式布局本身让压缩率提高了数倍——两个因素叠加,实际的 I/O 减少量往往比简单的列选择模型预测的大得多。
三、向量化执行(SIMD)
3.1 传统行式执行模型
传统数据库采用火山模型(Volcano
Model)执行查询。每个算子(Operator)实现一个
next()
接口,每次调用返回一行数据。查询执行树从根节点开始,逐层调用
next(),一行一行地处理数据:
┌──────────┐
│ 输出 │
└────┬─────┘
│ next() → 1 行
┌────┴─────┐
│ 聚合 │
└────┬─────┘
│ next() → 1 行
┌────┴─────┐
│ 过滤 │
└────┬─────┘
│ next() → 1 行
┌────┴─────┐
│ 扫描 │
└──────────┘
火山模型的问题在于每行数据都要经过多次虚函数调用(Virtual
Function
Call)、条件分支判断和函数返回,函数调用的开销在每行数据上都要重复一次。当表有
1 亿行时,next()
被调用上亿次,函数调用的开销累积起来非常可观。
更关键的是,一次处理一行的方式无法利用现代 CPU 的 SIMD(Single Instruction, Multiple Data)指令。SIMD 允许一条指令同时对多个数据元素执行相同的操作,但前提是这些数据元素必须连续存放在内存中、类型相同。行式存储中,同一列的值分散在不同的行中,无法直接喂给 SIMD 指令。
3.2 向量化执行模型
向量化执行(Vectorized
Execution)是对火山模型的关键改进。核心思路是把
next()
的返回单位从一行改为一批(Batch),通常是 1024 行或 4096
行组成的列式向量(Column Vector):
┌──────────┐
│ 输出 │
└────┬─────┘
│ next_batch() → 1024 行
┌────┴─────┐
│ 聚合 │
└────┬─────┘
│ next_batch() → 1024 行
┌────┴─────┐
│ 过滤 │
└────┬─────┘
│ next_batch() → 1024 行
┌────┴─────┐
│ 扫描 │
└──────────┘
每个批次是一组列式向量。例如读取 age 和
salary 两列,返回的批次包含两个向量:
age 向量: [28, 35, 31, 42, 26, 38, 29, 33, ...] (1024 个 INT32)
salary 向量: [15000, 22000, 18000, 25000, 13000, 20000, 14000, 19000, ...] (1024 个 INT32)
3.3 SIMD 加速原理
SIMD(Single Instruction, Multiple Data)是现代 CPU 的一类指令集扩展,允许一条指令同时处理多个数据元素。主要的 SIMD 指令集包括:
| 指令集 | 寄存器宽度 | 一次处理的 INT32 数 | 代表 CPU |
|---|---|---|---|
| SSE2 | 128 位 | 4 个 | 2001 年后的 x86 |
| AVX2 | 256 位 | 8 个 | Intel Haswell(2013)及之后 |
| AVX-512 | 512 位 | 16 个 | Intel Skylake-X(2017)及之后 |
| ARM NEON | 128 位 | 4 个 | ARM Cortex-A 系列 |
| ARM SVE | 可变宽度 | 可变 | ARM v9 架构 |
以 AVX2 为例,256 位寄存器可以同时容纳 8 个 32
位整数。一条 _mm256_add_epi32 指令可以同时完成
8 个整数加法,理论上是标量运算的 8 倍吞吐量。
以 SUM(salary) 操作为例,标量版本和 SIMD
版本的对比:
// 标量版本:一次处理一个值
int64_t sum_scalar(int32_t *data, int n) {
int64_t sum = 0;
for (int i = 0; i < n; i++) {
sum += data[i]; // 每次循环处理 1 个值
}
return sum;
}// AVX2 SIMD 版本:一次处理 8 个值
#include <immintrin.h>
int64_t sum_avx2(int32_t *data, int n) {
__m256i vsum = _mm256_setzero_si256(); // 8 个 32 位累加器
int i;
for (i = 0; i + 8 <= n; i += 8) {
__m256i v = _mm256_loadu_si256((__m256i *)(data + i)); // 加载 8 个值
vsum = _mm256_add_epi32(vsum, v); // 8 个加法同时完成
}
// 水平归约:把 8 个累加器的值加起来
int32_t tmp[8];
_mm256_storeu_si256((__m256i *)tmp, vsum);
int64_t sum = 0;
for (int j = 0; j < 8; j++) sum += tmp[j];
// 处理剩余不足 8 个的值
for (; i < n; i++) sum += data[i];
return sum;
}3.4 向量化过滤
向量化的过滤操作同样利用 SIMD 实现高效的批量比较。以
WHERE age > 30 为例:
// AVX2 向量化过滤
void filter_age_gt30(int32_t *age, int n, uint8_t *selection) {
__m256i threshold = _mm256_set1_epi32(30); // 广播 30 到 8 个位置
for (int i = 0; i + 8 <= n; i += 8) {
__m256i v = _mm256_loadu_si256((__m256i *)(age + i));
__m256i mask = _mm256_cmpgt_epi32(v, threshold); // 8 个比较同时完成
// mask 中每个 32 位段为全 1(满足)或全 0(不满足)
int bitmask = _mm256_movemask_epi8(mask);
// 将结果存入选择向量
for (int j = 0; j < 8; j++) {
selection[i + j] = (bitmask >> (j * 4)) & 0xF ? 1 : 0;
}
}
}向量化过滤产生一个选择向量(Selection Vector),标记哪些行满足条件。后续的聚合操作只处理被选中的行,不需要做分支判断——分支预测失败(Branch Misprediction)是行式逐行过滤的主要性能杀手之一。
3.5 延迟物化(Late Materialization)
列式存储的向量化执行还受益于延迟物化(Late Materialization)策略。传统的行式执行在扫描阶段就把需要的列拼成完整的行(早期物化,Early Materialization),后续所有算子都操作完整的行。
延迟物化的做法是尽可能推迟行的组装。过滤阶段只操作过滤列,产生一个行号集合(Position List);只有在最终需要输出结果时,才根据行号集合去读取需要输出的列。
查询: SELECT name, salary FROM employees WHERE age > 30 AND city = '北京'
早期物化:
扫描 → 读取 name, salary, age, city 四列 → 拼成行
过滤 → WHERE age > 30 AND city = '北京'
输出 → name, salary
延迟物化:
扫描 age 列 → WHERE age > 30 → 行号集合 P1 = {1, 3, 5, ...}
扫描 city 列(只扫描 P1 中的行) → WHERE city = '北京' → 行号集合 P2 = {3, ...}
最后用 P2 从 name 列和 salary 列中取值 → 输出
延迟物化的好处是:如果第一个过滤条件就淘汰了 90%
的行,第二个过滤条件只需要扫描 10% 的数据,最终输出的
name 和 salary 列可能只需要读取 1%
的数据。
3.6 向量化与编译执行的对比
除了向量化执行之外,还有一种利用现代 CPU 的方法:编译执行(Compiled Execution),也叫代码生成(Code Generation)。编译执行把查询计划编译成原生机器码,消除了虚函数调用和解释开销。
| 维度 | 向量化执行(Vectorized) | 编译执行(Compiled) |
|---|---|---|
| 代表系统 | ClickHouse、DuckDB、Velox | HyPer、Spark Tungsten、CockroachDB |
| 实现复杂度 | 中等 | 高(需要 JIT 编译器) |
| SIMD 利用 | 显式使用 SIMD 内建函数 | 依赖编译器自动向量化 |
| 启动延迟 | 低 | 高(编译耗时) |
| 短查询性能 | 好 | 差(编译开销占主导) |
| 长查询性能 | 好 | 更好(无函数调用开销) |
| 调试和可观测性 | 容易(可以断点调试) | 难(动态生成的代码) |
实际工程中,两种方式各有优劣。向量化执行的实现难度更低、调试更容易,且对短查询更友好;编译执行在长查询上可以进一步消除函数调用和数据搬运的开销。DuckDB 在最近的版本中同时使用了向量化和编译执行,根据查询特征自动选择。
四、列式写入挑战
4.1 行式写入 vs 列式写入
行式存储的写入模型非常简单:插入一行只需要在数据文件末尾追加一条定长或变长记录,通常只涉及一个磁盘页的写入。
列式存储的写入要复杂得多。插入一行意味着需要在 \(C\) 个列文件中各追加一个值。如果表有 100 列,一次插入操作就变成了 100 次写入,写放大(Write Amplification)达到 100 倍:
行式存储插入一行:
数据文件 → 追加 [id, name, age, city, salary] → 1 次写入
列式存储插入一行:
id 列文件 → 追加 id 值 → 第 1 次写入
name 列文件 → 追加 name 值 → 第 2 次写入
age 列文件 → 追加 age 值 → 第 3 次写入
city 列文件 → 追加 city 值 → 第 4 次写入
salary 列文件 → 追加 salary 值 → 第 5 次写入
更严重的是,如果每列使用了编码(如字典编码、RLE),单行插入意味着需要更新每列的编码结构——可能需要重新排序、重建字典或重新计算游程,这在工程上是不可接受的。
4.2 批量写入与 Delta Store
几乎所有列式存储系统都采用批量写入(Batch Write)来缓解写放大问题。核心思路是把写入操作先缓存在一个行式的内存缓冲区(Delta Store)中,积累到一定量后再批量转换为列式格式写入磁盘。
写入路径:
┌─────────────┐ 批量转换 ┌──────────────────┐
│ Delta Store │ ──────────→ │ 列式存储(主存储) │
│ (行式,内存) │ │ (列式,磁盘) │
└─────────────┘ └──────────────────┘
↑ 单行插入 ↑ 批量追加
│ │
新写入在这里缓冲 达到阈值后合并
读取时需要同时查询主存储(列式)和 Delta Store(行式),合并结果返回。这个合并操作增加了读取路径的复杂度,但由于 Delta Store 通常很小(几 MB 到几十 MB),对读性能的影响有限。
4.3 LSM-Tree 与列式存储的结合
一些系统使用类似 LSM-Tree(Log-Structured Merge Tree)的结构来处理列式存储的写入。ClickHouse 的 MergeTree 引擎就是典型的例子:
ClickHouse MergeTree 写入路径:
1. 写入请求到达 → 数据先写入内存中的排序缓冲区
2. 缓冲区满 → 按主键排序 → 以列式格式写入磁盘,形成一个数据片段(Part)
3. 后台线程持续合并(Merge)小片段为大片段
时间 →
┌───────┐
│ Part 1 │ ─┐
└───────┘ ├─合并→ ┌─────────┐
┌───────┐ │ │ Part 1+2 │ ─┐
│ Part 2 │ ─┘ └─────────┘ │
└───────┘ ├─合并→ ┌────────────┐
┌───────┐ │ │ Part 1+2+3 │
│ Part 3 │ ───────────────────────┘ └────────────┘
└───────┘
每个 Part 内部是列式存储,每列独立存储为一个文件,带有排序索引和编码。合并操作负责合并排序、去重和重建编码。
4.4 更新和删除的挑战
列式存储的更新(Update)和删除(Delete)比写入更困难。因为修改一行的某一列意味着在列文件的中间位置修改数据,这会破坏已有的编码和压缩结构。
常见的解决方案有两种:
标记删除(Tombstone): 不实际删除数据,而是在一个单独的删除位图(Delete Bitmap)中标记该行已被删除。读取时跳过被标记的行。
salary 列: [15000, 22000, 18000, 25000, 13000]
删除位图: [0, 1, 0, 0, 0 ]
读取时: [15000, -, 18000, 25000, 13000]
第 1 行被跳过
写时复制(Copy-on-Write): 不修改原始文件,而是写入一个新的数据片段,包含修改后的行。读取时用新片段覆盖旧片段中的对应行。这种方式需要后台合并来回收空间。
4.5 事务支持
列式存储的事务支持比行式存储更难实现。行式存储可以通过行级锁(Row-Level Lock)或多版本并发控制(MVCC,Multi-Version Concurrency Control)实现细粒度的事务隔离。列式存储的数据按列分散存储,一行数据涉及多个列文件,实现原子性(Atomicity)需要跨文件的协调。
实际工程中,大多数列式存储系统的事务能力有限:
| 系统 | 事务支持级别 |
|---|---|
| ClickHouse | 无完整事务,Part 级别的原子性 |
| DuckDB | 完整 ACID 事务(MVCC) |
| Redshift | 可序列化隔离级别 |
| Apache Parquet | 文件级别,无事务 |
DuckDB 是一个有趣的例外——它在列式存储之上实现了完整的 ACID 事务,使用基于行组(Row Group)的 MVCC。每个行组包含约 122880 行(120 × 1024),每个行组有自己的版本链(Version Chain),事务通过版本链来判断可见性。
五、PAX 混合布局
5.1 行式和列式的两难
前面两章分别讨论了行式和列式存储的优劣。总结起来:
- 行式存储:写入快、点查询快、事务简单,但分析查询慢。
- 列式存储:分析查询快、压缩率高、向量化友好,但写入慢、点查询慢。
有没有一种布局能同时兼顾两者的优点?PAX(Partition Attributes Across)布局是一种经典的折中方案。
5.2 PAX 布局的结构
PAX 布局的核心思想是:在磁盘页内部采用列式布局,在页与页之间仍然保持行的分组。每个磁盘页(通常称为行组,Row Group)包含一组连续行的所有列,但在页内部,同一列的值被集中存放:
┌─── 行组 0(Row Group 0)─────────────────────────────────┐
│ ┌─── id 列(行 0-999)──────────────────────────────────┐ │
│ │ [1, 2, 3, 4, ..., 1000] │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌─── name 列(行 0-999)────────────────────────────────┐ │
│ │ ["张三", "李四", "王五", ..., "第一千人"] │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌─── age 列(行 0-999)─────────────────────────────────┐ │
│ │ [28, 35, 31, 42, ..., 29] │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌─── salary 列(行 0-999)──────────────────────────────┐ │
│ │ [15000, 22000, 18000, 25000, ..., 16000] │ │
│ └────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
┌─── 行组 1(Row Group 1)─────────────────────────────────┐
│ ┌─── id 列(行 1000-1999)──────────────────────────────┐ │
│ │ [1001, 1002, ..., 2000] │ │
│ └────────────────────────────────────────────────────────┘ │
│ ... │
└───────────────────────────────────────────────────────────┘
5.3 PAX 布局的优势
PAX 布局试图兼顾两个世界的优点:
- 列式扫描效率: 分析查询扫描某一列时,在每个行组内部该列的值是连续的,可以做 SIMD 向量化处理。
- 行式点查询效率: 点查询按行号定位到所属行组后,该行的所有列都在同一个行组内,不需要跨文件读取。
- 行组级别的编码和压缩: 每个行组内的每列可以独立选择编码方式,行组级别的压缩可以利用列内的统计特性。
- 行组级别的元数据: 每个行组可以存储每列的最小值、最大值、空值数量等统计信息,用于谓词下推(Predicate Pushdown)跳过不满足条件的行组。
5.4 Apache Parquet 的行组设计
Apache Parquet 是 PAX 思想的典型实现。Parquet 文件由多个行组(Row Group)组成,每个行组包含该组所有行的所有列,每列存储为一个列块(Column Chunk),每个列块由多个数据页(Data Page)组成:
Parquet 文件结构:
┌──────────────────────────────────────────────┐
│ Magic Number: PAR1 │
├──────────────────────────────────────────────┤
│ Row Group 0 │
│ ├── Column Chunk: id │
│ │ ├── Data Page 0 │
│ │ ├── Data Page 1 │
│ │ └── ... │
│ ├── Column Chunk: name │
│ │ ├── Data Page 0 │
│ │ └── ... │
│ ├── Column Chunk: age │
│ └── Column Chunk: salary │
├──────────────────────────────────────────────┤
│ Row Group 1 │
│ ├── Column Chunk: id │
│ └── ... │
├──────────────────────────────────────────────┤
│ Footer │
│ ├── File Metadata │
│ │ ├── Schema │
│ │ ├── Row Group Metadata │
│ │ │ ├── Column Chunk Metadata │
│ │ │ │ ├── 编码方式 │
│ │ │ │ ├── 压缩算法 │
│ │ │ │ ├── 统计信息(min/max/null count)│
│ │ │ │ └── 文件偏移量 │
│ │ │ └── ... │
│ │ └── ... │
│ └── Footer Length │
├──────────────────────────────────────────────┤
│ Magic Number: PAR1 │
└──────────────────────────────────────────────┘
Parquet 的每个数据页可以独立选择编码方式(字典编码、RLE、Delta 等)和压缩算法(Snappy、Gzip、LZ4、Zstd)。读取时,查询引擎先读取 Footer 中的元数据,根据查询条件利用统计信息跳过不满足条件的行组和列块,只解压和解码需要的数据页。
5.5 行组大小的选择
行组大小的选择对性能有直接影响:
| 行组大小 | 分析查询性能 | 点查询性能 | 压缩率 | 元数据开销 |
|---|---|---|---|---|
| 小(1K 行) | 较差(行组切换开销大) | 好(定位快) | 较差 | 高 |
| 中(100K 行) | 好 | 中等 | 好 | 中等 |
| 大(1M 行) | 好(SIMD 长向量) | 差(读取冗余数据多) | 最好 | 低 |
Parquet 默认的行组大小约
128MB(行数取决于列宽度),DuckDB 使用 122880 行(120 ×
1024)为一个行组,ClickHouse 的默认
index_granularity 为 8192 行。
我认为,行组大小的选择本质上是一个 I/O 粒度与过滤效率的权衡。行组越大,顺序 I/O 效率越高,压缩率越好,但谓词下推跳过行组的概率越低——一个行组内只要有一行满足条件,整个行组都要被读取。在实际工程中,10 万到 50 万行的行组大小通常是比较好的折中点。
六、列式存储在数据库中的实现
6.1 ClickHouse
ClickHouse 是 Yandex 开发的开源列式分析数据库(Column-Oriented OLAP DBMS),以极致的查询性能著称。ClickHouse 的存储引擎是 MergeTree 家族,核心设计围绕列式存储和后台合并展开。
6.1.1 MergeTree 存储结构
MergeTree 的数据组织层次:
Table
└── Partition(按分区键划分,例如按月)
└── Part(一次插入或合并产生的数据片段)
├── primary.idx (稀疏主键索引)
├── id.bin (id 列的压缩数据)
├── id.mrk3 (id 列的标记文件,记录 granule 偏移)
├── name.bin (name 列的压缩数据)
├── name.mrk3
├── age.bin
├── age.mrk3
├── salary.bin
├── salary.mrk3
├── checksums.txt (校验和)
├── columns.txt (列信息)
└── count.txt (行数)
每列独立存储为一个 .bin
文件,列数据被切分为固定行数的粒度块(Granule),默认每个
Granule 8192 行。标记文件(.mrk3)记录每个
Granule 在 .bin
文件中的偏移量,用于快速定位。
6.1.2 ClickHouse 的稀疏索引
ClickHouse 不使用 B-Tree 或 B+Tree 索引,而是使用稀疏索引(Sparse Index)。主键索引只存储每个 Granule 的第一行的主键值:
Granule 0: rows 0-8191 → 主键索引记录第 0 行的主键值
Granule 1: rows 8192-16383 → 主键索引记录第 8192 行的主键值
Granule 2: rows 16384-24575 → 主键索引记录第 16384 行的主键值
...
查询时,ClickHouse 通过二分查找在主键索引中定位需要扫描的 Granule 范围,然后只读取这些 Granule 对应的列数据。对于 1 亿行的表(约 12207 个 Granule),主键索引只有几百 KB,可以常驻内存。
6.1.3 ClickHouse 的向量化引擎
ClickHouse 的查询执行引擎完全基于向量化,以列式向量(Column Vector)为基本处理单元。每个算子处理的批次大小等于 Granule 大小(默认 8192 行)。ClickHouse 大量使用手写的 SIMD 内建函数,针对不同的 CPU 架构生成优化代码:
// ClickHouse 源码中的向量化聚合示例(简化)
// src/AggregateFunctions/AggregateFunctionSum.h
template <typename T>
void addBatch(const T * data, size_t count) {
// 编译器在 -O2 下会自动向量化这个循环
for (size_t i = 0; i < count; ++i) {
sum += data[i];
}
}ClickHouse 还支持运行时 CPU 特性检测,根据当前 CPU 支持的 SIMD 指令集动态选择最优的代码路径(SSE4.2、AVX2 或 AVX-512)。
6.2 DuckDB
DuckDB 自称”SQLite for Analytics”,是一个嵌入式的列式分析数据库。与 ClickHouse 的服务器架构不同,DuckDB 以库的形式嵌入到应用进程中,不需要独立的服务器进程。
6.2.1 DuckDB 的存储架构
DuckDB 的存储基于行组(Row Group),每个行组包含 122880 行(120 × 1024),每列在行组内独立存储和压缩。DuckDB 会根据列的数据特征自动选择最优的压缩算法:
DuckDB 自动压缩选择:
├── 常量列 → Constant 编码(只存一个值)
├── 低基数列 → Dictionary 编码
├── 有序整数列 → FOR + Bit Packing
├── 近似有序数值列 → PFOR-Delta
├── 字符串列 → FSST(Fast Static Symbol Table)
└── 其他 → 无编码 + 可选 LZ4/Zstd 压缩
FSST(Fast Static Symbol Table)是 DuckDB 团队提出的字符串压缩算法,通过学习一张 256 项的符号表把常见的子串映射为单字节编码,压缩和解压都非常快,且不需要全局字典。
6.2.2 DuckDB 的向量化执行
DuckDB 采用基于向量(Vector)的解释执行模型,每个 Vector 包含 2048 个值。DuckDB 的算子实现高度模板化,针对不同的类型组合生成特化版本:
// DuckDB 向量化加法的伪代码
template <class LEFT_TYPE, class RIGHT_TYPE, class RESULT_TYPE>
void VectorAdd(Vector &left, Vector &right, Vector &result, idx_t count) {
auto ldata = FlatVector::GetData<LEFT_TYPE>(left);
auto rdata = FlatVector::GetData<RIGHT_TYPE>(right);
auto result_data = FlatVector::GetData<RESULT_TYPE>(result);
for (idx_t i = 0; i < count; i++) {
result_data[i] = ldata[i] + rdata[i];
}
}DuckDB 的一个重要设计决策是不使用手写的 SIMD 内建函数,而是依赖编译器的自动向量化(Auto-Vectorization)。这使得代码更易维护,且可以自动适配不同的 CPU 架构。DuckDB 团队发现,在现代编译器(GCC 11+、Clang 14+)下,自动向量化在大多数情况下可以达到手写 SIMD 的 80-90% 的性能。
6.2.3 DuckDB 的 ACID 事务
DuckDB 在列式存储之上实现了完整的 ACID 事务,使用乐观并发控制(Optimistic Concurrency Control)和 MVCC。每个行组维护一个版本链,事务写入时创建新版本,提交时原子地更新版本指针。
行组的版本管理:
Row Group 0:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Version 3│←──│ Version 2│←──│ Version 1│
│ (当前) │ │ (已提交) │ │ (已提交) │
└─────────┘ └─────────┘ └─────────┘
↑
事务 T5 看到 Version 3
事务 T3 看到 Version 2(快照读)
这使得 DuckDB 可以在 OLAP 查询的同时进行写入操作,不需要锁表。
6.3 Amazon Redshift
Amazon Redshift 是 AWS 的云数据仓库(Cloud Data Warehouse)服务,基于 ParAccel 的 MPP(Massively Parallel Processing)架构开发。Redshift 是最早在商业上大规模使用列式存储的数据仓库之一。
6.3.1 Redshift 的存储架构
Redshift 使用列式存储,每列独立存储,数据按 1MB 固定大小的块(Block)组织。每个块内部使用自动选择的编码方式:
-- Redshift 的 ANALYZE COMPRESSION 命令
-- 可以分析表中每列的最佳编码方式
ANALYZE COMPRESSION employees;
-- 输出示例:
-- Column | Encoding | Est_reduction_pct
-- id | delta | 75.00
-- name | lzo | 50.00
-- age | bytedict | 80.00
-- city | bytedict | 85.00
-- salary | delta | 70.00Redshift 支持的编码方式包括:
- RAW(无编码)
- BYTEDICT(字典编码,基数 <= 256)
- DELTA / DELTA32K(差分编码)
- LZO(LZO 压缩)
- MOSTLY8 / MOSTLY16 / MOSTLY32(大部分值可以用更少位数表示)
- RUNLENGTH(游程编码)
- TEXT255 / TEXT32K(字符串字典编码)
- ZSTD(Zstandard 压缩)
- AZ64(Amazon 自研编码,结合 SIMD 优化)
6.3.2 区域映射(Zone Map)
Redshift 为每个 1MB 块自动维护区域映射(Zone Map),记录该块内每列的最小值和最大值。查询时,Redshift 通过区域映射跳过不满足 WHERE 条件的块:
salary 列的 Zone Map:
Block 0: min=10000, max=25000 → WHERE salary > 30000 → 跳过
Block 1: min=22000, max=45000 → WHERE salary > 30000 → 需要扫描
Block 2: min=35000, max=60000 → WHERE salary > 30000 → 需要扫描
Block 3: min=8000, max=15000 → WHERE salary > 30000 → 跳过
如果数据按 salary 排序,Zone Map
的过滤效果最好,因为每个块内的值域范围最窄。如果数据是随机排列的,每个块可能包含从最小到最大的全部值域,Zone
Map 几乎无法过滤。
这就是为什么 Redshift 文档强烈建议根据查询模式选择排序键(Sort Key)——排序键的选择直接影响区域映射的过滤效率,进而影响扫描性能。
6.3.3 Redshift Spectrum 和数据湖集成
Redshift Spectrum 允许直接查询 Amazon S3 上的 Parquet、ORC 等列式文件格式的数据,不需要将数据加载到 Redshift 集群中。这种架构将存储和计算解耦(Disaggregated Storage),利用了列式文件格式的谓词下推和列裁剪(Column Pruning)能力:
-- 创建外部表,指向 S3 上的 Parquet 文件
CREATE EXTERNAL TABLE spectrum.sales (
sale_id BIGINT,
product VARCHAR(100),
amount DECIMAL(10,2),
sale_date DATE
)
STORED AS PARQUET
LOCATION 's3://my-bucket/sales/';
-- 查询时自动下推过滤条件和列选择到 S3 读取层
SELECT product, SUM(amount)
FROM spectrum.sales
WHERE sale_date >= '2024-01-01'
GROUP BY product;七、行列混合(HTAP:TiDB TiFlash)
7.1 HTAP 的需求背景
实际业务中,OLTP(Online Transaction Processing)和 OLAP(Online Analytical Processing)负载往往同时存在。传统方案是用 ETL(Extract-Transform-Load)把 OLTP 数据库的数据定期同步到独立的 OLAP 数据仓库:
传统 OLTP + OLAP 架构:
┌──────────────┐ ETL(每天/每小时) ┌──────────────────┐
│ OLTP 数据库 │ ───────────────────────→ │ OLAP 数据仓库 │
│ (MySQL 等) │ │ (Redshift 等) │
│ 行式存储 │ │ 列式存储 │
└──────────────┘ └──────────────────┘
↑ 事务查询 ↑ 分析查询
这种架构的问题是数据新鲜度(Data Freshness)——ETL 有延迟,分析查询看到的数据是几小时甚至一天前的。HTAP(Hybrid Transactional/Analytical Processing)的目标是在一个系统中同时支持 OLTP 和 OLAP,让分析查询能够访问最新的事务数据。
7.2 TiDB 的行列混合架构
TiDB 是 PingCAP 开发的分布式 HTAP 数据库。TiDB 的架构通过 TiKV(行式存储)和 TiFlash(列式存储)的协同来实现 HTAP:
TiDB HTAP 架构:
┌──────────────┐
│ TiDB Server │ (SQL 层,查询路由)
└──────┬───────┘
│
┌──────────┴──────────┐
│ │
┌───────┴───────┐ ┌───────┴───────┐
│ TiKV │ │ TiFlash │
│ (行式存储) │ │ (列式存储) │
│ Raft + RocksDB │ │ Delta + Stable │
│ OLTP 负载 │ │ OLAP 负载 │
└───────────────┘ └───────────────┘
│ ↑
│ Raft Learner │
└─────────────────────┘
异步复制列式副本
7.3 TiFlash 的数据同步机制
TiFlash 通过 Raft Learner 机制从 TiKV 异步复制数据。Raft Learner 是 Raft 协议的一种特殊角色——它接收 Raft 日志但不参与投票,不影响 TiKV 的写入延迟:
Raft 日志复制到 TiFlash 的流程:
TiKV(Leader)写入一条数据
│
├── 复制到 TiKV Follower 1 (参与投票,同步)
├── 复制到 TiKV Follower 2 (参与投票,同步)
└── 复制到 TiFlash Learner (不参与投票,异步)
│
▼
TiFlash 收到 Raft 日志
│
▼
行式数据写入 Delta Layer(内存 + 磁盘)
│
▼
后台线程将 Delta Layer 合并到 Stable Layer(列式,排序)
TiFlash 的数据延迟通常在秒级——从 TiKV 写入到 TiFlash 可查询,延迟一般在 1-5 秒。对于大多数分析场景,这个延迟是可以接受的。
7.4 TiFlash 的 Delta-Tree 存储引擎
TiFlash 使用 Delta-Tree 存储引擎,灵感来自 LSM-Tree 和 B-Tree 的混合设计:
TiFlash Delta-Tree 存储结构:
┌─── Segment(数据范围分片)──────────────────────────────┐
│ │
│ ┌── Delta Layer ──────────────────────────────────┐ │
│ │ 新写入的数据(行式,无序,支持高频更新) │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Delta Pack│ │ Delta Pack│ │ Delta Pack│ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │ 后台合并 │
│ ▼ │
│ ┌── Stable Layer ─────────────────────────────────┐ │
│ │ 历史数据(列式,排序,高度压缩) │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Stable Pack │ │ Stable Pack │ │ │
│ │ │ (列式存储) │ │ (列式存储) │ │ │
│ │ └────────────┘ └────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────┘
Delta Layer 以行式或小批量列式的方式存储新写入的数据,支持高频写入。Stable Layer 以列式格式存储已合并的历史数据,支持高效的分析扫描。后台合并线程持续将 Delta Layer 的数据排序、编码、压缩后合并到 Stable Layer。
7.5 查询路由与优化器协同
TiDB 的查询优化器(Query Optimizer)根据查询特征自动决定使用 TiKV 还是 TiFlash:
查询路由决策:
SELECT * FROM orders WHERE id = 42
→ 点查询 → 路由到 TiKV(行式存储)
SELECT product, SUM(amount) FROM orders GROUP BY product
→ 全表聚合 → 路由到 TiFlash(列式存储)
SELECT o.*, c.name FROM orders o JOIN customers c ON o.cid = c.id WHERE c.city = '北京'
→ 混合查询 → customers 的过滤在 TiFlash,orders 的点查在 TiKV
→ 或者全部在 TiFlash(如果优化器判断列式扫描更快)
TiDB 还支持通过 SQL Hint 手动指定存储引擎:
-- 强制使用 TiFlash
SELECT /*+ READ_FROM_STORAGE(TIFLASH[orders]) */
product, SUM(amount)
FROM orders
GROUP BY product;
-- 强制使用 TiKV
SELECT /*+ READ_FROM_STORAGE(TIKV[orders]) */
* FROM orders WHERE id = 42;7.6 其他 HTAP 方案
除了 TiDB 之外,还有几种 HTAP 实现方案值得关注:
| 系统 | 行列混合策略 | 数据同步方式 | 分析查询延迟 |
|---|---|---|---|
| TiDB(TiFlash) | 独立列式副本 | Raft Learner 异步复制 | 秒级 |
| SQL Server(Columnstore Index) | 同表列式索引 | 同步(索引维护) | 实时 |
| Oracle(In-Memory Column Store) | 内存列式缓存 | 同步(后台转换) | 实时 |
| MySQL HeatWave | 独立内存列式引擎 | 异步同步 | 秒级到分钟级 |
| SingleStore(MemSQL) | 行式 + 列式表 | 手动选择 | 实时 |
| AlloyDB(Google) | 列式引擎 | 预写日志复制 | 亚秒级 |
SQL Server 的列存储索引(Columnstore Index)是一种无需独立存储引擎的方案。在现有行式表上创建非聚集列存储索引(Nonclustered Columnstore Index),SQL Server 自动维护一份列式副本,分析查询使用列存储索引,事务查询使用行存储。这种方案的优点是对应用透明,缺点是索引维护的写放大。
我认为,行列混合的核心难题不是存储格式本身,而是如何在不影响 OLTP 写入性能的前提下保持分析数据的新鲜度。TiDB 通过 Raft Learner 解耦了写入和复制,SQL Server 通过 Delta Store 缓冲列存储索引的写入,两者都在工程上做了大量的取舍。
八、列式存储性能实测
8.1 测试方法论
性能测试的可信度取决于测试方法的严谨性。以下测试遵循几个原则:
- 使用公开的基准测试(TPC-H、ClickBench)。
- 明确标注硬件配置、软件版本和数据规模。
- 每项测试至少执行 3 次,取中位数。
- 区分冷启动(Cold Run,无缓存)和热启动(Warm Run,数据已缓存)。
8.2 测试环境
硬件配置:
CPU: AMD EPYC 7R13 (64 vCPU, 支持 AVX2)
内存: 128 GB DDR4-3200
磁盘: AWS gp3 SSD (500 MB/s, 3000 IOPS)
操作系统: Ubuntu 22.04 LTS (Kernel 5.15)
软件版本:
ClickHouse: 24.3 LTS
DuckDB: 1.0.0
PostgreSQL: 16.2(行式对照组)
8.3 TPC-H 基准测试
TPC-H 是标准的决策支持基准测试,包含 22 条分析查询。以下是 Scale Factor = 100(约 100GB 原始数据)的测试结果:
8.3.1 数据加载和存储空间
| 指标 | PostgreSQL(行式) | ClickHouse(列式) | DuckDB(列式) |
|---|---|---|---|
| 原始数据大小 | 100 GB | 100 GB | 100 GB |
| 加载后磁盘占用 | 110 GB | 18 GB | 22 GB |
| 压缩率 | 0.9:1 | 5.6:1 | 4.5:1 |
| 数据加载时间 | 约 45 分钟 | 约 12 分钟 | 约 15 分钟 |
ClickHouse 的压缩率最高,18GB 的磁盘占用只有原始数据的约 18%。PostgreSQL 加载后反而比原始数据略大,因为行式存储需要额外的行头(Row Header)、对齐填充和索引。
8.3.2 典型查询性能对比
选取 TPC-H 中几条代表性查询,对比冷启动和热启动的执行时间:
TPC-H Q1:简单聚合扫描(扫描 lineitem 表,无 JOIN)
-- TPC-H Q1: 定价汇总报表
SELECT
l_returnflag,
l_linestatus,
SUM(l_quantity) AS sum_qty,
SUM(l_extendedprice) AS sum_base_price,
SUM(l_extendedprice * (1 - l_discount)) AS sum_disc_price,
AVG(l_quantity) AS avg_qty,
AVG(l_extendedprice) AS avg_price,
AVG(l_discount) AS avg_disc,
COUNT(*) AS count_order
FROM lineitem
WHERE l_shipdate <= DATE '1998-12-01' - INTERVAL '90' DAY
GROUP BY l_returnflag, l_linestatus
ORDER BY l_returnflag, l_linestatus;| 指标 | PostgreSQL | ClickHouse | DuckDB |
|---|---|---|---|
| 冷启动 | 180 秒 | 1.2 秒 | 2.8 秒 |
| 热启动 | 95 秒 | 0.4 秒 | 0.9 秒 |
| 扫描数据量 | 约 75 GB | 约 3.2 GB | 约 4.1 GB |
ClickHouse 比 PostgreSQL 快约 150 倍(冷启动)和约 240 倍(热启动),主要得益于:只读取涉及的 6 列(而非全部 16 列)、列式压缩减少 I/O、向量化执行加速聚合计算。
TPC-H Q6:单表过滤聚合(最简单的分析查询)
-- TPC-H Q6: 预测收入变化
SELECT SUM(l_extendedprice * l_discount) AS revenue
FROM lineitem
WHERE l_shipdate >= DATE '1994-01-01'
AND l_shipdate < DATE '1995-01-01'
AND l_discount BETWEEN 0.05 AND 0.07
AND l_quantity < 24;| 指标 | PostgreSQL | ClickHouse | DuckDB |
|---|---|---|---|
| 冷启动 | 120 秒 | 0.3 秒 | 0.6 秒 |
| 热启动 | 68 秒 | 0.08 秒 | 0.15 秒 |
Q6 只涉及 4 列,列式存储的 I/O 优势最大。ClickHouse 热启动 80 毫秒,是 PostgreSQL 的约 850 倍。
TPC-H Q9:复杂多表 JOIN
-- TPC-H Q9: 产品类型利润度量(涉及 6 张表的 JOIN)
-- 此查询涉及 part, supplier, lineitem, partsupp, orders, nation 六张表
SELECT nation, o_year, SUM(amount) AS sum_profit
FROM (
SELECT n_name AS nation,
EXTRACT(YEAR FROM o_orderdate) AS o_year,
l_extendedprice * (1 - l_discount) - ps_supplycost * l_quantity AS amount
FROM part, supplier, lineitem, partsupp, orders, nation
WHERE s_suppkey = l_suppkey AND ps_suppkey = l_suppkey
AND ps_partkey = l_partkey AND p_partkey = l_partkey
AND o_orderkey = l_orderkey AND s_nationkey = n_nationkey
AND p_name LIKE '%green%'
) AS profit
GROUP BY nation, o_year
ORDER BY nation, o_year DESC;| 指标 | PostgreSQL | ClickHouse | DuckDB |
|---|---|---|---|
| 冷启动 | 350 秒 | 8.5 秒 | 5.2 秒 |
| 热启动 | 180 秒 | 3.2 秒 | 2.1 秒 |
复杂 JOIN 查询中,DuckDB 的性能超过了 ClickHouse。DuckDB 的查询优化器在 JOIN 顺序选择和 Hash Join 的内存管理上更成熟。ClickHouse 传统上更擅长单表或少量表的宽表扫描,复杂 JOIN 不是它的最强场景。
8.4 行式存储的反例
为了验证列式存储并非万能,我们也测试了 OLTP 风格的查询:
-- 按主键查一行
SELECT * FROM lineitem WHERE l_orderkey = 12345 AND l_linenumber = 1;| 指标 | PostgreSQL(有 B-Tree 索引) | ClickHouse | DuckDB |
|---|---|---|---|
| 执行时间 | 0.1 毫秒 | 5 毫秒 | 2 毫秒 |
点查询上,PostgreSQL 的 B-Tree 索引可以在 0.1 毫秒内定位到目标行。ClickHouse 需要通过稀疏索引定位 Granule,再在 Granule 内扫描,延迟高出约 50 倍。列式存储在点查询场景下没有优势。
8.5 ClickBench 的实测数据
ClickBench 是 ClickHouse 官方提供的更贴近实际分析负载的基准测试,使用来自 Web 分析的真实数据集(约 1 亿行,100+ 列)。以下是部分查询的对比数据:
ClickBench 部分查询对比(单位:秒,热启动中位数)
查询类型 PostgreSQL ClickHouse DuckDB
简单 COUNT 18.2 0.01 0.02
单列 GROUP BY 35.1 0.04 0.08
多列 GROUP BY 42.5 0.12 0.18
字符串 LIKE 过滤 28.3 0.08 0.15
时间范围 + 聚合 31.7 0.05 0.09
高基数 COUNT DISTINCT 45.2 0.35 0.42
多条件 AND/OR 过滤 38.9 0.09 0.14
这些数据清楚地表明:在典型的分析查询上,列式存储比行式存储快一到三个数量级。
九、何时选择列式存储
9.1 列式存储的最佳场景
根据前面的分析和实测数据,列式存储在以下场景下应该作为首选:
1. 数据仓库和 BI 报表
数据仓库的查询模式是扫描大量数据、聚合少量列。表通常有几十到几百列,但单条查询只涉及 3-10 列。这是列式存储的理想场景,I/O 减少和压缩优势可以带来数十倍的性能提升。
2. 时序数据分析
时序数据(Time-Series Data)的典型查询是在时间范围内对某些指标列做聚合(SUM、AVG、MAX)。时间戳列天然有序,差分编码的压缩率极高;指标列通常是数值类型,向量化聚合的效率最高。
3. 日志和事件分析
日志数据通常有宽表结构(几十到几百个字段),但分析查询只关注少量字段。列式存储的列裁剪(Column Pruning)可以大幅减少 I/O。ClickHouse 在日志分析领域被广泛采用就是这个原因。
4. 大批量数据导出和 ETL
数据湖(Data Lake)场景中,Parquet 和 ORC 格式的列式文件已经成为事实标准。列式文件格式的高压缩率节省存储成本,列裁剪和谓词下推减少计算成本。
9.2 行式存储仍然更好的场景
1. OLTP 事务处理
高并发的短事务(每秒数千到数万个事务)需要快速的单行读写。行式存储的 B-Tree 索引支持 O(log n) 的点查询,行级锁支持细粒度并发控制。列式存储在这些方面没有优势。
2. 窄表、全列查询
如果表只有 3-5 列,且查询经常需要所有列,行式和列式的 I/O 差异很小。此时列式存储的写入劣势和实现复杂度反而是负担。
3. 频繁的单行更新和删除
如果业务逻辑需要高频地更新单行数据(如修改用户状态、更新库存),行式存储的原地更新(In-Place Update)远比列式存储的标记删除和后台合并高效。
4. 对事务一致性要求极高的场景
银行核心系统、订单系统等需要强一致性和完整 ACID 事务的场景,行式存储(如 PostgreSQL、MySQL InnoDB)的事务模型更成熟。
9.3 选型决策树
你的负载是什么类型?
│
├─ 主要是 OLTP(短事务、点查询、高并发写入)
│ → 行式存储(PostgreSQL / MySQL)
│
├─ 主要是 OLAP(聚合查询、全表扫描、批量加载)
│ → 列式存储
│ │
│ ├─ 需要独立部署的分析数据库?
│ │ ├─ 数据量 < 100GB,单机即可 → DuckDB
│ │ ├─ 数据量 100GB-10TB → ClickHouse(单节点或小集群)
│ │ └─ 数据量 > 10TB → ClickHouse 集群 / Redshift / BigQuery
│ │
│ └─ 数据存在数据湖(S3/HDFS)中?
│ → Parquet 格式 + 查询引擎(Spark / Trino / DuckDB)
│
├─ OLTP 和 OLAP 混合(HTAP)
│ ├─ 可以接受秒级数据延迟 → TiDB + TiFlash
│ ├─ 需要实时分析 → SQL Server + Columnstore Index
│ └─ 在现有 PostgreSQL 上加分析能力 → Citus / TimescaleDB
│
└─ 嵌入式场景(嵌入到应用进程中)
├─ 需要分析能力 → DuckDB
└─ 需要事务能力 → SQLite / LMDB
9.4 迁移到列式存储的注意事项
从行式存储迁移到列式存储时,需要注意以下几点:
1. 数据建模的变化
行式存储通常采用范式化(Normalized)设计,通过 JOIN 关联多张表。列式存储更适合宽表(Wide Table)设计——把需要一起查询的数据预先 JOIN 好,存储在一张宽表中。ClickHouse 等系统的 JOIN 性能不如行式数据库,宽表设计可以避免昂贵的 JOIN 操作。
2. 写入模式的调整
行式存储可以一行一行地写入。列式存储必须批量写入——单行插入的性能很差,应该积累到至少几千行再一次性写入。ClickHouse 官方建议每次插入至少 1000 行,最好是 10000-100000 行。
3. 索引策略的差异
行式存储依赖 B-Tree 索引加速查询。列式存储通常不使用 B-Tree,而是依赖稀疏索引、Zone Map 和布隆过滤器(Bloom Filter)等轻量级索引。迁移后,需要重新设计索引策略。
4. 排序键的选择
列式存储的排序键(Sort Key / Primary Key / ORDER BY)对查询性能有巨大影响。排序键决定了数据的物理存储顺序,直接影响压缩率和谓词下推的效果。选择排序键时应该优先考虑最频繁的查询过滤条件。
-- ClickHouse 排序键示例
CREATE TABLE events (
event_date Date,
user_id UInt64,
event_type String,
payload String
)
ENGINE = MergeTree()
ORDER BY (event_date, user_id, event_type);
-- 按时间 → 用户 → 事件类型排序
-- WHERE event_date = '2024-01-15' 可以利用排序键快速过滤
-- WHERE user_id = 12345 需要扫描更多 Granule(不是排序键前缀)9.5 列式存储的未来趋势
列式存储技术仍在快速演进,几个值得关注的方向:
1. 存算分离(Disaggregated Storage and Compute)
Snowflake、Databricks、BigQuery 等云数据仓库都采用了存算分离架构:数据以 Parquet 等列式格式存储在对象存储(如 S3)中,计算节点按需启动。这种架构的弹性和成本优势使得列式存储从专用硬件走向了通用云基础设施。
2. 向量化与硬件加速的深化
随着 AVX-512、ARM SVE 等更宽的 SIMD 指令集的普及,以及 GPU 和 FPGA 在数据库加速中的探索(如 RAPIDS cuDF),列式存储的向量化执行还有进一步提速的空间。
3. 自适应布局
一些研究和系统开始探索自适应存储布局(Adaptive Layout)——根据查询负载自动在行式和列式之间切换,或者自动选择最优的行组大小和编码方式。CockroachDB 在 2024 年引入的列式执行引擎就是沿着这个方向的工程实践。
4. 列式存储与数据湖的融合
Apache Iceberg、Delta Lake、Apache Hudi 等表格式(Table Format)在 Parquet 等列式文件格式之上构建了事务支持、模式演化(Schema Evolution)和时间旅行(Time Travel)等能力,模糊了数据仓库和数据湖的边界。
本文从 I/O 模型出发,系统地拆解了列式存储在分析查询上快一到两个数量级的核心原因:列裁剪减少无关 I/O,专用编码提高压缩率进一步减少 I/O,连续内存布局释放 SIMD 向量化潜力,延迟物化减少中间数据量。同时也明确了列式存储的代价——写入放大、更新困难、事务复杂度高——以及 PAX 混合布局和 HTAP 架构如何在这些约束下寻找平衡。选型的关键不在于哪种布局”更好”,而在于负载特征与存储布局的匹配程度。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【存储工程】压缩算法工程实践
系统对比 LZ4、Zstd、Snappy、Brotli 等压缩算法在存储引擎中的工程实践——压缩率、速度、CPU 开销与选型指南
【存储工程】Btrfs:写时复制文件系统
ext4 和 XFS 走的是"就地更新"路线:数据写到哪个块,就直接覆盖那个块。这条路线简单、高效,但有一个根本性的问题——如果写到一半断电,磁盘上的数据处于半新半旧的状态,文件系统就损坏了。日志(Journal)机制可以缓解这个问题,但它本质上是"先写一遍日志,再写一遍数据",写放大不可避免。
数据库内核实验索引
汇总本站数据库内核与存储引擎实验文章,重点覆盖从零实现 LSM-Tree 及其工程权衡。
存储工程索引
汇总本站存储工程系列文章,覆盖 HDD、SSD、NVMe、持久内存、索引结构、压缩、分布式存储与对象存储。