news 2026/10/20 17:39:23

行式存储与列式存储:从磁盘布局到HTAP混合架构的选型指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
行式存储与列式存储:从磁盘布局到HTAP混合架构的选型指南

前阵子帮同事排查一个线上慢查询,那张表只有几百万行,按某个时间字段做分组汇总,跑一次要几十秒。索引建了,统计信息也更新了,SQL 写得没什么毛病,但就是快不起来。后来我把数据文件导出来看了一眼物理布局,才意识到问题不在 SQL,而在存储方式——这是一张典型的行式存储(Row-based Storage)表,却每天都在干列式存储(Column-base Storage)才擅长的活。

这个经历让我觉得,很多人对行式存储和列式存储的理解停留在“一个是横着存,一个是竖着存”的层面,但对这两种布局背后的 I/O 代价、压缩收益、适用场景和混合架构的演进逻辑,其实没有真正吃透。这篇内容就围绕“数据在磁盘上到底是怎么摆的”这个最底层的问题展开,把行式和列式的原理拆开揉碎,再结合工程实践聊聊选型时最容易踩的坑。

1. 一张表在磁盘上到底是怎么摆的:从数据页说起

很多人对存储引擎的理解是从 SQL 层开始的,SELECT、INSERT、UPDATE,这些都发生在优化器和执行器里面。但真正决定一条查询快慢的,往往是更底层的存储格式——数据在磁盘上是按行连续摆放的,还是按列连续摆放的。这个差异直接决定了磁盘 I/O 的次数和每次 I/O 拿到的数据有没有用。

1.1 两种布局的最小物理单元

先看行式存储。假设有一张用户表,有用户ID、姓名、城市、注册时间四个字段,一行数据大概长这样:

1001, 张三, 北京, 2024-01-15 1002, 李四, 上海, 2024-02-20

在行式存储的磁盘布局里,同一行的所有字段是连续存放在一起的。存储引擎按“页”(Page)来管理空间,典型页大小是 8KB 或 16KB。页内部按行顺序排列,读取一条记录时,存储引擎把包含这条记录的整个页加载进内存,然后从页里解析出需要的字段。

列式存储则完全不同。同样是这张用户表,在列式布局里,磁盘上会分成四个区域:所有用户ID连续存放,所有姓名连续存放,所有城市连续存放,所有注册时间连续存放。可以理解成把一张表竖着切成了几根长条,每一根长条只装某一列的数据。

1.2 一个 100 万行表的 I/O 差异推演

为了把差异量化出来,我按一个简单模型推演一下。假设一行数据平均 200 字节,一个页 8KB 大约能存 40 行,100 万行就需要 25000 个页。再做一次全表扫描,行式存储要把这 25000 个页全部读出来;但如果只是统计每座城市的用户数量,只需要读“城市”这一列,列式存储只用读数据总量的四分之一甚至更少。

更大的差异在“读了但没用”的数据比例上。行式存储把整行读出来,但查询可能只需要其中两三个字段,剩下的字节白白占用了 I/O 带宽。列式存储天然只读取涉及的列,这种能力叫“列裁剪”。列裁剪对 I/O 的节省不是线性的——在宽表场景下,假设表有 50 个字段,而查询只需要 3 个字段,行式存储相当于每次读取都买了 50 样东西但只用 3 样,列式存储则按需取货。

我见过很多从行式数据库迁移到列式数仓的团队,第一个直观感受就是“同样的数据量,磁盘占用小了好几倍”,这其实不只是压缩的功劳,列裁剪省下的 I/O 才是大头。

注意:列式存储不是不做行式存储的页管理。它同样有页、段、分区的概念,只是页内部的组织方式从“行连续”变成了“列连续”。理解这一点,后面看压缩和索引策略时就不会懵。

2. 行式存储为什么在 OLTP 场景里难被替代:一次点查和一串更新里的门道

OLTP(在线事务处理)场景的特点是大量短小事务,高频执行点查、小范围更新、插入。这类负载对存储格式要求很特殊——单次操作涉及的数据量极小,但操作次数极高,并且对延迟极其敏感。

2.1 点查场景:行式存储的天然优势

按主键 WHERE id = 1001 这样的查询,行式存储通过主键索引定位到对应的页,然后加载一个页,把这一行解析出来,完事。整个过程涉及的 I/O 就一两个页。如果换成列式存储做同样的点查,问题就来了——列是分开存的,为了组装出一行完整数据,需要分别读取用户ID列、姓名列、城市列、注册时间列各自对应的数据块,然后再做一次关联拼装。虽然每列只读一小块,但磁盘 I/O 次数从 1 次变成了 N 次,而且每次 I/O 可能还要跨越不同的磁盘位置,随机读次数增加。

