news 2026/10/8 9:10:12

行式存储原理与实战:从MySQL慢查询到列式存储选型

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
行式存储原理与实战:从MySQL慢查询到列式存储选型

从一次线上事故说起。去年夏天我接手了一个订单系统的优化,线上MySQL的CPU时不时飙到100%,慢查询日志里躺着好几条扫描几千万行的统计SQL。当时我第一反应是“加索引”,但加完发现情况并没有好到哪里去——有些SQL压根没走索引,有些走了索引也还是慢。排查到最后,问题回到了一个很多人忽略的最底层:存储引擎到底是怎么把数据放在磁盘上的。这就是行式存储,也是所有关系型数据库最基础的物理存储方式。这篇文章我想把行式存储的原理、适用场景、以及这些年我在实际项目里的选型经验和踩坑记录,一次讲透。

这篇文章适合谁看?如果你是刚入行的后端开发或DBA,它能帮你补上“为什么我的SQL这么慢”的底层知识;如果你已经在做数据架构选型,它能帮你理清什么时候该用行式、什么时候该上列式,以及两者之间到底该怎么配合。讲原理的时候我会尽量用大白话,但该有的细节一个不少。

1. 一次慢查询背后的“存储形态”问题

1.1 事故现场:索引都加了,为什么还是慢

先还原一下当时的场景。订单表orders大概4000万行,字段包括order_id、user_id、shop_id、amount、status、create_time,还有一些JSON扩展字段。线上有一条很典型的报表SQL:

SELECT shop_id, COUNT(*), SUM(amount) FROM orders WHERE create_time BETWEEN '2024-01-01' AND '2024-01-31' GROUP BY shop_id;

这条SQL在测试环境跑得挺快,一上生产就几十秒,直接把主库的IO打满。当时团队里有同事给create_time加了索引,给shop_id也加了索引,结果效果甚微。

问题出在哪?这条SQL要统计一个月的数据,假设这一个月有300万行订单,那么无论有没有索引,MySQL最终都要把这300万行数据读出来做分组聚合。加了索引只是让你更快地定位到符合时间条件的记录,但接下来还是要逐行读取这些记录的amount、shop_id字段。这个“逐行读取”的成本,恰恰取决于存储引擎的物理布局——如果一行数据在磁盘上是离散存放的,每读一行就要做一次随机IO,那速度就非常难看。

这让我意识到,很多人对“加索引能让SQL变快”的理解过于简单了。索引解决的是“快速定位到哪些行符合条件”,但定位之后要读取多少数据、每次读取的代价多大,才是决定查询性能的核心,而这就是存储形态说了算。

1.2 行式存储的物理形态:一行数据是“打包”存在一起的

行式存储,简单说就是把一条记录的各个字段连续存放在一起。以MySQL默认的InnoDB为例,表空间里的数据页默认是16KB,这些页构成了B+树的叶子节点。主键索引的叶子节点上,每一行记录的所有字段值(order_id、user_id、shop_id、amount……)都是挨着放的,读到一个数据页就等于拿到了很多条完整记录。

打个比方。行式存储就像你把一份通讯录在一个表格里,每个人的姓名、电话、住址、备注写在同一行。你翻开某一页,这一页上就躺着好几个人完整的信息。如果你要查某一个人的全部信息,翻开那一页就全都有了,非常方便。

但反过来说,如果我只想知道所有人的姓名,这一页里其他人的电话、住址、备注信息也都被翻了出来,但它们根本用不到,白白占用了磁盘IO和内存。这就是行式存储最核心的取舍:以“整行”为基本单位换取读取单条记录的效率,代价是处理“只关心少数列”的查询时会读到大量无用数据。

1.3 为什么“底层怎么存”会决定架构怎么选

很多同学会有个疑问:不管行式还是列式,逻辑上不都是二维表吗?为什么物理存储方式会影响到架构选型?

因为物理布局决定了两种关键成本:读放大和写放大。行式存储把一整行打包,写一条数据时只需要在一处(一个数据页或相邻几个数据页)写入,写放大很小;但读取时如果只用到几列,仍然要把整行数据从磁盘搬到内存,读放大明显。列式存储恰恰相反,同一列的数据连续存放,读特定列时IO极小,但插入一条记录要分别写入各个列文件,写放大很大。

