
做了这么多年数据平台和数据仓库我对“列式存储”这个词的感受是又爱又恨。爱它的人觉得它直接把 OLAP 查询性能拉高了一个量级Hive、Spark、Presto、ClickHouse、Doris 这些主流引擎几乎都绕不开它恨的人则是在实际项目中因为不了解存储格式和索引细节把小文件、乱嵌套、压缩选型这些坑都踩了个遍。所以我想专门把“大数据 OLAP 中的列式存储技术”这事从底层原理拆到实战调参再把常见的坑和排查思路串起来写一篇。不管你是刚接触大数据的初学者还是已经在数据平台摸爬滚打的工程师这篇文章都值得看完。它能帮你搞清楚三件事列式存储为什么快怎么把行存改造为列存以及生产中碰到性能问题该怎么查。1. 先搞清楚列式存储到底在解决什么问题1.1 行式存储为什么在分析场景里力不从心先看一张最普通的订单表大概二十个字段订单ID、用户ID、商品ID、下单时间、支付金额、支付状态、收货城市、渠道来源……单表几千万甚至几亿行。传统的关系型数据库比如 MySQL默认的 MySQL InnoDB 数据页组织方式本质上就是行式存储一行记录的所有字段连续地放在一个页里。这种行式布局最大的优势是“按主键点查”特别快。你要查一个订单ID对应的整条记录一次 IO 就能把一个页面上的完整行数据读出来。但 OLAP 场景完全不是这么玩的。日常分析查询大多是“扫描一大片数据然后对其中少数几个字段做聚合”。比如算昨天所有渠道的 GMVSQL 大概是SELECT channel, SUM(pay_amount) FROM orders WHERE order_date CURRENT_DATE - 1 GROUP BY channel;表面上看只是取 channel 和 pay_amount 两个字段但行式存储为了定位到这两列被迫把每一行涉及的几十个字段全部读出来。你明明只需要 2 个字段实际 IO 却搬了 20 个字段的数据量白白多读了 10 倍的磁盘。更尴尬的是压缩。行式存储里同一行的字段类型五花八门数值、字符串、日期混在一起很难用同一种编码做有效压缩。就算硬压压缩率也远不如列存。而大数据分析往往要扫几亿行磁盘 IO 和网络传输就是最大的瓶颈行式存储在这个场景下属于先天劣势。1.2 列式存储的核心思路按列存取而不是整行列式存储的思路正好反过来同一个字段的所有值在物理上连续存放。表还是那张表逻辑上没有变化但存储引擎把每一列单独拆成独立的数据块。比如把 orders 表的 order_date 列放一块pay_amount 列放另一块channel 列再放一块。于是执行上面那个聚合查询时存储层只需要读取 order_date、pay_amount、channel 三个列文件其他十七个列连碰都不碰。IO 量从读整个表变成只读三个字段性能提升往往是几十倍甚至上百倍。我经常用一个生活类比来解释超市进货整条货架上摆着各种商品你想统计某一个牌子的酱油今天卖出多少箱如果按“把整个超市的集装箱全部拆开数一遍”的方式做效率可想而知。但如果你在仓库里就已经把每个品牌的商品分区存放只用去那一个区域点数就行。列式存储就是给数据仓库做了这种“分门别类存放”的设计。现代 OLAP 引擎里列式存储已经不只是磁盘文件的存储组织方式它还延伸到内存布局和查询执行引擎。比如 ClickHouse、Doris 这样的引擎不仅在磁盘上用列存在内存中也把一批列的数据连续排布方便后续做向量化计算。列存是这一切高性能分析的基石这也是“大数据 OLAP 和列式存储”这对组合紧紧绑在一起的根本原因。2. 列式存储关键机制拆解压缩、索引与向量化执行2.1 数据编码与压缩为什么列存压缩率能高出好几倍列式存储带来的第一个红利是压缩率。原因也不复杂同一列的数据类型一致取值范围通常也相对集中重复值出现的概率比整行混合数据要高得多。拿用户活跃状态来举例。一个日活表里某个 int 类型的 status 字段整天只有 0、1、2 三种取值。如果这一列几千万行连续存储用字典编码可以做成一张只有三条记录的字典表原始数据全部用极短的数字编码替换。再配合 Run-Length Encoding游程编码如果一条状态连续重复几万次存的时候存一个值加一个重复次数就够了。这一列压下来压缩比能到几十比一。数值类型列也有成熟的编码方案。时间戳或订单金额这类连续变化的数值通常会做 Delta Encoding也就是不存原始值存相邻值的差值。差值往往很小适合再用 Bit-packing 压缩。行业里常见的 Parquet 和 ORC 文件就是通过组合这些编码方式让磁盘上单个数据块的文件体积经常只有原始文本的十分之一到二十分之一。压缩率高意味着同一块磁盘能存更多数据同样带宽能搬更多数据。对 OLAP 查询来说数据量本身就是最大的敌人压缩相当于直接帮查询引擎缩小了扫描范围这比什么都管用。2.2 稀疏索引、分区裁剪与谓词下推查询怎么加速光有压缩还不够列式存储真正厉害的地方在于配合了“索引”和“裁剪”机制。列存文件里通常会为一批连续的行比如 Parquet 里称为 Row GroupORC 里称为 Stripe记录这批数据里每一列的最小值、最大值、空值数量等统计信息。这就是常说的稀疏索引也叫 Zone Map。执行查询时引擎先看过滤条件里的字段。比如 WHERE order_date 2025-01-01存储层会拿这个条件去比对一个一个 Row Group 的 order_date 列统计信息。如果这个 Row Group 的最小值大于 2025-01-01或者最大值小于它整个 Row Group 直接跳过连压缩数据都不需要解压。这种跳过机制在分区表上更明显。如果列式文件本身按照日期做过分区那么查询可以精确裁剪到某个分区目录如果引擎层面还有谓词下推能力比如 Spark SQL、Hive 里的 Predicate Pushdown那么过滤条件会尽量下沉到数据扫描阶段执行而不是等把所有数据读回内存再用 Filter 算子过滤。很多刚接触大数据的人会问列式存储是不是没有索引确实它没有传统数据库的 B-Tree 或二级索引但它用稀疏索引这种更轻量的方式实现了对 OLAP 查询场景足够高效的加速。你不需要为了某个热点值单独建索引因为列存天生就把每一列拆开做了统计扫猫的时候依靠文件统计信息和分区裁剪就能把大范围的 IO 开销省下来。2.3 向量化执行列存如何把 CPU 用满列式存储的第三个关键点是向量化执行。简单说向量化就是让 CPU 一次处理一批数据而不是一条一条处理。如果数据在内存中按列连续存放引擎可以一次性加载连续 8 个或 16 个数值到 CPU 的 SIMD 寄存器里一条指令同时完成加法和比较。对比行式存储逐行读取、逐行计算的方式向量化执行不仅减少了指令条数还提升了缓存命中和内存带宽利用率。更直白地说原先计算一亿行的 SUM可能要执行一亿次循环向量化之后可能只需要循环几百万次每次处理一批。ClickHouse 是列存加向量化执行的代表。它的底层在执行聚合和过滤时大量使用宽位 SIMD 指令同时内存里按列组织数据块保证数据读取的连续性。很多人在 ClickHouse 上跑几亿行聚合只要几十毫秒除了硬件好更多就是靠这套“列式存储向量化执行”的组合拳。这里要专门提醒一点列式存储的好处在分析型负载上才能充分放大如果是频繁单行写入或点查更新列存的随机读写性能可能反而不如行存。这也是为什么 OLTP 和 OLAP 系统通常要分开设计的原因之一。列式存储解决的是“大规模数据的聚合分析”问题不是“高并发事务”问题。3. 大数据 OLAP 场景下的列式存储落地实操3.1 湖上文件格式选型Parquet 与 ORC 怎么选在大数据生态里最常见的两个列式文件格式是 Parquet 和 ORC。两者都支持列式存储、压缩、统计信息、谓词下推但如果要落地选型还是有一些差别需要注意。Parquet 由 Twitter 和 Cloudera 等推动开源底层借鉴了 Google Dremel 的嵌套数据模型对复杂嵌套结构Struct、Array、Map支持得非常完善。它的优点是生态兼容性强Spark、Flink、Presto/Trino、ClickHouse、Doris 等都能读。如果你的数据湖主要给 Spark 和 Presto 用Parquet 几乎是默认选择。ORC 的出身是 Hive 生态最早由 Hortonworks 主导。它在 Hive 上的集成度和优化做得非常深Strip 级别的统计信息比 Parquet 的 Row Group 更细粒度某些场景下过滤效果更好。但 ORC 在 Spark 生态里的支持曾经没 Parquet 那么顺滑现在虽然有改善遇到复杂数据源互通时兼容性问题还是多一些。我的建议简单直接如果团队主要用 Spark 和 Presto并且数据会跨多个计算引擎流动我首选 Parquet压缩选 Snappy 或 ZSTD。如果团队深度绑定 Hive 数仓不希望引入太多额外组件那 ORC 也不错压缩和索引效果在 Hive 上经常更出色。两者都是成熟的列式方案关键看你的计算引擎生态。3.2 用 Hive/Spark 快速将行式表改造为列式表很多企业的数仓一开始用的是 TextFile 或者 SequenceFile查询性能很不理想。把它改造成列式表是性价比极高的一件事。在 Hive 里最简单的方式就是建一张新的 Parquet 表然后从原表导入。实际操作时我会按下面这几步来-- 1. 创建列式存储目标表 CREATE TABLE orders_parquet ( order_id BIGINT, user_id BIGINT, item_id BIGINT, order_date STRING, channel STRING, pay_amount DECIMAL(12,2), status TINYINT ) PARTITIONED BY (dt STRING) STORED AS PARQUET; -- 2. 把原表数据写入注意设置压缩 SET hive.exec.compress.outputtrue; SET mapreduce.output.fileoutputformat.compress.codecorg.apache.hadoop.io.compress.SnappyCodec; INSERT OVERWRITE TABLE orders_parquet PARTITION (dt) SELECT order_id, user_id, item_id, order_date, channel, pay_amount, status, dt FROM orders_text;真正执行前最好确认分区字段不要放在普通字段里重复否则会导致数据冗余。另外控制单文件大小如果原表一天的数据量有几十 GB让文件保持在 128MB 到 256MB 这个区间最合适后面我会专门讲小文件问题。如果用 Spark 来做会更简洁// 读取文本表直接写 Parquet spark.read.table(default.orders_text) .write .mode(overwrite) .partitionBy(dt) .option(compression, snappy) .parquet(/warehouse/orders_parquet)写完后用spark.sql(select count(*) from parquet./warehouse/orders_parquet)验证数据完整性。改造完之后随便跑一个聚合查询对比下扫描行数你就能直观感受到列式存储带来的变化。3.3 ClickHouse MergeTree 的列存落地经验ClickHouse 跟 Hive、Spark 这种批处理引擎不一样它是一套完整的 OLAP 数据库列式存储已经深度集成进存储引擎里。其中使用最广的 MergeTree 引擎它的核心思路就是数据按照分区目录管理每个分区目录下每一列是一个独立的 .bin 文件并且按 Granule 粒度组织默认 8192 行一个 Granule。创建一张 MergeTree 表很简单CREATE TABLE orders_clickhouse ( order_id UInt64, user_id UInt64, order_date Date, channel String, pay_amount Decimal(12, 2), status TINYINT ) ENGINE MergeTree() PARTITION BY toYYYYMM(order_date) ORDER BY (order_date, channel) SETTINGS index_granularity 8192;这里 ORDER BY 定义了表的主键索引ClickHouse 会为每个 Granule 生成稀疏索引。查询时引擎根据 WHERE 条件中的 order_date 和 channel先通过稀疏索引快速定位到可能命中的 Granule 集合再只加载这些 Granule 对应的列数据。配合 ZSTD 压缩和向量化执行几亿行数据的聚合查询往往几百毫秒到几秒就能出结果。实际调优时我通常关注两个参数一个是index_granularity默认 8192 行一个索引点如果查询范围很密集可以适当调大减少索引文件体积另一个是压缩参数ClickHouse 默认用 LZ4磁盘空间敏感的场景我会改成 ZSTD压缩率更高查询时牺牲一点 CPU 也值得。4. 列式存储使用中的常见问题与排查技巧实录4.1 小文件问题列式存储的隐形杀手列式存储虽然扫描高效但真正拖垮生产系统的往往是“小文件过多”。如果一个分区目录下有几千个甚至上万个几十 KB 的小 Parquet 文件那么查询时单是打开文件、读取文件尾的元数据、启动任务的开销就足以把优势抵消掉。小文件是怎么产生的最常见的就是 Spark 里repartition(1000)或者 Hive 里开了太多 Reducer每个 Reducer 只写了很小一个文件。另一个常见原因是流式任务频繁写入比如 Flink 每个 checkpoint 写一批小文件到 HDFS。解决办法也很直接定期合并。我自己常用的方式是spark.read.parquet(/warehouse/orders_parquet/dt2025-01-01) .coalesce(8) // 根据数据量估算目标文件数 .write .mode(overwrite) .option(compression, snappy) .parquet(/tmp/orders_merged)合并完再检查目录文件大小让每个 Parquet 文件尽量接近 128MB 到 256MB。如果 Hive 场景可以在写入时通过distribute by或者设置set hive.merge.mapfilestrue、set hive.merge.size.per.task256000000来减小文件数量。小文件问题属于典型的“一次配置不好后续持续挨打”越早治理越好。4.2 Schema 变更与嵌套列设计避坑列式存储的 Schema 变更能力并不像传统数据库那么灵活。Parquet 虽然支持新增列但如果你把某个列的类型从 String 改成 Long或者把一个字段挪到嵌套结构里往往需要重写整个表。实际业务中经常出现上游字段变化如果数仓分层没有做好兼容就会引发一系列读取失败。另一个很容易踩的坑是过度嵌套。Parquet 底层用 repeated field 和 definition level 来表达嵌套嵌套层数一旦过深或者一个表里塞了几百个嵌套列解析成本会大幅上升。尤其是用 Spark 读取大量带复杂 Struct/Array 字段的 Parquet频繁触发 Kryo 序列化和对象拷贝性能会明显劣化。我的经验是设计列存表时尽量让大部分列保持扁平结构。比如把用户订单明细拆成“订单主表 明细子表”而不是在一个大的 Struct 里堆所有明细。同时控制单表列数几千列的表看着数据“齐全”实际上会大幅放大读放大问题查询只取十列都要背着几千列的元数据压缩率也会变差。4.3 压缩算法与编码选择的调参心得最后聊压缩算法选型。列式存储文件格式本身支持多种压缩但很多人习惯性选 Snappy主要因为它快CPU 开销小。但 OLAP 查询里磁盘 IO 往往比 CPU 更紧张尤其在云上按 IO 计费的环境这时候 ZSTD 可能是更优解。我做过一个实际对比同批次 10 亿行订单数据Snappy 压缩后的 Parquet 文件总大小是 6.8GB换成 ZSTD 之后只有 4.2GB查询时扫描数据量明显减少虽然 CPU 多花了一点在解压上但整体查询时间反而缩短了大约 20% 到 30%。所以我现在基本默认 ZSTD除非 CPU 非常紧张且有大量并发查询才会退回 Snappy。ORC 和 Parquet 的编码同样值得调。比如 Parquet 里对于字符串类型可以配置启用字典编码遇到重复率高的枚举值效果极好。Spark 写入时可以通过spark.sql.parquet.dictionary.enabledtrue开启。ClickHouse 则可以在建表时指定CODEC(ZSTD)、CODEC(LZ4HC)或者对特定列使用Delta编码。压得好不好不是玄学而是看你是否理解了数据的取值分布。再分享一个排查经验。如果发现某个列存表查询比预期慢很多别急着加机器先看EXPLAIN执行计划里扫描的数据量是不是明显超过了 SQL 需要的量。如果一行只查三个字段却扫了整个表的全部列多半是表文件写坏了比如把一个行格式的表直接套上了.parquet后缀或者建表语句用了STORED AS TEXTFILE但底层文件是 Parquet。这种低级错误在异构迁移时特别容易出现查一下实际文件格式就能定位。另外一个容易被忽略的点是统计信息失效。Hive 和 Spark 里如果长时间没执行ANALYZE TABLECBO 优化器拿不到准确的行数和大小估算生成的执行计划可能走上低效路径。列式存储再强也架不住优化器做了个错误的全表 Join因此定期更新表和分区的统计信息比临时调参更管用。我在实际项目中见过太多因为存储格式踩坑的案例也见过只改一个存储格式就让核心报表跑批时间从四十分钟降到八分钟的场景。列式存储不是银弹但只要按照数据特点把编码、压缩、分区、小文件治理这几件事做好OLAP 查询性能的提升会非常直接。这个技术方向深挖下去真的没有天花板无论你现在用的是 Hive、Spark、Presto 还是 ClickHouse花点时间弄清底层列存的机制都会让你在后续排障和调优时快人一步。