本文想聊的,是大数据OLAP场景中绕不开的核心技术——列式存储。如果你折腾过千万级甚至亿级数据的分析任务,一定遇到过同样的困惑:明明数据量不算夸张,MySQL或普通行存表却慢到让人怀疑人生;换了ClickHouse、Doris或者把Hive表换成Parquet之后,速度却像开了挂。差别就在“按行存”还是“按列存”。这篇文章从底层原理到实际项目落地,把列式存储讲透,适合正在做数仓设计、搞数据分析优化、或者刚接触OLAP引擎想搞懂“为什么这么快”的朋友,读完可以直接用来指导技术选型和调优。
1. OLAP场景为什么绕不开列式存储
1.1 从一次“慢查询事故”说起
先讲一个我自己的例子。两年前帮一个团队做网约车订单数据的分析项目,订单表大概有1亿行,用MySQL放着。业务方要查“最近一个月每个时段的订单量分布”,SQL很简单,group by一个小时字段然后count,结果跑了将近三分钟。后来我看了下,这个任务实际上要把订单表整表扫描一遍,每一行无论用不用得上,都得从磁盘读出来。这张表一行有几十个字段,光订单金额、GPS坐标、司机ID、乘客ID这些就占了接近2KB,1亿行算下来就是200GB的读取量。而group by真正用到的字段,撑死也就是两三个。
这就是行式存储最典型的问题:IO放大。在行存格式里,一行的所有字段连续放在一起,哪怕你只需要其中一个字段,也得把整行读进内存。OLAP查询基本都是大范围扫描加聚合计算,这种“读多列但只用少列”的模式被行存无限放大。列式存储解决的就是这个根本矛盾:把一列的所有值连续存放,查询只读取涉及的那几列数据。同样是刚才那个订单表,如果只读时段字段,你只需要扫大概几GB的数据,和200GB差了不止一个数量级。
所以我一直跟做数据的朋友说,判断一张表该不该列存,别看数据量大小,要看你的查询模式。如果是按主键查一行、改一行、事务性强,行存没问题;但只要是“扫描大批量数据、做聚合统计、分析趋势”这种操作,列式存储就是必选项。
1.2 列存到底改变了什么:从物理布局说起
列式存储的核心变化,是在物理存储层把“行的集合”重新编排成了“列的集合”。一张订单表如果按行存,文件里的数据大概长这样:订单A的ID、金额、时间、城市、司机……全部挨在一起,然后是订单B的同样字段。而列存格式会把这个表拆成独立的列文件或列块:所有订单ID放一块,所有订单金额放一块,所有订单时间放一块。
这个排列方式带来的直接好处有三个。第一,查询裁剪能力大幅提升,只读需要的列,IO量成倍下降;第二,同一列的数据类型一致,内容相似度更高,压缩率比行存好得多;第三,因为同一列的数据被连续存放,可以对连续值做批量计算,这个特性为后面要讲的向量化执行和SIMD优化提供了基础。
打个比方,行存就像图书馆按“一本书一个架子”来放,你想找所有书里提到某个单词的页码,就得一本书一本书翻;列存则是把所有书的目录页单独抽出来排在一起,找关键词只需要翻目录册。做分析的人多数时候看的不是某本书全文,而是从不同书里抽出来的同类信息,所以目录册式的组织方式天然占优。
1.3 行式存储并非“不好”,只是用错了地方
说实话,行式存储并没有被列式存储淘汰,也不能被淘汰。MySQL、PostgreSQL这类事务型数据库每天处理海量订单、用户登录、库存扣减,靠的就是行存下“按主键快速定位单行”的能力。行存配合B+树索引,点查一条记录的时延可以压到毫秒级,这是列存做不到的。列存设计理念是为吞吐量服务,它的单行点查要么退化成全列扫描后筛选,要么靠主键索引额外维护映射关系,开销都比行存高。
二者本质上是两种数据访问模式下的产物。访问模式偏“行”的,用行存;偏“列”的,用列存。下面这个对比表可以帮你快速做判断:
| 维度 | 行式存储 | 列式存储 |
|---|---|---|
| 数据写入 | 单行写入、更新删除友好 | 大块批量写入更优,单行写开销大 |
| 点查(按主键查一条) | 快,B+树索引直达 | 慢,扫描整列后过滤 |
| 范围扫描与聚合 | 慢,IO大 | 快,只读相关列 |
| 压缩率 | 低,字段类型混杂 | 高,同类型同语义连续存放 |
| 典型代表 | MySQL、PostgreSQL | ClickHouse、Doris、Parquet/ORC |
| 适用场景 | OLTP交易系统 | OLAP分析、报表、数据湖分析 |
把行存和列存放在“谁更好”的对立面上,是新手最常见的误区。技术选型的正确问法不是我该用哪种存储,而是我的查询到底在读什么、写什么。
2. 列式存储背后那些“看不见”的性能机制
2.1 数据压缩:同类型数据连续存放带来的红利
很多人以为列存的好处只是“少读列”,其实列式存储的另一大杀招是超高压缩率。同一列里的值往往高度相似,比如订单状态字段一共就“已完成、已取消、进行中”几种取值,城市字段就那几十个城市。行存的时候,这些值跟订单ID、金额等乱七八糟的类型混排在一起,压缩算法很难找到规律;列存把这些值归拢到一块之后,压缩算法识别规律的难度大幅降低。
实际项目中,列存格式一般会组合使用多种编码方式。低基数列(字段取值种类少),比如省份、状态、星期几,适合用字典编码或RLE(行程长度编码)。字典编码的做法是把“北京、上海、广州”映射成0、1、2这样的整数ID,数据里只存ID;RLE则更进一步,把连续重复的“2,2,2,2,2”直接记录成“值2,连续5个”。高基数列,比如金额、里程数,字典编码没意义,一般直接上通用压缩算法,比如LZ4、ZSTD。排序之后的数据还有额外红利:相近的值被排在一起,RLE和delta编码的效果会更好,压缩率还能再上一个台阶。
我自己做实验时看过一个很直观的差距:同样是网约车订单数据,存成没压缩的文本格式,1亿行能占70GB以上;转成Parquet列存并启用ZSTD后,压缩到大概8GB,压缩比接近9:1。这带来的好处不仅仅是省磁盘,更重要的是查询扫描时的IO量也跟着缩小了,磁盘读得越快,查询当然越快。
2.2 向量化执行与SIMD:让CPU不再“等待数据”
列式存储能跟向量化执行配合得这么紧密,不是偶然。向量化执行的思路是:不再像传统执行器那样一行一行的处理,而是一次读入一批数据(比如1024行的一个batch),然后对这一批数据的同一列做批量运算。
SIMD(单指令多数据)是CPU提供的一种能力,一条指令可以同时对多个数据执行相同操作。现代CPU里的AVX2指令集,寄存器宽度是256位,一条指令能同时处理8个32位整数或者4个64位整数;到了AVX-512就能一次处理16个32位整数。配合列存,只要把这一批订单金额数据连续放在内存里,执行器一条指令就能对16个金额值做累加,然后再来处理下一批。这种“批量洗衣服”的方式,比传统“一件一件洗”的效率高得多。
如果数据是行存的,情况就很尴尬:同一批数据里这行是金额、那行是城市ID、再下一行是时间戳,类型都不同,SIMD没法对它们批量运算。所以行存引擎很难做向量化,Column-oriented layout是向量化执行的物理前提。这也是为什么现在主流的分析型数据库,像ClickHouse、Doris、StarRocks,清一色是列存加向量化执行引擎的搭配。它们跑的快的秘诀,一半靠低IO,另一半靠CPU的高效批量计算。
2.3 延迟物化与块迭代:减少不必要的数据搬运
列存引擎里还有个经常被提及的概念叫“延迟物化”(Late Materialization)。它的意思是,在查询执行过程中,尽量不要过早地把分散在各列的字段拼接成完整的行。比如你要算每个城市的平均订单金额,执行引擎会先只读取城市列和金额列,各自完成过滤和聚合,最后才把结果组合成最终输出。整个过程里需要“变成完整行”的数据,只有最终那一点点聚合结果。
如果反着来,一开始就把所有需要读的字段物化成一行行的记录,中间会产生大量临时行数据,内存带宽和CPU开销全被浪费在“搬运没用字段”上。块迭代则是配合延迟物化的执行方式,数据以列块(比如ColumnBlock)为单位在算子之间流转,而不是以单行为单位。这进一步降低了函数调用开销,也更好地喂饱了SIMD流水线。
理解这个概念对排查性能问题很有用。你在看ClickHouse的查询计划时,会看到某些阶段读取列数很少,查询最后阶段才物化行,这其实都是延迟物化在起作用。如果你发现某个查询慢,除了看扫了多少数据,还要看执行计划里是不是过早物化了不必要的大字段,比如把整个GPS字符串拼进中间结果。
2.4 稀疏索引和ZoneMap:不读数据也能跳过数据
列式存储还有一个被低估的杀手锏,就是统计信息级别的过滤。典型的实现方式是在列存文件内部把数据按行组(Row Group)或数据块划分成多个段,每个段在元数据里记录这一列在这个段内的最小值、最大值,有的还会记null值数量。查询的时候,如果过滤条件里的值不在某个段的[min, max]范围内,那整个段就可以直接跳过,一个字都不用读。
这套机制跟分区裁剪的逻辑有点像,但粒度更细。分区裁剪是在表分区级别做的粗过滤,ZoneMap是在文件内数据块级别做的精细过滤。比如按时间分区存数据,查询某一天的数据,分区裁剪能帮你跳过其他所有日期的文件;而在某一天的文件内部,如果再把数据按小时排序切块,每一块的min/max可能进一步压缩扫描范围。好的列存表设计,可以把一次全表扫描变成几次小块的精确读取。
这里特别提醒一句:ZoneMap和稀疏索引能不能生效,很大程度上取决于数据的排序情况。如果数据乱序存放,比如一个块里既有1月又有12月的订单,那块的min=1月、max=12月,你查3月的数据也跳不过这个块;只有让数据按查询常用维度排序,让相邻数据尽量落在同一范围,ZoneMap才能充分发挥作用。
3. 从文件格式到数据库:列存的两层实现
3.1 Parquet:大数据生态里的“默认列存格式”
聊列式存储,不能不提Parquet,它几乎成了大数据生态的事实标准。Hive、Spark、Flink写数据湖表时,最常推荐的存储格式就是Parquet。Parquet的设计脱胎于Google的Dremel论文,有个很特别的能力是处理嵌套数据结构,像“用户下的多个订单、每个订单里的多个商品”这种复杂JSON,Parquet也能高效存储和读取。它通过Repetition Level和Definition Level两套元数据,还原出每条记录嵌套层级关系,不需要把整个JSON对象一步到位展开。
从物理结构上看,Parquet文件由多个Row Group组成,每个Row Group包含这个行组内所有列的一个Column Chunk,每个Column Chunk内部又划分为Page。查询引擎按Row Group读取元数据,然后只读取需要的列和Page。这个结构设计得很规整,既有列存的存储优势,支持谓词下推,也保留了行组层面的并行度,方便Spark这类分布式引擎拆任务并行扫。
实际用的时候,我建议关注几个Parquet参数。文件大小控制:如果目标文件明显小于64MB甚至32MB,说明Row Group太小,元数据占比高,扫描效率差;block size和page size的设置,也会影响压缩率和随机读取的粒度;开启统计信息和字典编码对低基数列效果尤其明显。Hive建表时用STORED AS PARQUET,Spark写数据时可以用parquet.block.size这类参数控制文件块大小,这些都是在实际项目里效果最直接的调优点。
3.2 ORC与Parquet的差异与选型
ORC是另一套著名的列存格式,主要由Hive社区推动,后来在Hive、Presto、Spark中也有广泛应用。ORC的文件结构按Stripe(条带)组织,每个Stripe包含数据列、索引列和字典信息,文件尾部有Footer存储整体信息,每个Stripe内部还有独立的索引段记录每列的min/max。这个设计让ORC在Hive生态里的的表现非常稳定,尤其Hive数仓里跑聚合和扫描类任务,ORC往往比Parquet更省存储。
两个格式的对比可以从几个维度看:
| 对比项 | Parquet | ORC |
|---|---|---|
| 出身 | Dremel论文,Twitter/Cloudera贡献 | Hive社区, Hortonworks推动 |
| 嵌套结构支持 | 原生支持,repetition/definition级别 | 支持但相对轻量 |
| 索引粒度 | Row Group级的统计信息 | Stripe级和文件级双层索引 |
| 写入生态 | Spark、Flink、Hive全家桶都很成熟 | Hive最成熟,Spark适配稍逊 |
| 压缩效果 | 高基数字段用ZSTD优秀 | 低基数字段和字符串常优于Parquet |
| 典型场景 | 数据湖通用格式、跨引擎分析 | Hive数仓、重聚合任务 |
选型上我的经验是:如果主要用Spark做分析,优先Parquet,生态最顺;如果主要跑Hive数仓,尤其表结构和查询相对传统,ORC也很稳;如果同时服务多个引擎,Parquet的兼容性更好。另外,现在Iceberg和Delta Lake这类表格式底层基本都默认用Parquet做数据文件,所以学会了Parquet,基本上就是拿到了湖格式的底层技能。
3.3 文件列存不等于数据库列存
这里必须拎清楚一个概念:Hive表用Parquet格式存储,确实已经是“列式存储”了,但这不等于Hive就是列存数据库。Parquet解决了数据在磁盘上的组织方式问题,但查询引擎是否高效利用了列存能力,是另一回事。Hive默认的执行引擎在读取Parquet文件时也能做到列裁剪、谓词下推,但其执行模型仍然是传统的逐行或逐批次迭代,缺少向量化执行和延迟物化的深度优化,所以性能天花板明显。
真正的列存数据库,比如ClickHouse、Doris、StarRocks、Greenplum,是存储层、索引层、执行层三层统一设计的。存储上列式组织,索引上内置主键稀疏索引和ZoneMap,执行上用向量化引擎配合SIMD。三层互相配合,才能达到单机数十亿行秒级响应的效果。你拿ClickHouse去分析1亿行订单数据和拿Hive on Parquet分析同样数据,体感差异是巨大的,但两者底层都用列式思想。
我见过不少团队把Hive表转成Parquet之后就以为万事大吉,结果查询还是慢到受不了,最后才意识到问题出在执行引擎。所以做数仓设计时要想清楚:你是要一个轻量的离线分析底座,用Hive/Spark加Parquet就足够;还是要一个能支撑高并发在线报表和即席查询的系统,那必须引入真正的列存OLAP数据库。
4. 项目落地:从选型到建表调优
4.1 不同OLAP场景下,列存数据库怎么选
现在的列存分析数据库选择非常多,但每家的侧重点不一样,选错了后期运维很痛苦。我的经验是先分场景:
ClickHouse适合日志分析、事件分析、大宽表聚合查询,单表查询性能极强,写入吞吐高,但复杂多表join能力偏弱,高并发点查也不是强项。Doris和StarRocks走的是MPP路线,SQL兼容性好,支持标准MySQL协议,适合做实时数仓、统一OLAP分析平台,既能跑大宽表聚合,也能做多表join,还能直接支撑报表和即席查询,部署运维上比ClickHouse要重一些。Greenplum这类传统MPP数据库则更偏向大规模并行处理的企业级数仓,适合海量数据离线分析,但组件多、部署维护成本高。
选型建议可以套用一个粗暴的判断标准:查询以单表大扫描为主,追求极致的性能,上ClickHouse;需要标准SQL、实时写入、多表join、还要统一支撑报表和即席分析,上Doris或StarRocks;已经在Hadoop体系里沉淀了大量任务,只想做数仓加速,考虑用Parquet/ORC优化存储加Spark SQL即可,不一定要上线新数据库。
结合前阵子网上很火的“网约车大数据综合项目”这类场景,如果需求是做离线数据清洗加可视化,用Hive/Spark加Parquet完全够;如果想做成一个能实时更新、后端报表秒级响应的分析平台,那就值得引入Doris或ClickHouse,把清洗后的明细数据导进去。
4.2 列存表设计的几个核心规范
建列存表的逻辑跟建MySQL表差别很大。一个常见的坑是,把所有索引字段都塞进主键,或者把排序键顺序搞反,导致过滤条件走不了稀疏索引。以ClickHouse为例,表的ORDER BY字段不仅决定数据排序,还直接构建稀疏索引。设计排序键时,优先把查询中最常见的等值过滤字段放前面,比如城市、日期这些低基数字段,再放需要范围过滤的字段。
给一个直观的建表示例:
-- ClickHouse 建表:网约车订单明细表(简化) CREATE TABLE dongche_order_dwd ( order_id String, city_id UInt32, driver_id String, passenger_id String, order_amount Decimal(10, 2), order_status UInt8, order_time DateTime, pickup_lat Float64, pickup_lng Float64 ) ENGINE = MergeTree PARTITION BY toYYYYMMDD(order_time) ORDER BY (city_id, order_time, order_id);这里分区字段选了日期,排序键第一顺位是city_id。为什么city_id放最前面?因为查询很可能是“某城市某几天”的分析,先按城市裁剪,再按时间范围过滤,效率最高。如果把order_id这种高基数字段放第一顺位,每次查询都很难利用稀疏索引裁剪,等于给每个查询都加了全列扫描的负担。另外,ORDER BY字段也影响压缩率,排序规整后低基数列的相邻重复值多了,RLE效果会好很多。
4.3 列存写入慢?批量导入才是正确姿势
列存数据库普遍有“写入放大”的问题。数据写进来时要排序、要生成索引、要做压缩,单条写入效率远低于行存。如果拿ClickHouse当MySQL用,一条条insert,很快就会被写入性能折磨。正确的姿势是批量导入,微批写入,攒够一批再统一提交。
ClickHouse的实际情况是,每次insert都会生成一个data part,大批小part产生后后台线程会做合并(merge)。如果持续高频小批量写入,part数量爆炸,merge跟不上,查询就要读取越来越多的part,性能直线下降。这就是为什么社区一直在强调“大批少次”的写入策略,比如每分钟攒几万行批量插一次。Doris在这方面的体验好一些,因为它的写入模型像数据库,支持行级实时导入,但在高峰期也需要控制并发度和批次大小。
还有一个容易被忽略的点:列存表对更新删除支持不好。你如果试图用Update语句高频修改历史数据,在MergeTree这类引擎上会非常痛苦,每次修改实际是把符合条件的整个part重写一遍。所以列存表适合“一次写入、多次读取”的数据流,数据清洗和修正尽量在上游完成,别把脏活留给OLAP引擎。
5. 实操复盘:一次网约车订单分析任务的列存优化
5.1 场景与初始方案
为了更具体地说明列存优化的效果,拿一个典型的“网约车大数据综合项目”场景来复盘。假设业务表是某城市一个月的网约车订单明细,大约1亿条记录,几十个字段,包括订单时间、城市、司机ID、乘客ID、起点经纬度、终点经纬度、订单金额、订单状态等。初始方案是存成Hive文本表,按天分区,直接用HiveSQL跑每日订单量和热门区域分析。
查询长这样:
SELECT from_unixtime(CAST(order_time / 1000 AS BIGINT), 'yyyy-MM-dd HH:00') AS hour_slot, city_id, COUNT(*) AS order_cnt, SUM(order_amount) AS total_amount FROM ods_taxi_order WHERE order_time >= '2024-06-01' AND order_time < '2024-06-08' AND order_status = 1 GROUP BY hour_slot, city_id;这个任务其实只用到order_time、city_id、order_amount、order_status几个字段,但文本表没有列裁剪能力,每个Map任务都得把整行读进来解析。1亿行乘每行几千字节,扫描量直接到了几个TB级别。我当时的实测是,查一周的数据跑了大概110秒,对业务方来说太慢了。
5.2 改造过程与效果对比
改造分三步走。第一步,把数据重新写入Parquet列存分区表,按天分区,并且写数据时用sortBy指定了order_time排序;第二步,查询入口保持不变,HiveSQL的读取引擎自动走Parquet的列裁剪和谓词下推;第三步,把hive.exec.orc.split.strategy和Parquet相关参数调了一下,确保每个文件大小合理,避免小文件过多导致NameNode压力大和扫描任务碎片化。
改造完以后的效果非常明显。数据量从70GB左右压到了8GB上下,压缩比接近9:1;查询耗时从110秒降到12秒左右,而且这个提升几乎不需要改业务SQL。提升的核心原因就是第一章和第二章讲的原理:只读了四个字段而不是整行,IO量降了一个数量级;Parquet的Row Group统计信息帮引擎跳过了大量不满足order_time和order_status条件的数据块;ZSTD压缩进一步缩小了读取量。
后来看Spark的执行计划,Streaming聚合阶段读取的字节数从原先的几百GB降到了二十几GB,这就解释了为什么查询快那么多。
5.3 排序键和分区键的真实影响
在另一个验证里,我把同样的数据导入了ClickHouse,测试了不同ORDER BY设计的查询差异。第一版排序键是(order_time, city_id),查询一周数据加指定城市时,ClickHouse虽然也用上了分区裁剪,但每个分区内还要扫描相对多的数据块,因为时间范围覆盖了多个分区,城市过滤在分区内起的作用有限。改成(city_id, order_time)之后,同一个城市的全部数据在物理上排到一起,查询城市维度时,稀疏索引直接命中少量granule,扫描数据量进一步下降,查询耗时有30%左右的提升。
这说明一个设计规律:分区键解决“从所有文件里选出哪些文件”的问题,排序键解决“从选中文件里读出哪些数据块”的问题。两者是串联关系,都把好钢用在刀刃上,查询才会真正快。如果分区键和排序键都选不好,那列存的底层能力就浪费了七八成,你只是在用行存的心态用列存。
6. 常见问题与排查技巧实录
6.1 建表看起来没问题,但查询还是慢
查列存数据库慢查询,我一般按下面几个步骤来排查。第一步看读取量:ClickHouse用profile事件看ReadRows和ReadBytes,如果发现明明只查几列,ReadBytes却很大,说明列裁剪没生效或者数据压缩率差。第二步看过滤有没有下推:日志里如果扫描了所有分区,说明分区键没被查询条件覆盖,检查SQL里的过滤字段和表的分区键是否对齐。第三步看有没有走稀疏索引:查询过滤字段如果不在排序键里,ClickHouse只能全量扫描该字段构建过滤结果,io成本翻倍。
一个很典型的坑是,在ClickHouse建表时把ORDER BY和PRIMARY KEY搞混,以为PRIMARY KEY才是索引。实际上ClickHouse的稀疏索引由ORDER BY决定,PRIMARY KEY只是去重和数据跳跃的辅助工具。如果你的过滤字段没出现在ORDER BY里,性能基本靠运气。
6.2 列存写入慢,到底慢在哪
写入慢的常见情形是高频小批量insert。ClickHouse每次insert生成独立part,part太多时select并行度受限于part数,merge线程长期繁忙,写入和查询互相抢资源。解决办法有两个方向:一是上游Kafka或日志系统攒批,按分钟级别刷数据;二是改用buffer引擎,或者直接用分布式表批量摄入,把单次写入行数抬上去。
Doris和StarRocks这类MPP数据库写入体验稍好,支持小批量实时写入,但如果并发写过多,也会出现版本合并压力。实践里可以观察BE日志的compaction耗时,如果很长,就要降低写入频率或增加副本数分担压力。总体思路就是:列存数据库不是用来承接高频单行写入的,你要在前面加一道缓冲层。
6.3 压缩率忽高忽低是怎么回事
压缩率与数据分布和排序情况强相关。同一张表,如果数据按时间乱序写入,状态字段的RLE效果会很差;如果把状态和城市这类低基数字段排到排序键前部,压缩率会明显提升。另一个影响因素是字段本身的基数:高基数的订单ID、GPS坐标,用什么编码也压不下去;但可以靠ZSTD这类通用算法做二次压缩,效果也不差。
还有一个经验是,字符串字段尽量用定长或字典化处理。比如城市名“北京市”直接存UTF-8字符串,在Parquet里走字典编码效果还行,在ClickHouse里则建议转成枚举类型或者UInt32编码,既降低存储,又提升过滤和聚合速度。表设计阶段多花半小时做的字段类型优化,可能在查询性能上是几个小时调优都换不来的收益。
我个人的体会是,列式存储不是银弹,它是对数据访问模式的一种精准回应。做数仓设计时,想清楚你的查询到底读哪几列、过滤哪些字段、按什么维度聚合,要比纠结“谁家列存更强”更先一步。把这一层想明白,无论你最后选Parquet、ORC、ClickHouse还是Doris,都能把列存的红利吃到最大,而不是迷信某一个组件本身。