MySQL索引全解:从B+树到回表优化,读懂分类与设计实战

发布时间:2026/10/9 6:30:32
MySQL索引全解:从B+树到回表优化,读懂分类与设计实战
做 MySQL 调优绕不开索引做索引设计先得把分类搞明白。很多人张口就是“建个索引”可真要问你 B树索引和哈希索引啥区别、主键索引跟唯一索引能不能互相替代、二级索引回表到底怎么回事、复合索引字段顺序为什么不能乱能一次说清楚的没几个。这篇文章我把 MySQL 索引按数据结构、逻辑功能、物理存储、使用场景这几个维度拆开讲结合我这些年维护千万级业务表的实际踩坑经历给出可以直接用的建索引方法和避雷清单。适合正在学 MySQL 原理的开发、刚接手慢 SQL 排查的后端以及想系统梳理索引知识的 DBA 新人。不绕弯子直接上干货。1. 先按底层数据结构分B树、哈希、全文索引谁在什么场景扛事1.1 B树索引MySQL 的“大门面”90% 的查询优化都靠它InnoDB 引擎最常见的索引底层结构就是 B树。B树跟普通二叉树不一样它有这几个特性值得记住非叶子节点只存键值和指针不存数据本身叶子节点存完整索引项并且叶子节点之间用双向链表串起来。这么一个设计带来一个关键结果——树的高度很矮一般三到四层就能放下千万级数据。也就是说你查一条数据最多做三四次磁盘 I/O 就到头了。我习惯用一个类比B树索引就像一本带目录和页码的工具书目录页不写正文内容只写关键词和页码真正内容都在正文。你在目录页一层一层往下翻很快就能定位到页码然后直接翻到那一页找到完整信息。这就是为啥 B树既能做等值查询又能高效做范围查询——叶子节点本来就是按顺序链表排列的找到起点之后沿着链表往后扫就行。实际项目里InnoDB 的主键索引、二级索引、普通索引、唯一索引默认全是 B树结构。你不需要为每个索引声明“我要用 B树”引擎默认就是它。这里有个技术面试常问的点为什么不用红黑树也不用跳表答案是 B树扇出高、树矮单次查询磁盘 I/O 次数少而且叶子链表天然支持范围扫描。MySQL 最常用的查询场景就是“找出某个范围内的数据”这恰好是 B树的强项。1.2 哈希索引和全文索引特定场景的专用武器哈希索引的结构是哈希表原理是把索引键通过哈希函数算出一个散列值指向对应数据行。它的最大优势是等值查询极快理论上是 O(1) 复杂度比 B树还要快最大劣势是没办法做范围查询也没有顺序概念ORDER BY 和 BETWEEN 到它这里基本歇菜。在 InnoDB 里你没法直接创建显式哈希索引但引擎内部有个“自适应哈希索引”是自动启用的特性。当某些等值查询被反复执行InnoDB 会在 B树索引之上再建一层内存哈希缓存用来加速这类高频等值访问。这个特性默认开启不需要手工干预但它只对缓冲池里的热点页生效而且只在等值匹配场景有意义。MyISAM 和 MEMORY 引擎倒是支持显式哈希索引但在实际生产里用得很少更多场景是在纯内存系统里发挥作用。全文索引就不靠 B树了它用的是倒排索引。简单说就是把文档内容拆成一个个词条再维护一张“词条到文档位置”的映射表搜索时直接查词条不用全表扫。MySQL 里用 FULLTEXT 关键字创建全文索引配合 MATCH ... AGAINST 语法做全文检索。适合文章、留言、商品描述这类大文本字段的模糊搜索。不过要注意MySQL 内置全文索引的解析器对中文分词支持一直比较一般生产环境真要搞中文搜索更多人还是会选 Elasticsearch而不是把全文索引当主力。1.3 空间索引与引擎差异冷门但存在的分类还有一类容易被忽略的索引是空间索引。MySQL 支持 SPATIAL 索引底层一般用 R-Tree 结构专门处理 GEOMETRY 类型的地理空间数据。比如地图应用里要查“某个经纬度点周围 5 公里内的门店”这类场景普通 B树索引很难高效支撑而 R-Tree 能对二维空间对象做快速范围检索。不过在日常业务开发里用到空间索引的机会确实不多知道它属于索引分类的一支、并且和 B树有本质区别就够了。另外讨论索引分类时一定要带上存储引擎的差异。InnoDB 和 MyISAM 都有 B树索引但 InnoDB 是聚簇索引、MyISAM 是非聚簇索引全文索引最早是 MyISAM 的卖点InnoDB 从 5.6 版本才开始支持。有些开发在 MyISAM 表上习惯了某种行为切到 InnoDB 后发现索引机制完全不同这就是因为引擎层面的物理组织方式变了。下面第 3 章展开讲聚簇索引时这个差异还会再放大。2. 按逻辑功能分主键索引、唯一索引、普通索引、全文索引怎么选2.1 一张表搞定四种功能索引的核心差异按功能划分是建表时最常接触的一层主键索引、唯一索引、普通索引、全文索引。它们底层实现可以都是 B树但约束规则和使用目的完全不一样。主键索引要求字段值非空且唯一并且一张表只能有一个主键。唯一索引只要求值唯一允许出现多个 NULL在 InnoDB 里多个 NULL 被认为是不同的值不违反唯一性。普通索引则啥约束都没有纯粹是加速查询。全文索引是为了解决大文本检索匹配问题。实际建表时很多人默认给表加一个自增 id 作为主键这通常是合理的因为自增主键写数据是尾部追加不容易产生页分裂索引树更紧凑。但也要分场景如果订单表需要用“订单号”关联查询把业务唯一键也建一个唯一索引而不是把主键改成订单号这样既能保证关联查询走索引又保留了主键索引的稳定性。主键一般建议选稳定、有序、占用空间小的字段最忌讳把会频繁更新的字段设成主键——主键移动会导致整行数据在聚簇索引里大规模挪动IO 压力非常真实这一点到第 3 章讲聚簇索引的时候感受会更直观。2.2 主键索引和唯一索引差的那一个 NULL 关键在哪主键索引和唯一索引的区别算得上老生常谈但又是实际操作中最容易被搞混的知识点。很多新人以为唯一索引加个 NOT NULL 约束就等于主键这种理解最大的漏洞在于忽略了两者在 InnoDB 物理存储层面的地位完全不同。最核心的差异可以归纳为三点。第一约束不同主键列不允许 NULL唯一索引列允许有多个 NULL。第二个数限制一张表只能有一个主键索引但可以有多个唯一索引。第三物理作用不同这一点比前两点重要得多——InnoDB 表中主键索引是聚簇索引数据的物理存储顺序跟主键顺序一致整张表的数据行挂在主键这棵 B树的叶子节点上而唯一索引只是普通二级索引叶子节点存的是索引键值和主键值不直接存行数据。这个差异带出一个实际影响如果你在唯一索引所在列上查询命中后还需要根据叶子节点里的主键值回表才能拿到整行数据而在主键索引上查询因为叶子节点直接就是数据行所以根本不用回表。所以说主键不仅是逻辑约束更是 InnoDB 数据组织方式的锚点。很多 DBA 会说“InnoDB 表一定要有主键”原因就在这里如果没有主键InnoDB 会偷偷挑一个非空唯一索引当主键连这个都找不到就生成隐藏的自增列这种情况下索引效率和可维护性都会打折扣。注意即使某列上有唯一索引InnoDB 也不会自动把它当主键用除非表里确实没有主键、没有其他非空唯一索引。业务上想要“既保证唯一又保留主键独立性”就老老实实建一个自增主键再另建唯一索引。2.3 普通索引不是凑数的适合建的几种业务场景普通索引是最常见的索引类型CREATE INDEX index_name ON table(column) 创建的就是普通索引。有人觉得普通索引太“弱”不如唯一索引有存在感这种想法不对。普通索引的真实价值在于它能加速查询同时不像唯一索引那样在每次写入时都要做唯一性校验所以写入性能开销更低。我之前维护过一个用户行为日志表每天写入量在百万级查询场景是“按 user_id 拉最近 30 天行为记录”。这张表高频写入高频按 user_id 查询但同一个 user_id 会重复出现无数次绝不可能加唯一索引。加一个普通 B树索引就非常合适写入多付出的代价只是维护一颗索引树查询则能从全表扫描变成索引定位收益几乎是数量级的。我记得特别清楚加索引前一条带 user_id 的查询要 800ms 左右加索引后基本稳定在 20ms 以内。当然普通索引也不是随便建。如果一张表只有几千行全表扫描本身就很快建索引的意义更多是心理安慰。还有一个常见问题是重复索引——比如已经有了 (a,b) 复合索引又单独建一个 (a) 索引除非 (a) 有非常独立的查询模式否则这个单列索引大部分情况下可以去掉。判断办法很简单复合索引 (a,b) 本身就能覆盖对 a 的等值查询、范围查询和排序需求单独建 (a) 基本是冗余。这种细节我后面第 5 章还会整理成清单。3. 按物理存储分聚簇索引、二级索引、回表与覆盖索引3.1 聚簇索引决定了整张表数据“放在哪”物理存储层面InnoDB 把 B树索引分成两大类聚簇索引和二级索引也叫辅助索引、非聚簇索引。聚簇索引的特殊之处在于索引的叶子节点直接保存整行数据。换句话说数据行就“住在”索引树里索引键的顺序决定了数据在磁盘上的物理排列顺序。一张 InnoDB 表有且只有一个聚簇索引。如果你定义了主键主键就是聚簇索引没定义主键但有非空唯一索引InnoDB 会选它做聚簇索引都没有就生成隐藏的 GEN_CLUST_INDEX 自增列。这里面最关键的实操点是你在 InnoDB 里新建的每一个二级索引叶子节点存的不是整行数据而是“索引键值 主键值”。所以主键选得长不长直接影响所有二级索引的存储占用。主键用 varchar(64) 的 UUID和用 bigint 自增二级索引叶子节点每行可能多出几十字节大表累计起来非常可观。同理如果主键是 UUID 这类无序值插入时因为新记录的主键落在已有索引树的随机位置很容易引发页分裂和索引碎片导致写入性能波动。这也是我对自增主键偏爱的一个原因。注意这里说的是 InnoDBMyISAM 没有聚簇索引它的索引和数据文件分离索引叶子节点存的是行指针数据行的物理存储顺序跟索引键完全没关系。3.2 二级索引为什么要回表覆盖索引怎么避免回表在二级索引上查询整行数据会有一个“回表”动作先在二级索引 B树上找到匹配的叶子节点拿到主键值再用主键去聚簇索引树上定位真正的数据行。整个过程一般是两次 B树搜索加一次额外读取所以二级索引查询天然比主键查询多一步这也是很多文档里说“能用主键查就别用辅助索引查”的由来。理解了回表就自然理解覆盖索引的价值。如果一条查询所需的所有列都能在二级索引的叶子节点里找到那就不需要回表这种索引就叫覆盖索引。最典型的例子有一张订单表带索引 idx_user_create(user_id, create_time, status)执行 SELECT user_id, create_time, status FROM orders WHERE user_id 1024 AND create_time 2024-01-01。因为查询列都在 idx_user_create 这棵二级索引树上MySQL 扫索引过程中直接返回结果一次回表都不用做。这才是真性能优化——不是单纯“加了索引变快”而是减少了整个查询的 I/O 链路。所以 SQL 优化的一个重要习惯是不光看 WHERE 条件能不能用索引还要看 SELECT 的列能不能被索引覆盖。条件索引和覆盖索引经常可以合体设计成联合索引这也是复合索引设计的常见思路。我一般会在 EXPLAIN 结果里盯着 Extra 列看有没有 Using index看到这两个字就说明这条查询已经走了覆盖索引如果看到 Using filesort、Using temporary说明某些环节还需要优化后面第 4 章、第 5 章会重点讲这些 Extra 提示。4. 复合索引与排序优化最左前缀原则、前缀索引、索引排序4.1 最左前缀原则与复合索引字段顺序设计复合索引也叫联合索引是 MySQL 索引里使用频率最高、也最容易出问题的一类。它的核心规则是最左前缀原则复合索引 (a,b,c) 相当于同时支持 (a)、(a,b)、(a,b,c) 三种前缀组合的检索但跳过了 a 直接用 b 或 c索引就失效。举个例子表里有索引 idx_ab(category_id, create_time)那么 SELECT * FROM products WHERE category_id 3 AND create_time 2024-01-01 可以走这个索引因为条件里覆盖了最左列 category_id但 SELECT * FROM products WHERE create_time 2024-01-01 没法走 idx_ab因为最左列没出现MySQL 只能全表扫或走别的单列索引。这里要专门强调一点所谓“最左”指的是索引定义里的列顺序跟 SQL 里条件的书写顺序无关优化器会自己按索引列顺序去匹配条件。设计复合索引时字段顺序一般按两个维度权衡一是区分度高的列放前面这样索引树能更快收敛二是结合查询频率高频等值条件列放最左范围条件放后面。还有一个容易被忽略的点如果 WHERE 里同时有等值条件和范围条件比如 a1 AND b100 AND c5复合索引建议把等值列 a、c 放在前范围列 b 放最后。因为如果范围列在中间范围条件之后的其他字段就用不上索引排序了。这个顺序问题没有绝对公式核心是拿着实际业务 SQL 去模拟而不是背口诀。提示排查复合索引失效时先确认 WHERE 条件里是否包含索引定义的第一个列。第一个列都没出现后面设计得再合理也白搭。4.2 前缀索引用“砍短”的列做索引省空间还能保性能前缀索引是另一个容易被忽视的分支。当我们对一个大文本字段比如用户地址、商品描述建索引时如果建全列索引索引体积大、写入开销高而且 B树每层能容纳的键值数变少树可能变高。这时候可以只取前 N 个字符建索引比如 ALTER TABLE users ADD INDEX idx_address(address(30))只针对 address 字段前 30 个字符建索引。这么做的收益是索引体积显著减小写入和查询的开销都下降代价是区分度可能下降——如果前 30 个字符大部分都相同比如全是“某省某市某区”开头索引选择性就差可能扫出大量重复前缀再做过滤。选 N 的时候我习惯用 SQL 对比不同前缀长度下的区分度比如统计 DISTINCT LEFT(address, n) 和 DISTINCT LEFT(address, n10) 的比例平衡在 90% 以上再定。还要注意前缀索引没法用于覆盖索引场景因为叶子节点没存完整列值SELECT 里带了该列就必须回表。对超长字符串字段如果业务上查询基本都是等值匹配另一个方案是建冗余 hash 列做索引把字符串算一个固定长度 hash 存到新列再用新列做唯一索引或普通索引。这个思路在精确匹配场景下比前缀索引更稳定缺点是表结构要多出一列写入时代码也要跟着维护。4.3 ORDER BY 什么时候能走索引排序优化关键点索引不仅能加速 WHERE还能加速 ORDER BY。因为在 B树里数据本身就是按索引键有序的如果排序字段刚好是索引的一部分MySQL 可以直接按索引顺序读取避免额外的文件排序filesort。判断方法还是看 EXPLAIN 的 Extra 列如果出现 Using filesort通常意味着排序没法完全利用索引MySQL 得先把结果取出来再做一次排序。举一个典型例子。表 orders 有复合索引 idx_user_create(user_id, create_time)执行 SELECT * FROM orders WHERE user_id 100 ORDER BY create_time DESC。因为 WHERE 命中了索引首列 user_idORDER BY 的 create_time 又是索引第二列数据本来就是按 (user_id, create_time) 排序的所以这个查询可以直接按索引倒序扫Extra 里不会出现 Using filesort。相反的如果你写 ORDER BY create_time ASC没带 user_id 条件或者排序字段跟索引顺序不一致那大概率就只能 filesort 了。关于文件排序本身还有个参数值得提sort_buffer_size。filesort 时 MySQL 会先尽力在排序缓冲区内完成如果排序数据量超过缓冲区就会生成临时文件做归并排序性能明显下降。所以对超大结果集的分页排序我一般会限制查询返回行数或者把排序条件跟覆盖索引结合。不过有一说一filesort 不等于灾难数据量小时几十毫秒内就能完成很多情况下为了消除 filesort 而硬加索引反而浪费写资源。要不要为了排序建索引得算清楚查询频率和写入频率的账。5. 索引失效场景与排障哪些 SQL 写法让你建的索引白搭5.1 最常见的五种索引失效写法我面试时爱问、平时排查慢 SQL 也总遇到的一类问题就是索引失效。建了索引查询还是慢大部分原因出在写法上。下面把最常见的几种索引失效场景列出来都是可以直接对照自查的实践结论。第一条件列上做函数运算或表达式计算。比如 WHERE YEAR(create_time) 2024这种写法让 create_time 上的索引完全失效因为 MySQL 无法采用原始列的 B树键值来匹配计算结果。解决办法是改成 create_time 2024-01-01 AND create_time 2025-01-01 这种范围条件索引就能用上。第二隐式类型转换。比如索引列是 varcharSQL 里传了数字 123MySQL 会尝试把列转成数字做比较导致列上发生隐式转换索引失效。排查时注意字段类型和参数类型的一致性这个坑在联表 join 时尤其常见两边 join 字段类型不一致不但索引失效还容易引发全表扫描。第三LIKE 以 % 开头。WHERE name LIKE %abc因为需要匹配以 abc 结尾的任意字符串没法从前缀开始定位索引失效但是 LIKE abc% 是可以走索引的因为 B树能按 abc 前缀扫描。第四OR 连接时一侧没索引。WHERE a 1 OR b 2除非 a、b 都有索引否则其中一侧是全表扫描整个查询通常就被拖垮。更稳的做法是拆成两个查询用 UNION ALL 合并或者对 b 也建索引。第五对索引列使用 !、NOT IN、IS NOT NULL 等否定条件。这类条件的匹配方式让 B树很难精准定位优化器大概率选择全表扫。实际业务中如果真的需要用排除条件可以考虑改写为正向条件或者用其他过滤条件把结果集先缩到足够小。5.2 实操心得建索引前必须问自己的 5 个问题这一节是我这些年踩坑攒下来的实操清单。每次有人拿着慢 SQL 来问“要不要加索引”我基本就是按这个思路走一遍比直接拍脑袋建索引靠谱得多。第一这条 SQL 查了多少行、满足条件的数据量占全表比例多大如果 SELECT 要返回的行数占全表 20% 以上B树索引基本没优势优化器甚至更倾向全表扫描这时候建索引可能是白费。第二查询字段能不能被覆盖索引包含如果 SELECT 列表里全是高频查询字段考虑把条件列和返回列一起做进复合索引用覆盖索引省掉回表。第三字段本身的区分度够不够性别、状态这类枚举值很少的列索引选择性差单独建索引价值很低加进复合索引当最前列意义也有限。第四写入代价能承受吗每多一个索引INSERT/UPDATE/DELETE 都要额外维护一棵树一张写多读少的热点表索引数量必须克制。第五是不是已经有可以合并的冗余索引有了 (a,b) 还要不要再建 (a)有了单独索引还要不要再加 (a,b)这类冗余排查建议定期做用 SHOW INDEX FROM table 把表的所有索引拉出来审视。提示遇到“加了索引还是慢”第一件事永远是跑 EXPLAIN 看执行计划而不是凭猜测继续加新索引。看 key 列有没有用上预期的索引、rows 列估算行数是否明显变小、Extra 列有没有 Using filesort 或 Using temporary这三个指标都正常了再测真实耗时才有意义。最后还有一个我特别想强调的现在 MySQL 8.0 的优化器已经相当成熟同样的 SQL 在不同数据分布下执行计划可能完全不同建完索引先跑一遍 EXPLAIN 验证而不是靠嘴上说“肯定走索引了”。执行计划才是真正的事实依据。5.3 索引相关常见问题速查表最后把这些年经常被问到的索引相关问题整理成速查表方便大家直接对号入座。为了节省篇幅每个问题我只给结论和关键动作原理前面章节都已经覆盖到了。问题核心结论建议动作一张 InnoDB 表能建几个聚簇索引只能有一个主键充当明确指定主键避免隐藏聚簇索引主键和唯一索引能互相替代吗不能物理存储作用不同保留主键业务唯一键另建唯一索引为什么二级索引更新时既锁二级索引项又回表锁主键更新二级索引列需要维护索引项随后回表定位并更新主键所在的聚簇索引行两个锁获取顺序不一致时可能形成交叉等待尽量让更新语句走覆盖索引或减少回表必要时优化事务顺序覆盖索引一定比回表快吗通常是的但要看数据量和缓存高频查询优先设计覆盖索引前缀索引能用于覆盖索引吗不能叶子节点没存完整列精确匹配可考虑冗余 hash 列OR 条件可以走索引吗如果两侧都有可用索引可能走索引合并否则全表扫拆 UNION 或用复合索引覆盖复合索引缺了最左列一定失效吗一定除非有另一个匹配的索引按实际查询模式设计索引顺序order by 字段不在索引里会产生 filesort高频排序场景把排序列合并进索引这张表不敢说覆盖所有场景但应付绝大多数面试和实际排障已经够了。重点还是要强调最后一个动作——EXPLAIN 是行走的真相凡是拿不准的 SQL跑一遍看执行计划再下结论。最后聊点个人体会。索引这个东西单看理论很容易理解但真正用好靠的是“读场景”和“看执行计划”这两个习惯。我见过太多人把索引当银弹恨不得给每一列都建索引结果写入性能掉得厉害也见过有人因为惧怕索引维护成本建索引时缩手缩脚一条 5 秒的慢 SQL 硬扛了半年。合理的做法永远是折中先跑 EXPLAIN 确认瓶颈是不是在索引再用覆盖索引、复合索引精确解决最后通过压测验证写入侧的代价。能把这一步做好MySQL 性能调优的基本盘就稳住了这也是我对 MySQL 索引导航看法的核心落点。