联合索引全解析:一棵树,撑起查询的半边天

目录

一、为什么联合索引是MySQL性能优化的"王牌"?

(一)索引的基本结构:从聚簇到非聚簇

[1. 聚簇索引(Clustered Index)](#1. 聚簇索引(Clustered Index))

[2. 非聚簇索引(Secondary Index)](#2. 非聚簇索引(Secondary Index))

(二)不同索引的适用场景一览

[(三)联合索引的登场:为什么它是慢 SQL 的解药?](#(三)联合索引的登场:为什么它是慢 SQL 的解药?)

二、联合索引的数据结构探究

(一)联合索引的基本结构

[1. 索引存储结构](#1. 索引存储结构)

[1.1 局部预读性原理](#1.1 局部预读性原理)

为什么这样做?

[2. 📦 InnoDB 的页(Page):存储的最小单元](#2. 📦 InnoDB 的页(Page):存储的最小单元)

[2.1 页面分配过程](#2.1 页面分配过程)

[2.2 🧬 页的类型](#2.2 🧬 页的类型)

[2.3 🧱 页(Page)的内部结构:三段式布局](#2.3 🧱 页(Page)的内部结构:三段式布局)

[✏️ 具体说明](#✏️ 具体说明)

[3. 聚簇索引 vs 非聚簇索引的叶子节点访问结构](#3. 聚簇索引 vs 非聚簇索引的叶子节点访问结构)

[3.1 🔷 聚簇索引(Clustered Index)的叶子节点访问结构](#3.1 🔷 聚簇索引(Clustered Index)的叶子节点访问结构)

[✅ 页面访问结构特点](#✅ 页面访问结构特点)

[✅ 优势](#✅ 优势)

[📌 结构图示意:](#📌 结构图示意:)

[3.2 🔷 非聚簇索引(Secondary Index)的叶子节点访问结构](#3.2 🔷 非聚簇索引(Secondary Index)的叶子节点访问结构)

[✅ 页面访问结构特点](#✅ 页面访问结构特点)

[✅ 特点与影响](#✅ 特点与影响)

[📌 结构图示意](#📌 结构图示意)

[3.3 🔍 聚簇索引 vs 非聚簇索引 访问结构对比总结](#3.3 🔍 聚簇索引 vs 非聚簇索引 访问结构对比总结)

(二)B+树结构

[1. B+树简介](#1. B+树简介)

[2. 聚簇索引中的 B+树](#2. 聚簇索引中的 B+树)

[2.1 B+树访问过程与内存页加载机制](#2.1 B+树访问过程与内存页加载机制)

[2.2 启动加载与缓存驻留](#2.2 启动加载与缓存驻留)

[2.3 双向链表与范围查询优化](#2.3 双向链表与范围查询优化)

[3. 非聚簇索引中的 B+树](#3. 非聚簇索引中的 B+树)

(三)联合索引B+树结构

[1. 🌐 联合索引的底层结构与设计注意事项](#1. 🌐 联合索引的底层结构与设计注意事项)

[2. 🔍 联合索引的遍历逻辑:最左前缀原则](#2. 🔍 联合索引的遍历逻辑:最左前缀原则)

[3. 📦 磁盘页结构对索引性能的影响](#3. 📦 磁盘页结构对索引性能的影响)

[4. 🧠 业务场景启示](#4. 🧠 业务场景启示)

三、联合索引的原理与优化机制

(一)防止索引策略干扰:联合索引为何更稳定可靠

[1. ✅ 示例对比分析](#1. ✅ 示例对比分析)

[1.1 情况一:使用多个单列索引](#1.1 情况一:使用多个单列索引)

[1.2 情况二:使用联合索引 (name, age)](#1.2 情况二:使用联合索引 (name, age))

[2. ✅ 专业总结:为何联合索引能规避策略干扰?](#2. ✅ 专业总结:为何联合索引能规避策略干扰?)

[✅ 小贴士:什么是索引选择性?](#✅ 小贴士:什么是索引选择性?)

[3. ✅ 实践建议](#3. ✅ 实践建议)

(二)索引覆盖与查询优化:提高查询效率

[1. 联合索引的覆盖能力:避免回表](#1. 联合索引的覆盖能力:避免回表)

[2. 实践中的应用:如何设计合适的联合索引](#2. 实践中的应用:如何设计合适的联合索引)

[a. 确保索引覆盖所有查询字段](#a. 确保索引覆盖所有查询字段)

[b. 考虑最左前缀原则](#b. 考虑最左前缀原则)

[c. 避免冗余的索引设计](#c. 避免冗余的索引设计)

[d. 避免在联合索引中使用较大字段](#d. 避免在联合索引中使用较大字段)

(三)索引下推:提升查询效率

[1. 什么是索引下推(Index Condition Pushdown)?](#1. 什么是索引下推(Index Condition Pushdown)?)

[2. 索引下推与联合索引的结合](#2. 索引下推与联合索引的结合)

[2.1 联合索引与索引下推的优势结合](#2.1 联合索引与索引下推的优势结合)

[2.2 示例演示:联合索引与索引下推](#2.2 示例演示:联合索引与索引下推)

[3. 索引下推的工作原理与实现](#3. 索引下推的工作原理与实现)

[3.1 索引下推如何减少磁盘I/O](#3.1 索引下推如何减少磁盘I/O)

[3.2 性能提升:案例分析](#3.2 性能提升:案例分析)

[4. 启用和查看索引下推功能](#4. 启用和查看索引下推功能)

[4.1 如何启用索引下推](#4.1 如何启用索引下推)

[4.2 配置索引下推](#4.2 配置索引下推)

四、联合索引的落地实践

(一)联合索引"最左前缀"原则

(二)利用联合索引有效避免索引策略干扰

(三)数据极端分布下联合索引的作用

(四)聚合操作场景

(五)多表关联查询

[1. MySQL join算法](#1. MySQL join算法)

[1️⃣ Index Nested Loop Join(索引嵌套循环连接)](#1️⃣ Index Nested Loop Join(索引嵌套循环连接))

[2️⃣ Block Nested Loop Join(块嵌套循环连接)](#2️⃣ Block Nested Loop Join(块嵌套循环连接))

[2. 多表查询中联合索引提升join算法查询效率](#2. 多表查询中联合索引提升join算法查询效率)

[3. 联合查询中的排序字段对执行计划的影响分析](#3. 联合查询中的排序字段对执行计划的影响分析)

[查询语句 A(按 area.id 排序):](#查询语句 A(按 area.id 排序):)

[查询语句 B(按 config.id 排序):](#查询语句 B(按 config.id 排序):)

[🎯 小结](#🎯 小结)

五、总结:联合索引,不止是"组合字段"这么简单


干货分享,感谢您的阅读!

你以为联合索引只是"多个字段排排坐"?不,它其实是数据库界的"特种兵小队"------协同作战、擅打硬仗、执行精准!别看它低调,关键时刻能让查询飞起、慢 SQL 秒跪。本文就带你拆解这支神秘部队的战术编排,看看它是如何在亿级数据中"以一敌百"。

一、为什么联合索引是MySQL性能优化的"王牌"?

在日常开发中,只要接触 MySQL,就绕不开"索引"这个话题。而一提到索引,很多人脑子里马上想到的可能是"建索引能加速查询"。但现实情况往往没那么简单:建索引是门技术活,不合适的索引不仅不会提速,反而可能拖慢性能,甚至引发慢查询。

在所有索引类型中,联合索引(Composite Index)因其高灵活性、适用面广而被广泛应用,几乎成了每个业务系统必备的性能优化手段。但你是否真正了解,联合索引到底是怎么工作的?它适合哪些场景?什么时候会失效?又该如何正确使用?

本节我们就从 MySQL 的索引分类入手,逐步剖析联合索引的结构、原理和优势,为后面深入讨论其在实战中的应用打好基础。

(一)索引的基本结构:从聚簇到非聚簇

MySQL 中的索引,其背后核心的数据结构是 B+ 树,但根据数据与索引的物理存储方式不同,又可以分为以下两种类型:

1. 聚簇索引(Clustered Index)

  • 概念: 聚簇索引是指数据行的物理顺序和索引顺序一致,索引的叶子节点上就直接存储了整行的数据。这意味着,聚簇索引既是索引又是数据本身,它们是合并在一起的。

  • InnoDB中的特性: 在 InnoDB 引擎中,每张表只能有一个聚簇索引,通常就是主键索引。如果你没设置主键,InnoDB 会帮你选择一个唯一非空的字段作为主键;如果没有唯一字段,它甚至会隐式创建一个内部主键。

  • 优点: 查询主键非常快,不需要"回表"操作。

  • 缺点: 只能有一个,且一旦主键频繁更新或插入顺序不合理,会导致页分裂、磁盘碎片,影响性能。

2. 非聚簇索引(Secondary Index)

  • 概念: 非聚簇索引的叶子节点并不包含完整的行数据,而是存储了对应主键的值。要获取完整数据,MySQL 需要再通过主键索引进行一次查找,这一步叫做"回表"。

  • 优点: 可以为表中多个列单独创建多个非聚簇索引,查询灵活。

  • 缺点: 如果查询字段不在索引覆盖范围内,都会触发回表,影响查询效率。

(二)不同索引的适用场景一览

索引类型 概念简介 适用场景
主键索引 表的唯一标识,是聚簇索引 精确查找、主键排序
唯一索引 保证某列值唯一,允许 NULL 唯一约束、邮箱、用户名等
普通索引 最基础索引结构,通常单列 范围查询、模糊匹配
全文索引 针对文本内容的关键词检索 文本搜索、文章匹配
联合索引 多列组合成一个索引结构 多条件查询、范围+排序、覆盖查询

(三)联合索引的登场:为什么它是慢 SQL 的解药?

联合索引(Composite Index)是把多个列组合在一起,按照固定顺序建立一个 B+ 树索引结构。

这种索引能极大提高多条件查询的效率,是现在互联网业务中使用频率最高的索引类型之一。

  • 高适配性: 可以覆盖多个列的查询条件,减少回表次数。

  • 高性能: 支持覆盖索引,避免访问主键索引,减少 I/O。

  • 低局限性: 在组合顺序设计合理的前提下,适配各种业务逻辑。

但同时,联合索引也很容易"被用错":

很多业务表都建了联合索引,但不少情况下查询并没有命中索引,甚至导致慢查询。这往往是因为开发人员不了解 最左前缀匹配原则索引下推策略,或者未考虑字段顺序对索引命中的影响。

因此,下一部分我们会重点展开讲解:联合索引在结构上的特点、在实际业务(如美团外卖广告系统)中的使用策略,以及如何规避常见的使用误区。

二、联合索引的数据结构探究

(一)联合索引的基本结构

1. 索引存储结构

在理解联合索引如何提高查询效率之前,首先需要了解它背后的存储结构。索引的底层存储方式直接影响着查询的性能和效率。通过深入分析InnoDB的存储结构,我们可以更清楚地理解为什么联合索引能够在多条件查询中提供优势。

InnoDB中的索引结构分为多个层次,其中包含 (segment)、 (extends,有些文献也称为区)和(page)。

1.1 局部预读性原理

每一层次的结构都与磁盘I/O操作和局部预读性原理相关。

  • 段(Segment):段是磁盘存储的顶层结构,包含多个簇。每个段用于存储一个特定类型的数据,如数据表的实际数据或索引数据。

  • 簇(Extends/区):簇是分配给一个表或索引的连续的存储空间单位,通常由64个页面组成。簇的作用是管理存储的分配,每个簇会根据需要从段中分配页面。当簇满时,系统会创建一个新的簇继续分配空间。

  • 页面(Page):页面是InnoDB磁盘存储的最小单位,每个页面大小通常是16KB。页面中存储的数据可能是表中的实际数据,也可能是索引数据(如B+树节点)。通过页面的组织,数据库可以有效地管理和检索数据。

局部预读性原理是指计算机在进行数据读取时,会不仅仅读取请求的数据,还会读取与请求数据相邻的数据。这是操作系统为了提高数据读取效率所采取的一种策略。由于操作系统通过 I/O 操作进行磁盘数据的加载,因此系统会更倾向于读取一个范围内的数据,而不是随机读取不相关的数据。

为什么这样做?
  • 内存到CPU的局部预读性 :当CPU从内存中读取数据时,数据通常会以缓存行为单位进行预读取。这是因为相邻的内存地址数据很可能被后续的计算访问到,读取相邻数据能减少CPU的等待时间,提高效率。

  • 磁盘到内存的局部预读性 :磁盘在与内存进行数据交换时,会按照内存页 来读取数据。磁盘中的数据被分成多个页面(例如16KB一个页面),操作系统会一次性将一个页面的数据加载到内存。这种加载方式并非只是加载单一的数据项,而是加载整块数据,以提高读取数据的效率,因为相邻数据的读取往往是需要的。

2. 📦 InnoDB 的页(Page):存储的最小单元

在 InnoDB 存储引擎中,页(Page)是磁盘管理的最小单位 ,也是B+树索引节点的实际物理载体。每一个页的大小固定为 16KB,用于存放表中的记录、索引结构,或其他辅助系统信息。

页面编号(Page Number)在逻辑上和物理上都是连续递增的 ,即编号是线性增长的,这样设计可以更好地利用局部预读性(例如读取一个页面时顺带读取相邻页面,从而提升性能)。

2.1 页面分配过程

当你向表中插入新数据时,InnoDB 的页面分配策略如下:

  • 如果当前页面已写满:系统会在当前簇中寻找下一个空闲页面

  • 如果当前簇中的 64 个页面都用完了:InnoDB 会从当前段中分配一个新的簇,然后从新簇中继续分配页面。

  • 所有这些空间的动态扩展都由InnoDB自动完成。

这种按簇批量分配页面的策略,正是为了更好地支持局部预读性,减少频繁的磁盘I/O。

2.2 🧬 页的类型

不同用途的页在结构和用途上有所区别。主要包括:

页类型 名称 说明
BTreeNode 数据页 用于存储索引结构和表数据(主键或辅助索引的B+树节点)
Undo Log Page Undo页 存储回滚日志信息,支持事务的原子性
System Page 系统页 存储内部元数据,例如空间目录
Transaction System Page 事务页 管理事务状态
2.3 🧱 页(Page)的内部结构:三段式布局

每个16KB大小的页内部结构如下图所示:

✏️ 具体说明
  • 页头(38字节)

    • 存储该页的元数据,比如:页类型、页编号、前后页指针(双向链表结构)等。

    • 通过双向链表,所有页形成了一个可遍历的链表。

  • 数据区域(中间部分)

    • 用来存储实际的用户记录或系统记录:

      • 主键索引页 :叶子节点存放 key + data(整行记录)

      • 辅助索引页 :叶子节点存放 key + 主键值

      • 非叶子节点 :存放 key + page 指针

    • 数据按照单向链表结构存放,形成索引的有序结构。

  • 页尾(8字节)

    • 存放该页的校验和等信息,用于防止数据损坏。

页是InnoDB磁盘管理的基本单位,每页16KB。通过段 → 簇 → 页三级分配结构,结合局部预读性原则,InnoDB高效地管理数据和索引,保证了数据插入和查询的性能。不同类型的页承载不同的内容,比如数据页、Undo页、系统页等。而每个页面内部结构也遵循"页头 + 数据区域 + 页尾"的固定布局,为B+树索引结构提供了强有力的物理支持。

3. 聚簇索引 vs 非聚簇索引的叶子节点访问结构

在InnoDB中,索引是以B+树结构 组织的,无论是聚簇索引(Clustered Index)还是非聚簇索引(Secondary Index),其内部节点的管理都依赖于页面(Page)这一最小单位。尤其在叶子节点层面,页面间的访问结构存在明显差异,这种差异对查询路径、数据定位、甚至I/O性能都有深远影响。

3.1 🔷 聚簇索引(Clustered Index)的叶子节点访问结构

聚簇索引是InnoDB的默认索引类型,每张表都有且仅有一个聚簇索引。它的最大特点在于:B+树的叶子节点就是数据本身,即数据记录按照主键顺序存储在叶子节点上。

✅ 页面访问结构特点
  • 页面之间通过双向链表连接,即每个页面在页头中记录前向指针(Prev Page)和后向指针(Next Page)。

  • 页面内部的数据记录构成单向链表结构 ,通过记录之间的next record字段串联。

  • 整个叶子层天然形成了一个有序的数据块区间,便于范围查询(如 BETWEEN><)。

✅ 优势
  • 查询主键或主键范围时,可以通过叶子页的顺序遍历快速定位数据,无须回表

  • 范围扫描时,仅需一次B+树定位,再通过叶子页双向链表顺序拉取即可,极大提升磁盘预读效率。

📌 结构图示意:
3.2 🔷 非聚簇索引(Secondary Index)的叶子节点访问结构

非聚簇索引也基于B+树,但其叶子节点中不存储完整的数据行记录,而是存储:

  • 索引列的值(key)

  • 对应记录的主键值(rowid)

要通过非聚簇索引获取完整数据,必须通过主键回表查询

✅ 页面访问结构特点
  • 与聚簇索引一样,页面之间通过双向链表组织,用于支持范围扫描。

  • 页内部的记录依旧以单向链表结构串联

  • 但叶子节点中存放的是索引项,而不是整行数据。

✅ 特点与影响
  • 由于叶子页只保存索引项 + 主键值,因此在索引列不能完全满足查询字段需求时,需要"回表"操作。

  • 范围查询依然能通过链式结构支持预读,但比聚簇索引多了一跳回表I/O,性能可能受限。

📌 结构图示意
3.3 🔍 聚簇索引 vs 非聚簇索引 访问结构对比总结
对比维度 聚簇索引叶子节点 非聚簇索引叶子节点
页面间结构 双向链表 双向链表
页面内结构 单向链表(数据记录) 单向链表(索引项+主键)
存储内容 主键+整行记录 索引列+主键值
查询完整行 直接命中 需要回表
范围查询效率 次之(受限于回表)

(二)B+树结构

在 MySQL 的 InnoDB 存储引擎中,索引的底层实现采用的是 B+树结构。无论是主键索引(聚簇索引)还是二级索引(非聚簇索引),都基于 B+树来组织数据。而在理解联合索引之前,首先需要厘清 B+树的基本结构及其在聚簇索引与非聚簇索引中的应用差异。

1. B+树简介

B+树是一种多路平衡查找树,它具备以下关键特征:

  • 所有的数据记录都保存在叶子节点

  • 非叶子节点不存储实际数据,只存储索引键(主键值)和指向子节点的指针

  • 叶子节点之间通过双向链表连接,便于范围查询;

  • B+树具有良好的磁盘读写局部性IO效率,是数据库系统中普遍使用的数据结构。

这部分在历史博客中已经讲解多次了,可以翻一下专栏,这里不展开说了。

2. 聚簇索引中的 B+树

如图所示,聚簇索引的 B+树结构具有以下特点:

  • 非叶子节点(如图中的磁盘块1~3)仅存储主键值和子节点指针,用于导航;

  • 叶子节点(如磁盘块4~7)存储的是整行数据,也即数据页。每条记录以主键作为顺序排列的键;

  • 叶子节点之间通过双向链表相连,实现范围扫描能力;

  • 插入数据时,数据会按照主键顺序插入到相应叶子节点中,确保树结构的有序性。

聚簇索引的关键特性在于:数据行存储于叶子节点中,B+树的叶子节点本身就是数据页。因此,表中数据的物理存储顺序是按照主键排序的,这对于主键范围查询性能有极大提升。

2.1 B+树访问过程与内存页加载机制

根据磁盘预读性原理,数据库在访问 B+树结构时,底层会以磁盘块(Disk Block)为单位从磁盘读取数据,然后将其加载到内存中,由于磁盘访问的速度远慢于内存,因此这种基于页的预读策略对于提升查询性能至关重要。

上图展示了 B+树从磁盘到内存加载的过程,以及对应的访问路径:

  • 磁盘块与内存页的映射 :InnoDB 中磁盘的数据页大小固定为 16KB,内存中以相同大小的页(Page)管理。图中橙色区域表示磁盘层 ,黄色区域表示内存层

  • 访问路径

    • 访问数据行时,首先从磁盘块1加载根节点(图中绿色框)到内存;

    • 然后根据索引键查找,依次向下读取子节点(磁盘块2~4);

    • 最终定位到叶子节点所在的磁盘块(磁盘块5~7);

    • 叶子节点中存储的是主键信息和整行数据,如图中张三、李四的数据。

每一次访问路径上的磁盘块,都会触发一次 IO 操作,系统会尝试将磁盘块预读并驻留在内存中,避免重复 IO。

2.2 启动加载与缓存驻留
  • 启动加载:在数据库启动阶段,InnoDB 会尝试将 B+树的根节点等关键索引页预先加载至内存,常驻以提高后续访问效率;

  • 页缓存机制(Buffer Pool):这些被加载入内存的页将驻留在 InnoDB 的 Buffer Pool 中,并参与 LRU 策略管理;

  • 页面结构一致性:为了便于在内存中统一管理磁盘页与内存页,InnoDB 将页大小设置为一致,即磁盘页与内存页容量严格匹配(均为16KB),方便直接映射。

2.3 双向链表与范围查询优化

如图所示,叶子节点中的记录通过双向链表 连接起来(ID=1 张彦峰 ↔ ID=100 程琦),这使得范围查询(如 WHERE id BETWEEN 10 AND 100)不需要回溯上层索引,而是可以在叶子节点链表中线性遍历,大幅提升查询效率。

3. 非聚簇索引中的 B+树

与聚簇索引不同,非聚簇索引的叶子节点中不存储完整数据行,而是存储主键值作为"指针",用于回表查询。这一特性使得非聚簇索引相比聚簇索引更小、更浅,但需要通过主键再次定位数据(回表)才能取到完整记录。

也就直接在下图中进行了标注,请注意数据内容的替换(绿色的数据更变为了红色):

非聚簇索引在磁盘加载机制上与聚簇索引基本一致,依然采用 B+ Tree 结构。在 InnoDB 中,B+ Tree 的根节点在数据库实例启动时就会被加载进缓冲池(Buffer Pool)并常驻内存,以加快索引定位效率。其他非叶子节点与叶子节点则采用按需加载策略,即只有在访问时才从磁盘读取对应的数据页加载至内存中。具体如下:

与聚簇索引的区别在于:非聚簇索引的叶子节点中不存储完整的行数据,而是存储索引列的键值及对应记录的主键值(即 RowID) 。因此,在使用非聚簇索引进行查询时,若查询列不包含在索引中,就需要根据主键值回表到聚簇索引中进一步查找行数据。这种回表行为使得非聚簇索引的叶子节点在加载时,其主要作用是提供回表所需的主键定位信息,而不是数据本身。

(三)联合索引B+树结构

下图展示的是一个由两个列组成的联合索引的 B+ 树结构。在该结构中,非叶子节点(内部节点)中存储的是索引键值组合(即联合索引键的前缀)与指向子节点的页号(Page Number)作为指针,用于定位下一层索引页。索引键值按照联合索引定义的列顺序进行字典序排列。

叶子节点同样存储联合索引键值组合,但其指针部分不再指向下一级索引页,而是指向对应数据行所在的聚簇索引中的主键(RowID 或聚簇索引页地址)。因此,联合索引的叶子节点不直接包含完整的行数据,而是通过主键指针支持回表查询。

1. 🌐 联合索引的底层结构与设计注意事项

在 MySQL 的 InnoDB 存储引擎中,联合索引的底层结构依然采用 B+ 树 ,其本质属于二级索引(非聚簇索引)。因此,B+ 树中叶子节点所存储的内容并不是完整的行数据,而是:

  • 索引键值(联合多个列的组合)

  • 以及指向主键索引记录的 RowID(即主键值),用于执行回表操作获取完整数据。

2. 🔍 联合索引的遍历逻辑:最左前缀原则

联合索引中每条索引记录的键值,是按照创建索引时指定的列顺序依次组合而成的。例如:

sql 复制代码
KEY idx_name_age_sex (name, age, sex)

B+ 树遍历的规则是:

  • 优先比较最左列(如 name);

  • 如果 name 相同,再继续比较 age

  • 若前两列都相同,最后比较 sex

  • 遍历过程从根节点逐层向下,直到叶子节点。

这也是为什么联合索引必须遵守 最左前缀匹配规则 的根本原因。

3. 📦 磁盘页结构对索引性能的影响

InnoDB 将磁盘划分为固定大小的数据页(Page),每页默认大小为 16KB。每个页中存储的是若干条"键值 + 指针"组合。假设:

  • 每条索引键值及其指针约占 10 字节;

  • 那么每页大约可以容纳 16KB / 10 ≈ 1600 条记录

  • 若 B+ 树高度为 3(常见场景),则最多可管理 1600 × 1600 = 约 250 万个叶子节点

  • 每个叶子节点又可存储多个行指针,因此总共可支持的数据量非常庞大。

然而,如果你的联合索引字段中包含了如 TEXTVARCHAR(255) 或 JSON 字段这类长度大、变动频繁的列,每个索引项的大小可能远超 10 字节。结果就是:

  • 每个磁盘页可容纳的索引项数量大幅减少;

  • B+ 树为了保持数据组织,会被迫增加更多的节点或提高树高

  • 这直接导致:更多磁盘页、更深的树、更高的磁盘 I/O 次数

  • 从而拖慢了索引遍历和数据定位的效率。

4. 🧠 业务场景启示

在大型互联网业务中,系统查询量大、实时性强,对索引设计提出了非常高的要求。综合以上特性,我们可以得出一些非常实用的设计建议:

  • 优先使用长度短、区分度高的字段建联合索引 ,如 user_idstatustimestamp 等;

  • 避免将 TEXTBLOB 或超长 VARCHAR 字段放入联合索引;

  • 如果需要索引部分长字段,尽量使用前缀索引(如 varchar(255) 建索引时指定 varchar(20));

  • 控制索引的数量与冗余度,避免因为"索引失控"带来维护成本和写入性能下降;

  • 联合索引的顺序要匹配实际查询场景,以最大化命中率,减少回表次数。

联合索引并非"列越多越强",它的底层结构决定了其在存储和查找过程中的性能瓶颈。一旦索引列设计不合理,索引页膨胀、B+ 树高度增加、磁盘 I/O 激增等问题都会显现,进而影响数据库整体性能。在千万级数据规模的互联网系统中,合理设计联合索引字段,控制其宽度、顺序与使用频率,才能真正发挥出联合索引的性能优势。

三、联合索引的原理与优化机制

现在我们开始系统讲解联合索引相较于单列索引的优势,覆盖联合索引在执行计划中减少策略干扰、优化回表效率、加强索引下推能力等方面的作用,并结合 MySQL 的执行逻辑,分析联合索引的使用场景和性能提升原理。

(一)防止索引策略干扰:联合索引为何更稳定可靠

在 MySQL 中,查询语句中 WHERE 条件涉及多个字段时,如果这些字段分别建立了单列索引 ,那么 MySQL 在执行计划生成阶段,并不会"天真"地遍历所有索引。相反,它会评估各个索引的选择性 (即索引字段的区分度),优先选择过滤效果最好的那一个 作为基础索引 。这种优化策略虽然能提升某些场景下的效率,但也带来了不确定性 ------ 多个单列索引可能会被"择一执行"而非联合使用,从而影响查询结果或性能表现

而联合索引结构则能有效规避这一执行策略干扰问题。由于其多个字段值是组合成一个索引键值 存储在同一个 B+ Tree 的数据域中(而非分散在多个索引结构里),因此 MySQL 在执行计划评估时会将其视为一个整体索引单位 进行优化,从而避免了被拆解或替换的风险,执行更稳定,性能也更具可预测性。

1. ✅ 示例对比分析

假设我们有如下 SQL 查询:

sql 复制代码
SELECT id, name, age, sex FROM user_table 
WHERE name LIKE '张%' AND age = 18;

此时,nameage 字段都建立了单列非聚簇索引 ,但未构建联合索引

1.1 情况一:使用多个单列索引

在这个场景下,MySQL Optimizer 会根据 nameage 各自的区分度(cardinality)决定选择哪一个作为执行的主索引路径。

例如:

  • 如果 age 字段为常用值(如 18 占比非常高),而 name 字段分布更稀疏,优化器很可能会选择 name 作为主索引;

  • 但如果两个字段的区分度接近(差异小于 1~2 个数量级),可能会采用 Index Merge 策略(即两个索引都用,结果集再合并);

  • 无论是哪种方式,都需要从选中的索引叶子节点中获取主键 ID,再回表查找完整记录,最后再根据另一个字段进行过滤。

这意味着,即使我们明确在 WHERE 条件中使用了多个索引字段,MySQL 也不一定按我们预期的"同时使用"它们来缩小数据范围,而是采用某一个或合并的方式,结果可能偏离预期。

1.2 情况二:使用联合索引 (name, age)

如果我们在 user_table 上建立了 (name, age) 的联合索引:

sql 复制代码
CREATE INDEX idx_name_age ON user_table(name, age);

此时,MySQL 在执行该 SQL 查询时,会直接从 idx_name_age 联合索引的 B+ Tree 中进行查找:

  • 首先匹配第一列 name LIKE '张%'

  • 然后在所有匹配到的 name 前缀中继续筛选 age = 18

  • 如果查询字段都在联合索引中被覆盖(如 id, name, age),甚至无需回表即可直接返回结果,极大提升查询效率。

更关键的是,联合索引不会被 MySQL 拆解成多个索引执行,而是被优化器视为一个整体路径来走,避免了单列索引中"谁更优就用谁"的决策过程。这正是所谓的:

"联合索引是一棵树,而非多棵树的竞选"

2. ✅ 专业总结:为何联合索引能规避策略干扰?

特性 多个单列索引 联合索引
是否作为一棵 B+ 树结构 各自独立构建 B+ Tree 所有字段构建在一棵 B+ Tree 中
是否可能被执行计划拆解 ✅ 可能择优选择一项 ❌ 始终整体参与执行
是否能稳定命中所有条件字段 ❌ 依赖区分度及执行计划选择 ✅ 顺序匹配字段稳定命中
是否具备索引覆盖优势 ❌ 需要多次回表 ✅ 可一键覆盖查询字段
✅ 小贴士:什么是索引选择性?

索引选择性 = 唯一值数量 / 总记录数

数值越接近 1,过滤能力越强,MySQL 越倾向选择它作为主索引路径。

例如,name 有 100 万个唯一值,age 只有 5 个值,那么 name 的选择性远高于 age,优化器更倾向于使用 name 的索引来过滤。

3. ✅ 实践建议

  • 多字段查询中,优先考虑使用 联合索引,而非依赖多个单列索引;

  • 联合索引字段顺序需符合最左前缀匹配原则;

  • 可结合 EXPLAIN 观察实际索引使用情况,验证是否命中联合索引;

  • 对于高并发读写场景下的精确匹配联合索引,可极大减少磁盘 I/O 和回表次数,提升整体吞吐。

(二)索引覆盖与查询优化:提高查询效率

索引覆盖 (Index Covering)是指查询操作中的所有列都能被一个索引覆盖并返回结果,从而避免了 回表(table lookup)操作。在传统的查询中,如果所需的数据列不在索引中,MySQL 必须先通过索引定位到数据行,然后根据主键或行指针查找对应的表记录,进行回表操作,这个过程会增加磁盘 I/O 开销,降低查询性能。

而在索引覆盖的情况下,所有查询需要的字段都已存在于索引的叶子节点中,无需回表即可直接从索引中获取所需数据。通过利用 联合索引,可以将多个字段一起存储在一个索引结构中,使得查询时能够一次性获取所有相关数据,减少了不必要的磁盘 I/O。

1. 联合索引的覆盖能力:避免回表

联合索引能够提供强大的索引覆盖能力 ,特别是在查询条件涉及多个字段时。假设你在表中创建了一个联合索引 (name, age),并且查询中涉及到这两个字段,例如:

sql 复制代码
SELECT name, age FROM table WHERE name LIKE '张%' AND age = 18;

如果表中存在 (name, age) 的联合索引,MySQL 就可以直接从该联合索引的叶子节点中提取 nameage 的值,无需回表到主键索引去获取数据行。这样,查询结果就能通过联合索引一次性获取,而不需要像非联合索引那样,通过主键指针回表查询。

这种方式的好处在于:

  • 减少回表操作:查询所需的所有字段都包含在联合索引中,避免了多次查询和磁盘 I/O。

  • 提升查询性能:通过减少回表次数,能够显著提升查询的响应速度,特别是在数据量大、查询频繁的情况下。

2. 实践中的应用:如何设计合适的联合索引

为了在实际应用中最大化利用 索引覆盖 优势,设计合适的联合索引至关重要。以下是一些实践中的设计建议:

a. 确保索引覆盖所有查询字段

在设计联合索引时,首先要确保查询条件中的所有字段都包含在联合索引的覆盖范围内。这样,MySQL 就能在索引的叶子节点中直接获取数据,避免回表。

例如,对于以下查询:

sql 复制代码
SELECT name, age, sex FROM table 
WHERE name LIKE '张%' AND age = 18 AND sex = '男';

如果我们创建一个联合索引 (name, age, sex),则 MySQL 会直接从联合索引的叶子节点中获取所有字段的值,无需回表。

b. 考虑最左前缀原则

MySQL 的联合索引遵循 最左前缀匹配原则 。也就是说,查询条件必须与索引字段的顺序一致,才能有效利用联合索引。如果查询条件中只使用了索引字段的前几个(例如只用 nameage),而不包括后续字段(例如 sex),则 MySQL 只能利用联合索引中的部分字段。

因此,创建联合索引时,应根据常用查询的字段顺序来安排索引字段,以确保查询时能够顺利命中索引。

c. 避免冗余的索引设计

虽然联合索引可以提高查询性能,但不应随意创建冗余的联合索引。例如,如果已经有一个 (name, age) 的联合索引,再创建一个 (name, age, sex) 的联合索引就可能显得冗余。要根据具体的查询需求,合理选择字段并避免过多的联合索引。

d. 避免在联合索引中使用较大字段

如果某些字段的长度较大(例如 TEXT 类型或长 VARCHAR 字段),那么它们不适合作为联合索引的组成部分。长字段会导致每个索引项占用较大空间,影响查询性能,并增加磁盘 I/O 和内存使用。应尽量避免将大字段放入联合索引中。

(三)索引下推:提升查询效率

索引下推(Index Condition Pushdown,ICP),该机制通过将查询条件的过滤操作从 MySQL 服务器层推送到存储引擎层,显著减少了内存中的数据加载量,从而提升了查询性能。我们将详细讲解索引下推的工作原理,结合实际应用示例,分析如何利用它与联合索引结合,提高查询效率,尤其是在高并发、大数据量的场景中。

1. 什么是索引下推(Index Condition Pushdown)?

索引下推 是 MySQL 中的一项查询优化技术,它将查询条件的过滤操作从 服务器层 推送到 存储引擎层,使得存储引擎可以在读取数据时就完成条件筛选,从而减少不必要的数据加载。这样,存储引擎只会将符合查询条件的数据返回给 MySQL 服务器,显著降低内存和磁盘 I/O 的开销。

传统查询中的数据加载流程是:

  • 服务器层生成查询执行计划并通过索引定位数据;

  • 数据页加载到内存;

  • 服务器层再次进行条件过滤,筛选符合条件的数据。

索引下推 将第二步的条件过滤操作提前到存储引擎进行。通过提前筛选,存储引擎只将符合条件的数据行加载到内存,从而减少了大量不必要的数据传输和处理过程。

2. 索引下推与联合索引的结合

2.1 联合索引与索引下推的优势结合

联合索引索引下推 的结合使得查询优化更加高效。假设我们有一个联合索引 (name, age),并且查询条件正好是关于这两个字段的筛选:

sql 复制代码
SELECT name, age FROM users WHERE name = '张彦峰' AND age = 30;
  • 索引下推 会使得 MySQL 在存储引擎层完成条件 name = '张彦峰' AND age = 30 的筛选,而不是在查询执行后再筛选。

  • 联合索引将两个字段存储在同一个索引结构中,使得 MySQL 可以直接在索引层完成数据过滤,无需扫描整个数据页。

通过结合联合索引,索引下推不仅能提高查询效率,还能大幅减少数据加载量和回表操作,特别是在大型数据表中,能够显著提升查询性能。

2.2 示例演示:联合索引与索引下推

考虑如下查询:

sql 复制代码
SELECT name, age FROM users WHERE name LIKE '张%' AND age = 30;

如果在 users 表上创建了一个 (name, age) 联合索引,且开启了索引下推,则执行步骤如下:

  • 索引扫描 :MySQL 通过联合索引 (name, age) 进行扫描,并根据 name LIKE '张%'age = 30 的条件在存储引擎层完成过滤;

  • 数据加载 :只有符合查询条件的记录(即 name = '张%'age = 30)会被加载到内存,其他不相关的记录会被直接跳过。

在这一过程中,索引下推减少了对不符合查询条件的数据的加载,从而提高了查询速度。

3. 索引下推的工作原理与实现

3.1 索引下推如何减少磁盘I/O

索引下推能够减少磁盘 I/O,因为它避免了在内存中加载不符合条件的数据。通常,在没有索引下推的情况下,MySQL 会将整个数据页加载到内存中,然后在服务器层进行条件过滤。而在索引下推的情况下,存储引擎仅加载符合查询条件的数据行,从而减少了不必要的数据加载。

这种优化尤其在以下场景中有效:

  • 范围查询 :例如,查询 age > 30 的记录时,索引下推可以直接在存储引擎层筛选出符合条件的记录,而不需要加载整个数据页;

  • 复杂条件查询 :如 WHERE name = '张彦峰' AND age > 30,索引下推能够将多个条件过滤操作提前进行,减少数据传输量。

3.2 性能提升:案例分析

假设我们有一张 users 表,包含上百万条记录,查询条件为 WHERE name LIKE '张%' AND age = 30,并且在 nameage 字段上创建了联合索引。在启用索引下推的情况下:

  • 存储引擎会提前根据 name LIKE '张%' AND age = 30 筛选出符合条件的记录;

  • MySQL 只会加载符合条件的记录,减少了无关数据的读取。

这一优化显著减少了查询的 磁盘 I/O,尤其是在查询条件范围较广或数据量较大的情况下,性能提升尤为明显。

4. 启用和查看索引下推功能

4.1 如何启用索引下推

在 MySQL 5.6 及以上版本,索引下推 默认是启用的,但我们可以通过以下命令检查其状态:

sql 复制代码
SHOW VARIABLES LIKE 'optimizer_switch';

检查结果中的 index_condition_pushdown 参数是否为 ON,表示索引下推已启用。如果值为 OFF,则说明该功能未启用。

4.2 配置索引下推

若想禁用索引下推,可以在 MySQL 配置文件中设置如下:

sql 复制代码
[mysqld] optimizer_switch='index_condition_pushdown=off'

或者可以在会话级别进行配置:

sql 复制代码
SET SESSION optimizer_switch='index_condition_pushdown=off';

四、联合索引的落地实践

我们将结合实际的业务慢查询优化经验,通过一系列慢查询优化案例来探讨联合索引的使用注意事项,并分享我们在实际工作中遇到的一些问题。通过这些案例,大家将更清楚地了解如何在特定场景下使用联合索引来提升查询效率。

(一)联合索引"最左前缀"原则

在讨论 MySQL 联合索引时,最左前缀匹配原则是一个无法回避的话题。该原则指明在检索数据时,查询会从联合索引的最左侧字段开始匹配。因此,在创建联合索引时,必须考虑查询场景中能够覆盖的字段顺序。接下来,我们将通过一个示例,结合 B+树的原理,来解析为什么联合索引会遵循最左匹配原则。测试表结构如下:

sql 复制代码
CREATE TABLE `status_test` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `sku_id` bigint(20) NOT NULL COMMENT '商品ID',
  `poi_id` bigint(20) NOT NULL COMMENT '门店ID',
  `date_status` bigint(20) NOT NULL COMMENT '是否生效',
  `period` varchar(100) NOT NULL COMMENT '生效时间段',
  `nums` int(11) NOT NULL DEFAULT '1',
  `ctime` int(11) NOT NULL COMMENT '创建时间',
  `utime` int(11) NOT NULL COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_sku` (`sku_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 
DEFAULT CHARSET=utf8mb4 
COLLATE=utf8mb4_unicode_ci COMMENT='SKU生效日期';

我们建立`date_status`+`poi_id`+`nums`的联合索引,按照"最左匹配"原则相当于我们对 (`date_status`)、(`date_status`,`poi_id`)和(`date_status`,`poi_id`,`nums`)创建了索引,但是并未对(`poi_id`)和(`poi_id`,`nums`)建立索引。

接下来,来看三个查询语句:

  1. SELECT * FROM status_test WHERE poi_id = 607002 LIMIT 100;

  2. SELECT * FROM status_test WHERE date_status > 0 AND poi_id = 607002 LIMIT 100;

  3. SELECT * FROM status_test WHERE date_status = 1 AND poi_id = 607002 LIMIT 100;

上述三个查询语句中:

  • 对于第一个查询 poi_id = 607002,由于并没有单独为 poi_idpoi_id, nums 创建索引,MySQL 只能执行全表扫描。尽管我们有 poi_idnums 的联合索引,但由于查询条件没有从联合索引的最左前缀字段(即 date_status)开始,它无法利用这个索引进行有效的查找。

  • 对于第二个查询,date_status > 0 AND poi_id = 607002,它能命中 date_status 索引,但由于索引是按顺序从最左前缀开始匹配的,查询会首先通过 date_status 索引过滤出符合条件的数据。然后,尽管 poi_id 被查询,但由于 poi_id 是索引的第二个字段,它并未完全匹配索引的前缀部分,因此 MySQL 无法继续使用联合索引,而是退回到全表扫描。

  • 对于第三个查询,date_status = 1 AND poi_id = 607002,由于查询字段顺序与联合索引的最左前缀(date_status, poi_id)匹配,因此它能够完全命中该索引,在 date_statuspoi_id 的索引中直接找到数据,无需回退到全表扫描。

下面从B+树索引角度分析如下,对于查询1和查询2索引查询过程如下:

可以看到,通过date_status范围过滤之后,在命中范围内数据,poi_id并非有序(图中红色字段),因此无法通过索引直接过滤,需要将范围内数据全部遍历扫描。

对于查询3索引查询过程如上图所示,通过date_status = 1的数据进行过滤查询,在结果集中poi_id局部有序(图中红色字段),因此可以通过`poi_id`索引直接过滤和查询,也即命中联合索引。最左前缀匹配原则的核心为通过左侧前缀过滤查询到的数据,对右侧的查询来说是局部有序的、能够通过索引结构检索的。

除了在简单查询中,满足最左匹配可以提升查询效率;在包含排序的查询,也可以通过满足联合索引的最左匹配来提升查询效率。比较简单的场景为:条件过滤为常量过滤,并且order by字段能够配合常量过滤满足最左前缀,则可以通过联合索引优化排序性能;

sql 复制代码
CREATE TABLE `ad_poi_date_status_copy` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `sku_id` bigint(20) NOT NULL COMMENT '商品ID',
  `poi_id` bigint(20) NOT NULL COMMENT '门店ID',
  `date_status` bigint(20) NOT NULL COMMENT '是否生效',
  `period` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '生效时间段',
  `nums` bigint(20) NOT NULL DEFAULT '123456' COMMENT '注释',
  `ctime` int(11) NOT NULL COMMENT '创建时间',
  `utime` int(11) NOT NULL COMMENT '修改时间',
  PRIMARY KEY (`id`),
  KEY `idx_poi_nums_status` (`poi_id`,`nums`,`date_status`)
) ENGINE=InnoDB AUTO_INCREMENT=428091624 
DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='SKU生效日期';

如果表结构,建立了 (`poi_id`,`nums`,`date_status`) 联合索引,则下述语句a)、b)可以通过联合索引来检索和排序;

sql 复制代码
a) select * from ad_poi_date_status_copy 
where poi_id = 607002 order by nums;

b) select * from ad_poi_date_status_copy 
where poi_id = 607002 order by nums,date_status; 

另外,如果过滤条件不是常量,但是order by中列能够满足联合索引的最左前缀,也是可以通过该联合索引来提升排序性能的,示例如下:

sql 复制代码
c) select * from ad_poi_date_status_copy 
where poi_id > 606999 order by wm_poi_id,nums;

而对于语句d)和语句e),(`poi_id`,`nums`,`date_status`)联合索引的生效情况是怎样的呢?我们又需要怎么创建索引来优化我们的查询效率呢?

sql 复制代码
d) select /*!40001 SQL_NO_CACHE */ * from ad_poi_date_status_copy 
where poi_id = 607002 and date_status > 34043232555 
order by nums desc limit 100;
 
e) select /*!40001 SQL_NO_CACHE */ * from ad_poi_date_status_copy 
where poi_id = 607002 and nums > 331133 
order by date_status desc limit 100;

对于语句d)来说,(`poi_id`,`nums`,`date_status`)的联合索引相当于在`poi_id`和`nums`列上面建立索引,则查询过程中,首先通过poi_id过滤符合查询条件的记录,再对`date_status`进行过滤,此时无法走到索引;最后在查询到的子集上进行排序,此时由于排序字段是`nums`,(`poi_id`,`nums`,`date_status`)索引,在`poi_id`相同的情况下,`nums`有序,也即可以走`nums`索引;

其执行explain结果如下:

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE ad_poi_date_status_copy NULL ref idx_poi_nums_status idx_poi_nums_status 8 const 186388 33.33 Using where

通过Extra信息可以看出,order by操作省略了单独排序动作(未出现Using filesort关键字),也即利用了索引的有序性。

对于语句e)来说,(`poi_id`,`nums`,`date_status`)的联合索引也相当于是在`poi_id`和`nums`列上面建立索引,过滤查询时走`poi_id`和`nums`索引过滤不符合条件的记录;由于nums是一个范围查询,此时过滤结果子集中`date_status`不是完全有序的,此时order by操作需要单独进行排序,b)语句执行explain结果如下:

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE ad_poi_date_status_copy NULL range idx_poi_nums_status idx_poi_nums_status 16 NULL 203372 100% Using index condition; Using filesort
  • ⚠️ 说明 :虽然使用了联合索引,但由于 nums > ... 是范围查询,导致在索引结构中 date_status 的有序性失效,无法利用索引进行排序 ,因此执行中需额外 filesort,性能较差。

对比语句d)和语句e)的执行效率,语句a)执行时间大概为2ms,语句b)执行时间大概600ms。

序号 duration (秒) SQL 语句摘要
a) 0.001702 where poi_id = 607002 and date_status > ... order by nums desc limit 100
b) 0.589737 where poi_id = 607002 and nums > ... order by date_status desc limit 100

但是这并不意味着,类似此类查询,order by不单独排序(也即利用索引排序)就一定是更高效的查询;同样的表以及索引结构,如下语句f)和语句g):

sql 复制代码
f) select /*!40001 SQL_NO_CACHE */ * from ad_poi_date_status_copy 
where poi_id = 607002 and date_status > 999007069921 
order by nums desc limit 100;
​
g) select /*!40001 SQL_NO_CACHE */ * from ad_poi_date_status_copy 
where poi_id = 607002 and nums > 678092599 
order by date_status desc limit 100;

explain执行结果和执行时间分别如下:

📊 执行计划表格(语句 f)

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE ad_poi_date_status_copy NULL ref idx_poi_nums_status idx_poi_nums_status 8 const 186388 33.33% Using where
  • 说明 :查询以poi_id 为等值条件,符合联合索引的最左前缀匹配规则,可有效利用索引过滤,并使用索引中的 nums 字段进行排序,无需 filesort

📊 执行计划表格(语句 g)

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE ad_poi_date_status_copy NULL range idx_poi_nums_status idx_poi_nums_status 16 NULL 99 100% Using index condition; Using filesort
  • ⚠️ 说明nums > ... 为范围过滤,因此联合索引中后续字段 date_status 的顺序性失效,导致 无法使用索引排序 ,执行中触发 filesort

⏱️ 实际执行耗时对比

序号 duration (秒) SQL 语句摘要
f) 0.034625 WHERE poi_id = 607002 AND date_status > ... ORDER BY nums LIMIT 100
g) 0.001776 WHERE poi_id = 607002 AND nums > ... ORDER BY date_status LIMIT 100

🔍 结论分析

对比点 语句 f) 语句 g)
排序字段 ORDER BY nums ORDER BY date_status
过滤条件 date_status > ...(范围) nums > ...(范围)
是否利用排序索引 ✅ 是(排序字段在范围条件之后,顺序性保留) ❌ 否(排序字段顺序性失效)
是否触发 filesort ❌ 未触发 ✅ 触发
实际耗时 约 34ms(中等) 约 1.7ms(快)

💡 尽管语句 g)触发了 filesort,但因扫描数据量小(仅 99 行),整体执行耗时仍然低于语句 f)。这说明实际执行时间不仅取决于是否 filesort,还与数据量、过滤选择性等有关。

(二)利用联合索引有效避免索引策略干扰

在一个表中即使有多个单列索引,并且覆盖到查询语句的所有查询条件,MySQL在查询时,也不一定会选择多个索引**(并非说数据库查询时只能用一个索引,但是与只使用一个索引的速度相比,分析两个索引二叉树更耗费时间,所以多数情况下,数据查询都只能用到一个索引),**这种情况下,由于MySQL内部索引优化策略的存在,可能导致实际查询命中的索引与我们预期相悖,而联合索引本身就是一个B+树结构,所以就不存在要选择哪一个索引字段B+树作为基础索引结构来遍历的问题。

如下act_test_table表中,创建了`aor_id`索引,也创建了`dt`索引:

sql 复制代码
CREATE TABLE `act_test_table` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `poi_id` bigint(20) NOT NULL COMMENT '门店ID',
  `aor_id` bigint(20) NOT NULL COMMENT '蜂窝ID',
  `act_info` varchar(500) DEFAULT NULL COMMENT '活动信息',
  `dt` int(11) NOT NULL COMMENT '日期,20170928格式',
  `ctime` int(11) NOT NULL COMMENT '创建时间',
  `utime` int(11) NOT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_poi_id_dt` (`poi_id`,`dt`),
  KEY `idx_aor_id` (`aor_id`),
  KEY `idx_dt` (`dt`)
) ENGINE=InnoDB AUTO_INCREMENT=1 
DEFAULT CHARSET=utf8mb4 COMMENT='信息表';

但是下述语句在执行时,在一些数据情况下,只会走到`aor_id`索引;在特定查询条件下`aor_id`和`dt`两个索引都会走到。而如果,原表中增加`aor_id` + `dt`的联合索引,则不管查询条件怎样,都会走到联合索引,从而提高查询效率。

sql 复制代码
SELECT aor_id, dt FROM act_test_table WHERE aor_id = ? AND dt = ?


/* 走两个索引 */ 
EXPLAIN SELECT aor_id, dt
FROM act_test_table
WHERE aor_id = 0  /* 0 数据量多 */
AND dt = 20210601
  
 /* 走一个索引 */
EXPLAIN SELECT aor_id, dt
FROM act_test_table
WHERE aor_id = 350
AND dt = 20210601

增加联合索引之前在数据分布不同的情况下,会产生两种情况:

1)走到了两个索引,使用`dt`和`aor_id`进行扫描过滤,然后进行index merge操作,从扫描行数看,虽然走到了两个索引,rows还是很大。

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE act_test_table NULL index_merge idx_aor_id,idx_dt idx_dt,idx_aor_id 4,8 NULL 1,073,015 100% Using intersect(idx_dt,idx_aor_id); Using where; Using index
  • ⚠️ 说明 :MySQL 使用了 Index Merge 策略,对 idx_dtidx_aor_id 分别进行扫描后取交集,再执行过滤。

  • 🔍 问题:虽然命中多个索引,但合并后仍扫描了超百万行,效率较低。

2)走一个索引,`aor_id`索引

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE act_test_table NULL ref idx_aor_id,idx_dt idx_aor_id 8 const 3088 23.95% Using where
  • 说明 :此时仅使用 aor_id 索引作为主过滤条件,扫描行数显著减少(3088 行)。

  • 💡 优势:虽然过滤精度稍低(23.95%),但扫描代价远低于 Index Merge 情况。

为何同一个语句命中索引不同,执行效率千差万别,我们从索引结构分析一下:

结合上图我们逐个分析,第一种情况下,索引字段为`dt`和`aor_id`,extra列可以看到有Using intersect,也即在查询时,首先通过`dt`和`aor_id`执行同步扫描和过滤,然后通过Using intersect算法进行index merge操作取交集(语句执行数据如图中红色箭头所示);而第二个语句,只使用了`aor_id`索引,此时执行顺序是根据`aor_id`过滤出符合条件的记录,从命中范围的最左侧叶子节点,顺序取出数据,每条数据根据WHERE条件中`dt`判断是否匹配,如果符合WHERE条件,则取出数据,直到下一条数据的`aor_id`比350大为止(语句执行数据如图中绿色箭头所示);第一种情况下,`dt`过滤之后命中约命中20W数据,根据`aor_id`过滤后命中700W数据,优化器判断取交集的效率高于遍历700W的数据(id有序,取交集可以使用dt命中的20W数据到右侧700W的数据中查找,第一次检索按照二分查找需要20次+,但是由于数据左侧数据有序且id基本连续,后续的查找时间复杂度为O(1),因此查询和取交集遍历数据近似为为20W数据,且取交集过程无需回表);而第二种情况下,`aor_id`命中数据只有几千条数据,直接遍历然后判断是否符合条件的成本更小。

增加`aor_id` + `dt`的联合索引:

sql 复制代码
CREATE TABLE `act_test_table` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `poi_id` bigint(20) NOT NULL COMMENT '门店ID',
  `aor_id` bigint(20) NOT NULL COMMENT '蜂窝ID',
  `act_info` varchar(500) DEFAULT NULL COMMENT '活动信息',
  `dt` int(11) NOT NULL COMMENT '日期,20170928格式',
  `ctime` int(11) NOT NULL COMMENT '创建时间',
  `utime` int(11) NOT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_poi_id_dt` (`poi_id`,`dt`),
  KEY `idx_aor_id` (`aor_id`),
  KEY `idx_dt` (`dt`),
  KEY `idx_aor_id_dt` (`aor_id`,`dt`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='信息表';

通过为 act_test_table 添加联合索引 (aor_id, dt) 后,查询语句可直接命中该索引,执行效率显著提升。

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE act_test_table NULL ref idx_aor_id,idx_dt,idx_aor_id_dt idx_aor_id_dt 12 const,const 385 100% Using index

🔍 说明:

  • keyidx_aor_id_dt 表示命中我们新建的联合索引。

  • type :使用 ref 类型访问方式,表示通过索引等值匹配,效率较高。

  • refconst,const 说明 aor_iddt 均参与索引过滤。

  • rows:预估仅需扫描 385 行数据,明显优于 Index Merge 情况下的百万级扫描。

  • ExtraUsing index 表示查询仅使用索引中的数据,无需回表,属于覆盖索引优化的一种体现。

增加联合索引的情况下:

(三)数据极端分布下联合索引的作用

一般情况下,如果DB中某一个字段的枚举值数量很小,则一般不建立索引,例如性别,但是如果数据分布比较极端,在一定的业务查询场景下,在该列创建索引可以极大的提高查询效率。如下为我们在业务遇到的一个慢查询语句:

sql 复制代码
 explain SELECT /*!40001 SQL_NO_CACHE */  * FROM p_id_test 
WHERE p_id = 44241135 AND status = 0 ;

其中p_id_test表中大概100W+数据,由于业务原因,表中status字段分布如下表,字段值为1占比超过90%

status字段值枚举 数据统计
1 972089
0 30889
2 1

表p_id_test初始结构为:

sql 复制代码
CREATE TABLE `p_id_test` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `p_id` bigint(20) NOT NULL COMMENT 'id',
  `name` varchar(30) NOT NULL COMMENT '名称',
  `w_s_id` bigint(20) NOT NULL COMMENT 's_id',
  `status` int(11) NOT NULL DEFAULT '1' COMMENT '状态',
  `ctime` int(11) NOT NULL COMMENT '创建时间',
  `utime` int(11) NOT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`),
  KEY `idx_p_id` (`p_id`),
  KEY `idx_w_s_id` (`w_s_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='测试数据表';

在此索引结构下,执行时间约为800ms,此时如果增加`p_id`和`status`组合索引,同样的语句执行时间约为6-9ms,效率提高100倍左右;添加`p_id`+`status`组合索引前后的explain结果分别如下:

|--------|----|-------------|-----------|------------|------|--------------------------|-----------------|---------|-------------|--------|----------|-------------|
| | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
| 未加索引 | 1 | SIMPLE | p_id_test | NULL | ref | idx_p_id | idx_p_id | 8 | const | 375628 | 10 | Using where |
| 增加组合索引 | 1 | SIMPLE | p_id_test | NULL | ref | idx_p_id,idx_p_id_status | idx_p_id_status | 12 | const,const | 1 | 100 | NULL |

可以看到命中`p_id`+`status`组合索引的情况下,row会远远少于未创建组合索引的情况;添加`p_id`+`status`组合索引前后`p_id`都是走到了索引的,而`status`字段只有三个值,区分度很低,但是添加`p_id`+`status`索引之后为什么查询效率会明显提高?

结合status数据分布,观察`p_id`+`status`联合索引B+树结构,可以很直观的看到,当查询条件查询的`status`不为1时,每次查询通过`p_id`+`status`索引可以将`p_id`命中的数据过滤掉很大的一部分,命中的数据量很小,结合业务中查询中多为查询`status`为0的情况,增加联合索引可以轻易的解决业务中的慢查询。

(四)聚合操作场景

我们在对group by语句进行查询优化,一般会试图通过松散索引扫描(loose index scan)和紧凑索引扫描(tight index scan)来提高语句执行效率。在不满足松散索引扫描和紧凑索引扫描的情况下,group by语句需要先扫描整个表,提取数据创建一个临时表,再根据group by语句中指定的列进行排序,排序之后就可以找到所有的分组,然后执行聚集函数(例如max、min等),这个过程在执行计划中的表现为会出现类似"Using temporary; Using filesort"的关键字;通过松散索引扫描和紧凑索引扫描来提高语句执行效率,主要的思路是希望在读取索引的基础上直接完成group by操作,以跳过创建临时表和排序操作,由于很多情况下,我们的聚合操作的分组依据的列都有多个,要想使用松散索引或者紧凑索引,需要他们来自同一个索引,也就必须配合联合索引。

下面看一个例子:

sql 复制代码
SELECT  /*!40001 SQL_NO_CACHE */ poi_id,date_status,max(nums)
FROM status_test 
GROUP BY date_status,poi_id
LIMIT 300

操作数据表结构如下:

sql 复制代码
CREATE TABLE `status_test` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `sku_id` bigint(20) NOT NULL COMMENT '商品ID',
  `poi_id` bigint(20) NOT NULL COMMENT '门店ID',
  `date_status` bigint(20) NOT NULL COMMENT '是否生效',
  `period` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '生效时间段',
  `nums` int(11) NOT NULL DEFAULT '1',
  `ctime` int(11) NOT NULL COMMENT '创建时间',
  `utime` int(11) NOT NULL COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_sku` (`sku_id`),
  KEY `idx_poi` (`poi_id`),
  KEY `idx_date_status` (`date_status`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 
COLLATE=utf8mb4_unicode_ci COMMENT='SKU生效日期';

由于group by中字段不在同一个索引中,无法使用松散索引或者紧凑索引,在测试过程中发现语句执行时间大概为3.5s,执行explain,结果如下表,Extra显示确实使用了临时表,并且有单独的排序操作。

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE status_test NULL ALL NULL NULL NULL NULL 2095163 100 Using temporary; Using filesort

去掉`poi_id`单列索引,增加`date_status`,`poi_id`,`nums`联合索引,再次执行,执行时间约12ms,执行explain,结果如下表,松散索引扫描生效,扫描行数减少90%,执行效率提高近300倍。

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE status_test NULL range idx_poi_status_num,idx_status_poi_num idx_status_poi_num 16 NULL 168054 100 Using index for group-by

首先,我们大致的构造出表中`date_status`+`poi_id`+`nums`联合索引的B+树结构,如图所示:

案例中语句命中`idx_poi_status_num`,由于联合索引特性,其索引列有序,因此在按照wm_poi_id和date_status分组后,能直接通过索引计算出max(num),不需要取的叶子节点的主键索引更不需要回表操作。因此查询效率远高于没有未使用到紧凑索引和松散索引的情况。聚合查询走松散索引很重要的一个条件是group by的所有列是表中一个索引或者这个索引的最左前缀,并且不包含这个索引之外的其它列,一旦group by的字段超过一个,就必然要求表中有符合条件的联合索引,否则都无法走松散索引扫描。

(五)多表关联查询

在多表关联查询时,MySQL只支持Nested Loop Join(嵌套循环连接)这一种join 算法。Nested Loop Join算法的主要思想是,将驱动表的数据结果集作为循环基础数据,然后循环的从驱动表的数据结果集中每次取一条数据作为下一个表的过滤条件,进行数据查询,再将两个表的查询结果合并起来;如果有多个表进行join查询,则以此类推的,将前面的表的查询结果集作为循环基础数据,每行循环的到关联的下一个表中匹配。

1. MySQL join算法

在MySQL的实现中,Nested Loop Join的实现主要为 Index Nested Loop Join算法和 Block Nested Loop Join算法两种。

1️⃣ Index Nested Loop Join(索引嵌套循环连接)

定义:对外层表(驱动表)的每一行数据,从内层表中使用索引查找匹配的行。

使用场景

  • 内层表的连接字段上存在合适的索引

  • 数据量适中或选择性较高(即过滤效果好);

  • 通常是 MySQL 默认优先选择的 Join 方式。

2️⃣ Block Nested Loop Join(块嵌套循环连接)

定义:MySQL 会将外层表的一批记录(block)读入内存,并对这些记录构造临时哈希结构,再批量与内层表进行比较。

使用场景

  • 内层表没有合适的索引;

  • 可分配较大 join_buffer_size

  • 通常在内层表无法通过索引高效访问时触发;

  • 也称为 BNL Join

📌 总结对比

特性 Index Nested Loop Join Block Nested Loop Join
是否使用索引 ✅ 是 ❌ 否
是否依赖 join_buffer ❌ 否 ✅ 是
内层表访问方式 基于索引查找 全表扫描
外层表访问方式 行遍历(单行或块) 块遍历
性能表现 高(依赖索引和外层表大小) 较低(数据量大会非常慢)
避免方式 确保内层表连接字段有索引 添加索引/限制 join_buffer 使用

如果匹配表(也叫非驱动表)里没有索引来做join关联,就会走Block Nested Loop Join算法,即从上图可以看出,会把驱动表所有join相关的列先缓存到join buffer中,然后批量与匹配表进行匹配。这种匹配方式首先浪费内存,因为需要临时缓空间,导致驱动表join列多的时候,导致消耗很多内存,对MySQL server端内存造成很大压力,也会同时挤占MySQL正常索引空间。其次,匹配效率比Index Nested Loop Join算法低,因为Block Nested Loop Join算法的匹配方式本质还是从join buffer里一个一个跟匹配表里的字段匹配,只是它是从内存里读取出来,比最原始的Simple Nested Loop算法从磁盘里查询出来效率高而已。

这里MySQL对join操作,会根据join左右两张表的数据量决定哪个作为驱动表(即一般数据量较小的驱动表)。

综上所述,使用join的时候,一定要在匹配表里join关联字段加上索引,否则随着数据量变大,性能会越来越慢。

2. 多表查询中联合索引提升join算法查询效率

下面的示例可以看一下联合索引在join查询中怎样提升join算法的查询效率:

sql 复制代码
SELECT config.id, config.open_type, config.spread_date, config.status, config.category, config.ad_pos, config.ctime
FROM config_test_table config
LEFT JOIN area_test_table area ON area.role_config_id = config.id AND area.open_type = config.open_type

表area_test_table没有创建索引时,如下所示,会取出config_test_table表中每一条记录,循环与area_test_table中的每一条记录进行对比;

给area_test_table增加role_config_id+open_type的联合索引,该查询中jion算法会提升为Index Nested Loop Join算法,其匹配过程过如图,config_test_table表记录,会循环的在area_test_table的联合索引的B+树中匹配,当匹配成功时,会根据索引叶子节点的主键id,再从匹配表中取出其他字段值;其查询的效率将大大提升。

在多表关联查询中使用联合索引,需要注意哪个表为驱动表,哪个为匹配表,如下:

sql 复制代码
SELECT config.id, config.open_type, config.spread_date, config.status, config.category, config.ad_pos, config.ctime
FROM area_test_table area
LEFT JOIN config_test_table config ON area.role_config_id = config.id AND area.open_type = config.open_type

虽然语句写的是area_test_table在左侧,config_test_table在右侧,实际因为config_test_table远远小于area_test_table表数据,MySQL在执行时,优化器会选择config_test_table表为驱动表,如果只在config_test_table表上创建索引,而未在匹配表创建索引,则上语句与没有索引实际是相同的,执行时无法走Index Nested Loop Join优化。

分析如图,虽然config_test_table创建了索引,但是由于config_test_table为驱动表,数据查询时,匹配的过程中,依旧需要与area_test_table表中的每一条数据进行匹配,匹配效率依旧无法提高。

3. 联合查询中的排序字段对执行计划的影响分析

下面是我们在一次查询优化中遇到的一个典型场景。我们对表 wm_ad_cpc_role_config(简称 config) 和 wm_ad_cpc_role_config_open_area(简称 area) 做 LEFT JOIN 联表查询,观察不同的 ORDER BY 排序字段对执行计划的影响。

查询语句 A(按 area.id 排序):
sql 复制代码
EXPLAIN SELECT config.id, config.open_type, 
config.spread_date, config.status, config.category, 
config.ad_pos, config.ctime 
FROM ad_cpc_role_config config 
LEFT JOIN ad_cpc_role_config_open_area area 
ON area.role_config_id = config.id 
AND area.open_type = config.open_type 
ORDER BY area.id;
id select_type table type possible_keys key key_len ref rows filtered Extra
1 SIMPLE config ALL NULL NULL NULL NULL 513 100 Using temporary; Using filesort
1 SIMPLE area ref idx_role_config_id, idx_open_type, ... idx_role_config_id 4 config.id 64 100 Using where

解读 :由于排序字段是来自被驱动表 area,MySQL 无法直接通过索引完成排序,因此触发了临时表 + 文件排序,即 Using temporary; Using filesort。这通常意味着更高的 CPU 和 IO 开销,尤其是当结果集较大时。

查询语句 B(按 config.id 排序):
sql 复制代码
EXPLAIN SELECT config.id, config.open_type, 
config.spread_date, config.status, config.category,
 config.ad_pos, config.ctime 
FROM ad_cpc_role_config config 
LEFT JOIN ad_cpc_role_config_open_area area 
ON area.role_config_id = config.id 
AND area.open_type = config.open_type ORDER BY config.id;
id select_type table type possible_keys key key_len ref rows filtered Extra
1 SIMPLE config ALL NULL NULL NULL NULL 513 100 Using filesort
1 SIMPLE area ref idx_role_config_id, idx_open_type, ... idx_role_config_id 4 config.id 64 100 Using where

解读 :本次排序字段为驱动表 config 的主键 id,虽然仍然触发了 filesort(因为没有使用索引完成排序),但避免了临时表创建,因此相较于 A 来说性能更好,资源占用更少。

🎯 小结
  • 排序字段位置不同,会直接影响是否使用临时表、排序算法的类型

  • 排序字段来自被驱动表(如 area.id)时,MySQL 通常无法利用索引完成排序,容易触发 Using temporary; Using filesort

  • 排序字段来自驱动表(如 config.id)时,如果该字段存在合适的索引,或是主键,优化空间更大

  • 若排序字段是必要的,可考虑使用覆盖索引提前子查询排序等方式优化。

五、总结:联合索引,不止是"组合字段"这么简单

经过上文的层层剖析与实战演练,相信你已经意识到:联合索引绝不仅仅是"多个字段合在一起建索引"这么简单。它是一种系统性的设计艺术,更是一项数据库调优中的"硬核武器"。

我们从 B+ 树的物理结构谈起,逐步揭示了联合索引底层是如何组织页、记录、指针的,又是如何通过"最左前缀"原则和叶子节点的有序结构,实现高效的数据定位与范围检索。联合索引能有效防止索引策略干扰、优化回表路径、提升覆盖查询效率、触发索引下推机制,这些能力背后,都离不开它作为"整体一棵树"的结构设计。

更重要的是,我们通过一系列贴近实战的案例,看到了联合索引在真实业务中是如何"显神通"的:从广告系统的高频检索,到复杂多变的排序优化策略,再到对慢查询的精准"打击",每一次提升都凝结着索引设计的智慧与经验的沉淀。

可以说,联合索引是连接数据库"理论世界"与"实际查询"的桥梁,既需要你对 MySQL 执行计划有足够的敏感度,也需要你对业务数据分布了如指掌。只有理解它、掌握它、审慎使用它,联合索引才能真正成为你手中稳定、致命的一张"性能王牌"。

🎯 最后送你一句话作结

如果说单列索引是"单打独斗"的士兵,那么联合索引就是"协同作战"的特种部队 ------ 在对的战场,它总能一击必中。

相关推荐
葵野寺1 小时前
【MySQL】MySQL索引—B树/B+树
数据库·b树·mysql·b+树
程序新视界1 小时前
MySQL中COUNT(\*)、COUNT(1)和COUNT(column),到底用哪个?
mysql
隔壁老登1 小时前
解决dbeaver连接不上oceanbase数据库的问题
数据库·oceanbase
····懂···2 小时前
抢占先机,PostgreSQL 中级专家认证的职业跃迁
数据库·postgresql
GBASE2 小时前
“G”术时刻:南大通用GBase 8c典型运维场景-扩缩容场景快速定位性能瓶颈
数据库
Elastic 中国社区官方博客2 小时前
用于 UBI 的 Elasticsearch 插件:从搜索查询中分析用户行为
大数据·数据库·elasticsearch·搜索引擎·全文检索
小白不想白a2 小时前
【MySQL安全】什么是SQL注入,怎么避免这种攻击:前端防护、后端orm框架、数据库白名单
数据库·sql·mysql·安全
大路谈数字化2 小时前
Oracle 19C 在centos中安装操作步骤和说明
数据库·oracle
熏鱼的小迷弟Liu3 小时前
【MySQL】MySQL中锁有哪些?
数据库·mysql