ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

行式存储原理与实战:从MySQL慢查询到列式存储选型 从一次线上事故说起。去年夏天我接手了一个订单系统的优化线上MySQL的CPU时不时飙到100%慢查询日志里躺着好几条扫描几千万行的统计SQL。当时我第一反应是“加索引”但加完发现情况并没有好到哪里去——有些SQL压根没走索引有些走了索引也还是慢。排查到最后问题回到了一个很多人忽略的最底层存储引擎到底是怎么把数据放在磁盘上的。这就是行式存储也是所有关系型数据库最基础的物理存储方式。这篇文章我想把行式存储的原理、适用场景、以及这些年我在实际项目里的选型经验和踩坑记录一次讲透。这篇文章适合谁看如果你是刚入行的后端开发或DBA它能帮你补上“为什么我的SQL这么慢”的底层知识如果你已经在做数据架构选型它能帮你理清什么时候该用行式、什么时候该上列式以及两者之间到底该怎么配合。讲原理的时候我会尽量用大白话但该有的细节一个不少。1. 一次慢查询背后的“存储形态”问题1.1 事故现场索引都加了为什么还是慢先还原一下当时的场景。订单表orders大概4000万行字段包括order_id、user_id、shop_id、amount、status、create_time还有一些JSON扩展字段。线上有一条很典型的报表SQLSELECT 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默认的数据页大小是16KBOracle是8KBPostgreSQL也是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 ANALYZE8.0.18或者EXPLAIN加FORMATJSON重点看三层信息——扫描了多少行rows、索引用没用上type、有没有回表Extra里如果是Using index则覆盖。执行计划不会骗人它比任何经验都更能告诉你存储引擎真实做了什么。我个人见过太多人对着一条SQL凭空猜问题结果猜三天不如看一眼执行计划来得准。行式存储作为数据库最底层的物理实现不懂它的人会以为它就是“一张表”懂它的人才明白这一张表怎么放、怎么读、怎么换决定了整个系统的性能边界。把这些基本功补齐了以后遇到再复杂的架构你也能一眼看出问题在哪层。
返回列表