行式存储到列式存储:大数据存储格式的技术变革与选型实践
2026/9/11 2:16:41 网站建设 项目流程

1. 为什么“行式存储”突然成了大数据圈的热点

先抛一个我最近经常被问到的问题:同样是跑一张订单表的聚合查询,在MySQL里扫全表可能要几分钟,换成Hive或Spark之后,换了个存储格式,同样的SQL十几秒就出结果,差距到底出在哪?

绝大多数人第一反应是“分布式并行计算”,觉得是因为节点多、并行度高。这个答案只对了一半。真正拉开数量级差距的,还有一个很多人忽略的底层设计:数据到底是怎么在磁盘上摆的。这就是我们平常说的存储格式,其中最关键的分水岭,就是行式存储和列式存储的取舍问题。

这也是为什么最近圈子里聊“大数据行式存储的技术变革”越来越多。这个标题听着挺唬人,但拆开看就三件事:行式存储是什么、为什么在大数据场景下顶不住了、后来又演化出了什么更好的方案。

这篇文章我会从底层存储原理讲到实际业务落地,把行式存储、列式存储、行列混合方案讲透,再结合我实际做过的一个订单明细表改造项目,给出可以直接参考的选型思路和避坑清单。无论你是刚入行还在学数据科学、正备考大数据开发面试,还是在公司里负责数仓建模,这部分内容都值得认真看一遍。

2. 行式存储设计逻辑与大数据场景下的先天不足

2.1 行式存储最初的设计逻辑

行式存储,说白了就是一行数据的所有字段连续存放在磁盘上。比如一张用户表有id、name、city、age四个字段,那么磁盘上的物理顺序就是:

[1, 张三, 北京, 25][2, 李四, 上海, 30][3, 王五, 深圳, 28]...

传统关系型数据库,比如MySQL的InnoDB、PostgreSQL,从一开始就是这种思路。为什么这么设计?因为在那个年代,业务系统的主要操作是“根据主键找一条记录”,比如用户登录时查一下用户信息、下单时插入一条订单记录。这些都是典型的点查和写入操作。

点查场景下,行式存储的优势非常明显:我要找id=100的用户,只要定位到对应的数据页,一次I/O就能把这一行的所有字段全部读出来,不用做多张表的关联拼装。写入场景同理,一条完整记录连续写下去,落盘效率很高。

这个设计在单机数据库时代是合理的,也是经过几十年验证的成熟方案。即使到了今天,只要你的核心场景还是登录、下单、改状态这类高频点查,行式存储依然是最优解之一。

2.2 大数据分析场景下,行式存储开始“吃力”

问题出在数据分析上,尤其是数据量从百万行涨到亿级、十亿级之后。分析型查询和点查的最大区别在于:它通常不关心某一行的所有字段,而是关心大量行中特定列的聚合结果。

举个例子。电商订单表orders,字段有order_id、user_id、city、amount、status、create_time等,一共20多个字段。现在要跑一条统计SQL:

SELECT city, SUM(amount) FROM orders WHERE create_time >= '2024-01-01' GROUP BY city;

这条查询实际用到的字段只有city、amount、create_time三个,其他十几个字段完全用不上。

但行式存储没有“跳过不想读的字段”这个能力。因为一行数据是连续存储的,想把某几列取出来,就必须把整行读进内存,然后丢弃不需要的字段。假设一行记录平均200字节,实际需要的三个字段大约40字节,那就意味着磁盘上每读200字节,只有40字节是能用的,剩下的160字节全都白读了。

数据量小的时候无所谓,但到了1亿行就是另外一个故事了:

  • 全表物理数据量约20GB,行式存储扫描需要完整读取全部20GB。
  • 如果能只读三列,I/O量理论上可以降到约4GB。
  • 如果再算上列式存储的压缩增益,实际落盘数据可能连1GB都不到。

从20GB到不到1GB,差了20倍以上。这个差距,靠单纯增加并行节点来补,成本会非常难看。

这里插一句,很多人会问:那我给create_time加索引不就行了?索引确实能快速定位日期的范围,但范围过滤之后,如果命中行数很多,依然要回到原表读取大量数据行,然后做聚合。索引在点查场景下是杀手锏,在全表范围扫描和聚合场景下基本帮不上大忙。

