在数据库系统的设计与优化中,列式存储与规范化之间的关系常常被简单地归结为「列存适合扁平化宽表,行存适合多表关联」。这种直觉化的认知忽略了更深层的理论联系:从关系代数的视角审视,列式存储布局本质上提供了一种物理层面的等价变换能力,使得查询优化器能够在不改变查询语义的前提下,将针对规范化逻辑模型编写的查询,透明地重写为针对列式物理布局优化的执行计划。
关系代数等价性的理论基础
关系代数提供了一套形式化的查询表达工具,其核心概念之一是等价性:两个关系代数表达式被认为是等价的,当且仅当对任意输入数据库实例,它们产生相同的查询结果。这一看似简单的定义构成了现代查询优化器的理论基石。优化器的工作可以概括为:给定一个逻辑查询计划(对应某种关系代数表达式),在保持等价性的约束下,寻找具有最小执行成本的物理计划。
传统的基于行存的优化器已经充分挖掘了关系代数等价性的潜力:谓词下推(predicate pushdown)将选择运算尽可能移动到数据源附近,连接重排序(join reordering)探索最优的连接顺序,投影下推(projection pushdown)避免读取不必要的列。这些优化策略在行存引擎中早已成为标准实践。然而,列式存储的出现为这些等价变换提供了全新的物理基础 —— 它使得某些在行存上代价高昂的变换在列存上变得天然高效,甚至催生了一批仅在列式布局下才有意义的优化手段。
列式存储如何重塑等价变换的物理基础
列式存储的核心特征是将数据按列而非按行组织:每一列拥有独立的存储空间和编码方式。这一看似简单的物理布局变化,对关系代数等价变换产生了深远影响。
首先是投影下推的效率差异。在规范化良好的关系模型中,一个涉及少量列的查询通常需要连接多个表才能完成。以典型的订单分析场景为例,查询用户的购买历史可能涉及用户表、订单表、商品表的连接。在行存引擎中,即使查询只需要用户姓名和订单金额,物理读取时仍需将每一行的完整记录加载到内存中,因为行存以页面为单位进行 I/O,页内数据紧密排列。列存则不同:用户姓名存储在一个独立的列文件中,订单金额存储在另一个列文件中。当执行上述查询时,物理计划可以直接跳过其他列文件的读取,仅加载两个列向量(column vector),I/O 量级的差异可达数十倍。这种效率提升并非来自优化算法的新发明,而是列式布局为「投影下推」这一经典等价变换提供的物理加速。
其次是谓词下推与向量化执行的协同。在列式存储中,每一列的数据以数组形式连续存储,这种布局天然适合向量化执行(vectorized execution):对同一列的多个值可以在 CPU 单指令流中并行处理,完成过滤、转换等操作。当谓词下推将过滤条件移动到扫描节点时,列存引擎可以直接在列向量上应用向量化过滤器,生成满足条件的位图(bitmap),随后仅对该位图指向的行号进行后续处理。这种工作模式在行存中需要复杂的预取和行号映射机制,而在列存中几乎是零成本的原生能力。
第三是列存与规范化模型的有机共存。长期以来存在一种误解,认为列式存储要求扁平的宽表结构,因而与规范化设计存在冲突。实际上,列存引擎完全可以在保持规范化逻辑模型的同时,享受列式存储的性能优势。关键在于查询优化器的重写能力:当用户编写一条涉及多表连接的查询时,优化器可以将其重写为针对列存物理布局优化的执行计划 —— 先在列存上完成过滤和投影,再进行连接运算,抑或利用列存支持的列式连接(columnar join)算法高效完成连接操作。这种重写正是关系代数等价性的体现:优化后的物理计划产生的查询结果与原始逻辑计划完全一致。
列存引擎的物理设计选择
理解了列式存储与关系代数等价性的内在联系后,我们可以更清晰地审视列存引擎在物理设计层面所做的选择。
列存引擎通常为每列选择独立的压缩编码策略。不同的列数据具有不同的值分布特征:基数(cardinality)低的列适合字典编码(dictionary encoding),重复值多的列适合游程编码(run-length encoding),数值型列适合位压缩或差值编码。这一设计选择背后的关系代数意义在于:压缩后的列数据在某些等价变换中具有更高的效率。例如,对于低基数列的等值过滤,字典编码可以将比较操作转化为整数索引的比较,大幅提升过滤速度;而对于高基数字符串列的前缀匹配,列存引擎可能选择跳过压缩、直接进行字符串比较以避免解码开销。
另一个关键设计维度是数据组织方式。纯列存(pure column store)将每一列完整地存储为独立文件,如 Parquet、ORC 等列式文件格式;面向列的行存(row-oriented column store)则在行存基础上为每个列建立独立的索引结构,如 ClickHouse 的 MergeTree 家族。两者的区别体现在查询计划的物理实现层面:纯列存擅长全列扫描和向量化聚合,适合大规模分析查询;面向列的行存在点查询和范围查询场景下可以利用列索引快速定位,减少无关数据的读取。在关系代数等价性的框架下,这种差异体现为优化器对物理计划搜索空间的约束 —— 不同的物理布局决定了哪些等价变换具有实际可行性。
物化视图(materialized view)的设计同样可以从等价性角度理解。在列存引擎中,物化视图本质上是对特定关系代数表达式的预计算结果,其物理布局可以选择为列式或行式,取决于视图被访问的模式。当查询模式相对稳定时,物化视图可以将复杂的连接 - 聚合运算预先完成,查询时仅需在物化结果上进行轻量级的过滤和投影 —— 这本质上是将原本需要在运行时执行的等价变换提前到数据加载阶段完成。
工程实践中的参数与阈值
对于在生产环境中部署列存引擎的团队,以下参数和监控点值得特别关注。
列组(column group)或列簇(column family)的划分策略直接影响查询的 I/O 效率。常见的做法是将高频共同访问的列归入同一列组,以减少读取时的列数量;将低频共访的列分离存储,以提升单次读取的列压缩比。这一划分本质上是物理设计的规范化权衡 —— 过于细粒度的列划分会增加元数据开销,过于粗粒度的宽表则削弱列存的压缩优势。建议通过分析历史查询的列访问模式,以列共现频率为依据进行聚类划分。
向量批处理大小(batch size)是影响 CPU 效率的关键参数。典型的向量化执行引擎将列数据划分为 1024 至 4096 行的批次进行处理。批处理过小无法充分利用 SIMD 指令的并行能力,批处理过大则可能导致 L1/L2 缓存失效。现代列存引擎通常支持自适应批处理大小,根据运行时统计信息动态调整。
谓词下推的效果应通过监控扫描过滤率(scan selectivity)来评估。如果大量查询的过滤率极低(即扫描了大量数据但实际返回结果很少),说明谓词下推未能有效发挥作用,可能需要检查统计信息(statistics)的准确性或调整分区策略。列存引擎通常依赖列值的统计信息(最小值、最大值、直方图)来判断能否跳过数据块,统计信息过时会导致优化器做出错误的下推决策。
小结
列式存储并非简单的「按列存放」这一物理技巧,它为关系代数等价变换提供了全新的物理执行基础。通过投影下推、谓词下推等经典等价变换与列式布局的深度结合,列存引擎能够在保持规范化逻辑模型的前提下,实现高效的物理执行。这一理论视角帮助我们超越「列存 vs 行存」的二元对立,转而关注如何利用关系代数等价性这一工具,在具体业务场景下做出最优的物理设计选择。
资料来源:本文参考了 CMU 15-445 数据库系统课程关于查询规划与优化的 lecture notes,以及 ClickHouse 官方文档中关于列存数据库设计原理的技术阐述。