最早让我意识到“列式存储”不是锦上添花的优化点,而是一个绕不开的关键底座,是在一次异常日志分析任务里。当时数据量已经到亿级,放在传统关系库里,每次按用户维度和时间范围算聚合,要么跑几分钟,要么直接把连接池打满。后来我试着把同样一份数据落到Parquet文件上,配好压缩和裁剪策略,再跑同样的分析,耗时从“能接受”直接变成了“秒级”。从那一刻起我才真正确信,大数据场景下的性能差距,首先不是集群规模决定的,而是存储模型决定的。
这篇文章不打算从教科书定义讲起,也不会把每个组件都浮光掠影带一遍。我会按照自己做项目的路径,讲清楚列式存储解决什么问题、为什么快、哪些场景该选什么方案,以及真正落地时那些容易踩的坑。适合做数据平台、数仓、实时分析的后端和数据的同学,也适合正在准备大数据面试、想把这些概念讲明白的人。
1. 列式存储到底解决了什么问题
1.1 从一次真实的查询卡顿说起
很多同学第一次听说列式存储,是因为某个线上查询慢到受不了。我自己的经历也差不多:业务方要一份“最近30天每个渠道的活跃用户数”报表,行数大概1.2亿行。原来用的MySQL实例已经加了索引,但索引在这种大范围聚合场景里几乎帮不上忙,因为要的数据散落在大量行里,优化器扫描完索引后还要回表,磁盘IO全部浪费在搬无用数据上。
后来我把原始数据按天分表、预聚合,问题暂时缓解了,但代价是开发成本高、口径多、维护难。真正一劳永逸的方案,是把存储底座换成列式模型。因为分析型查询绝大多数只关心少数几个字段,比如上面这个需求只需要“渠道”“活跃时间”“用户ID”,并不需要把每一行几十个字段全部加载到内存。列式存储天生就是为这种“按列读、按列算”的模式服务的。
1.2 行式存储的逻辑与代价
要理解列式的优势,先得知道行式存储为什么在分析场景吃亏。传统关系数据库(比如MySQL的InnoDB)在磁盘上把一条记录的多个字段连续存放,组成一个行,然后一个页里放若干行。这样做的好处是写入快、单行点查快,特别适合OLTP场景:用户登录、订单修状态、购物车更新,都是“先找到这一行,再改这一行”的操作。
但分析型查询是完全相反的访问模式。它不关心单行,而关心“某一批行的某几列”。行式存储这时候就很被动:虽然你只想要3个字段,引擎也不得不把每行所有字段都读出来,再在内存里丢掉不需要的部分。字段越多、行宽越大,浪费就越严重。IO带宽就这么明晃晃地被消耗掉,查询自然慢。所以在OLAP场景里,沿用行式存储本质上就是在为无关数据付账。
1.3 列式存储的存储模型详解
列式存储的核心很朴素:把同一列的数据连续存放在一起,而不是把同一行的数据放一起。
以Parquet为例,它的文件布局大致分三层概念。最上层是Row Group,文件被水平切分成若干个行组;每个Row Group内部不按行存放,而是按列拆成Column Chunk;每个Column Chunk又由若干Page组成,Page是压缩和编码的最小单元。实际读取时,引擎可以快速定位到某个Row Group、某个Column Chunk,再跳过不需要的Page。这种结构使得查询只需要拆开涉及到的列文件,其它列几乎完全不用碰。
拿一张用户表来对比,如果建表字段是user_id、name、age、city、last_login_at,行式存储在磁盘上大致是这个形态:
Row1: [user_id, name, age, city, last_login_at] Row2: [user_id, name, age, city, last_login_at] Row3: [user_id, name, age, city, last_login_at]列式存储则会变成:
Column user_id: [u1, u2, u3, ...] Column name: [n1, n2, n3, ...] Column age: [a1, a2, a3, ...] Column city: [c1, c2, c3, ...] Column last_login_at: [t1, t2, t3, ...]这两种布局没有绝对的好坏。行式在“取整行、改整行”场景下效率极高,列式在“取若干列、大批量扫描”场景下优势巨大。大数据分析恰好属于后者。所以你会看到,数仓引擎、数据湖格式、分析型MPP数据库几乎无一例外拥抱列式模型。
2. 列式存储性能背后的四个关键机制
2.1 高压缩比:同一列天生更有规律
列式存储之所以压缩比远高于行式,根本原因在于同一列的数据往往具有相似性。拿“城市”字段举例,一亿行里可能只有几十个城市名,字典编码后每个值只需一个很短的数字ID;再用RLE游程编码处理连续重复值,压缩空间非常可观。时间字段也一样,日志流水里的时间戳递增,用Delta Encoding记录差值,数值范围大幅缩小,占用位宽也大幅下降。
我实际碰到过一个典型例子:一份约3GB的JSON行式日志,清洗后转成Parquet,开启snappy压缩后只有约400MB,压缩比超过7:1。换用zstd后体积更小,只在压缩和解压时多付出一点CPU。这个存储成本下降是纯白赚的,因为数据没变,变的是组织方式。
相比之下,行式存储虽然在某种程度上也可以对页做压缩,但一行里有字符串、数字、时间、文本,类型混杂、规律性差,压缩算法很难发挥威力。这是存储模型带来的结构性差异,不是换个压缩算法就能抹平的。
2.2 谓词下推与Min/Max索引:跳过无用的数据块
列式存储的第二大杀器,是能让引擎在扫描数据之前就跳过大量Page和Row Group。比如查询“统计2024年1月1日以后登录过的用户”,如果数据按时间分区,并且每个Row Group在写入时统计了某列的最小值和最大值,引擎在读文件元数据时就能判断:这个Row Group里该列的Min和Max都在1月1日之前,那就整体跳过。
这其实就是元数据级的数据跳过机制,很多引擎在实现层面把它叫做Stats、Page Index或Zone Map。原理上跟“书本目录先翻页,不看正文就判断有没有用”差不多。效果非常明显:理想状态下,只读全量数据的几个百分点,就能完成一个需要全表扫描的任务。
不过还要提醒一句:Min/Max索引的效果高度依赖于数据排序。如果某列的值毫无规律地乱序分布,Min是全局最小值、Max是全局最大值,那所有Row Group都满足过滤范围,索引就直接失效。所以大数据表设计时常会根据过滤字段调整排序键,不是没道理的。
2.3 向量化执行与CPU缓存:从“逐行处理”到“批量处理”
跳过无用数据之后,真正需要处理的数据还得继续榨取效率。列式存储配合向量化执行,在现代CPU上能把计算速度再拉高一个量级。
传统行式引擎处理数据常用迭代模型,一行一行进入表达式计算,每算一行就有一堆函数调用、分支判断、类型解析。对现代CPU来说,这种分支密集、数据依赖强的方式很容易把流水线堵住。列式引擎则会把一批数据(比如1024个值)读入连续内存,按“一列一列批量计算”的方式处理,循环内做的事情极其简单,非常适合SIMD指令集,也就是CPU单次指令同时处理多个数据。
这就是为什么ClickHouse跑聚合函数会快得惊人。我测过一个简单场景:10亿行数据做GROUP BY day,ClickHouse在第一颗普通云主机上只需要不到1秒完成扫描和聚合。这里面既有列式存储的功劳,也有向量化执行和大量底层指令级优化的共同作用。
2.4 列裁剪:只读需要的那几个字段
最后一个常常被忽略但对IO影响巨大的机制是列裁剪。听起来简单,做起来却非常关键:查询SELECT city, COUNT(*) FROM users GROUP BY city,引擎只需要读取city这一列,user_id、name、age这些列连碰都不碰。
在行式存储中,“只读需要的列”很难彻底做到;但在列式存储中,这是存储结构天然支持的行为。尤其在宽表场景,一张表有50列,分析查询只用其中3列,列裁剪可以让实际扫描的数据量减少一个数量级。可别小看这点,很多数仓查询慢,慢就慢在全表所有列都被无脑扫了一遍。
不过列裁剪也有前提:文件格式和查询引擎都必须支持谓词下推到文件层。如果数据在存储时被强行做了行式嵌套,比如把一整个对象塞进一个大JSON字段,列裁剪就会失效,又退回到“读一堆无用JSON串”的老路上。这也是建模时要尽量避免“大宽列、深嵌套”这类结构的原因。
3. 主流列式存储方案与选型思路
3.1 文件格式层:Parquet和ORC怎么选
把列式思想落到文件格式,最常遇到的就是Parquet和ORC。Parquet在Spark、Hadoop生态、数据湖场景里几乎成了默认标准,兼容性最好;ORC在Hive里表现亮眼,尤其是Hive做大型扫描任务时,压缩比和读取性能在某些场景下会比Parquet更激进。
在选型上我的经验是:如果集群以Spark、Flink为主,数据要进Iceberg或Hudi等数据湖,优先Parquet,因为它生态成熟,各类引擎对它的支持最稳定;如果主要跑Hive数仓、且非常在意存储占用,可以试试ORC,但需要确认所有下游引擎都支持读取,避免兼容性问题。
从底层看,两者核心思路一致:行组内按列存储、列内分段压缩、元数据记录统计信息。所以有些场景下差异不显著。真正会拉开差距的,是写入端的排序策略、压缩器选择,以及读取端的过滤条件下推能力。
3.2 数据库引擎层:ClickHouse、Doris和DuckDB
文件格式之外,还有一批直接把列式存储作为核心的数据库引擎。ClickHouse是目前最典型的代表,它的MergeTree表引擎把列式存储、分区、稀疏索引、向量化执行全部集成在一起,单表聚合性能非常能打。Doris(以及StarRocks)在MPP架构上做了类似的事情,擅长多表关联和明细查询,适合即席分析BI场景。DuckDB则是嵌入式单机列式引擎,适合在本地、小团队、分析师手里快速做数据实验。
这几个引擎的共同特点是:列式存储让它们能以很小的机器成本完成很高的扫描吞吐。选择关键不在于“哪个更强”,而在于“你的场景更像哪一种”。CLickHouse对单表大宽表聚合极强,但多表复杂关联不是它的强项;Doris这类MPP引擎更贴近传统SQL数仓体验;DuckDB则追求开箱即用,不折腾分布式。
3.3 场景导向的选型对照表
下表是我们内部做技术选型时经常对标的参考逻辑:
| 场景 | 推荐方案 | 核心理由 |
|---|---|---|
| 离线数仓、数据湖分析 | Parquet + Hive/Iceberg/Spark | 生态成熟,跨引擎兼容,表结构灵活 |
| Hive重场景、存储成本敏感 | ORC | 压缩激进,Hive读写优化好 |
| 大规模明细实时聚合 | ClickHouse | 列式+向量化,单表聚合极强 |
| 大规模多表关联即席分析 | Doris / StarRocks | MPP架构,复杂查询优化更好 |
| 单机快速数据分析 | DuckDB | 嵌入式,零运维,列式引擎完善 |
| 在线高并发点查 | 不要硬用列式,回到行式KV/关系库 | 列式点查成本和并发能力并不占优 |
不要试图让一个引擎干所有事。在我见过的大数据项目里,最容易翻车的不是单个引擎不够强,而是选型时不加区分,把列式引擎当成万能钥匙,最后发现在点查和高并发写入场景里完全不是关系库的对手。
4. 最佳实践:从建表到查询的完整落地
4.1 以ClickHouse为例的建表、分区、排序键设计
先给一套我在实际项目里常用的ClickHouse建表模板,后面再解释关键参数。
CREATE TABLE events_local ( app_id UInt32, event_name LowCardinality(String), user_id UInt64, event_time DateTime, cost_ms UInt32, extra String ) ENGINE = MergeTree() PARTITION BY toYYYYMM(event_time) ORDER BY (app_id, event_time) TTL event_time + INTERVAL 180 DAY SETTINGS index_granularity = 8192;建表时最重要的决定就是分区键和排序键。分区键控制数据在物理上如何切分,合理的分区可以显著加快时间范围查询,也方便TTL过期清理;但分区不是越细越好,过多分区会带来大量小目录和文件碎片,反而拖慢查询。上例按月份分区,对日志场景很合适,因为业务查询基本都带时间范围,而且老数据可以定期删。
排序键则决定了每列数据的局部有序性,直接影响稀疏索引和数据跳过效果。写成(app_id, event_time),相当于数据先按应用ID排序、再按时间排序,这样查询“某个应用某段时间”的时候可以快速定位到对应的granule区间,而不是全表扫描。如果业务里高频过滤条件是event_name而不是app_id,那排序键就要改成(event_name, event_time),选错排序键的代价,往往到数据量涨起来才暴露。
4.2 Spark写Parquet时如何设置压缩和排序
在Spark里把数据落地成Parquet,看起来只是write.format("parquet")一行,但实际调优点不少。下面这段是我常用的一种写法:
( df .repartition(200) # 控制输出文件数量 .sortWithinPartitions("date", "user_id") # 分区内排序 .write .format("parquet") .mode("overwrite") .option("compression", "zstd") .partitionBy("date") .save("hdfs:///warehouse/events") )repariition(200)能避免产出成千上万个小文件。sortWithinPartitions和distribute by不同,它不改变整体分区数量,但保证每个分区内的数据按指定列有序,这样Parquet每个Row Group的Min/Max信息密集度更高,后续过滤下推会更有效。
压缩器选择上,追求压缩比时用zstd,追求吞吐时用snappy或lz4。不要一上来就无脑选压缩比最高的,因为查询侧解压同样要CPU,如果机器CPU资源紧张,高压缩比反而会让查询变慢。
特别注意的是partitionBy和排序键的顺序关系。如果按date分区,那么date字段其实已经天然隔离了,排序键里再放date就是重复劳动,排序价值不大。正确做法是让排序键覆盖每个分区内部高频维度的过滤字段,而不是重复分区字段。
4.3 怎么确认谓词下推和列裁剪真的生效了
很多人以为写完SQL,引擎就自动做了优化,其实不一定。最稳妥的办法是看执行计划。以Spark为例,可以用explain("formatted")或explain("extended")查看FileScan节点,会看到PushedFilters和ReadSchema两处关键信息。
- PushedFilters如果显示isnotnull(date#23) AND (date#23 = 2024-01-01)这类内容,说明谓词被下推到了文件读取层。
- ReadSchema如果只包含查询涉及的字段,说明列裁剪生效了;如果出现整表所有字段,就要检查是不是有什么UDF或select *把字段全部拉进来了。
在ClickHouse里可以用EXPLAIN语句配合trace_profile,查一下read_rows、read_bytes和selected_rows的对比。如果read_bytes特别大但selected_rows很小,说明过滤并没有送达存储层,或者排序键设计失效了。学会看这两个指标,比零零散散调参数更能直击问题本质。
5. 常见问题与排查技巧实录
5.1 小文件泛滥:列式存储最隐蔽的杀手
列式存储要发挥优势,依赖数据块足够大、元数据足够集中。如果一次写入产生了成千上万个几MB甚至几百KB的小Parquet文件,每个文件都有自己独立的footer和元数据,查询时光打开文件、解析元数据就要耗费大量时间,压缩和索引带来的收益全被吃掉了。
产生小文件的典型原因有两个:一是上游任务Spark分区数设置过大,输出文件数跟着膨胀;二是实时流式写入频繁flush,每次都落一个文件。
解决思路也很直接:离线场景下,写之前按目标大小推算分区数,比如目标单个文件128MB,单分区数据量1GB,就设置约8到16个输出分区;实时场景下,在存储前面加一层攒批合并机制,或者使用Iceberg这种支持compaction的文件布局,让小文件在后台自动合并。
5.2 压缩格式选错:有的查询反而更慢了
压缩格式不是“选越大越好”。我见过一个团队把全链路换成zstd后,存储占用确实下降明显,但原本用snappy只需1秒的聚合查询变成3秒,因为CPU在解压上花的时间比省下的IO时间还多。
这里建议做个简单对照:机器CPU核数富裕、数据量大、磁盘慢,优先用zstd;CPU紧张、磁盘快、查询频繁,优先用snappy或lz4。如果不确定,就在同一批数据上分别跑一轮查询,对比read_rows、read_bytes、CPU时间三个指标再决定。
另一个容易被忽略的坑是Parquet写入端和读取端的压缩配置不一致。不同引擎默认压缩不同,Spark写Parquet默认snappy,Hive读Parquet如果不显式指定压缩也能解,但某些版本上元数据信息处理可能有差异。列式文件格式没有强类型校验,建议在写入端明确写死compression,不要靠引擎默认值。
5.3 排序键设计不合理:数据跳过优势荡然无存
有个很典型的案例:用户表按照user_id排序,但业务查询几乎都是按city过滤,然后再统计人数。由于user_id和city没有任何相关性,数据在整个范围内乱序分布,每个Row Group的city字段都几乎覆盖全量城市,Min/Max索引完全无法过滤。查询只能扫描整表,表现还不如有B+树索引的关系库。
解决办法是让排序键尽量贴近高频过滤条件。如果多个字段都要过滤,排序键可以设计成组合顺序,把最常过滤、区分度最高的字段放前面。注意改排序键往往需要重写数据,所以最好在建表前就把业务查询模式梳理清楚。
5.4 常见问题速查表
| 故障现象 | 可能原因 | 排查路径 | 推荐处理 |
|---|---|---|---|
| 查询扫描字节数巨大但结果很小 | 谓词下推未生效/排序键失效 | 查看EXPLAIN、check PushedFilters | 调整排序键,重写数据 |
| 大量小文件拖慢查询 | 分区数过多/流式频繁flush | 统计文件数量和平均大小 | 合并分区、Compaction |
| 压缩比低,存储膨胀 | 列内规律性差/嵌套结构过多 | 查看各列压缩率 | 拆分嵌套列、增加字典编码 |
| 导入数据很慢 | 分区/排序键太重,每批都触发merge | 查看写放大和merge耗时 | 减少分区数、批量写入 |
| 点查慢并发低 | 选型错误,不适用列式 | 观察查询延迟曲线 | 改用行式引擎或加缓存 |
6. 列式存储落地中的几点个人体会
讲了这么多原理和案例,最后说几点我从项目里总结出来的体会。
第一,先弄清楚你的查询模式,再谈建模和存储选型。我见过太多团队先把数据灌进ClickHouse,等业务方来提需求,结果发现实际查询全是点查和精确匹配,反而把列式引擎用成了“昂贵的行式数据库”。数据模型一定是为查询服务的,脱离查询模式谈存储,很容易南辕北辙。
第二,存储优化的收益不要只看单次查询快慢,要看整体IO和成本。列式存储带来的压缩和跳扫,降低的是整个平台的磁盘占用、网络传输和计算资源成本。调优时我会同时关注扫描字节数和执行时间,因为前者决定了集群能扛多少并发,后者只决定了单个查询的体感。
第三,不要迷信单一指标。有人只看压缩比,有人只看压缩速度,有人只看单条SQL跑得有多快,这些都不全面。最好的方式是建一个“查询画像”:列出高频SQL、低频SQL、期望延迟、数据量级、更新频率,用这组信息指导选型,而不是被某一个炫酷数字带偏。
最后,如果正在准备面试,我建议把“列式存储为什么适合分析场景”这个问题练到能不打草稿说三分钟,不用背概念,而是从IO、压缩、CPU执行三个层面讲清楚。这比背一百个名词更能体现你真正理解大数据系统的底层逻辑。
再分享一个最近的小案例。业务方要做一个用户行为漏斗分析,数据量很大,下游系统一开始准备直接上一套重型MPP集群。我先让他们把最核心的20个分析SQL列出来,发现80%都是单表聚合、按天过滤、按用户维度去重。根据这个画像,最终方案是用Parquet + Spark做离线预计算,加上ClickHouse承接明细维度的即席查询,整个成本只有原方案的三分之一。列式存储不是银弹,但在对的场景里,它带来的收益往往超乎想象。