理解了“读放大/写放大”这对概念,很多现象就解释得通了:为什么OLTP系统几乎清一色用行式存储?因为这类系统的核心特征是高频的插入、更新和小范围点查,写放大越小越好;为什么数据分析场景几乎都被列式存储统治?因为分析SQL只关心少量列,读放大越小越好。这个逻辑后面我会展开讲。

2. 行式存储的底层原理:从数据页到B+树

2.1 数据页是磁盘和内存交换的最小单位

先讲一个最容易被忽略但极其关键的知识点:数据库读写磁盘,最小单位不是“行”,而是“页”。MySQL InnoDB默认的数据页大小是16KB,Oracle是8KB,PostgreSQL也是8KB。也就是说,哪怕你只想查询一行记录,存储引擎也会把包含这行记录的那个16KB的数据页整体从磁盘读到内存缓冲池里。

为什么要设计成页而不是精确按行读取?因为磁盘IO是按扇区/块进行的,一次随机IO的寻道时间(HDD时代)或者访问开销(SSD时代)远远大于读取数据本身的时间。如果按行读取,读1000行就得发起1000次IO;按页读取,假设每页装了100行,读1000行只需要10次IO。以页为单位的批量调度,就是为了摊薄每次IO的固定成本。

数据页的大小还有一层含义。页太大会浪费内存和缓存空间,页太小则IO调度效率低。16KB这个值是MySQL经过大量测试后权衡出来的,大多数场景下表现都不错。实际工作中我们很少需要调这个参数,但理解“页”才能理解后面讲的全表扫描代价。

2.2 聚簇索引与非聚簇索引:行数据到底挂在哪个索引上

行式存储的数据库,索引和数据的关系可以分成两类:聚簇索引和非聚簇索引。MySQL InnoDB是聚簇索引的典型代表,表里的数据行本身就存放在主键索引(聚簇索引)的叶子节点上。你通过主键定位到一条记录,叶子节点上就直接带着该行的完整数据,一步到位。

非聚簇索引则不同,比如你在user_id字段上建了一个普通索引,这个索引的叶子节点并不直接存整行数据,而是存“主键值”。查询时先通过user_id索引找到主键,再用主键到聚簇索引里查完整行。这第二次查找叫做“回表”。

回表不是免费的。假设二级索引命中了1000行,那就是1000次回表。如果运气不好,这些主键对应的数据分布在很多不同的数据页里,就相当于做了很多次随机IO。这也是为什么很多时候建了索引却依然慢的原因——不是索引没生效,而是回表的代价压过了收益。

一个常见的优化手段是“覆盖索引”。如果查询需要的所有字段都已经包含在二级索引里,就不需要回表了。例如上面的订单统计SQL,如果有一个(create_time, shop_id, amount)的联合索引,那统计时所有需要的数据都在索引页里,可以直接扫描索引完成聚合,省掉回表的开销。这也是为什么建议写SQL时尽量“把需求限定在索引能覆盖的范围”。

2.3 顺序扫描 vs 索引扫描:行式存储的场景优势

全表扫描在很多人眼里是洪水猛兽,但严格来说,它并不总是低效的。全表扫描是顺序读,数据在磁盘上是连续排布的,存储引擎可以一次性预读大量数据页,吞吐量很高。如果一张表很小,或者需要读取的行数占全表的比例很高,全表扫描反而比走索引再逐行回表更快。很多数据库优化器在判断“小表”或“大比例查询”时,会主动选择全表扫描而不是索引扫描,就是这个原因。

那什么时候索引扫描更优?当查询条件能筛掉大量行,最终需要读取的行数占全表比例很低的时候。比如一个用户查看自己的某个订单详情,这个查询只涉及一行数据,走主键索引一次的IO开销远小于全表扫描。

行式存储在“点查+整行读取”场景下是天然的王者。因为索引定位到具体数据页后,一次IO就能把整行的所有字段拿到手。而这个场景正是OLTP的核心——用户的登录信息查询、订单详情页、购物车结算,全是这种模式。这是行式存储至今无法被列式存储替代的根本原因。

3. 行式存储与列式存储的物理差异:为什么分析场景天差地别

3.1 同一张表,两种存法的磁盘表现完全不同

我们把问题抽象成一组数字算一笔账,这比文字描述直观得多。

假设有一张用户表,10个字段:id、name、age、gender、email、phone、address、register_time、last_login_time、level。每行平均1KB,一共1亿行,总数据量约100GB。

