☰
行式存储与列式存储:慢查询优化与数据库选型核心解析
2026/10/19 5:48:54 网站建设 项目流程

前阵子排查一个内部报表的慢查询,同一张千万行流水表,原来跑一次“按天统计金额”的聚合要六秒多,换到另一套以列式存储为核心的引擎后,同样的 SQL 只要三百毫秒。很多人第一反应是“这个数据库比那个数据库快”,但真要问清楚“为什么差这么多”,绕不开的就是行式存储(Row-based Storage)和列式存储(Column-base Storage)这两种数据在磁盘上的组织方式。这篇文章想把这两个概念讲透,尤其是它们各自适合什么场景、底层是怎么运作的,以及你在选型和优化时真正要看的是哪些点。适合正在做数据库选型、排查慢查询,或者刚接触数据仓库和 OLAP 引擎的开发者参考。

1. 追一个慢查询:两种布局的第一次交锋

先说那次排查。业务方给的表其实不复杂,订单流水,四十多个字段,一千两百万行。需求的 SQL 大致是:

SELECT pay_date, sum(amount) FROM order_flow GROUP BY pay_date;

在原来的行式存储关系库里,这个查询要把整张表扫一遍。一千两百万行,每行约五百字节,算下来接近六 GB 数据。就算数据库只关心pay_date和amount两个字段,它也得先把完整的行读出来,再跳过其他字段。IO 上的浪费是肉眼可见的。

换到列式引擎后,同样的表,但数据按列存放。查询只需要读取pay_date和amount两个连续的数据块,其他列一碰都不碰。这两列的原始数据合计可能就几百 MB,再加上列式引擎普遍自带的压缩算法,实际从磁盘搬进内存的数据量可能只剩一百 MB 以内。量级一差,耗时就不可同日而语了。

1.1 问题被带偏了:别急着给数据库下结论

刚开始组里有人提议“干脆把数据库换掉”。我拦了一下,因为问题根本不是数据库软件的性能差异,而是数据布局和查询模式是否匹配。同一个 MySQL 体系里,如果换成一张窄表、只保留必要字段,聚合查询也不会慢得离谱;反过来,如果让一套列式引擎频繁做单行点查和更新,它一样会很难受。

所以那次最后没有盲目迁移,而是做了一组对比测试,把同一份数据导进两套环境,分别记录三类典型查询的耗时。后面文章里会给出包括单条点查、分组聚合、状态过滤的完整测试数据。现在先把基础原理说清楚。

1.2 这个对比对我们的启发

这次对比让我意识到一个很容易被忽略的事实:行式和列式不是“谁替代谁”的关系,而是面向不同工作负载的两套数据结构。理解它们的关键,不是背几个“行式适合 OLTP、列式适合 OLAP”的结论,而是看清楚:

  • 数据在磁盘和内存里到底是怎么排列的;
  • 一次查询实际要触碰的最小数据单元是什么;
  • 压缩、索引、执行引擎的能力边界分别在哪。

下面几节就按这个顺序拆开讲。

2. 磁盘上的真实样貌:从数据页到扫描足迹

存储布局决定了 I/O 的最小单位。无论查询引擎多聪明,最终都得把数据从物理介质搬进内存,而这个“搬多少数据”的问题,基本在数据落盘那一刻就注定了。

2.1 行式存储:一行数据住同一条街

行式存储的典型实现是“段页式”管理。数据库把磁盘切割成固定大小的页,常见的有 4KB、8KB 或 16KB。写入一行数据时,这一行的所有字段会被连续地放在同一个页里。也就是说,某条记录的order_id、user_id、amount、status、pay_date在物理上是挨在一起的,下一行记录再接着往后排。

这种布局对“取出完整一行”特别友好。一条记录的所有字段只需要一次页读取就能拿到,配合主键索引或聚簇索引,可以很快定位到目标页。但代价也很明显:当你要扫描大量数据、只需要其中两列时,行式存储依然必须把包含这两列的完整页全部读出来。属于“一人占用整条街,你就为全街买单”。

页内部通常还有页目录、空闲空间位图等管理结构,这些也会占用空间。尤其当你频繁更新变长字段时,页内碎片会逐渐增多,行可能被迁移到页内新位置,留下一条转发指针。这些都是行式存储页层面的典型细节。

2.2 列式存储:同一列住同一栋楼

列式存储的物理布局完全不同。它不再把完整行放在一起,而是把一个列的所有值连续存放。如果一张表有四十个字段,那么在磁盘上,这四十个列各自拥有独立的存储区域,每个区域内的数据类型是一致的。这带来一个直观好处:同一个列块里,所有值都是date、都是decimal、都是varchar,没有混搭。