我做过一个简单的对比测试,在单机环境下一张 500 万行的表,用等值条件做逐行点查,行式存储的 P99 延迟基本在 2-3 毫秒,列式存储同样条件下要跑到 10 毫秒以上。这个差距在单次查询上毫不起眼,但放在每秒几千次并发的事务负载里,就是几倍的性能差距。

2.2 更新和事务:行式存储的隐形护城河

OLTP 场景还有一个容易被忽略的点——高频更新。UPDATE 一条记录时,行式存储只需要定位到这个行所在的页,原地修改数据。因为同一行数据在一个页里,修改操作天然是原子的,配合预写日志就能实现崩溃恢复。列式存储做更新则很痛苦,因为一行的多个字段分散在多个列文件中,更新一行实际上要写多个列文件,事务的开销大得多。

更重要的是并发控制。行式存储的锁粒度可以做得很细,行锁、页锁、表锁逐级递进,同一行被并发修改的概率低,锁冲突也少。列式存储的压缩格式决定了它不适合原地更新——一个压缩后的数据块里,修改某个值往往意味着解压、修改、重新压缩再写回,代价极其高昂。所以大多数列式存储引擎都采用追加写的方式,把更新变成“新版本插入”,再通过合并机制处理旧版本,这个设计天然增加了读放大和写放大。

2.3 行式存储的软肋在哪里

行式存储最大的问题出在分析型查询上。做全表扫描、分组聚合、多列关联的时候,行式存储被迫把用不上的列也读进来,I/O 放大严重。还有一个隐患是页内碎片——频繁的随机更新会让页内部产生碎片,行数据不再紧凑排列,扫描效率会进一步下降。

所以我的判断是:行式存储不是“落后”,它是为写入和点查优化的方案。只要你的核心负载是大量小事务,行式存储依然是不可替代的选择。硬要用列式存储去承接高并发点查,基本上是把系统的短板无限放大。

3. 列式存储的加速链条:列裁剪、压缩和向量化是怎么滚雪球的

列式存储的分析优势,网上讲得很多,但大多数讲得比较散。我倾向于把它理解成一条完整的加速链条:数据布局决定了列裁剪,列裁剪促成高压缩比,压缩后的数据又为稀疏索引和向量化执行提供了基础,每一环都在放大上一环的收益。

3.1 同类型数据的压缩红利

行式存储要压缩一页数据,面对的是一堆不同类型的值混在一起:整数、字符串、日期,类型差异大,压缩算法发挥空间有限。列式存储完全不同——一列的数据全部是同一类型,压缩算法的针对性可以发挥到极致。

以城市列为例,假设数据里有 100 万行,城市总共有 30 个不同的取值。字典压缩可以把每个城市映射成整数 ID,再做位图编码或差值编码,压缩比可以轻松做到 10:1 甚至更高。时间列则适合增量编码,相邻值之间的差值往往很小,用变长整型存储差值即可。数值列可以做行程编码,连续相同值直接记录起始位置和重复次数。

这个压缩红利是列式存储在存储成本上的核心优势,也是很多团队迁移到列式数仓后“数据盘从 2TB 缩到 400GB”的真正原因。注意,这里省的不只是购买存储硬件的钱,更重要的是后续查询扫描的数据量直接降了一个量级。

3.2 稀疏索引:不用读的块直接跳过

列式存储里常见的稀疏索引也叫 Zone Map 或 Data Skipping 索引。它的原理很简单——每个数据块(Block)存储时,额外记录这个块里某一列的最小值和最大值。查询如果带有范围过滤条件,存储引擎先对比每个块的最小值和最大值范围,如果查询范围跟块的值范围没有交集,整个块直接跳过,连解压都不用。

这个机制在时间范围查询上效果尤其惊人。我经手过一个日志分析场景,每天产生约 2 亿条日志,保留 30 天,总共 60 亿行。按小时范围查询某一天的日志时,如果只靠分区裁剪,至少要扫 2 亿行;但列式存储配合稀疏索引,直接把数据块级别跳过,实际扫描量可能只有几百万行,查询延迟从分钟级降到秒级。

不过要记住,稀疏索引的效果高度依赖数据分布。如果某个块的列值范围跨越了查询区间的边界,这个块就必须被读出来。数据乱序时,每个块的最小最大值都很大,稀疏索引基本失效。这也是为什么列式存储通常建议按查询常用的过滤字段排序——排序后的数据,块之间的值域更紧凑,稀疏索引才能发挥最大价值。