现在执行一个查询:统计所有用户的平均年龄。这个查询只需要读取age这一列,age字段如果用int存储占4字节,那么真正有价值的数据只有4字节×1亿=400MB。

行式存储要怎么做?它必须把整张表的100GB全部读一遍。因为每行的10个字段是打包在一起的,要拿到age就必须把整行从磁盘搬上内存。哪怕你在age上有索引,索引扫描也是一样的逻辑——先通过索引拿到主键,再回表读整行取age,整个过程依然要动大量数据页。

列式存储怎么做?所有用户的age值连续存放,直接顺序读取这个列文件,400MB的数据读完就能算出平均值。如果列式存储再用上压缩,实际读取的数据量可能只有40MB。

同样是算一个平均数,行式要读100GB,列式可能只要读40MB。差了250倍,这就是存储形态对分析查询的致命影响。数据库圈子经常说“分析场景列式完胜”,原因就在于这个简单的IO算术。

3.2 压缩率的巨大差异:同类型数据连续排列带来的红利

除了IO差异,列式存储还有一个隐藏优势:压缩率高得惊人。

行式存储的一个数据页里,连续存放的是不同字段,类型五花八门,有整数、字符串、时间戳、JSON。压缩算法面对这种混合类型数据,很难找到高效的压缩模式。一般来说,行式存储全表压缩率能做到2:1到3:1就不错了。

列式存储里,同一列的数据类型完全一致,而且很多列的值重复度很高。比如gender列只有“男”“女”两种取值,level列可能只有10个档位,状态列可能只有几个枚举值。这种高度重复的数据非常适合字典编码、位图编码、RLE(游程编码)这一类算法。实际项目中,列式存储的压缩率做到10:1到20:1是很正常的,某些低基数列甚至能到50:1。

压缩率带来的连锁反应是:磁盘占用更少,IO更少,扫描时缓存里能装下更多有效数据,内存命中率更高。分析场景下,这直接决定了SQL能不能在几秒内跑完。

但要注意,高压缩率背后也有代价。列式存储的数据通常不可原地更新,因为一个值改了,要压缩编码后重新写入对应列文件。所以列式存储的设计哲学是“一次写入多次读取”(write-once, read-many),适合批量导入、追加写入,不适合频繁的单行更新。这也是ClickHouse这类列式引擎在数据更新上表现得非常别扭的原因。

3.3 为什么OLTP还是离不开行式

既然列式存储读分析SQL那么快,那干脆所有场景都用列式存储行不行?答案是不行,至少在当前硬件和软件条件下行不通。

OLTP场景有三个硬核诉求,行式存储在这三个诉求上都有着天然优势。

第一,点查极快。用户订单详情、账号信息这类查询,一次索引定位、一次IO拿到整行,延迟能控制在毫秒级。列式存储要拼凑出一行完整数据,需要去各个列文件分别取数再合并,反而慢得多。

第二,写放大极小。插入一条订单,行式存储只需要在数据页里追加一行,写一个地方就够了。列式存储插入一行,等于要在每一列的文件里各写一笔,10列就是10次写入,写放大10倍。高频写入场景根本没得比。

第三,事务和并发控制成熟。MySQL InnoDB的MVCC、行级锁、redo/undo日志,这套机制是建立在“行”作为基本操作单元之上的,非常成熟可靠。列式存储在这方面的积累远不如行式存储,甚至很多列式引擎干脆不做完整的事务支持。

所以你看,行式存储和列式存储根本不是谁替代谁的关系,而是各自守着不同的半场:行式守写多读少、点查密集的OLTP半场,列式守批量扫描、聚合分析为主的OLAP半场。

4. 选型判断与常见踩坑

4.1 一张快速判断表:什么时候选行式,什么时候选列式

我把自己这些年做技术选型时用的判断标准整理成了一张表,不一定覆盖所有细节,但作为初筛足够用了:

判断维度更倾向行式存储更倾向列式存储
查询模式点查、小范围查、按主键查大范围扫描、聚合统计、多表宽表分析
写入模式高频插入、更新、事务型写入批量导入、低频追加,很少更新
关注字段数经常需要整行数据每次只关心少数几个列
数据访问延迟要求毫秒级响应秒级到分钟级可接受
事务要求强一致、ACID允许宽松一致性或最终一致
典型引擎MySQL InnoDB、PostgreSQL、SQL ServerClickHouse、Doris、Parquet/ORC文件