列式存储通常把数据划分为多个 Row Group 或列块,每个列块再按列单独编码和压缩。列块的典型行数在几万到几十万行之间。查询时,每个列块内部可以通过数据页、行号索引或位图来定位具体记录。但要注意,这里的“行号”只是一个逻辑编号,物理上并不存在一条完整的记录连续存放,它更像是一幢楼每层只有一个房间,取一整行要跑每层去拿对应房间。

按照千万行的表来计算,行式按 16KB 页存,一张 6GB 的表大约要扫描 37 万个页;列式只需读取目标列对应的列块,那些不涉及的列块可以直接跳过。这也是“列裁剪”能成立的根本前提:数据已经按列分开存放,扫描的足迹天然可控。

2.3 两种布局下,一次查询的“工作集合”差异

把一次查询需要读取的数据量叫做“工作集合”。行式存储的工作集合往往大于查询真正用到的字段集合,因为最小物理单位是整行;列式存储的工作集合可以精准对齐查询字段,因为最小物理单位是列块。

举个直观例子,一张 44 列的表,行式存储下要算sum(amount),哪怕只用到 1 个字段,也至少要扫过整张表的 44 个字段所占的页;列式存储下,这个查询的工作集合就是一列的数据。44 倍的差距,在很多场景下就是几百毫秒和几秒的差距。

但工作集合小不代表什么都快。如果查询条件需要关联多个字段,才能定位目标记录,列式存储反而需要分别读取多个列的位置,再做行号对齐。这比行式直接从页里拿到一条完整记录要复杂得多。这也解释了为什么“点查详情”这类操作在列式存储上不一定占优。

3. 行式存储的舒适区:交易型负载的天然主场

行式存储这么多年稳稳统治 OLTP,不是没有道理的。它和“高频小事务”“主键点查”“行级更新”这些需求几乎是天生一对。

3.1 插入一条订单有多省事

想象把一条订单记录写入行式存储。数据库先在页目录里找到有足够空闲空间的页,然后就在那个页的尾部追加一行。这个操作通常只涉及一个页面,最多再更新一下页目录和索引结构,事务日志里记的也是非常紧凑的行变更记录。因为所有字段本来就在一个页里,写入完成即代表整行可见。

列式存储面对同样一条 insert 就比较麻烦。它要把这条记录按列拆开,分别写到四十多个不同的列块文件里。一次逻辑上的插入,物理上可能变成几十次小写入,写入放大系数很容易到几十。更麻烦的是,列块通常以批量追加为设计目标,单行写入无论怎么优化都绕不过“拆列-定位-写各列文件”的成本。所以列式引擎要么用内存缓冲把若干小批量攒成一个列块再刷盘,要么干脆只支持批量导入,不适合一条条高频插入。

3.2 更新和事务:行粒度锁与旧版本链

行式存储的更新逻辑也不复杂。聚簇索引下,数据页里可以找到目标行,直接在页内修改对应字段,必要时处理行迁移和页分裂。事务控制最小单位天然就是一行,索引项也顺着主键维护,锁定粒度清晰。

列式存储处理更新就要慎重得多。很多列式引擎本身是 append-only 设计,数据写入后不允许原地修改,更新会产生新的列块版本或者把更新转成一个增量写入动作,查询时再合并新旧版本。单看一次更新,成本是可观的:旧列块还可能参与扫描,合并逻辑也会增加 CPU 开销。这也是为什么列式存储更适合“写多读少、很少改历史”的场景。

从一致性角度看,行式存储对事务的支持成熟度远远领先。大量 OLTP 系统依赖 ACID 和两阶段提交,这在以批量导入为核心的列式系统里很难看到同等成熟度的实现。如果你遇到“高并发写+事务性操作”的硬需求,就别指望列式方案能轻松替代行式方案。

3.3 哪些情况真的适合继续用行式

  • 主键点查或者大批量点查,需要快速取回完整记录。
  • 系统里有频繁的 INSERT/UPDATE/DELETE 混合操作。
  • 需要成熟的事务语义,比如订单扣库存。
  • 每张表的字段宽度不大(比如十列以内),行式扫描的浪费可以接受。
  • 系统里跑的大多是带WHERE pk = ?的短查询,而不是全表聚合。

行式存储的页结构天然支持高并发随机读写,主键索引的跳跃查找能力非常成熟。这是很多业务系统二十多年一直选择它的核心原因。

4. 列式存储的爆发力:聚合、压缩与向量化执行

真正让列式存储爽翻的点,是分析型查询四大件:列裁剪、高压缩率、向量化执行、块级统计信息跳过。这四样加起来,聚合查询的速度就有了数量级优势。

4.1 列裁剪机制

