行式存储与列式存储:从底层原理到工程选型全解析
行式存储和列式存储这两个词做数据库和大数据相关工作的人基本天天能碰到但很多朋友对它们的理解停留在“一个按行存一个按列存”这个层面。真正碰到选型、调优、排查慢查询的时候才发现自己其实没吃透这背后的一整套逻辑。今天我就把这两个概念从头到尾掰开揉碎讲一遍包括底层的数据页到底长什么样、压缩为什么在列存里效果特别好、不同的查询模式在两个架构下走了怎样不同的执行路径以及在实际建表、选引擎的时候到底该怎么判断。这篇文章适合刚接触存储引擎的开发者也适合那些已经在用MySQL、PostgreSQL、ClickHouse、Doris等产品但一直没系统梳理过这块知识的人。1. 行式存储与列式存储底层逻辑的一次彻底拆解要理解这两种存储方式先忘掉那些复杂的数据库术语回到最基本的物理事实数据最终要落到磁盘上磁盘是最小单位是扇区文件系统一般按块读写数据库则通常按页Page来管理数据。问题只在于一个页里面我们到底按什么顺序排数据。1.1 行式存储一切都是按“完整记录”组织的行式存储最直观的理解方式就是Excel表格。你打开一张订单表第一行是订单号、客户名、商品、金额、时间第二行又是另一单的完整信息。在磁盘上这些字段一个接一个地连续排列在一起这就是一个完整的行记录。行式存储的核心特征在于一行的所有列在物理存储上是被放在一起的。这意味着数据库只要定位到某一行在磁盘上的位置就能一次性把这一行的所有字段都读出来不需要再做额外的拼接。为什么早期的数据库基本都是行式存储因为数据库诞生早期的主要场景是交易系统。你去银行柜台转账风控系统需要先读你的账户信息账号、姓名、余额、状态然后扣钱再写一条流水。这一类业务的核心特征是按“实体”读写一次操作牵扯一个完整记录而不是一次操作牵扯几千条记录的某一个字段。我用一个比较贴切的类比来帮助理解。行式存储就像档案室里每一位顾客一个文件夹文件夹里夹着这个人的所有纸质材料。你要调取某个人的完整资料直接抽出文件夹就行非常快。但如果你想统计全城所有顾客的平均年龄你就得把几千个文件夹挨个抽出来翻到年龄那一栏记录再放回去。这个过程的效率就很低。行式存储在事务处理上的优势在写入阶段体现得尤其明显。插入一行本质上是往文件末尾追加一条完整记录或者在一个页里找到一个空闲位置塞进去更新一行也是直接改那一行对应的页面偏移位置。这种操作模式天然支持高频、小粒度的读写配合B树索引做行定位能够把单行操作的延迟压到毫秒级甚至更低。但行式存储的短板也在上面的平均年龄统计场景中暴露了。分析型查询往往只关心表的几个列比如所有订单的金额、所有用户的注册时间。行式存储在读取时却必须把一个页里的完整行全部读入内存哪怕你只需要其中两列其他十几列数据也白白增加了磁盘IO和内存带宽的消耗。1.2 列式存储把每一列变成一张独立“宽表”列式存储的布局方式与行式存储正好相反同一个列的所有值在物理存储上是连续存放的。还是用订单表举例订单号这一列的所有值放在一个数据块里金额这一列的所有值放在另一个数据块里客户名又放在另一个块里。回到档案室的类比列式存储相当于把全校学生的语文成绩单独装订成册数学成绩单独装订成册英语成绩又单独装订成册。我要统计全校数学的平均分抱出数学那本册子算就行完全不用碰语文和英语的册子。这个布局在分析场景下的优势是压倒性的。首先查询引擎只需要读取查询涉及的那几列数据块无关列连碰都不碰。比如一个订单表有30个字段但统计每日销售额只需要读取金额和时间两列IO量可能只有行式存储的十五分之一。在大数据场景下磁盘IO往往是最大的瓶颈列存直接把IO量降了一个数量级效果立竿见影。其次同一列的数据类型是一致的这意味着这一列的数据能够用非常高效的压缩算法处理。比如订单状态这个列值只有“待支付”、“已支付”、“已取消”三五种压缩率能做到几十比一。时间列虽然每个值都不同但有序分布的时间戳可以用增量编码、帧编码等方式大幅压缩。压缩率上去了磁盘上实际读取的字节量又进一步下降等于IO节省的效果又叠加了一层。列式存储的短板在于单行操作。如果你想查出订单号为2025010001的整条订单记录列式存储需要先从订单号那一列定位到这个值在哪一行然后根据行号分别去客户名列、金额列、时间列的对应行号位置取值最后拼接出完整行。这个过程相比行式存储直接抓取多了很多随机寻址和拼装开销。这就是为什么没有哪家数据库敢用纯列存来做高并发的交易系统。我在实际项目中常跟同事说一句话行存是“以记录为中心”列存是“以列为计算单位”。这两种思路没有绝对的优劣只是各自为不同的工作负载而生。理解到这一层后面聊数据页、压缩、执行引擎差异才有根基。2. 存储引擎核心细节从数据页到查询执行概念层面的理解往往给人一种错觉觉得行存和列存的差别无非是数据排列方式不同。但深入到存储引擎内部两者在数据页的组织方式、压缩策略、查询执行路径上都有本质区别这些细节才真正决定了为什么在特定场景下两者的性能会差出几个数量级。2.1 数据页Page结构与存储放大差异数据库的存储基本单位是页Page传统行存数据库的页大小一般是8KB或16KB列式存储的页或块大小通常设计得更大ClickHouse默认的压缩块甚至可以到64KB到1MB。这个大小差异不是随意定的背后是两种截然不同的IO策略。行式存储的页结构由一个页头Page Header、若干槽位Slot和一个数据区组成。页头里存着页的元信息比如页号、空闲空间偏移量槽位数组记录每行在页内的偏移量数据区存真正的行数据。对于变长字段比如VARCHAR、TEXT数据区里存实际内容槽位指向内容起始位置。这种结构的好处是定位单行非常直接通过主键索引找到页号再通过槽位偏移量就能精确读取那一行。但行存页在频繁更新场景下会有一个很让人头疼的问题页碎片化。你不断删除、变长更新一页里的数据数据区会出现越来越多的空洞页的空间利用率下降甚至触发页分裂或页合并。这也是为什么MySQL的InnoDB在大量随机更新之后需要做OPTIMIZE TABLE来重建表。列式存储的页结构完全不同。一个列的数据块本质上是一列值的连续数组。以ClickHouse为例每一列的数据被拆成多个压缩数据块每个块里除了真实数据还会记录块内最小值、最大值、数据行数等元信息这些元信息在查询时可以用于数据跳过Data Skipping。比如查询条件是“金额大于10000”引擎可以先看每个数据块的最大值如果块的最大值都不超过10000整个块直接跳过连解压都不用做。这个特性在行式存储里实现起来非常困难因为行存一个页里可能包含各种不同范围的列值。列存写放大问题是一个必须正视的代价。列式存储每插入一批数据实际上要把一条记录拆散写入到每一列对应的数据块中好比一行数据被横向切开分送到不同的文件。如果做单行更新涉及的每个列块都要重新压缩写入开销远高于行存。所以市面上几乎所有列式存储引擎都是为批量写入设计的鼓励你把数据攒一批再刷盘。2.2 压缩为何在列存中“如鱼得水”压缩是列式存储最核心的性能武器之一很多人在做技术方案对比时只盯着查询速度忽略了压缩带来的存储成本下降其实对于动辄几十TB的数据仓库压缩率往往直接决定了方案能不能落地。为什么列存压缩效果远好于行存三个原因同质性、局部性、可预测性。同质性最简单。同一列的数据类型相同这个类型决定了可以采用专门的编码方式。整数列可以用Delta Encoding增量编码用相邻值的差值替代原值存储差值往往比原值小得多需要的存储位数就少。时间戳列可以转为相对时间一次性节省大量空间。字符串列可以用字典编码Dictionary Encoding把重复出现的长字符串映射成短整数再对整数序列做压缩。这些手段在行存里都很难施展因为一行里什么类型都有只能做通用压缩效果自然差一大截。局部性体现在同一列的数据往往有强烈的局部规律。比如订单状态列虽然业务上可能有五六种状态但在任何一个时间窗口内绝大多数订单的状态高度重复。列存把连续一段时间的订单状态放在一起运行长度编码RLE几乎能把这些重复序列压到极限。又比如用户ID列如果数据是按时间批量导入的同一批用户的ID连续出现这种局部规律让各种压缩算法都能发挥出惊人效果。可预测性说的是列存引擎可以根据列的统计特征提前估算压缩率。很多列存引擎在建表或导入时会自动扫描列的数据分布选择合适的压缩算法。比如低基数列用RLE或字典编码高基数列用LZ4、ZSTD等通用压缩。这种自适应能力让列存引擎在异构数据面前总能找到相对合理的存储方案。从压缩率看实测数据我曾经在一套ClickHouse集群上对比过一张约50亿行的用户行为日志表。这份数据的原始文本格式约占2.1TB导入ClickHouse后用默认的LZ4压缩存储降到约430GB压缩率接近5比1。而同样一份数据放在Hive的行式TextFile里即使开启Snappy压缩也超过了900GB。单这一项就省了一半以上的存储成本冷热分层存储方案也因此变得非常充裕。压缩还有一个容易被忽略的间接好处减少内存和CPU的开销。因为磁盘读入的压缩数据量小传输带宽占用少解压后的数据在内存中以紧凑的列式数组形式存在缓存命中率更高。这正是列式存储能在CPU层面实现向量化执行的前提条件。2.3 查询执行层面的差异延迟物化与SIMD存储布局的不同不仅影响IO还深刻影响查询引擎的执行策略。这里有两个概念值得仔细展开延迟物化和向量化执行。所谓物化Materialization就是把列数据从存储格式还原成完整的行格式。行式存储天然是一行一行读取的数据读出来就已经是物化的行。列式存储则不同它读出来的是一列一列的数据如果引擎每读一列就立刻拼成行那叫“提前物化”性能会大打折扣。优秀的列存引擎都采用“延迟物化”Late Materialization策略先只针对查询涉及的少数列执行过滤、聚合、计算最后在结果集已经很小的时候才按需回表提取必要字段。举个例子统计“上海地区高价值订单的客户年龄段分布”。在列存引擎里执行顺序大概是这样的从地区列的数据块扫描通过压缩块元信息跳过不包含“上海”的数据块对命中的块解压后进行过滤得到一个行号集合然后根据行号集合去金额列和年龄段列对应的数据块中提取对应行的值最后在内存中完成聚合。整个过程只有地区和金额、年龄段三列参与了IO和计算并且在整个链路的前半段数据都以列数组的形式存在CPU可以一次性加载多个值到寄存器做批量计算。这就是SIMD单指令多数据流向量化执行能够发挥的空间。现代CPU普遍支持AVX-256/512指令集一条指令可以同时处理8个甚至16个整数运算这对分析型查询的加速效果是成倍的。行式存储的查询执行则走了完全不同的路径。MySQL的InnoDB执行一条SQL时最典型的模式是用索引定位到起始行然后逐行读取、逐行判断条件、逐行投影字段一行的数据被读进执行器后其他列即便用不上也已经白白读出来了。这种按行迭代的执行模型Iterator Model在处理千万级行数的聚合查询时CPU和内存带宽的浪费非常明显。我还遇到过不少从MySQL迁移到分析型列存引擎的团队他们经常会惊讶地发现原本十几秒的报表SQL在列存上跑到了几百毫秒。这里面确实有列存的功劳但也不全是列的功劳向量化执行和延迟物化带来的CPU效率提升同样贡献巨大。如果只是把列存当“省IO的存法”那就低估了它的价值。不过这里要说明延迟物化的实现复杂度远高于提前物化这也是列存数据库开发难度高于传统行存数据库的原因之一。需要维护精确的行号映射需要设计高效的位置索引需要在并行执行计划中处理列与列之间的依赖关系这些技术门槛最终也反映在列存数据库的学习和维护成本上。3. 实操指南面对一个场景如何判断该用行存还是列存很多开发者在做技术选型时喜欢问“哪个快”这是一个典型的错误提问方式。正确的问法是“我的工作负载特征是什么”。判断一个业务场景适合行存还是列存不需要背复杂的评分卡抓住几个核心问题就够了。3.1 从业务模式出发读多写少还是写多读少第一个判断维度是数据的写入模式。如果你的业务在持续地、高频地、小批量地写入数据每条记录需要立刻可查可改这是典型的OLTP场景行式存储几乎是不二之选。典型的例子是订单系统、用户账户系统、库存系统、社交产品的消息表。反过来如果你的数据写入是周期性批量导入比如每小时同步一次日志、每天凌晨从业务库抽取前一天的全量数据到数仓写入后基本不再修改这是典型的OLAP场景列式存储的收益会非常明显。典型例子就是用户行为分析、财务汇总报表、运营监控大屏、推荐算法的特征宽表。最怕的是中间态业务既要高频写入又要实时分析。这种场景投资界有个说法叫“既要又要”技术界同样麻烦。我的处理经验是优先明确实时分析的延迟要求。如果分析查询要求秒级甚至毫秒级响应且数据量在千万级以上纯行存往往扛不住需要引入专用的分析引擎或行列混合方案。如果分析可以接受分钟级延迟可以走“业务库行存 离线ETL 数仓列存”的典型Lambda架构让不同的存储各司其职。为了帮助大家快速判断我整理了一个对比表把两类场景的关键特征并列起来维度OLTP适合行存OLAP适合列存写入方式高频、随机、单条批量、追加、定期更新操作频繁小范围更新极少一般是全量重算查询特征按主键点查、小范围扫描全表扫描、大范围聚合关心字段几乎要全部字段通常只关心少数几列并发要求高并发、低延迟并发不高但单查询计算量大数据量级GB到TB级TB到PB级典型产品MySQL、PostgreSQL、OracleClickHouse、Doris、Greenplum、HBase列族等这张表只是一个参照不是绝对标准。比如HBase虽然是列族存储但它的列族本质上是把一组相关列放在一起的类行存结构和纯列式存储并不完全一样。判断一个系统到底算行存还是列存关键还是看它的物理存储布局和数据访问模式不能只看产品宣传口径。第二个判断维度是数据保留与清理策略。如果你需要频繁删除历史数据比如保留最近90天的日志行存和列存的表现差异也很明显。行存删除单条记录很方便但大批量删除要反复扫描定位列存虽然删除粒度粗但配合分区策略直接DROP分区比行存的大批量DELETE要爽快得多。这一点在做数据生命周期管理时非常关键。3.2 同一系统里的折衷方案行列混合的工程实践现实中的系统很少是绝对的行存或绝对的列存。近些年的趋势是在一个数据库系统内部同时支持行存和列存两种存储格式或者在同一张表上叠加行列两套布局。这种混合方案的出现本质上是因为业务对“高吞吐写入”和“低延迟分析”的需求同时存在而且都不愿意妥协。最典型的案例是MySQL。传统认知里MySQL是一个纯行存数据库但Oracle在MySQL企业版里推出了HeatWave列存引擎可以在InnoDB的同一份数据上构建列存二级索引。查询优化器自动判断哪些查询走列存加速写入仍然走行存。把这个能力下放到业务系统后原来需要额外同步到数仓的报表查询可以直接在业务库上完成省掉了一层ETL。TiDB的TiFlash是这个思路的另一个代表。TiDB本身是一个分布式行存数据库底层用Raft协议做多副本。TiFlash让部分副本采用列式存储通过RAFT Learner机制把行存储副本的日志实时同步到列存副本。应用程序完全无感知查询优化器自动决定是用行存副本走OLTP路径还是用列存副本走OLAP路径。这种行列混合方案的核心价值在于一份写入两套布局按查询类型自动路由。ClickHouse严格来说是一个列存数据库但它的MergeTree表引擎家族也做了不少行存能力的融合。比如ReplacingMergeTree允许按主键去重CollapsingMergeTree支持行级的折叠删除这些机制本质上都是在列存上模拟行级更新语义。懂原理的开发者在用这些表引擎时能够理解它们为什么要求数据按批次写入、为什么删除是异步的、为什么实时更新能力远不如真正的行存。DorisApache Doris原百度Palo也是行列混合的优秀代表。它采用“列存为主行存为辅”的架构默认列存支撑分析查询同时支持在明细模型上建立行存索引来优化点查。在建表时可以指定开启行存这为少数需要高频点查的场景保留了退路。在架构上我个人的建议是不要轻易引入一个复杂的行列混合系统作为唯一存储。先想清楚主写路径是什么、查询特征是什么把系统的“主存格式”确定下来。如果需要分析能力优先考虑离线导出到专门的列存引擎如果实时性要求高再考虑行列混合方案。很多团队踩坑都是因为一开始想用一个系统解决所有问题结果所有场景都做得不够好。3.3 设计表时可以直接套用的两套模板判断完场景真正的实操难点在于建表和建模。下面给出两套我在实际项目中经常使用的建表模板供参考。第一套是面向OLTP的订单表适合MySQL这类行存数据库CREATE TABLE t_order_oltp ( order_id BIGINT PRIMARY KEY, customer_id BIGINT NOT NULL, product_id BIGINT NOT NULL, order_amount DECIMAL(12,2) NOT NULL, order_status TINYINT NOT NULL, create_time DATETIME(3) NOT NULL, update_time DATETIME(3) NOT NULL, KEY idx_customer_time (customer_id, create_time), KEY idx_status (order_status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个设计有几个要点用自增或雪花ID做顺序主键保证插入时以追加方式写入减少页分裂为高频查询条件客户时间建联合索引订单状态用TINYINT而不是VARCHAR既能节省存储也能提升索引效率DATETIME(3)保留毫秒精度方便后续对账。第二套是面向OLAP的订单分析表适合ClickHouse、Doris这类列存数据库。以ClickHouse为例CREATE TABLE t_order_olap ( order_id UInt64, customer_id UInt64, product_id UInt64, order_amount Decimal(12, 2), order_status LowCardinality(String), order_date Date, create_time DateTime ) ENGINE MergeTree PARTITION BY toYYYYMM(order_date) ORDER BY (order_date, order_status, customer_id) TTL order_date INTERVAL 2 YEAR;设计要点分区键选择时间字段便于按月份管理数据和生命周期清理排序键就是列存的“索引”必须按照查询模式设计。如果业务上常按日期状态过滤、按客户聚合这个排序键就是合理的。order_status用LowCardinality类型ClickHouse会为它建立字典编码压缩率和查询性能都能大幅提升。TTL设置过期时间让冷数据自动过期省去定期删数据的烦恼。这两套表结构放到同一个业务里并不冲突。OLTP表服务于交易前台OLAP表服务于数据分析后台。真实项目里一般是OLTP库实时写入再通过CDCChange Data Capture把binlog同步到列存引擎做分析。这套链路设计得当能做到数据延迟在秒级以内同时两种存储都能发挥最大优势。4. 常见误解与选型避坑实战中我见的那些“坑”做了这么多年数据相关工作我见过太多团队在行存和列存的选型上栽跟头。这些坑不是技术文档里会明确警告你的但踩过的人都印象深刻。4.1 误解一列存就是更快所以列存万能这是我在各种技术群里看到频率最高的一种论调。很多人被列存在报表查询中的卓越表现震撼后就恨不得把所有表都建成列存。这个冲动可以理解但现实很快会教做人。列存引擎在需要逐行处理的场景下表现非常糟糕。最典型的就是高并发点查询根据用户ID查出用户详情根据订单ID查订单详情这种请求如果落在列存引擎上每一条都要经历“定位行号 → 逐列取数 → 拼接行”的全流程。在ClickHouse上用主键做点查单条延迟能做到几十毫秒已经不错了而MySQL配合主键索引通常是1毫秒以内。相差两个数量级。我有个朋友之前把一个用户表原样搬到了ClickHouse做分析结果业务方反馈点查用户资料接口超时最后不得不保留一份MySQL主库提供在线服务ClickHouse只负责离线分析。这个案例很典型列存适合“扫描少数列的大范围数据”不适合“随机访问大量完整行”。这个边界必须时刻记住。另一个相关误区是觉得列存能替代OLTP数据库。真这么干的团队最后都在写入链路和事务一致性上吃尽苦头。列存引擎的写入路径天然面向批量单条写入的代价可能是行存的几十倍。即便TiDB、Doris这类系统声称支持实时写入那也是通过内存表缓冲、后台批量合并实现的本质是对写入的批量化优化不会改变列存不擅长行级更新这个物理事实。4.2 误解二行存不支持大数据分析和上面那个误解相反的方向也存在很多传统数据库背景的开发者认为行存也可以扛分析场景没必要引入额外的列存引擎。这种观点有一定道理因为行存确实能跑分析SQL但性能天花板非常明显。我接手过一个中等规模电商项目的报表系统底层用的是MySQL订单表超过3亿行。一开始报表SQL还能凑合跑得益于精心设计的汇总索引和分页。但随着业务方要求的分析维度越来越多比如按小时、按商品类目、按用户城市交叉统计SQL开始频繁出现全表扫描即使加了索引也无法覆盖所有过滤组合。报表查询从最初的2秒恶化到30秒以上BI系统的页面缓存都扛不住最后只能迁移到ClickHouse。事实是行存数据库在数据量超过千万级以后做任意维度的多维聚合分析性能会断崖式下跌因为它在物理上没有针对“按列扫描”做任何优化。虽然可以靠预聚合表、物化视图、分区裁剪硬撑但这些手段本质上是预先算好结果一旦分析维度不固定方案就会变得极其笨重。与其说行存不能做分析不如说行存不适合做“灵活的分析”。如果你的报表维度固定、查询模式稳定、数据量可控行存加预聚合完全能覆盖但要做到真正的自助式分析、任意维度交叉探索列存几乎是唯一现实选择。4.3 做技术选型前一个必须自己过的自检清单基于这些年在项目里积累的经验我在做一个存储方案选型时一定会先过一轮自检清单。这套清单也分享出来希望能帮大家少走弯路。写入频率数据是每秒几百次的持续写入还是每天几次的批量导入高频持续写入倾向行存或行列混合批量导入倾向列存。查询形态核心查询是“找几行要全部字段”还是“扫几千万行算几列”前者选行存后者选列存。数据变化记录需要被反复修改吗需要实时反映最新状态吗高更新需求选行存只追加不改动选列存。分析延迟报表查询允许二级响应还是要求毫秒级交互分析延迟要求越苛刻越需要列存或专业分析引擎。团队能力团队是否熟悉要引入的引擎是否有人能写出高效的建表语句、读懂执行计划这个因素被低估的频率最高。最后这条特别想多说一句。我曾经见过一个团队因为追新引进了维护成本极高的分布式列存集群结果半年后发现整个团队没人能处理数据均衡和副本修复问题最终又迁回了传统的单机MySQL方案。技术选型从来不只是性能对比还要算上人力成本、维护成本和演进成本。好了行式存储和列式存储的核心内容就讲到这里。这两种存储方式没有好坏之分它们的本质是两种不同的“索引组织形态”各有各的设计目的和适用边界。搞清楚它们的工作原理和适用场景你就能在面对具体的业务问题时做出更清醒的判断不会被产品宣传和一时的性能测试数据带偏。根据我个人经验在这个话题上花时间做一次系统整理比盲目追新框架要值得得多。