2.3 行式存储被“吐槽”的另外两个问题:CPU浪费和压缩率偏低

除了I/O浪费,行式存储还有两个隐性负担。

第一个是CPU和内存开销。因为行式存储需要把完整字段读到内存再做裁剪,内存带宽和CPU周期都消耗在无用的字段解析上。分析型SQL往往要处理上亿行,这种无谓的解析开销会被无限放大。

第二个是压缩率。行式存储文件里,一行数据混合了整数、字符串、日期、浮点数等多种类型。压缩算法在混杂类型数据中很难找到规律。比如ID字段可能趋近随机分布,但相邻行的ID没什么相关性;city字段虽然重复度高,但被穿插在其他字段之间,字典编码的收益也会受影响。

我实测过一组数据:同一份订单表,行式存储(如CSV/Text格式)用gzip压缩,一般能达到2到3倍的压缩比;但同样的数据转成列式存储格式后,用snappy或zstd压缩,轻松跑到5到8倍以上。存储成本直接差出一个量级,这在按TB计费的大数据平台上,是实打实的钱。

3. 技术变革的核心:从行式到列式再到行列混合

3.1 列式存储到底改变什么

既然问题出在“一行连续存储”这个基本布局上,那自然就有人想到:如果反过来,把一列的所有值连续存储,会怎么样?

还是上面的用户表,列式存储的物理布局长这样:

id列: [1, 2, 3, ...] name列: [张三, 李四, 王五, ...] city列: [北京, 上海, 深圳, ...] age列: [25, 30, 28, ...]

这个改动看着简单,但带来了一系列连锁收益。

收益一是查询时只读需要的列。你要统计city分布,就只读city列,其他列完全不用碰。收益二是同类数据放一起,压缩率大幅提升。city列里大量重复值,字典编码后几乎可以做到一个编码代表一个城市,压缩率非常可观。收益三是可以用向量化计算。连续读出来的数据天然就是数组,现代CPU的SIMD指令可以批量处理,不用逐行解析,进一步压榨硬件性能。

这就是为什么像ClickHouse能跑得那么快,底层就是列式存储加上向量化执行。Parquet和ORC这两种大数据领域的事实标准格式,核心也是列式存储,只是在具体实现上做了更多工程化处理。

3.2 压缩率到底能差多少,我们来算一笔账

用一个实际算例来感受差距。假设有一张1亿行的订单表,每行200字节。

行式存储Text格式,数据量约20GB。用gzip压缩后,实际压缩比通常2.5倍左右,落盘约8GB。

换成列式存储Parquet格式,启用snappy压缩。只需要读三列聚合时,city列重复度高,字典编码后压缩比能做到10倍以上;amount列是数值型,delta编码加snappy,压缩比也能到4倍以上;create_time列是时间戳,按单调递增的delta-of-delta编码,压缩比同样可观。整体落盘可能只要2到3GB。

简单说,在同样数据量的前提下:

  • 存储成本从8GB降到2-3GB,省了三分之二。
  • 查询I/O从读全表变成只读相关列,再叠加压缩,实际扫描量可以降到200MB量级。扫描时间从几十秒降到几秒,是正常现象。

很多公司做数据仓库优化,第一条规劝就是“把Text/CSV格式表迁移到Parquet/ORC”,不是因为Parquet含有什么黑魔法,就是因为它把行式布局改成了列式布局,把数据重排了一遍。

3.3 纯列式也有短板,行列混合布局是怎么来的

列式存储这么香,那是不是所有场景都用列式就完事了?并不是。

列式存储有一个明显的弱项:点查和频繁更新。因为一列的数据被拆分到不同文件或不同区域,要还原一行完整记录,就需要从多个列文件中分别读取再进行拼装。对单条主键点查来说,列式反而更慢。另外,列式存储对单行更新也不友好,因为一行数据被打散在不同列文件中,更新一行往往要动多个文件,这在HDFS这类只支持追加的文件系统上尤其痛苦。

