列式存储与行式存储:从物理布局到性能差异的深度解析

发布时间:2026/9/16 2:02:28
列式存储与行式存储:从物理布局到性能差异的深度解析
列式存储和行式存储这两个名字几乎每个做数据的人都能说上两句可真要追问一句“它们的数据在磁盘上到底是怎么摆的、为什么摆法不同性能就差出几十倍”很多人一下就卡住了。我在帮团队做技术选型和慢查询治理时被问过太多次类似的问题同样一张表为什么在MySQL里跑一个月的销售汇总要十几秒放到ClickHouse里一秒出头就出来了有人说是引擎优化好有人说是索引的功劳其实最底层的答案就一句话——数据的物理存储形式不同。这篇文章就把两种存储形式的底细彻底讲透从物理布局、I/O行为、压缩与编码机制到事务处理和真实选型全部掰开揉碎。适合刚入门的数据库学习者也适合正在做存储选型、被分析查询压得头疼的开发者和架构师。看完你不仅知道区别还能动手验证、能对着自己的业务判断该用哪种形式。1. 数据在磁盘上“躺着”的方式决定了它快在哪1.1 行式存储一整行像一条记录被“焊”在一起行式存储的物理布局非常直观数据以行为单位连续存放同一行的所有字段紧挨在一起。传统关系型数据库如MySQL的InnoDB、PostgreSQL默认都采用这种形式。想象一张用户表有id、name、age、city四个字段。在行式存储中物理上大概是这样的排列(1, 张三, 28, 北京) (2, 李四, 35, 上海) (3, 王五, 22, 广州) ...如果要读第二行系统只需要定位到第二个记录的起始位置一次性把这一整条记录的所有字段都读进内存。InnoDB里数据页默认16KB数据按主键顺序在B树的叶子节点上排列页内的记录就是一行接一行地连续存在一起的。注意一个细节行里的字段不一定要定长变长字段需要通过长度标记或偏移量来定位但整体“一行数据集中存放”这一核心特征不变。这个摆放方式对“按主键取单行”非常友好但对“只取某几列做全表聚合”就不那么聪明了——因为即便你只需要age这一列系统也得把每行的id、name、city一起读进内存数据页里绝大部分字节是这次查询用不到的。1.2 列式存储把同一列单独拎出来连续摆放列式存储的物理组织方式则完全反过来同一列的所有值在磁盘上连续存放不同列的数据彼此分离。以Parquet文件为例文件内部先按行组Row Group切分数据每个行组内再按列存放数据块Column Chunk每个列块内部就是这一列所有值的连续排列。同样的用户表列式存储的物理布局是这样的id列: 1, 2, 3, ... name列: 张三, 李四, 王五, ... age列: 28, 35, 22, ... city列: 北京, 上海, 广州, ...每列单独存放带来的最直接好处就是查询只需要把涉及的那几列加载进内存完全无关的列碰都不用碰。ClickHouse、Apache Doris、Parquet、ORC、BigQuery底层都是这个思路。你查所有用户的平均年龄系统只需要读age这一列其他列的数据块躺在磁盘上根本不会被扫描到。而且列内数据的物理位置天然形成了一种“隐式索引”——查询可以只扫描某一段范围内的数据块配合稀疏索引和统计信息跳过大量无关数据块。这种“只读该读的”能力在分析型查询中是决定性能差异的第一要素。1.3 形式的差异不是风格问题而是访问路径问题理解到这里很多人会问那无非就是一个横着放一个竖着放为什么性能差别能到数量级关键在于存储形式决定了访问路径访问路径决定了I/O次数而I/O是数据库最昂贵的操作。同一份数据行式存储在数据页内必须按“整行”为单位搬运列式存储则按“整列”为单位搬运。一次全表扫描中行存要读的数据量是这个表的总字节数列存要读的数据量只是查询涉及的列的数据量。当表很宽、列很多时这两者的数据量差距可能是几倍到几十倍累积下来的磁盘读取时间自然天差地别。这个差距还会被内存缓存效率放大。行式存储把一页数据读进内存其中可能只有一小部分字段是查询需要的列式存储读进内存的每一块数据都是要用的缓存命中率明显更高。形式不同决定了从磁盘到CPU的整条数据通路上每一环的效率都不同——这才是“形式”问题的本质而不是什么风格偏好。2. 用一次聚合查询拆解两种存储的I/O账本2.1 场景一张一亿行的订单销售表光说理论容易飘我拿一个实际场景把账算给你看。假设有一张销售订单表sales字段包括id4字节、order_date4字节、region平均24字节、product_id4字节、quantity4字节、amount8字节平均一行约48字节。这张表有一亿行算下来数据量大约是4.8GB加上页头、索引等额外开销实际占用的存储会更多。业务上有条很典型的分析查询按区域统计销售额SQL大概是这样的SELECT region, SUM(amount) FROM sales GROUP BY region;这条查询只需要region和amount两列加起来每行12字节。在两种存储形式下它的磁盘读取成本完全不同。2.2 行存储在分析查询中的“连带读取”开销行式存储要执行这条SQL走的是全表扫描路径把整张表的所有数据页从头到尾读一遍对每条记录解析出region和amount两个字段再做分组聚合。关键在这里——一亿行数据行存平均每行48字节全部读进来是4.8GB。但查询真正用到的只有每行12字节剩下的36字节是被“连带着”读进来的id读了用不上order_date读了用不上product_id和quantity同样读了用不上。用公式表达行存实际读取字节数 全表总字节数 行数 × 平均行宽 ≈ 4.8GB读进4.8GB有效数据只有1.2GB有效占比25%。磁盘I/O的大部分开销都浪费在无关字段上了。如果这张表是宽表有40个字段甚至嵌入了JSON扩展字段平均行宽到几百字节那这个浪费会被放大到极其夸张的程度查询慢是必然的。你可能想给region和amount建联合索引不就行了可以但那是另一回事。索引本质上是在行式存储之上模拟一种“按列组织”的访问路径能改善点查和小范围扫描却解决不了大范围聚合的根本问题——扫描整个索引同样要读大量数据页而且索引维护有成本。在分析型海量数据场景下索引能做的很有限。2.3 列存储在分析查询中的最小化读取路径再看列式存储执行同样的SQL。存储层只加载region列和amount列的数据块id、order_date、product_id、quantity的数据块完全不会被读取。列存实际读取字节数 相关列的总字节数 行数 × 相关列宽 1亿 × 12字节 ≈ 1.2GB这个账面上已经是4倍的I/O差距。真正到生产环境里差距远不止4倍——列存还能通过压缩进一步缩小数据体积下面的章节细说压缩后可能只需要读取300MB到500MB的数据而InnoDB这类行存虽然也有压缩特性但受限于行内字段类型混杂压缩率远远达不到列存那种程度。我在ClickHouse上做过一个类似的测试同一张5亿行的日志表行存与列存的聚合查询耗时差距稳定在10倍到20倍之间。I/O是起点列存在这条链路上不仅起步数据量就少后面还叠加了压缩、向量化等优势差距越拉越大。分析查询的场景下列式存储几乎是碾压性的存在。3. 列存独有的三项“特权”压缩、编码和向量化3.1 同类型聚集带来的高压缩率列式存储最容易被低估的优势是压缩。数据压缩率的高低很大程度上取决于待压缩数据块里的“规律性”。行式存储的一个数据页里整数、字符串、日期、浮点数交错排列压缩算法面对这样的混合数据很难找到高重复度的模式压缩率自然上不去。列式存储则完全是另一番光景每个数据块内部都是同一列的同一类型数据。想象一下压缩一个全是整数的数组、一个全是国家名称的字符串数组、一个全是时间戳的数组每种数据都有高度一致的结构通用压缩算法如LZ4、ZSTD在这种输入上的表现会好非常多。很多分析型数据库里列存的压缩率能做到原始数据的五分之一到十分之一这不是吹的是数据特征决定的。3.2 RLE和字典编码重复值的杀手锏除了通用压缩算法列式存储还允许针对数据特征使用专用的编码方式这是行式存储很难做到的。RLERun-Length Encoding游程编码如果一列数据有大量连续重复值比如country列里连续100万行都是“CN”RLE可以直接把这一百万个“CN”存成一条(CN, 1000000)的记录百万行数据压缩成一个计数对。这在存储用户地域、订单状态、产品类目这类“低基数”列时效果极其恐怖。ClickHouse对这类数据甚至有专门的数据类型和编码选项。字典编码Dictionary Encoding适用于枚举值不多但又不连续重复的列。做法是给每个不同的值分配一个整数编号物理上只存这些整数字典表单独存放。比如城市列有1000个不同城市一亿行数据只需要存一亿个小整数每个值用若干字节表示而不是存完整的城市名称字符串。查询时如果需要展示城市名再通过字典把整数翻译回字符串。Parquet和ORC文件格式都内置了这种编码。增量编码Delta Encoding针对时间戳、自增ID这类有序数值列存“差值”而不是存“原值”。相邻值差很小数据范围缩小后用更少的位就能表示压缩率有质的提升。你在OLAP数据库里看到监控指标表、订单时间序列表的存储体积小得惊人背后就靠这些编码。3.3 向量化执行为什么在列存上才“顺”压缩之外列式存储还给现代CPU的向量化执行提供了绝佳土壤。所谓向量化执行就是一次处理一整批数据而不是一行一行处理。传统行式存储的执行引擎处理一亿行数据就要循环一亿次每次循环都对一行里的多个字段做解析、类型判断、函数调用CPU大量时间花在指令分派和分支预测失败上真正计算的时间占比很低。列式存储下某列的一亿个值在内存里就是连续的一亿个同类型数值。执行引擎可以把它们按批次比如每批4096个加载到CPU的SIMD寄存器里一条指令同时处理多个数据元素。比如SUM(amount)CPU可以直接用向量加法指令批量累加循环次数减少几个数量级。ClickHouse之所以能以毫秒级响应海量数据聚合查询就是吃透了这一点——数据连续、类型统一、批量处理把CPU的效率压榨到了极致。举一个我自己调试过的案例同样的聚合查询在禁止向量化执行和开启向量化执行两种模式下耗时差距能达到5到10倍。行式存储也想做向量化但数据一行的字段类型是混合的每条记录要单独拆字段、判类型很难形成整齐的批量数据流向量化执行有心无力。列存的物理形式天然和现代CPU的执行方式“对齐”了。4. 行存没有被淘汰更新、事务和点查是它的主场聊到这里可能有人会觉得列存这么强行存是不是该淘汰了绝对不是。列存的所有优势都是针对“批量扫描”和“只读场景”的一旦你往表里高频更新单行、频繁执行事务、按主键快速点查列式存储的短板就会暴露无遗而行式存储在这几个场景下依然是当之无愧的第一选择。4.1 行级更新的写放大问题先看更新操作的代价。行式存储更新一条记录定位到所在数据页就地修改即可涉及的数据量非常小。列式存储就麻烦了一行的各列分布在不同的列文件里更新一条记录意味着要同时修改所有涉及的列文件。更麻烦的是列存通常配合高倍压缩和编码。修改一个压缩块里的单个值基本意味着整个压缩块要解压、修改、再重新压缩写回——这不叫更新这叫“拆了重装”。写放大系数可能高达几十倍。所以我常说在列存系统里做高频单行更新是在用数据库自身的物理特性去对抗。这也是为什么ClickHouse这类列式数据库的UPDATE操作被设计成异步Mutation本质上是后台重写整个数据Part而不是原地改一行。能用但代价高昂只适合低频的表结构变更或数据订正绝不适合业务系统里动辄每秒上千次的更新请求。4.2 MVCC与行级锁事务友好型结构事务处理是行式存储的另一个牢固地盘。MySQL InnoDB和PostgreSQL都实现了成熟的MVCC多版本并发控制机制配合行级锁可以在高并发读写下保证事务的ACID特性同时尽量降低并发冲突。为什么InnoDB能实现行级锁因为数据页里每一行都是完整的物理记录锁可以精准地挂在某一行上。这种“一行就是一个最小管理单元”的特性是行式存储的天然优势。列式存储要实现同样的事务能力就非常困难。数据按列拆分后一个事务要修改多个列文件要保证“多列同时更新成功或同时失败”需要分布式事务或两阶段提交加上列文件往往跨多个数据块分布行级锁的精度很难实现并发冲突和锁竞争也会异常激烈。市面上绝大多数列存数据库在并发写入和事务支持上都做了牺牲要么是append-only追加写入要么把批量合并作为主要写路径。你让ClickHouse去处理每秒几千笔带事务的订单写入它不是不能做但架构上就拧巴了。4.3 点查场景的索引回表逻辑第三个行存占有明显优势的场景是按主键点查。SELECT * FROM user WHERE id 123这类查询InnoDB通过聚簇索引的B树结构从根节点到叶子节点几次磁盘I/O就能定位到目标行。更妙的是因为数据本身就是按主键组织的找到叶子节点后这一行的所有字段都在一起一次读取就能把整行完整取出来。列式存储做这种查询就难受了SELECT *意味着要把所有列的数据都取出来每一列都要一次独立读取。行存做一次I/O能拿全一整行列存要做N次N为列数。如果“星型模型”里的维度表、配置表频繁按ID查询列存的优势完全发挥不出来反而因为多次I/O和列拼接而变慢。所以在很多OLAP系统里小维表依然建议用行式存储引擎来管理就是这个道理。5. 怎么选从业务特征反推存储形式5.1 判断业务属于OLTP还是OLAP选行存还是列存最核心的判断依据不是“哪个技术更先进”而是业务读写的模式。你可以用三个问题来问自己第一数据是频繁更新还是写入后长期不变订单状态、账户余额这种频繁变动的数据行存是合理选择埋点日志、交易事实流水这种“写一次读多次”的数据列存才能发挥最大价值。第二查询是取单行还是扫大范围用户登录查询个人信息、后台按ID查订单详情属于取单行行存合适做月度汇总、多维分析、趋势报表属于扫大范围并聚合列存合适。第三数据模型是行数少但更新多还是行数巨大但基本不更新配置表几万行、频繁修改行存足够好行为日志一天几亿行、只增不删必须列存才能扛住。本质上就是一个判断你的业务更像在线交易系统OLTP还是更像数据分析系统OLAP。前者行存是主场后者列存有绝对优势。最怕的是不知道自己是什么类型硬把一个OLAP分析场景放到OLTP数据库里跑或者反过来把高频交易写到列存里结果两边都痛苦。5.2 真实数据场景下的选型对照我整理了这几类典型场景的选型参考可以直接对照自己的业务业务场景典型操作推荐存储形式常见技术选型用户中心、订单库、账务系统按ID点查、高频更新、事务行式存储MySQL、PostgreSQL用户行为分析、BI报表大范围扫描、聚合计算列式存储ClickHouse、Doris、BigQuery数据湖存储、离线数仓批量写入、按列分析列式文件Parquet、ORC日志检索、监控指标时间范围扫描、高压缩存储列式存储ClickHouse、Elasticsearch倒排列存配合有一个真实的例子可以说明差别我之前接手过一个项目团队的订单数据最开始全部放在MySQL里业务运行没问题但一到月底对账、销售分析几条聚合SQL就能把数据库CPU打满报表页面几十秒出不来。后来把订单流水同步一份到ClickHouse用列式表存储同样的聚合查询从几十秒降到几百毫秒MySQL的只保留了在线事务所需的数据和索引。没有增加任何昂贵硬件查询体验和数据库负载问题一次性解决。5.3 HTAP行与列同时在场的混合架构现实中也有不少业务既需要行存的事务能力又需要列存的分析性能于是有了HTAP混合事务/分析处理架构。现在主流的实现思路是“一份数据两种形式”底层存储同时维护行式副本和列式副本通过日志同步保持两边数据一致。典型代表是TiDB TiFlash架构行式存储的TiKV负责日常事务处理列式存储的TiFlash作为分析副本两者通过Raft日志实时同步。写入走行存保证事务分析查询自动路由到列存副本互不干扰。SQL Server的列存索引、Oracle的In-Memory Column Store也是类似的思路——对同一张表同时开放行式和列式两种访问路径让优化器根据查询类型自动选择。我个人并不建议一上来就上HTAP更务实的做法是先想清楚主场景用你最熟悉的OLTP或OLAP方案解决核心问题等到分析需求确实成为瓶颈再考虑引入一套列存方案或者尝试HTAP。技术好不好得看它解决的问题值不值得那个复杂度。6. 实践中常见的误判和验证方法6.1 五个高频误判与背后的原理做了这么多年数据相关工作我见过太多在存储形式上栽跟头的案例总结起来主要是这几个误判误判一列存一定比行存快。前面强调过列存的优势集中在批量扫描和聚合分析。换成单行点查或高频更新列存可能比行存慢一个数量级。曾经有团队把用户表从MySQL迁到ClickHouse登录模块的SELECT * WHERE user_id ?接口从几毫秒变成几十毫秒反而是倒退。脱离业务场景谈快慢都是耍流氓。误判二索引能解决一切慢查询。行式存储里很多团队面对分析慢的第一反应是加索引。但大范围的聚合查询索引的树形检索机制帮不上忙还是得依靠全表扫描或大范围扫描I/O的浪费无可避免。更合理的方向是考虑存储形式的调整、预聚合、或者引入列存分析引擎而不是在索引这条路上死磕。误判三列存数据库不能更新数据。现代列存基本都支持更新只是代价比行存高得多适合低频、批量的订正操作。ClickHouse的ALTER TABLE ... UPDATE就是异步重写数据Part一条更新语句几秒甚至更久才能生效。正确的用法是设计上尽量少更新把需求往追加写的方向引导。误判四压缩率越高越好。压缩率高不等于性能好解压要消耗CPU。低基数列用RLE或字典编码收益巨大高基数列比如UUID、订单流水号压缩率有限还可能拖慢查询。实践中要按列设置编码方式而不是无脑开最高压缩等级否则可能换来个位数百分比的压缩收益却让热点查询明显变慢。误判五列存适合所有分析场景。列存对单表大范围扫描很擅长但对多表频繁JOIN并不天然友好因为以列为单位读取再按行号对齐、拼接多列数据本身就有开销。分析查询里如果大量涉及多表关联还要依赖查询优化器的物化策略。列存能大幅提升速度但不是银弹查询模型和表结构设计同样重要。6.2 用Explain和真实压测来验证很多疑问其实不必争论用工具和数据说话就够了。行式存储数据库里先用EXPLAIN ANALYZE看执行计划。如果发现某个本该走索引的查询变成了全表扫描你就知道“连带读取”到底有多浪费看输出的扫描行数和用时能直观感受到问题在哪。还可以通过performance_schema观察这条SQL的磁盘读取字节数算一下有效数据占比。列式存储数据库同样有验证手段。ClickHouse的EXPLAIN可以显示每个查询阶段读取了哪些列、扫描了多少行、消耗了多少磁盘带宽你甚至能禁用某些优化特性来对比向量化执行的收益。更有说服力的是压测。用TPC-H这类标准的分析型测试集或者干脆拿自己的生产数据脱敏后做同样的查询对比分别记录行存和列存在冷缓存与热缓存下的响应时间。我自己在做技术方案汇报时就习惯用这个方式数据摆上来不需要说服谁结果就说明了一切。选型这种事拍脑袋最容易埋雷实测一遍比任何理论都踏实。6.3 先看形式再谈优化最后分享一个我在实际项目里养成的习惯拿到一套新的数据链路先别急着调参数、建索引、上缓存先用一句话描述这份数据的“一生”——它被创建之后是不断被修改还是写入后就再也不动了它被查询的方式是每次取几条还是每次都扫几十亿行这个问题的答案基本就把存储形式定死了。存储形式是数据系统的地基地基方向错了后面花再多力气优化索引、优化SQL、优化缓存都是在错误的道路上修补。反过来形式选对了很多看似复杂的性能问题会自然消失——这就是我一直强调“先看形式再谈优化”的原因。列存和行存没有谁取代谁它们各自守着OLAP和OLTP两座山头搞清楚自己的数据在哪种形式下“躺”得最舒服比追任何新名词都管用。