列裁剪解决的是“搬多少数据”的问题。进行SELECT pay_date, sum(amount)时,列式引擎在生成执行计划阶段就知道只需要两列,于是读取层只调度这两列对应的数据块,其他字段完全不进 IO。这种“按需取列”的能力,是列式引擎对宽表分析场景最大的解放。

4.2 压缩率为什么能那么高

压缩效果很大程度取决于“待压缩数据的相似度”。列式存储让每个列块里只有一种类型的数据,所以能用最匹配的编码算法:

  • 低基数列(比如状态字段status只有十几种取值)适合字典编码或游程编码,能把几十字节的字符串集约成 1 字节的字典下标加计数;
  • 数值列(比如amount)适合差值编码加通用压缩算法,因为相邻值往往变化不大,差值分布很集中;
  • 时间列按排序顺序存储后,差分区间更小,压缩空间也更大。

行式存储为什么压不快?因为每个页里塞的是 44 个不同类型的字段,编码器每次面对的都是混合类型,很难选到一个统一的、高效的编码策略;压缩率上不去,解压也麻烦。这在存储硬件成本上的差距,会随着表变大不断放大。

4.3 向量化与 SIMD:让 CPU 干得更多

列式存储的另一个杀手锏是向量化执行。因为每个列块在内存里是一块连续的、同类型的数组,执行引擎可以用 SIMD 指令同时对多条数据进行运算,比如一次处理 8 个 64 位浮点数求和。相比逐行取数、逐行计算,这种批量处理模式能够极大提升 CPU 执行的吞吐。

行式存储的数据是结构体数组,一条记录里不同类型的字段挤在一起,CPU 要跳过偏移量去取字段,很难转换为高效的向量化循环。所以哪怕是同样的数据库技术栈,只要存储内核是行式的,做大规模聚合时 CPU 利用效率就明显吃亏。

4.4 块级统计信息:连查都不用查

列式存储高级一点的设计会在列块上维护元数据,比如每个列块的min、max、null数量、字典表等。查询引擎可以只扫描数据块的元数据就能判断:这个块里有没有满足条件的值?如果没有,整个块直接跳过。这就是常说的 small materialization 或 zone map 跳过。

拿WHERE status = 'PAID'来说,如果一个 64K 行的列块里根本没有PAID,那么引擎可以直接跳过这个列块。理想情况下,扫描的块可能只有全表的 5%。行式存储即便建立了索引,面对“状态”这种低基数列也很难做到如此精准的块级剪枝;而如果没索引,那就只能全表扫描。

5. 同一张业务表,在两套引擎里的表现差异

抽象讲一堆不如直接给数据。我在一次对比测试中,准备了一张模拟订单流水表,1250 万行,44 列,单行约 500 字节,整表约 6GB。硬件是普通服务器,两套引擎均部署在同机环境,结果数值仅代表一次测试的相对水平,不同产品和硬件下会有浮动,但趋势有说服力。

查询类型行式存储列式存储关键差异点
按主键查询单条记录约 5ms约 20ms行式走聚簇索引一次页读取;列式需多列定位后对齐
按pay_date聚合 1250 万行约 6.4s约 280ms列式只读取两列并压缩;行式扫全表
按status过滤统计约 4.2s约 700ms列式块级 min/max 跳过大量不相关块
批量插入一万行约 90ms约 1.2s行式单行追加;列式拆 44 列写入放大严重
数据整体压缩率原始 6GB压缩后约 1.1GB低基数列 + 差值编码效果好

从表格能看出,列式存储并不是“全面更快”。主键点查场景,行式更利落;聚合分析场景,列式几乎没有对手;写入场景,行式更是完胜。这里面没有“银弹”,只有“匹配”。如果你把 44 列宽表放在行式引擎里做GROUP BY,每天还会运行上百次,那长期累积的 IO 成本会非常可观。

再看状态过滤这个例子。订单status是典型的低基数列,只有十几种取值。列式存储把status列单独存成一个块,上面挂了字典和 min/max 元数据。扫描时一眼就能跳过 80% 的列块,剩下的列块用字典编码直接完成过滤,速度当然快。同样的列要是放在行式存储里,除非你精心做物化索引,否则全表扫描基本是唯一选择。

这张表测试完之后,我们做了一个很有意思的结论:业务接口继续保留行式副本,分析报表走列式副本,两边各干各擅长的活。这也是目前混合架构的主流思路。

6. 选型时真正要看的四个权衡点

刚接触行式和列式的人,总想找到一个“哪个更好”的最终答案。事实上更合理的提问是“我的负载长什么样”。我建议从这几个维度去权衡。

6.1 核心负载比例:多写少查,还是多查少写