所以业界并没有走向“全列式”,而是提出了行列混合的概念。最经典的是PAX布局——Partition Attributes Across。思路是在数据页内部保持按行分区,但页内的数据按列组织。简单理解就是一个大的“数据块”内部,先用行存的方式切分成多个page,每个page内部再把字段按列排列。这样的好处是,既保留了列式的高压缩比和列裁剪能力,又在数据块级别保留了一定的行式局部性,点查时可以更快定位数据块。

在大数据系统里,PAX思想被大量借鉴。比如ORC文件格式的Stripe内部,就是按列存储的Stream;Hudi、Iceberg、Delta这些数据湖格式,底层文件可以用Parquet存储分析列,同时用Avro存记录级增量数据,本质上就是一种行列混合策略。

更广义的行列混合,是在一个分布式系统里同时支持两种存储引擎。比如StarRocks里可以同时建明细表(按列存储)和主键表(按主键索引做高频更新),ClickHouse也有类似机制。这类系统在选型上会让架构师有更多腾挪空间。

4. 落地实操:从行式表迁移到行列混合方案的完整过程

4.1 主流存储格式选型对比

先给出一张选型对比表,这是我做架构评审时经常画的:

格式存储布局压缩率点查分析查询适用场景
Text/CSV行式临时数据、外部交换
AVRO行式较好数据写入、消息序列化、增量数据存储
Parquet列式一般很好离线分析、数据仓库、数据湖
ORC列式一般很好Hive数仓、大规模扫描聚合
ClickHouse MergeTree列式+稀疏主键索引按主键点查/范围查询较好极好实时OLAP、监控分析

这里要注意,Avro虽然也是大数据生态里的常见格式,但它属于行式存储。很多人在面试时会踩这个坑,觉得Avro是列式。判断标准很简单:一行数据在物理上是连续存储还是被列拆分开。Avro默认是连续存储一行,所以它是行式。

4.2 实战案例:订单明细表从Text迁移到ORC

我之前在一家做电商数据分析的公司优化过一个真实场景。那会儿ods层的订单明细表在Hive里用Text格式存储,一天的分区大约100GB,全表一共300多天的分区,总数据量30多个TB,用完怎么查都慢,还有存储成本压力。

第一步,我先整理这张表的字段。一共24个字段,其中业务核心字段有order_id、user_id、city_id、shop_id、amount、status、pay_time、create_time等,另外一半是各种冗余的扩展字段,平时分析很少用到。

第二步,选格式。考虑到Hive生态兼容性和各种复杂查询的稳定性,我最终选了ORC而不是Parquet。原因是Hive对ORC的原生支持最好,尤其是矢量化查询和索引裁减的支持比Parquet更成熟。如果你主要用Spark SQL,Parquet也完全可以,两者在底层原理上没有本质差别,只是各家引擎的支持程度不同。

第三步,改造建表语句。核心是STORED AS ORC,以及选择合适的压缩算法。

CREATE TABLE dwd_orders ( order_id STRING, user_id BIGINT, city_id BIGINT, shop_id BIGINT, amount DECIMAL(12,2), status INT, pay_time TIMESTAMP, create_time TIMESTAMP, ... ) PARTITIONED BY (dt STRING) STORED AS ORC TBLPROPERTIES ( 'orc.compress' = 'snappy' );

我选snappy而不是zlib或者zstd,原因后面专门说。

第四步,执行数据回填。用INSERT OVERWRITE语句把Text表的数据整批写入新的ORC表,同时利用Spark或Tez的并行任务,让每个Reducer输出文件尽量在512MB到1GB之间,避免小文件堆积。

INSERT OVERWRITE TABLE dwd_orders PARTITION (dt) SELECT order_id, user_id, city_id, shop_id, amount, status, pay_time, create_time, ..., dt FROM ods_orders_text;

改造完成后我做过一个对比测试。同一时间段三个月的数据,原来Text格式压缩后大约90GB,ORC加snappy压缩后只有约14GB,压缩比提升大约6.5倍。一条经典的周维度城市交易额统计SQL,原来跑一遍平均37分钟,迁移后降到6分钟出头。这里既有列裁剪的功劳,也有ORC自带的索引裁减在发挥作用。

