ARTICLE DETAIL

资讯详情

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

MySQL索引实战:从B+树原理到慢查询优化,一次讲透

MySQL索引实战:从B+树原理到慢查询优化,一次讲透 干这一行久了你会发现一个特别有意思的现象面试的时候MySQL索引人人都能聊两句B树、最左前缀、回表这些词张口就来可真到了线上一条慢SQL把数据库拖到CPU飙满、连接堆积能快速定位并解决的人反而少得可怜。这篇文章我就把自己这些年折腾MySQL索引的实战经验整理出来从底层原理到执行计划从失效场景到面试高频题一次讲透。不管你是刚入门的开发还是正在做性能调优的老手这篇都能帮你在索引这件事上少走弯路。1. 索引底层逻辑为什么偏偏是B树要搞懂索引先得明白索引到底解决什么问题。简单说就是让MySQL从全表翻一遍变成按目录直接翻到那一页。没有索引的时候InnoDB只能把整张表的数据页一个个读出来逐行比对条件这叫全表扫描。数据量小的时候无所谓可一旦表里有个几百万行全表扫描的磁盘IO和CPU开销直接能把数据库拖垮。1.1 从二叉树到B树的演进逻辑用树形结构做查找核心目的是减少磁盘IO次数。这里有个关键指标——树的高度。每查一层就要读一次磁盘页InnoDB默认页大小16KB树越矮IO越少。先看二叉树每个节点最多两个子节点几百万数据的高度能到二十多层二十多次磁盘IO这在数据库场景下是无法接受的。红黑树虽然是平衡的但本质上还是二叉树高度依然下不来。B树的思路是多路复用每个节点存多个key多个子节点同样数据量高度只有三四层。那为什么MySQL最终选了B树而没用B树关键在于两点第一B树的非叶子节点不带数据只存索引键和指针。这意味着一个16KB的页能塞进去的key数量远多于B树树更矮更胖。第二B树的叶子节点之间用链表串起来并且按key有序排列。这个设计太聪明了——范围查询走到叶子节点后直接沿着链表往下走就行不用再回溯父节点重新遍历。日常业务里WHERE id BETWEEN 100 AND 500这种范围查询太常见了B树的这个特性让范围扫描效率极高。1.2 InnoDB的聚簇索引与二级索引InnoDB里表本身就是按索引组织的这句话一定要记住。每张InnoDB表都有一个聚簇索引也叫主键索引它的叶子节点直接存整行数据。如果你建表时没指定主键InnoDB会找第一个非空的唯一索引作为聚簇索引再没有它会生成一个隐藏的rowid列。这就是为什么我强烈建议每张表都要显式指定主键最好是自增id——这样数据在物理上按id顺序存储插入性能高也方便基于主键的查询。二级索引非聚簇索引则不同它的叶子节点存的是索引列的值和对应的主键值。通过二级索引查数据会先找到主键再回聚簇索引里捞整行这个过程叫回表。搞明白这个逻辑后面讲覆盖索引你就自然理解了——如果查询的列恰好都在二级索引里那根本不需要回表直接返回索引里的数据就行速度直接起飞。注意如果表没有主键二级索引叶子节点存的是隐藏的rowid回表效率会更差。所以建表时一定显式指定主键别偷懒。2. 索引分类与选型每种索引都有自己的脾气MySQL索引的坑十有八九是选型不对或者理解偏差造成的。我见过有人在status字段上建了普通索引也见过有人把VARCHAR字段不加前缀就去做索引结果索引体积巨大效果还不如全表扫描。这块得好好捋一捋。2.1 主键索引、唯一索引、普通索引与全文索引主键索引刚才说了聚簇索引叶子节点存整行。唯一索引保证字段值不重复底层还是B树。这里要特别注意唯一索引和普通索引在查询性能上其实差距不大因为B树查到第一个匹配值之后对于唯一索引直接就停了普通索引还得继续扫到下一个不匹配的key。这个差异在极端情况下存在但日常你可感知不到。全文索引则是另一套东西。它本质是倒排索引不是B树主要用在文本搜索场景。MySQL的全文索引说实话功能相对基础中文分词支持也不好。我实测下来除非是极简单的场景否则建议直接用Elasticsearch别在MySQL里折腾全文索引。如果项目里用的是MongoDB那边也有类似的概念但MongoDB的索引模型和MySQL差异很大函数索引、复合索引这些概念其实各家的实现逻辑都有区别不要把MySQL的经验原封不动搬过去。还有个小众的——哈希索引。Memory引擎支持InnoDB里也有自适应哈希索引这是InnoDB内部自动维护的你没法手动建。哈希索引的查找是O(1)的但它不支持范围查询也不支持排序所以对等值查询有奇效范围查询就废了。2.2 联合索引与最左前缀原则联合索引复合索引是日常开发里用得最多也最容易出错的。ALTER TABLE user ADD INDEX idx_age_name (age, name)表示先按age排序age相同的再按name排序。所以查询条件必须从最左边的列开始用并且要连续。这就是最左前缀原则。具体到实操WHERE age 20—— 能用到索引WHERE age 20 AND name 张三—— 能用到索引WHERE name 张三—— 用不到索引因为跳过了ageWHERE age 18 AND name 张三—— 这种情况age能用索引但name用不上因为range之后索引就断了这里有个很多老手都会犯迷糊的点联合索引的字段顺序该谁放前面我的原则是区分度高的放前面或者把等值查询频繁的字段放前面。比如按(status, create_time)建索引status就两个值区分度很低但如果你业务里大量按status过滤放前面也能极大缩小扫描范围。重要的不是生搬硬套区分度优先而是看你的实际查询模式。2.3 覆盖索引与索引下推两个性能利器覆盖索引Using index是减少回表最有效的手段。当SQL查询的列全部都在索引里时InnoDB直接从索引树返回结果不用再回聚簇索引取数据。所以设计索引时可以顺手把高频查询的select字段加进联合索引里。举个例子表user有id, name, age, email列假设你建了idx_name_age(name, age)查询SELECT name, age FROM user WHERE name 张三就是覆盖索引Extra里会显示Using index。但如果你查SELECT name, email FROM user WHERE name 张三email不在索引里就必须回表了。索引下推ICPIndex Condition Pushdown是MySQL 5.6引入的优化。在没有ICP时联合索引(name, age)查询WHERE name 张三 AND age 20InnoDB只能根据name定位到第一个张三的记录然后回表再把age 20的过滤掉。开启ICP后age 20这个过滤条件会被下推到索引层在索引遍历时就先过滤掉不满足条件的记录明显减少回表次数。这个优化默认是开启的你只需要知道它的存在然后尽量写能利用它的SQL就好。3. 索引失效的六大场景每一个都是血泪坑索引建了SQL也看着挺正常的可执行计划一出来全表扫描。这种情况我排查过无数次最后发现基本都是下面这几个坑。我按出现频率排个序你对照着自查。3.1 函数操作与隐式类型转换对索引列做函数操作索引必废。WHERE DATE(create_time) 2024-01-01MySQL会对每行都调用DATE函数索引在这时候就没意义了。正确写法是WHERE create_time 2024-01-01 AND create_time 2024-01-02这样create_time本身参与比较索引才能生效。隐式类型转换是另一个极隐蔽的坑。表里phone字段是VARCHAR存的是字符串你写WHERE phone 13800138000MySQL会把字符串隐式转成数字去比较索引直接失效。这里可以理解为字段的类型是字符串但比较的对象成了数字MySQL必须把字段类型转换后才能比一旦对字段做了转换操作索引就废了。所以VARCHAR字段比较时一定要写字符串字面量比如WHERE phone 13800138000。3.2 LIKE通配符与OR连接的坑LIKE %abc和LIKE %abc%用不到索引这大家都知道。因为通配符在开头B树没法从某个key直接开始扫描。但LIKE abc%是可以的因为B树可以定位到前缀为abc的第一个记录然后向后范围扫描。OR连接的情况我见得太多了WHERE name 张三 OR status 1即使name和status都有单列索引优化器也可能选择全表扫描尤其是两个条件都区分度不高的时候。要破这个局可以改写UNION ALL让两个索引都能各自生效然后合并结果。或者如果你了解MySQL 8.0的索引跳跃扫描特性有些场景它能自动优化这种问题但并不是所有情况都适用改写SQL反而更可控。3.3 优化器的任性选择很多时候索引没失效但优化器就是不选你建的索引直接全表扫描。为什么因为优化器基于统计信息算了一笔账用索引要回表那么多次可能比全表扫描还慢。这个判断经常出在数据分布不均匀的时候比如性别字段建了索引查询WHERE gender male如果male占了一半数据优化器果断全表。这种情况你没法强行让优化器用索引最好的办法是让索引覆盖查询——用覆盖索引把查询列都包进去降低回表成本优化器自然就选了。4. 实操从EXPLAIN读懂执行计划纸上谈兵再多不如跑一条EXPLAIN看看。EXPLAIN是MySQL提供的执行计划分析工具用法就是在SQL前面加EXPLAIN关键字EXPLAIN SELECT * FROM user WHERE age 20。它会输出一行关键信息把这行看明白了SQL为什么慢你就心里有数了。4.1 执行计划核心列速查列名含义值得关注的取值type访问类型const eq_ref ref range index ALL性能从左到右依次变差key实际用到的索引NULL说明没走索引rows预估扫描行数越小越好Extra额外信息Using index覆盖索引、Using filesort文件排序、Using temporary临时表type字段这列是全表扫描还是走索引的关键。ALL就是全表扫描必须警惕。index是全索引扫描比ALL好一点但也扫了整棵索引树。range是范围扫描ref是等值查询用到非唯一索引eq_ref是唯一索引等值查询const是主键或唯一索引等值查询这是最高效的。我平时看执行计划eyeball先横扫type如果看到ALL且rows很大直接开干。Extra里出现Using filesort要特别注意——这意味着排序是在内存或磁盘上做的当排序数据超过sort_buffer_size就会用到磁盘文件性能断崖式下跌。解决办法是让排序字段和查询条件走同一个索引因为B树本身就是有序的。4.2 一次真实慢查询的排查过程之前有个线上接口特别慢一查是这条SQLSELECT order_no, amount, status FROM orders WHERE create_time BETWEEN 2024-03-01 AND 2024-03-31 ORDER BY amount DESC;EXPLAIN结果typeALLrows180万Extra里还有Using filesort。表里其实有create_time的单列索引但优化器觉得按时间范围查出来70万行再回表取amount排序还不如全表扫一次划算所以压根没用索引。我的改法是建了一个联合索引idx_create_time_amount(create_time, amount)。由于B树叶子节点是按(create_time, amount)排序的ORDER BY amount也能直接从索引里按序读取Using filesort立刻消失走了range扫描总耗时从2.8秒降到0.15秒。一个小改动性能翻了几十倍。4.3 大表加索引的正确姿势给大表加索引是个高危操作千万别在业务高峰期直接ALTER TABLE。虽然InnoDB 5.6之后就支持Online DDL了加索引的过程中不阻塞读写但加索引本身要在后台扫描整张表构建索引CPU、磁盘IO、内存都有明显开销主从延迟也会被放大。我的经验是先用SHOW INDEX FROM table确认这张表现有索引情况避免重复建索引用pt-online-schema-change工具在线变更原理是先建一张新表通过触发器同步增量数据最后rename。风险更低而且可以限流如果数据量特别大上亿行建议分批次操作或者在外围系统做双写绝对不能在生产环境硬跑5. 面试高频题与调优体系索引是Java、后端开发面试几乎必考的一个点我把这些年被问过的问题整理了一下当然也包括我面试别人时候喜欢问的几个。这块弄明白了你去找工作这一关基本就稳了。5.1 经典问题速答为什么用B树而不用红黑树磁盘IO次数取决于树的高度红黑树是二叉树高度高几百万数据就要二十多次IO。B树多路非叶子节点不存数据一页能存上千key三到四层就能支撑千万级数据查询只要三四次IO。主键为什么建议用自增id而不是UUID自增id在插入时是顺序追加B树叶子节点直接往右写就行UUID是无序的新数据可能插在中间导致页面分裂、数据行搬迁产生大量碎片插入性能会急剧下降。这一点在热词里也有人提到主键索引其实背后就是这个道理。为什么不要在低区分度字段建索引比如性别字段一个值占了50%数据索引的筛选能力有限回表成本还高优化器大概率不用它。区分的标准简单说就是COUNT(DISTINCT col) / COUNT(*)比例越接近1越好低于10%的话就别折腾了。联合索引和单列索引怎么选我见过一张表建了七八个单列索引结果一个都用不上。索引越多写入越慢磁盘占用越大。我的习惯是能用一个联合索引撑起多个查询的就不建多个单列索引。可以结合业务查询模型来设计高频的查询条件组合优先建联合索引而不是每个字段单独建。5.2 慢查询排查的完整链路排查性能问题我有一套固定流程可以拿给你参考。先开慢查询日志定位到底哪些SQL慢。SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; -- 超过1秒的记录 SET GLOBAL slow_query_log_file /var/log/mysql/slow.log;拿到慢SQL后EXPLAIN看执行计划重点看type、key、rows、Extra四列。如果走了索引但还慢可能就是索引没覆盖有大量回表或者数据量太大单次IO太多这时候考虑改写SQL或优化索引结构。还有一种情况是明明数据量不大但查询就是慢这就要看是不是并发压力太大——比如连接数打满、锁等待严重。这时SHOW PROCESSLIST看当前会话都在干什么如果是大量Waiting for table metadata lock多半是有人手动改了表结构没提交把后面所有查询都堵住了。5.3 MySQL 8.0索引新特性最后提一嘴8.0带来的几个好用的新特性因为热词里也有人关注新版本的功能。第一个是函数索引Functional Index。以前WHERE DATE(create_time) 2024-01-01不能用索引8.0可以直接建INDEX idx_create_date ((DATE(create_time)))底层是用虚拟生成列实现的。这样函数查询也能走索引了。第二个是降序索引Descending Index。8.0之前索引默认都是升序存储ORDER BY col DESC虽然也能用索引但要额外做反向扫描。8.0支持建降序索引INDEX idx_col (col DESC)对排序查询的性能提升很明显。第三个是不可见索引Invisible Index。你可以把某个索引设为不可见测试它是否还需要但又不删它。比如ALTER TABLE user ALTER INDEX idx_age INVISIBLE;这条操作非常实用能在不改业务代码的情况下快速验证某个索引是不是还有用。实测没问题再最终删除特别安全。提示索引不是越多越好。每建一个索引插入和更新时就要多维护一棵B树写放大是真实存在的。一个几千行的表索引再优化也快不到哪去这时候该考虑的是别的瓶颈。写了这么多说句掏心窝的话索引优化的本质是理解你的数据和你的查询模式。任何经验法则都是参考最终一定要结合EXPLAIN里的真实数据说话。我每次改SQL前的习惯是先把执行计划打出来看一遍改完了再跑一遍对比rows和Extra的变化。这个习惯帮我避免了很多次自以为优化了实际上没变化的尴尬。建议你把这套流程跑熟了以后再遇到慢查询心里就有底。
返回列表