先数数读写比例。每秒钟写几百上千条、每条带事务,这种场景直接站在行式这边,别硬试列式的批量缓冲方案。每天批量导入千万行、然后跑一堆聚合报表,这种很明显是列式更合适。真正的难题是“读写都很重”,这时通常要拆成两层:事务层用行式,分析层用列式,中间通过数据同步链路把数据搬过去。

6.2 表的宽度与字段利用率

表字段少(比如 8 到 12 个),即使做聚合,行式扫描的冗余也就是两三倍的代价,压缩优势也不突出,没必要为列式增加架构复杂度。但如果是一张 50 列以上的宽表,每次分析只需要其中 5 到 8 个字段,列式带来的 IO 节省就会被明显放大。宽表+低字段利用率,是列式最理想的舞台。

6.3 数据新鲜度要求

很多列式分析引擎的同步链路是分钟级甚至小时级,因为列块批量追加和压缩都适合周期性导入。如果业务要求实时报表,比如延迟控制在十秒内,那就要认真评估:列式引擎是否能支持高频微批量写入?还是说必须保留一套实时查询层?我之前见过一个项目,为了让报表“实时”,试图把一个列式仓库改成逐条写入,结果写入放大拖垮了整个导入流程,最后只能回到混合链路。

6.4 团队运维和生态

列式存储选型不只是挑数据结构,它是一个完整的系统:要么是有助于批量 ETL 的导入工具,要么是有成熟的查询引擎、扩展函数、权限体系。行式数据库生态显然更成熟,DBA 经验也丰富。如果团队很小、没有专职数仓工程师,为一个分析需求硬上一套复杂的列式分布式集群,运维成本可能比收益还高。不妨先从“行式数据库 + 物化视图/汇总表”做起,等数据量和查询复杂度确实扛不住了再迁到列式。

7. 踩坑与心得:从“行转列”到混合架构

最后分享一些实操中的体会。这些坑不是理论推导出来的,是真在项目里折腾过的。

7.1 我以为换了列存就万事大吉,结果……

第一次把一张宽表搬到列式存储后,第一个大跌眼镜的场景居然是简单查询变慢了。原因是业务接口经常按主键拿某条订单的完整详情,比如SELECT * FROM order_flow WHERE order_id = ?。这恰恰是列式存储的弱项:它要分别读取所有列的对应位置再拼装回一行,反倒绕了远路。解决方案不是逼列式引擎去优化点查,而是把详情接口继续指向行式副本,只在分析场景切到列式副本上。两套副本各管一段业务,数据通过同步工具保持一致。

7.2 排序键、分区和压缩顺序很讲究

列式存储的压缩效果和扫描效率,跟数据排布强相关。一次我建表时直接把“写入时间”做了排序键,觉得这样最自然。实际跑查询发现,大量过滤条件都是按status来做的,列块内status分布高度分散,min/max 几乎无法剪枝。后来把排序键改成(status, write_date),并把分区键设成write_date的月份,一个高频过滤查询从原来两秒降到三百毫秒。列式引擎里,排序键不仅要考虑时间递增,还要把高过滤频率的低基数列往前放;分区本质上也是为了让大查询能跳过无关区域,这两个设计对性能的影响往往不弱于存储格式本身。

7.3 小表也要列存吗?我的答案是不用

有一次为了统一架构,我把一张只有二十万行的配置表也导进了列式引擎。结果这块数据压缩率只有一点几倍,扫描也快得看不出差异,反而因为列式导入、权限、查询接口多了一层链路,维护成本一点没省。后来我的判断标准很直接:单表超过一千万行,或者单次分析要扫描多个大宽表,再考虑列式;小表用行式就行,没必要为了架构“好看”付出额外成本。

7.4 给还没动手的读者四点建议

  • 先拿真实工作负载做对比,而不是用线上流量打游击。把典型查询列成清单,记录每类查询在行式和列式下的耗时、IO、CPU,再决定迁移。
  • 数据模型上要尽量物化好字段语义,避免过多的嵌套和复杂 JSON 结构。列式存储擅长扁平列,嵌套数据结构处理起来很别扭。
  • 同步链路要留出监控和补偿机制。列式与行式之间的数据不一致,通常是时间戳、去重逻辑、删除策略造成的,上线前要把这些细节测透。
  • 关注压缩与编码是否贴合实际数据分布。有些列式系统支持在表或列级指定编码方式,如果默认压缩不理想,可以针对低基数列、数值列单独调编码。

如果只让我留一条经验,那就是:行式和列式之争,本质是数据布局和查询模式的对齐。你选的存储引擎,决定了哪些查询会很快、哪些查询会很难受。先弄清楚你的工作负载,再去选存储格式;先搞懂它的物理布局,再去调排序键和压缩策略。很多慢查询问题的根子,往往不是数据库不够新,而是数据摆放的方式和业务读它的方式打架了。

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

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

立即咨询