4.3 压缩算法的选择逻辑

压缩算法的选择,是大数据行式存储改造里最容易忽略但影响极大的细节。我见过很多人无脑选snappy,也有的人只看压缩比选了zstd,结果性能不升反降。

压缩算法主要看两个维度:压缩比和压缩/解压速度。两者往往是矛盾的。snappy的特点是速度快,压缩比中等;zstd的特点是压缩比高,速度也不差;zlib偏向高压缩比但解压慢;lz4则追求极致的速度。

在大数据查询场景中,数据在查询时是要被解压的,解压速度直接决定扫描吞吐量,而扫描吞吐量直接决定SQL跑多快。所以对绝大多数交互式分析场景,我不建议一开始就上最高压缩比的算法,而是选snappy这种平衡型选手。如果你的存储成本压力极大、查询频率又不高,那再考虑zstd。

之前测试过一次,同一份ORC表,用zstd压缩后存储从14GB降到11GB,但一次全表扫描查询耗时反而比snappy版涨了12%。原因就是这个集群CPU资源偏紧张,解压成了新的瓶颈。

4.4 只考虑存储格式还不够:分区、排序和表设计要一起改

很多人以为把表改成ORC或Parquet就万事大吉了,但实际效果往往没有预期好。原因在于存储格式只是底子,上层表设计不合理,再好的格式也没办法发挥全部实力。

第一,分区列的选择。大数据量表一定要分区,通常按日期分区,这个没问题。但要注意,分区不是越多越好,分区的粒度最好配合查询条件。比如分析任务绝大多数按天或者按月过滤,那就按天分区;如果经常要跨多年的全量扫描分析,也可以考虑只在必要的时间范围上建分区,不要无脑把所有维度都做成分区列。

第二,排序键和桶划分。列式存储并不是完全不支持顺序优化。以ORC为例,写入时如果按照某个字段排序,那么Stripe内部会生成该字段的min/max索引,查询过滤这个字段时可以快速跳过大量Stripe。我的处理方式是把经常用于过滤且基数不太高的字段,比如city_id、order_status,放在表的排序键前列。数据在写入前先做一次排序再落盘,初始成本是高了,但后续查询收益非常大。

第三,数仓分层时给行式存储留位置。这里要强调,行式存储并没有完全退出。在我负责的表结构中,明细层dws层大部分表是ORC列式,但ods层接实时消息数据时依然会用Avro这类行式格式,因为Avro在序列化和反序列化上表现更好,也更容易兼容schema演化。列式格式在增量更新、单行级变更上天然有短板,所以数据湖方案里通常会把Avro用于日志级增量,把Parquet用于基线数据,两者配合,这就是最典型的行列混合落地方式。

5. 常见问题排查与技术避坑实录

5.1 行式转列式后查询反而变慢,问题出在哪

我之前帮朋友排查过一个案例,他们公司把一张核心业务表从MySQL导到Hive数仓,直接选了Parquet格式存储,结果发现一条点查SQL在Hive上比MySQL还慢两个数量级。

这其实不是Parquet本身的问题,而是这张表的查询模式还是“根据主键查一行”,典型的点查场景。Parquet读一个主键值时,需要读取Parquet文件footer中的元数据,定位到对应的RowGroup,再扫描该RowGroup中相关的列Chunk。这个过程涉及多个文件的随机读,在HDFS分布式架构下,随机读的成本远高于连续扫描。

所以排查SQL性能问题时,第一步先判断查询属于点查还是分析。点查为主,老老实实用Redis或者MySQL等行式存储解决方案,不要勉强用列式格式硬扛。分析为主,才轮到Parquet、ORC这类方案上场。

5.2 小文件爆炸问题

这是行式转列式时最常踩的坑。不调整并行度直接回填数据时,每个Map/Reduce任务都会产出一个Parquet/ORC文件,如果任务数有大几千,就会产生几千个小文件。

小文件对列式存储的伤害比行式更明显。因为每个列式文件都有自己的元数据footer,查询引擎要打开每个文件、读footer、定位RowGroup。文件数量一多,NameNode内存压力变大,查询计划的调度开销也剧增。我见过一个集群,数据量只有50GB,但小文件有几十万个,跑个count(*)都要卡很久,就是这个原因。