注意,上面说的是“更倾向”,不是绝对。现实中大量系统是混合架构:核心交易数据放在行式数据库,每天同步到列式引擎做分析。这不是什么高深的设计,而是对两种存储形态特点的合理利用。

4.2 行式存储上常见的“伪优化”

行式存储用不好,很多时候不是存储引擎的问题,而是使用姿势的问题。这几个坑我几乎每年都会遇到几个项目踩进去。

第一个坑:给所有列都建索引。即使SQL只按某列过滤,索引也不是越多越好。每个索引都要占磁盘、拖慢写入,优化器还得在多个索引之间做选择。索引选错反而比不用索引更慢。我见过一张订单表建了30多个索引,最后插入性能严重下降,排查了很久才意识到是索引冗余。

第二个坑:把大字段塞进行式存储的主表。有些团队习惯把JSON扩展字段、大段文本、图片URL等直接放在业务表里。这些字段平时根本不会被查询用到,但它们驻留在每一行里,做全表扫描或者回表时,这些大字段会被一起读出来,白白吃掉大量内存和IO。正确做法是拆成独立的扩展表,或者把大对象放到对象存储,数据库里只存一个引用。

第三个坑:函数和隐式转换导致索引失效。这是SQL层面的经典问题,但和行式存储的索引机制紧密相关。比如对create_time用date()函数做比较,索引就失效了;再比如varchar类型的user_id字段,查的时候传了数字类型,MySQL做了隐式转换,索引一样失效。排查慢查询时,第一步先看执行计划里type是不是range/ref/const,如果看到all,基本就是索引没走成。

4.3 硬件进化对行式存储的影响:护城河还在不在

很多人问过我一个问题:现在SSD普及了,随机IO性能提升了几个数量级,行式存储“一行打包一次IO”的优势是不是就没那么重要了?

我的看法是:随机IO成本确实大幅下降了,这对行式存储是利好,很多以前不敢做的SQL现在都敢跑了;但这并没有动摇行式/列式选型的根本逻辑。因为列式存储的三大优势——只读目标列、压缩率高、缓存命中率高——全部建立在“IO数据量更少”这条线上,无论底层是HDD还是NVMe,数据量少永远占优。

换句话说,硬件发展让两类存储都变快了,但它们之间的相对差距并没有消失。真正改变游戏规则的是内存数据库和计算下推这些技术,但它们也不是用来替代行式存储的,而是在内存这个更快的层次上另起炉灶。行式存储在OLTP领域的护城河,短期看依然稳固。

5. 实战案例:一个订单系统怎么从“全表扫描”走向“行式+列式”

5.1 背景:订单量增长后,统计报表把主库打挂了

回到开头说的那次事故。orders表4000万行,MySQL InnoDB存储,主库承担所有读写。最开始的优化是尝试SQL改写:把统计SQL的时间窗口从一个月缩到一周,把不必要的字段去掉,增加覆盖索引。效果有一点,但只要业务要求跨月汇总,SQL还是会扫描上千万行,主库依然被拖垮。

后来我把监控拿来看得更细:报表SQL的执行时间基本都在20秒以上,平均扫描行数1500万行,平均返回字节数接近2GB。主库的磁盘IO在报表时间段长期处于饱和状态,很多正常的订单查询都跟着遭殃。用行式存储跑这种聚合分析,本质就是用错工具。

5.2 优化三层做法:SQL改写、分析引擎迁移、实时聚合

这个案例最后一共做了三层改造,我按顺序说一下,每一层都可以单独落地,不一定必须一次到位。

第一层,SQL改写和索引优化。这是成本最低、见效最快的一步。我们把所有统计类SQL的查询条件从“函数包裹字段”改成范围查询,确保索引生效;把所有可能用到的字段组合成覆盖索引,减少回表;把一个月以上的统计拆成按天分区的中间结果表,事后汇总,避免每次都全量扫明细。这一层做完,报表SQL从20秒降到了6秒左右。

第二层,引入列式分析引擎。我们搭建了一套ClickHouse集群,通过binlog把orders表实时同步过去。所有跨月、跨年的大范围聚合查询全部迁移到ClickHouse执行。这是本质性的一步:分析SQL不再碰MySQL主库,主库的IO压力立刻释放。

