深入理解列式存储与向量化引擎
前言
在现代数据分析(OLAP)领域,性能是至关重要的。为了处理海量数据并实现近乎实时的查询响应,数据库和计算引擎的技术已经从传统的面向事务(OLTP)的设计,演进到了以**列式存储(Columnar Storage)为基础,以向量化执行(Vectorized Execution)**为核心的新范式。
本文档将详细拆解这两项关键技术,帮助你理解它们是什么、为什么需要、以及它们如何协同工作,创造出数量级的性能提升。
一、 列式存储(Columnar Storage)
1.1 什么是列式存储?
列式存储是一种数据在磁盘或内存中按**列(Column)**连续组织和存放的策略。
为了直观理解,我们来看一个简单的用户表示例:
UserID (整型) | Name (字符串) | Age (整型) |
---|---|---|
1 | Alice | 30 |
2 | Bob | 25 |
3 | Charlie | 35 |
存储方式对比
-
行式存储(Row-based / OLTP 常用)
数据按行连续存放,一行的数据紧挨在一起。[1, 'Alice', 30], [2, 'Bob', 25], [3, 'Charlie', 35]
-
列式存储(Column-based / OLAP 常用)
数据按列连续存放,同一列的所有数据紧挨在一起。[1, 2, 3], ['Alice', 'Bob', 'Charlie'], [30, 25, 35]
1.2 为什么需要列式存储?(与行式对比)
特性 | 行式存储 (Row-based) | 列式存储 (Column-based) |
---|---|---|
适用场景 | OLTP (在线事务处理):频繁的增、删、改、查(通常按主键查整行)。例如:订单系统、银行交易。 | OLAP (在线分析处理):海量数据的复杂查询、聚合、统计。例如:数据仓库、BI 报表。 |
I/O 特点 | 读取整行高效:一次 I/O 即可获取一行所有数据。但查询若只涉及少数几列,会读取大量无关数据。 | 读取少数列高效:只需读取查询所需列的数据,极大减少了 I/O 量。这是 OLAP 场景性能提升的关键。 |
压缩效率 | 较低:一行内数据类型各异(字符串、数字、日期),相似度低,难以高效压缩。 | 极高:同一列数据类型相同、业务含义相似,数据重复度高,非常适合压缩。常用算法:字典编码、RLE、Delta 编码等。 |
1.3 为什么性能高?
列式存储的高性能主要源于以下三点:
-
最小化 I/O:这是最核心的优势。对于分析类查询,如
SELECT AVG(age) FROM users
,列式存储只需读取Age
这一列的数据,而行式存储则必须扫描每一行的所有数据(包括 UserID 和 Name),I/O 开销可能相差数十甚至数百倍。 -
高压缩率:由于同列数据的高度同质性,可以实现非常高的压缩比。这意味着更少的磁盘空间占用和更低的 I/O 带宽需求。数据从磁盘加载到内存的时间也相应减少。
-
为向量化执行铺平道路:数据按列连续存放在内存中,这为 CPU 高效处理数据创造了完美条件。这种布局使得后续的向量化执行成为可能。
二、 向量化执行引擎(Vectorized Execution Engine)
如果说列式存储解决了 I/O 瓶颈,那么向量化执行则致力于解决 CPU 计算瓶颈。
2.1 什么是向量化执行?
向量化执行是一种数据处理模型,它一次处理一批数据(一个列的片段,称为“向量”或“批”),而不是一次处理一行(Tuple-at-a-time)。
执行模型对比:深入理解性能差异
要理解两种模型的根本区别,我们首先需要了解“查询计划树(Query Plan Tree)”。当数据库执行一条SQL时,会先生成一个由多个**操作符(Operators)**组成的执行蓝图,数据从树的叶子节点(如Table Scan
)流向根节点(最终结果)。
现在,我们来看数据是如何在这棵树上流动的。
-
传统行式/火山模型 (Tuple-at-a-time / 零售模式)
这是一个典型的“拉动(Pull-based)”模型,数据**一次一行(一个元组)**地在树中被“拉”着向上流动。- 顶层
Aggregate
操作符调用Filter.next()
,说:“给我下一行符合条件的数据”。 Filter
操作符调用Table Scan.next()
,说:“给我下一行数据”。Table Scan
从表中读出一行数据,返回给Filter
。Filter
检查这一行是否满足条件。如果满足,就将这一行返回给Aggregate
;如果不满足,则回到第2步,继续向Table Scan
要下一行。
在这个模型中,处理每一行数据,都会触发一整条从上到下的方法调用链,控制流开销巨大。
- 顶层
-
向量化执行模型 (Block-at-a-time / 批发模式)
向量化执行采用的也是“拉动”模型,但拉动的是**“一次一块(Block-at-a-time)”**数据。“一个块由固定的一组元组(记录)组成,它代表一组向量,这些向量和列 / 字段有一一对应的关系。向量块是数据的基本单元,它经由执行计划树,从一个操作符流向另一个操作符。”
- 顶层
Aggregate
操作符调用Filter.nextBatch()
,说:“给我下一批符合条件的数据”。 Filter
操作符调用Table Scan.nextBatch()
,说:“给我下一批数据”。Table Scan
从表中一次性读出一个数据块(例如1024行),形成一个ColumnarBatch
,返回给Filter
。Filter
对这一整块数据进行批量处理(例如使用掩码),生成一个新的、只包含符合条件数据的数据块,然后返回给Aggregate
。
在这个模型中,方法调用的开销被均摊到了一个批次的上千行数据上,极大地降低了单位数据的处理成本。
- 顶层
为什么传统行式模型开销大?
看似简单的 while
循环背后,隐藏着巨大的性能开销:
-
大量的虚方法调用 (Virtual Function Calls):在 Java 或 C++ 中,算子通常是接口或基类(如
Operator
),next()
是一个虚方法。对于一个包含多个算子(扫描、过滤、投影、聚合)的复杂查询,处理每一行数据,都会触发一连串的虚方法调用。这会阻碍 JIT 编译器的内联优化,并导致高昂的动态分派开销。 -
频繁的分支预测失败 (Branch Misprediction):循环中的
if
条件(如WHERE amount > 10
)会引入大量分支。现代 CPU 为了提高效率,会进行“分支预测”,提前执行它认为最可能的分支。如果数据分布不均(amount > 10
的结果时真时假),CPU 就会频繁猜错。每次猜错,都会导致整个 CPU 流水线被清空和重建,这会浪费几十个甚至上百个 CPU 周期。 -
缓存未命中 (Cache Miss):行式数据在内存中通常不是连续的(特别是包含变长字符串时),CPU 在处理数据时需要进行“指针追逐”,这会频繁导致缓存未命中,被迫从慢得多的主内存中读取数据。
2.2 核心概念解析:向量化如何战胜性能瓶颈
向量化执行通过一系列精妙设计,完美地规避了上述问题。
2.2.1 SIMD:用一条指令干多个人的活
SIMD (Single Instruction, Multiple Data),即“单指令,多数据”,是现代 CPU 提供的一种强大的并行计算能力。
- 是什么:它允许 CPU 用一条指令同时对多个数据执行相同的操作。你可以把它想象成 CPU 内部的一条“流水线”,一次可以处理一整“托盘”的数据,而不是一个一个地处理。
- 如何实现:CPU 内部有特殊的向量寄存器(如 128位的 SSE 寄存器、256位的 AVX 寄存器)。例如,一个 256 位的 AVX 寄存器可以一次性装入 8 个 32位整数,或 4 个 64位浮点数。CPU 的一条 SIMD 加法指令,就可以瞬间完成这 8 个整数对的相加。
- 与向量化的关系:列式数据在内存中连续存放,完美契合了 SIMD 的工作模式。引擎可以直接将一个列向量(数组)的一部分加载到向量寄存器中,用 SIMD 指令进行计算,性能提升是数量级的。
2.2.2 掩码/位图:消灭 if
分支的艺术
为了避免分支预测失败带来的巨大成本,向量化引擎采用**掩码(Mask)或位图(Bitmap)**来处理条件逻辑,这是一种将“控制流依赖”转换为“数据流依赖”的技巧。
-
是什么:一个掩码/位图就是一个布尔值数组或一个比特序列,用于记录一批数据中哪些行符合条件。
-
如何实现:
- 生成掩码:不再使用
if
来逐个判断,而是执行一个向量化的比较操作,生成一个掩码。例如,对于amount > 10
,引擎会一次性比较一批amount
数据,生成一个类似[true, false, true, ...]
的布尔掩码。 - 应用掩码:后续的算子根据这个掩码来只处理有效的数据。例如,聚合算子会根据掩码,只累加那些对应位置为
true
的price
值。
- 生成掩码:不再使用
-
为什么性能高:
- 无分支:整个过程没有
if
跳转,CPU 流水线可以顺畅地执行,不会被打断。 - SIMD 友好:生成掩码和应用掩码的过程,本身也可以被 SIMD 指令高度优化。
- 无分支:整个过程没有
2.3 向量化引擎的设计原理
一个现代的向量化引擎通常包含以下关键设计:
2.3.1 核心技术:字典编码与延迟物化
这两项技术通常结合使用,尤其是在处理字符串等变长、比较耗时的数据类型时。
-
字典编码 (Dictionary Encoding)
- 是什么:一种高效的列压缩技术。它会扫描一列数据(如国家名称),为所有不重复的值创建一个“字典”,并用紧凑的整数ID来替换原始值。
- 示例:
- 原始列
country
:['美国', '中国', '美国', '日本', '中国']
- 字典:
{0: '美国', 1: '中国', 2: '日本'}
- 编码后的数据:
[0, 1, 0, 2, 1]
- 原始列
- 优势:极大减少了内存占用;更重要的是,将耗时的字符串比较/哈希操作,转换成了极速的整数操作。
-
延迟物化 (Late Materialization)
- 是什么:在整个查询执行过程中,尽可能地推迟将数据从其压缩或编码形式(如字典ID)转换回原始值的过程。
- 示例:执行
WHERE country = '美国'
时,引擎首先将'美国'
在字典中查询到其ID为0
。然后,它在编码后的整数数据[0, 1, 0, 2, 1]
上执行ID == 0
的过滤操作。这个过程完全是整数比较,速度极快。 - 优势:只有在查询的最后一步,当需要向用户展示结果时,引擎才会根据ID(如
0
)去字典里查找原始值('美国'
)。所有中间的过滤、聚合、关联操作都在高效的编码数据上完成,避免了对海量原始数据的昂贵操作。
2.3.2 其他关键设计
-
批处理模型(Batch Model)
- 数据以
ColumnarBatch
或RecordBatch
的形式在算子间流动。一个 Batch 包含多列(ColumnVector
),每列包含一批数据(如 1024 或 4096 行)。
- 数据以
-
标准列式内存格式(如 Apache Arrow)
- 为了实现高效的跨语言、跨进程数据交换(甚至是零拷贝),业界广泛采用 Apache Arrow 作为内存中的列式标准。
-
原生算子设计 (Native Operators)
- 所有的计算算子(如 Filter, Project, Aggregate, Join)都必须重新设计,使其能直接操作
ColumnarBatch
。
- 所有的计算算子(如 Filter, Project, Aggregate, Join)都必须重新设计,使其能直接操作
三、总结:
列式存储和向量化执行是现代高性能分析引擎的两个核心支柱,它们相辅相成,缺一不可。
- 列式存储负责在宏观上(磁盘 I/O)减少数据读取量,并提供一种对 CPU 缓存和 SIMD 友好的内存布局。
- 向量化执行则在微观上(CPU 计算)充分利用这种数据布局,通过批处理、SIMD 指令、无分支计算和延迟物化等技术,将 CPU 的计算能力压榨到极致。
正是这种从存储到计算的全链路优化,才使得像 Spark (with AQE)、StarRocks、ClickHouse 等现代数据系统能够实现惊人的查询性能。
参考资料
- 《列式数据库和向量化》 - InfoQ
- 《向量化引擎怎么提升数据库性能》 - 墨天轮