解决办法有几个,不是选一个就完事,而是结合使用:

  • 在写入时控制分区和Reducer数量,使得每个文件控制在512MB到1GB。
  • 定期用文件合并任务,把同一分区的多个小文件合并成一个大文件。
  • 使用Spark的repartition/coalesce或者Hive的concatenate命令来梳理文件。

注意:频繁合并文件会产生临时数据,建议放到凌晨低峰期执行。合并完之后记得做一次ANALYZE TABLE更新表的统计信息,否则查询优化器可能还按旧元数据做预估。

5.3 压缩率很高,但扫描性能依然拉胯

还有一类问题:文件已经用ORC了,压缩比也很漂亮,但查询速度就是上不去。这个问题的根源往往在排序键和统计索引失效上。

ORC、Parquet这类格式都默认对每个Stripe或RowGroup维护min/max索引。但如果你写入的数据没有排序,而且字段基数很高,比如order_id本身几乎每条都不重复,那么min/max索引的裁剪效果就约等于零,几乎每个Stripe都得扫。

解决思路是:第一,把查询过滤最频繁的字段作为排序键,并且保证写入前按排序键排序。第二,不要把高基数字段作为唯一的过滤条件,比如order_id,这种字段用索引裁剪没有意义,得靠分区或者hash分桶先缩范围。第三,如果业务确实需要primary key级别的唯一性查询,那就别指望列式存储的索引机制,给这张表专门建一个支持主键的行式存储表,或者接宽表引擎。

5.4 宽表和窄表到底怎么选

最后说一个数据建模里常年的争论:一张表字段多好(宽表),还是拆成多张小表(窄表)好?

列式存储出现之前,大家倾向于窄表,因为要避免扫描时读取大量无关列,减少磁盘I/O。但列式存储普及之后,这个逻辑发生了微妙变化。因为列式存储按列读取,一张宽表只要查询中用到的列不多,实际I/O并不会随总字段数膨胀。宽表还有一个好处,就是省去了ETL环节大量的join操作。

但这不意味着可以无脑堆字段。宽表的主要问题在于列数过多时,元数据管理和schema演化的成本上升,而且如果频繁往宽表里加列,历史分区的文件可能都要重写。我的习惯是:层级越高越宽,ads层可以直接做宽表,dm和dws层拆成主题域模型,ods层保持原始结构。不要一刀切。

6. 我对行式存储技术变革的一点实践体会

聊了这么多,最后说点感性的东西。

我见过很多刚入门大数据的同学,一上来就学各种框架,Hadoop、Spark、Flink装了一圈,但遇到性能问题往往无从下手,因为他们最容易忽略的就是最底层的存储格式和表设计。行式存储到列式存储的这场变革,不只是一次技术选型的变化,它从根本上改变了数仓工程师分析数据的方式。

做行式转列式这件事,我最大的体会是:不要为了新而新。技术选型一定要回到查询特征上。你的系统是点查多,还是扫描聚合多,决定你该用行式还是列式,两者并不矛盾,完全可以配合使用。对大多数公司来说,一套理想的数据架构里,行式存储和列式存储各有自己的位置,Avro在管道入口负责稳健的写入,Parquet和ORC在分析层负责高效的扫描,ClickHouse和StarRocks这类引擎又在更实时的场景里把行列混合玩出了新花样。

另外补充一个面试和学习方向上的小建议。大数据面试里“行式存储和列式存储的区别”几乎必考,但多数候选人只会背几句概念。真正能加分的回答,一定要带上实际的量化对比和选型思考。比如你能说出“行式适合高频点查、列式适合分析型扫描,压缩率能差出几倍,PAX解决方案是两者的折中”,面试官通常就会眼前一亮。如果你正在规划大数据学习路线,建议把存储格式的原理作为重点之一,这部分知识收益极高,学一次贯穿数仓、数据湖和OLAP引擎多个方向。

行式存储并没有消失,它只是退回到了更适合它的位置。理解这场技术变革的核心,不只是一个知识点,更是一种解决问题的视角。希望这篇文章能帮你理清思路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询