3.3 向量化执行:让 CPU 不再被"卡脖子"

压缩不只是节省存储,还为计算加速创造了条件。列式存储里,一整个数据块解压后,是连续的同类型数组,比如 65536 个整数紧凑排列在内存里。执行引擎可以直接对这个数组做批处理操作,一次处理一批数值,而不是一行一行地循环,这就是向量化执行(Vectorized Execution)。

向量化切切实实地提高了 CPU 的利用率。传统逐行执行模式下,CPU 每处理一行数据就要执行大量指令,包括解析行结构、跳转分支、类型判断等固定开销;向量化模式把这些固定开销摊到一批数据上,同时利用 CPU 的 SIMD 指令集一次对多个数据做运算。如果底层存储还是行式的,向量化很难派上用场——行式存储的数据在内存中不连续,CPU 加载数据时会频繁遭遇 cache miss,计算单元被迫空转等待内存数据。

3.4 破除一个常见误区:列式存储不是全场景更快

我碰过不少人,以为把行式数据库迁移到列式数据库后,所有查询都会变快。这是典型的误读。列式存储的快是有前提的——查询只涉及部分列、有过滤条件、做聚合或扫描型操作。如果你的场景是高频点查、频繁更新、需要读取完整行数据,列式存储反而会变慢。

我之前遇到一个项目,团队把一张宽表的单行详情查询接口底层从行式存储切到列式存储,结果接口延迟翻了一倍。原因就是点查需要跨多列拼装数据,查一行要触达多个列文件,I/O 次数暴增。最后只能老老实实保留一份行式存储的明细副本,列式存储只承接分析查询。这也印证了一个结论:做选型之前,先看负载特征,而不是看存储技术标签。

4. OLTP 与 OLAP 的正面冲突:为什么单一种存储方案走不通

既然行式存储擅长事务,列式存储擅长分析,那能不能让一张表同时满足两种负载?理论上行式存储加一堆二级索引也能跑分析查询,列式存储加一些辅助结构也能处理点查,但实际工程里会遇到一组正面冲突,逼着架构做取舍。

4.1 写入放大与读放大的两难

OLTP 场景要求写入低延迟。行式存储的写入是典型的追加写,每次 INSERT 只需要顺序写一个页,配合日志先行的机制,单条写入耗时很短。列式存储写入则要面对写放大问题——一行数据要拆到各列文件分别写,每一列还有独立的缓冲区、压缩和索引维护,写入链路比行式存储长得多。

换个方向看,OLAP 场景要求扫描低延迟。行式存储扫描时把大量无关列也读进来,读放大严重。你可以通过建一堆覆盖索引来缓解读放大,但索引每次写入都要更新,又反过来放大写放大。本质上,当你只有一套存储时,就只能在“写入友好”和“读取高效”之间选一边,不可能两头都占到极致。

4.2 缓冲区与缓存策略的不同取向

行式存储的缓冲池按页缓存,通常缓存的是整行的数据页,适合频繁读同一行的访问模式。列式存储的缓存则倾向于按“列块”缓存,适合重复扫描同一列的分析模式。如果两类负载混合在一起,缓存策略会互相打架——分析查询把大量列块塞进缓存,把事务查询常用的行页挤出去,延时一下就起来了。

我见过一套系统里,白天跑 OLTP,晚上定时任务跑分析报表,结果白天查询偶尔出现抖动。定位后发现,夜间大批量扫描把缓冲池里的行页全部淘汰了,白天业务高峰重新加载数据页,I/O 压力骤增。这其实就是存储形态和缓存策略在处理混合负载时的矛盾。

4.3 真实世界的解法:双副本还是混合存储

应对这种冲突,工程上最朴素的方案是“双写两份”——一份行式存储承接在线事务,一份列式存储承接分析查询,中间通过同步工具做数据流转。这样做的好处是各取所长,代价是数据冗余和同步延迟,极端情况下还有一致性问题。

更激进的方案是行列混合存储——同一张表同时保存行式布局和列式布局,查询优化器根据 SQL 特征自动选择访问哪种布局。这个方向很诱人,但对底层实现要求极高:写一条数据要同时更新两份布局,事务提交时两份数据要保持一致,空间占用也近乎翻倍。目前这个方案在少数数仓和 HTAP 数据库里有落地,通用场景下还不成熟。

5. 混合存储与 HTAP 演进:折中不是无奈的妥协,而是工程权衡