第三层,实时流式聚合。针对需要实时性的指标(比如当天实时销售额),我们用Flink消费订单消息,做分钟级的流式聚合,结果只写回MySQL/Redis供应用查询。这层的引入让报表从“跑批”变成了“查结果”,延时控制在分钟级。

5.3 最终效果与复盘

改造完成后,那条曾经让主库CPU飙到100%的报表SQL,在ClickHouse上执行时间从20多秒降到了300毫秒左右,MySQL主库的报表负载几乎归零,订单查询的P99延迟也稳定了下来。

回头复盘,我最深的感触是:行式存储本身没有任何问题,出问题的是我一开始拿它干了不该它干的活。订单明细表的核心职责是支撑交易事务和订单详情查询,这部分它做得又快又好;统计分析是分析引擎的活,不应该硬塞给OLTP库。

这个案例也让我形成了一个习惯:任何慢查询优化,先问三个问题——这条SQL要读多少行?每行之间是否分散?哪个存储引擎读这些数据更划算?想清楚这三个问题,再谈加索引、拆表、上列存,思路会清晰得多。

最后分享一个我自己的实操经验:优化SQL前,先花十分钟看执行计划。MySQL用EXPLAIN ANALYZE(8.0.18+)或者EXPLAIN加FORMAT=JSON,重点看三层信息——扫描了多少行(rows)、索引用没用上(type)、有没有回表(Extra里如果是Using index则覆盖)。执行计划不会骗人,它比任何经验都更能告诉你存储引擎真实做了什么。我个人见过太多人对着一条SQL凭空猜问题,结果猜三天不如看一眼执行计划来得准。

行式存储作为数据库最底层的物理实现,不懂它的人会以为它就是“一张表”,懂它的人才明白,这一张表怎么放、怎么读、怎么换,决定了整个系统的性能边界。把这些基本功补齐了,以后遇到再复杂的架构,你也能一眼看出问题在哪层。

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

车辆稳定性相平面分析:二自由度模型与Matlab鞍点临界轨迹提取

做车辆稳定性分析,相平面是绕不开的工具。最近我把二自由度车辆模型在质心侧偏角-横摆角速度平面上完整跑了一遍,连鞍点和临界轨迹也一起画出来了。这套流程前前后后给不少做底盘控制和车辆动力学仿真的同学改过,我觉得很有必要把完整思路、M…

作者头像 李华
网站建设 2026/10/8 9:09:23

存储函数与存储过程:三大数据库语法对比与实战陷阱

先说一个我常在代码评审里遇到的场景:有人花了几个星期把存储过程(Stored Procedure)吃得很透,结果第一次上手写存储函数(Stored Function)就被报错教育了一下午。要么是 Oracle 的 ORA-06553,要…

作者头像 李华
网站建设 2026/10/8 9:09:06

独立开发者推广指南:从零到一让产品被看见

很多人以为独立开发的难点在“做出来”,我做了几年之后才明白,真正的分水岭是“卖出去”。代码写不出来可以学,产品没人知道连学都不知道该学什么。这个标题下我想说的不是那种“花钱投广告”的推广,而是独立开发者最该走的那条路…

作者头像 李华
网站建设 2026/10/8 9:08:32

磁珠与磁环的区别:原理、选型与EMC整改实战指南

上周帮朋友看一块控制板的EMC预测试报告,30MHz到230MHz这段辐射超标得有点难看。按照常规思路,先在场电源入口加磁珠,再在对外线缆上套磁环。朋友顺口问了一句:这俩东西看着差不多,到底有啥区别?我愣了一下…

作者头像 李华
网站建设 2026/10/8 9:08:31

UV打印机PrintExp高级模式马达参数调校全攻略:从原理到实操

写UV打印机调试的同行,或者自己开广告加工店、代工厂的朋友,对PrintExp这软件应该不陌生。平时大家用得最多的就是标准打印模式:放材料、对原点、调喷头高度、按打印。这些操作只要培训一两天基本就熟了。但机器用上半年一年,你总…

作者头像 李华
网站建设 2026/10/8 9:08:30

网页大文件分片上传与断点续传:前端JS切片、后端C#合并全解

去年给公司内部做资料库系统的时候,用户经常要传几百MB甚至几个GB的安装包和日志包。最开始我想得太简单,直接写了个普通HTTP上传接口,本机测试一切正常,结果一上生产就被连续打脸:传到一半网关超时断开、服务器报413、…

作者头像 李华