我早期对行列存储的理解是非黑即白的——要么全行式、要么全列式。后来在真实项目里被教育了一顿,发现现实里大量系统走的是“混合”路线,而且很多混合架构设计得比纯行式或纯列式更聪明。

5.1 增量写入缓冲:列式存储的补丁方案

列式存储最大的软肋是高频写入,但很多分析场景偏偏要源源不断地吃数据。比如用户行为日志、订单流水等,写入是流式的,分析是批式的。怎么办?纯列式方案扛不住高频写入,纯行式方案分析性能太差。于是出现了一个经典设计:写入先落到行式缓冲区,缓冲区积累到一定规模后,批量转成列式块合并到主存储。

这个“增量行缓冲 + 批量列转换”的设计,本质上是把写入延迟问题用行式存储解决,把分析性能问题用列式存储解决。它带来的额外好处是批量合并时可以做列级压缩重排,让新写入的数据块也能享受到稀疏索引的加速能力。

5.2 同一份数据,两套计算引擎的尴尬

更复杂的混合形态是同一份底层数据,同时暴露出行式计算引擎和列式计算引擎。查询路由层根据 SQL 类型分发——点查走行式引擎,聚合扫描走列式引擎。这个架构的难点在于:两个引擎对数据的访问模式不同,缓存、锁、事务协议都要各维护一套,复杂度成倍上升。

我在一个数据服务中间件的项目里尝试过类似的思路。底层是一份按列式布局存储的宽表,但为了兼容上游高频点查接口,在内存里冗余了一份最近活跃数据的小型行式索引,相当于给列式主存储加了一个热数据行式缓存。效果不错,但维护成本确实高——两份数据的一致性校验、缓存淘汰策略、热点数据识别,每一个环节都需要专门的监控和调参。

5.3 HTAP 为什么难做:它要的不是存储层改造,而是全链路打通

HTAP(混合事务分析处理)这个词被讨论了很多年,核心诉求是“一份数据,既能跑事务,也能跑分析”。听起来很美好,但实际做起来困难在细节。存储层虽然可以做到行列混合,但事务和分析对资源的需求模式完全不同——事务要求低延迟,分析要跑大查询,两者共享同一批资源时,分析查询很容易把事务的毛刺拉高。

更关键的是执行引擎的差异。行式存储和列式存储在数据读取接口、表达式计算方式、谓词下推能力上都不一样,一套执行引擎很难同时高效适配两种布局。这也是为什么现有 HTAP 产品大多是在存储层做混合,但执行引擎依然分两套,或者通过分布式调度把两类负载隔离到不同节点上。所以与其说 HTAP 是技术上的“统一”,不如说是工程上的“隔离与协作”。

6. 工程实践中怎么选:四个判断条件和两个最常踩的坑

聊了这么多原理,最终还是得落地到选型上。我总结了四个判断条件,对应一张决策表,大家可以直接对着检查自己当下的场景。

6.1 选型判断:先搞清楚你的负载是哪种动物

第一个判断是“查询是否高频返回完整行”。如果你的业务大量执行 SELECT * 或返回绝大多数字段,行式存储往往更合适,因为列式存储组装整行数据的开销反而更大。

第二个判断是“更新比例高不高”。UPDATE、DELETE 频率高的场景,行式存储的原地更新优势明显;列式存储的更新链路长,事务代价大,不适合高频写。

第三个判断是“分析查询是否只涉及少量列”。分析查询如果总是 SELECT 两个字段后 GROUP BY,列式存储的收益巨大;如果分析查询也经常 SELECT *,那列式存储的优势会被大幅削弱。

第四个判断是“是否需要对扫描数据做实时过滤”。如果查询反复按条件过滤大表,列式存储的稀疏索引和列裁剪能显著降低扫描范围;如果数据集本身很小,全表扫描也就几秒,列式存储的优势就不明显了。

判断维度倾向行式存储倾向列式存储
访问模式点查、按主键返回整行批量扫描、部分列聚合
写入频率高(每秒大量事务)低(批量导入为主)
更新/删除频繁极少
查询列数大部分字段少量字段
数据量级百万到千万级明细亿级以上明细

6.2 坑一:小表用列式存储,反而把简单查询拖慢

列式存储的优势建立在“数据量大且只读部分列”的基础上。小表全表扫描本身开销就小,列式存储的列裁剪、压缩收益体现不出来,反而引入了额外的读取多列文件拼装数据的开销。我之前见过有人把一张只有几万行的配置表做成列式存储,一个简单等值查询跑了 50 毫秒,放在行式存储里 1 毫秒都不到。

我的建议是:百万行以下的小表,不要为了“先进”去选列式存储;真正适合列式的是那些已经明显拖慢行式数据库查询的大型明细表。

6.3 坑二:忽视排序对列式存储的致命影响

还有一个坑更隐蔽。列式存储选择某个列作为排序列时,如果排序列的值分布不均匀,或者查询过滤字段跟排序列不一致,稀疏索引的效果会大打折扣。具体表现为:虽然用了列式存储,但每次查询还是要扫描大量数据块,存储引擎的“跳过”能力形同虚设。

为了避免这个坑,列式表的排序键一定要跟最频繁的过滤字段对齐。比如日志表经常按时间范围查询,时间列就应该是第一排序键;订单表经常按客户ID查最近订单,客户ID就应该是第一排序键。排序键选错了,后期重建排序的代价非常大,相当于把整表重新写一遍。

6.4 一个实用的取巧思路:分而治之

现实项目里很难做到纯行式或纯列式到底。我倾向于按数据分层设计——在线业务明细用行式存储,提供事务支撑;归档和明细汇总进入列式存储,承担分析报表和实时查询的宽表部分;两者通过批同步或增量同步衔接。这个方案既不追求存储层面的“统一”,又能让两类负载都拿到相对最优的存储形态。

此外,在做数据迁移时别急着动底层存储,先跑一周查询日志,统计线上读写比例、点查占比、平均扫描行数。数据永远比感觉靠谱。等迁移完成后,再用同样的查询集做前后对比,重点看 P99 延迟、I/O 量、CUP 使用率三个指标,基本能判断选型是否正确。

最后再分享一个小经验:我每次做存储选型,都会写一个“一分钟判断法”——把线上最典型的十个查询拿出来,看它们平均读取的列数占表总列数的比例,低于百分之二十甚至百分之十的,列式存储会给你惊喜;动不动读全列的,老老实实留行式存储。这个方法虽然粗糙,但在我经手的项目里几乎没有误判过。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 17:38:52

跑通GitHub项目:一套可复现的标准化动作链

1. 别再把“跑通GitHub项目”当成玄学——它本质是一套可拆解、可复现的标准化动作链你是不是也经历过:在GitHub上找到一个标着“star 5k”的项目,README里写着“只需三步即可运行”,结果卡在第一步的git clone之后,就再也没能往前…

作者头像 李华
网站建设 2026/10/20 17:38:20

JasperReports动态IN查询:$X{IN}与Collection参数实战解析

做报表开发的人,十有八九都碰过这么个需求:用户在前台勾选了几个城市,点查询,后台就得把这几座城市的订单数据汇总成一张表。到了 JasperReports iReport 这条技术栈里,这个“SQL 语句里有 IN,要动态赋值”…

作者头像 李华
网站建设 2026/10/20 17:37:17

Nacos适配达梦数据库:从驱动替换到数据源改造全攻略

简介:本资源是一份围绕Nacos与达梦数据库适配的完整实施方案,面向在国产数据库环境下搭建微服务治理体系的后端开发、运维及架构设计人员。内容涵盖达梦JDBC驱动的加载、数据源连接参数配置、服务注册与发现、配置动态刷新、健康检查及监控报警等核心环节…

作者头像 李华
网站建设 2026/10/20 17:36:37

CentOS 7无外网环境离线安装Docker完整指南

干运维这些年,“离线装docker”这个需求我前前后后碰过不下十几次。有的是机房不给外网,有的是客户要求内网裸奔部署,最后都只能靠一个U盘或者内网FTP把东西带进去。标题里这三个词——CentOS 7、Linux、离线安装docker——几乎概括了这类场景…

作者头像 李华
网站建设 2026/10/20 17:36:08

Selenium进阶:JS执行、Cookie管理与浏览器配置实战

做Selenium自动化做了几年,我最大的感受是:能被 find_element 和 click 解决的需求,只能算 Selenium 的入门玩法。真正让你在复杂页面、登录态、无人值守环境中活下来的,往往是三样进阶技能——JavaScript 执行、Cookie 管理和…

作者头像 李华
网站建设 2026/10/20 17:35:49

文字识别数据不够?程序合成带标注图像实战指南

1. 文字识别项目为什么需要自己造数据做过文字识别项目的人都有一个共同体会:模型效果好不好,七分靠数据,三分靠调参。但现实情况往往是,手头能拿到的真实标注数据少得可怜,尤其是涉及特定字体、特定版式、特定背景的场…

作者头像 李华