【MySQL】索引创建与B+树原理:MySQL性能优化的核心一课

目录

索引创建与B+树原理:MySQL性能优化的核心一课

本文将带你从零开始,全面掌握数据库索引的创建方法,包含大量实战代码和避坑指南

一、为什么需要索引?

想象一下,你在一本没有目录的书中找一句话,只能一页页翻。数据库也是一样,没有索引就会进行全表扫描,数据量一大就慢得离谱。

sql 复制代码
-- 没有索引:扫描100万行数据
select * from users where name = '张三';  -- 耗时3秒

-- 有索引:通过目录快速定位
create index idx_name on users(name);    -- 创建索引
select * from users where name = '张三';  -- 耗时0.01秒

二、索引的核心概念

2.1 索引是什么?

索引是一种以空间换时间的数据结构,就像书的目录或字典的拼音检索表。

2.2 索引的类型对比

索引类型 关键词 允许重复 生活例子 使用场景
普通索引 index ✅ 允许 学生姓名(可重名) 日常查询字段
唯一索引 unique ❌ 不允许 身份证号 需要唯一性的字段
主键索引 primary key ❌ 不允许 学号 每张表的唯一标识
组合索引 index(a,b) ✅ 允许 拼音+笔画查字典 多条件查询
全文索引 fulltext ✅ 允许 百度搜索 文章关键词搜索

三、创建索引的五种方式

3.1 建表时创建

sql 复制代码
create table users (
    -- 主键索引(自动创建)
    id int primary key auto_increment,
    
    -- 唯一索引
    email varchar(100) unique,
    
    -- 普通索引
    name varchar(50),
    age int,
    create_time datetime,
    
    -- 在字段定义后创建索引
    index idx_name (name),                    -- 普通索引
    index idx_age_time (age, create_time),    -- 组合索引
    unique index idx_email2 (email)           -- 唯一索引
);

3.2 使用create index语句

sql 复制代码
-- 普通索引
create index idx_name on users(name);

-- 唯一索引
create unique index idx_email on users(email);

-- 全文索引
create fulltext index idx_content on articles(content);

-- 组合索引
create index idx_name_age on users(name, age);   # 以name, age组合作为索引

-- 前缀索引(只取前10个字符)
create index idx_name_prefix on users(name(10));   # users表名,idx_name_prefix 索引名

3.3 使用alter table语句

sql 复制代码
-- 添加普通索引
alter table users add index idx_age (age);

-- 添加唯一索引
alter table users add unique index idx_phone (phone);

-- 添加主键
alter table users add primary key (id);

-- 添加全文索引
alter table articles add fulltext index idx_content (content);

实战案例:电商系统索引设计

用户表

sql 复制代码
create table users (
    id bigint primary key auto_increment,
    username varchar(50) not null,
    email varchar(100) not null,
    phone char(11),
    real_name varchar(20),
    age tinyint,
    city varchar(50),
    status tinyint default 1,
    last_login datetime,
    create_time datetime
);

-- 索引设计
-- 1. 登录相关(唯一索引)
create unique index idx_username on users(username);
create unique index idx_email on users(email);
create unique index idx_phone on users(phone);

-- 2. 统计查询(组合索引)
create index idx_status_city on users(status, city);

-- 3. 时间查询
create index idx_last_login on users(last_login);

-- 4. 前缀索引(节省空间)
create index idx_real_name on users(real_name(5));

-- 验证索引效果
explain select * from users where username = 'zhangsan';

为什么需要加 EXPLAIN

  • 不加 EXPLAIN:只是正常执行查询,返回查询结果(数据行),你看不到索引的使用情况。
  • EXPLAIN :不返回实际数据,而是返回查询执行计划,告诉你 MySQL 是如何执行这条 SQL 的(用了哪个索引、扫描了多少行等)。

四、创建索引的最佳实践

4.1 命名规范

sql 复制代码
-- 推荐命名格式:idx_表名_字段名
create index idx_users_name on users(name);
create index idx_users_age_status on users(age, status);

-- 唯一索引:uidx_表名_字段名
create unique index uidx_users_email on users(email);

-- 主键:pk_表名
alter table users add primary key pk_users (id);

4.2 创建索引的黄金法则

sql 复制代码
-- ✅ 适合创建索引的场景
-- 1. where条件中的列
create index idx_age on users(age);

-- 2. order by和group by的列
create index idx_create_time on orders(create_time);

-- 3. join连接的列
create index idx_user_id on orders(user_id);

-- 4. 区分度高的列(唯一性强)
create index idx_id_card on users(id_card);  -- 几乎唯一

-- ❌ 不适合创建索引的场景
-- 1. 数据量小的表(<1万行)
-- 2. 频繁更新的列
-- 3. 区分度低的列(如性别)
-- 4. 很少使用的条件列

4.3 组合索引的顺序原则

sql 复制代码
-- 原则:等值查询在前,范围查询在后,高区分度在前

-- ✅ 正确顺序
create index idx_name_age on users(name, age);
-- 查询:where name = '张三' and age > 18  -- 都能用到索引

-- ❌ 错误顺序
create index idx_age_name on users(age, name);
-- 查询:where name = '张三' and age > 18  -- name用不到索引

五、索引的查看、修改和删除

5.1 查看索引

sql 复制代码
-- mysql
show index from users;
show keys from users;

-- 结果示例:
+-------+------------+----------+--------------+-------------+-----------+-------------+----------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Index_type | Cardinality |
+-------+------------+----------+--------------+-------------+-----------+-------------+----------+
| users |          0 | PRIMARY  |            1 | id          | BTREE     |        1000 |
| users |          1 | idx_name |            1 | name        | BTREE     |         950 |
| users |          1 | idx_age_time |        1 | age         | BTREE     |          50 |
| users |          1 | idx_age_time |        2 | create_time | BTREE     |         500 |
+-------+------------+----------+--------------+-------------+-----------+-------------+----------+

-- 查看索引详细信息
select * from information_schema.statistics 
where table_name = 'users';

-- 查看索引大小
select 
    table_name,
    index_name,
    round(stat_value * @@innodb_page_size / 1024 / 1024, 2) as size_mb
from mysql.innodb_index_stats 
where stat_name = 'size';

5.2 修改索引

sql 复制代码
-- mysql不支持直接修改,需要删除后重建
-- 1. 删除旧索引
drop index idx_name on users;

-- 2. 创建新索引
create index idx_name_new on users(name);

-- 或者使用alter table
alter table users drop index idx_name;
alter table users add index idx_name_new (name);

5.3 删除索引

sql 复制代码
-- 方法1:drop index
drop index idx_name on users;

-- 方法2:alter table
alter table users drop index idx_name;
alter table users drop primary key;  -- 删除主键

六、索引失效的常见场景

sql 复制代码
-- 1. 使用函数
where date(create_time) = '2024-01-01'  -- ❌ 失效
-- 改为
where create_time >= '2024-01-01' and create_time < '2024-01-02'  -- ✅

-- 2. 隐式类型转换
where phone = 13800138000  -- ❌ phone是varchar
-- 改为
where phone = '13800138000'  -- ✅

-- 3. 前导通配符
where name like '%张三'  -- ❌ 失效
-- 改为
where name like '张三%'  -- ✅

-- 4. or条件(两边都要有索引)
where age = 25 or name = '张三'  -- 如果name没索引,❌

-- 5. 不等号
where age != 25  -- ❌ 大部分情况失效

-- 6. 组合索引不满足最左前缀
-- 索引(a,b,c)
where b = 2  -- ❌ 失效
where a = 1 and c = 3  -- ❌ 跳过b

-- 7. 使用is not null
where email is not null  -- ❌ 通常失效

七、索引的结构

7.1 MySQL与磁盘交互

  • 磁盘(机械硬盘)的物理结构:"整体 → 盘面 → 磁道 → 扇区"
  • 每个扇区都有独立编号,是磁盘最小寻址单位。每个扇区容量为 512 字节。
  • 系统读取磁盘,是以块为单位的,基本单位是4KB。叫做一个Page
  • MySQL和磁盘进行数据交互的基本单位是16KB。也叫做Page

让我们理清这两个 Page 的关系:

  • 操作系统的 Page (4KB):这是文件系统与磁盘交互的最小单位。磁盘硬件按 512 字节或 4KB 寻址,操作系统按 4KB 组织数据。你可以把它想象成"乐高积木的最小颗粒"。
  • MySQL (InnoDB) 的 Page (16KB):这是 MySQL 自己定义的和操作系统(进而和磁盘)交互的基本单位。它是数据库自己"组装"的一个大积木块。
  • 接下来,我们讲的Page 都是这指的是MySQL (InnoDB) 的 Page (16KB)

7.2 理解单个Page

不同的Page,在MySQL 中,都是16KB,使用prev和next构成双向链表。因为有主键的问题,MySQL会默认按照主键给我们的数据进行排序

7.3 理解目录

  • 索引使用B+树
  • 非叶子节点是目录(只导航),叶子节点是数据页(存数据)。
  • 每个目录项的构成是:键值+指针(prev,next)。

7.4 B+ 树的结构

B+树 是一种专门为磁盘存储(如数据库、文件系统)设计的数据结构。它解决了传统数据结构(如二叉搜索树、B树)在磁盘I/O上的性能瓶颈。

B+树 = 多路搜索树 + 所有数据都在叶子节点 + 叶子节点形成有序链表

核心结构(用你熟悉的Page来理解)

复制代码
                    [根节点 Page]
                   /     |     \
    [内节点 Page]   [内节点 Page]   [内节点 Page]
      /  |  \         /  |  \         /  |  \
[叶子][叶子][叶子]→[叶子][叶子][叶子]→[叶子][叶子][叶子]
   ↑所有数据都在这里,并且用指针连成链表↑

B+树的4大核心特性

特性 说明
1. 多路分支 每个节点可以有多个孩子(如1000个),而不是二叉树的2个 → 树极矮
2. 数据只在叶子 非叶子节点只存键值+指针(目录),不存数据 → 一个Page能放更多键
3. 叶子有序链表 所有叶子节点通过指针连成双向链表 → 范围查询极快
4. 节点=Page 每个节点存储在一个Page中 → 一次I/O读一个节点

为什么B+树比B树更适合数据库?

B树:非叶子节点也存数据

复制代码
[50|数据]  ← 非叶子节点就存数据了
   /  \
[20|数据] [80|数据]

问题:一个Page能放的键变少了 → 树变高 → 更多I/O

B+树:非叶子节点只存键

复制代码
[50]  ← 只存键,不存数据
  /  \
[20] [80]
 ↓    ↓
[20|数据]→[50|数据]→[80|数据]  ← 叶子节点存数据+链表

优势:非叶子节点Page能放更多键 → 树更矮 → 更少I/O

这也解释了为什么MySQL用16KB的Page:每个Page能存放1000个键 → 树只有3-4层 → 亿级数据只需3-4次磁盘I/O

7.5 为什么不使用别的数据结构

B+树并非在所有场景下都是最优的,只是在"磁盘数据库"这个特定场景下,它是最佳选择。

我们可以通过分析"为什么不选其他结构"来反向理解 B+ 树的优势。

  1. 为什么不选哈希表?

哈希表在等值查询(WHERE id = 5)上极快(O(1)),但致命缺陷如下:

问题 说明 实际后果
不支持范围查询 数据无序,无法执行 WHERE id BETWEEN 5 AND 10 需要全表扫描,彻底失效
不支持排序 无法 ORDER BY,无法做 >、<、MAX、MIN 几乎每个查询都要额外排序
哈希冲突 大量数据映射到同一哈希桶 退化成链表,性能崩溃
不适合磁盘 哈希表是"内存友好"结构,随机跳转多 磁盘I/O剧增

结论 :哈希表只能做等值查询的内存缓存,不能做磁盘数据库的主索引。


  1. 为什么不选二叉搜索树(BST)或 AVL 树?
问题 说明 实际后果
树太高 2叉 → 百万级数据需要约20层 查询需要20次磁盘I/O(约200ms)
磁盘预读失效 每一层能覆盖的数据量太少了 每次I/O只能读一个Page,预读的其他Page都用不到
B+树一页1000叉 百万级数据只需3层 3次磁盘I/O(约30ms)

对比 :B+树比二叉树快 6-7 倍


  1. 为什么 B 树也不行?

B 树和 B+树很像,但有一个关键区别:B 树在非叶子节点也存数据

问题 说明 后果
非叶子节点存数据 一个 Page 能放的键数量变少 树变高,I/O 变多
范围查询慢 叶子节点之间没有链表 需要反复回溯父节点,遍历成本高

B树范围查询:找到起点 → 回溯到父节点 → 再找下一个叶子 → 多次回溯

B+树范围查询:找到起点 → 沿叶子链表直接走 → 一次回溯都不需要

结论:B+ 树在磁盘 I/O 和范围查询上全面优于 B 树。


  1. 为什么不选跳表(Skip List)?

跳表是 Redis 有序集合的底层结构,在内存中很快。

问题 说明
内存随机访问 跳表依赖指针跳跃,内存中快(纳秒级)
磁盘随机访问 磁盘跳一个指针需要 10ms,无法接受
空间局部性差 节点在磁盘上分散存储,预读失效

结论 :跳表是内存数据结构,不适合磁盘。

八、聚簇索引VS 非聚簇索引

聚簇索引非聚簇索引 是InnoDB中最核心的两个概念,它们的本质区别是:数据究竟存放在哪里

一句话总结

  • 聚簇索引(Clustered Index)数据和索引放在一起。找到了索引,就找到了数据本身。
  • 非聚簇索引(Secondary Index,辅助索引)索引和数据分开存放。索引只存一个"门牌号"(主键值),找到门牌号后,再用这个门牌号去聚簇索引里找数据。

  1. 聚簇索引(主键索引)
  • 必须有一个。如果你不主动定义主键,InnoDB会偷偷给你加一个隐藏的行ID来充当。

  • 叶子节点Page里存放的是整行的完整数据(所有列:id、name、age...)。

  • 整个表就是一棵B+树。表数据本身就是聚簇索引的叶子节点。

    聚簇索引的叶子节点Page(16KB):
    ┌────────────────────────────────────────┐
    │ page_prev │ page_next │ 页头 │ 其他元信息 │
    ├────────────────────────────────────────┤
    │ id=1 │ 欧阳锋 │ 教主 │ ...(整行数据) │
    │ id=2 │ 黄蓉 │ 帮主 │ ...(整行数据) │
    │ id=3 │ 杨过 │ 大侠 │ ...(整行数据) │
    └────────────────────────────────────────┘


  1. 非聚簇索引(辅助索引)
  • 可以有多个(你可以在name、age等列上分别建立索引)。

  • 叶子节点Page里不存完整数据,只存两样东西

    1. 索引列的值(比如name='欧阳锋'
    2. 对应的主键值 (比如id=1

    非聚簇索引的叶子节点Page(name列上建的索引):
    ┌────────────────────────────────────────┐
    │ page_prev │ page_next │ 页头 │ │
    ├────────────────────────────────────────┤
    │ name='欧阳锋' │ 主键 id=1 │
    │ name='黄蓉' │ 主键 id=2 │
    │ name='杨过' │ 主键 id=3 │
    └────────────────────────────────────────┘

整个 B+树按照 name 的字典序(字母顺序/拼音顺序/Unicode顺序)组织。


查询过程对比:

聚簇索引查询(主键查找)

sql 复制代码
SELECT * FROM user WHERE id = 5;

步骤:直接走聚簇索引(主键B+树)

  1. 从根节点Page开始,二分查找
  2. 走到叶子节点Page
  3. 直接在叶子节点拿到整行数据(id=5, name='欧阳锋', age=56...)

磁盘I/O:3-4次(B+树的高度)

非聚簇索引查询(非主键查找)

sql 复制代码
SELECT * FROM user WHERE name = '欧阳锋';

步骤

  1. 走name列上的非聚簇索引(另一棵B+树)
  2. 在非聚簇索引的叶子节点上找到:name='欧阳锋' → 主键 id=5
  3. 拿着主键 id=5,再走聚簇索引(主键B+树)
  4. 在聚簇索引的叶子节点上拿到完整数据

磁盘I/O:4-8次(两次B+树查找)

这个"先找辅助索引,再回主键索引"的过程,就叫"回表"


什么时候不需要"回表"?(覆盖索引)

如果你查询的所有列都在非聚簇索引里,就不需要回表了。

sql 复制代码
-- 只需要 name 和 id(id是主键)
SELECT id, name FROM user WHERE name = '欧阳锋';

因为非聚簇索引的叶子节点里已经包含了 name 和 id,不需要再去聚簇索引里拿其他列。

这就叫"覆盖索引"------索引覆盖了查询需要的所有列。


聚簇索引的优缺点

优点 缺点
主键查询极快 插入依赖主键顺序(乱序插入可能导致页分裂)
范围查询快(叶子节点链表) 更新主键代价高(会导致数据物理移动)
回表快(只需一次) 非聚簇索引需要两次查找
节省空间(数据只存一份)

一个常见面试题

问:为什么InnoDB表强烈建议设置一个自增整数作为主键?

答:

  • 自增:新插入的主键值总是比之前的大,B+树在叶子节点最右边插入 → 页分裂次数最少 → 性能好
  • 整数:聚簇索引的键值比较快(整数比较比字符串快得多)
  • 不推荐用UUID做主键:UUID是随机的、很大的字符串 → 插入位置随机 → 频繁页分裂 → 性能差、空间浪费

总结一句话

聚簇索引 = 整个表本身就是B+树,数据挂在叶子节点上;非聚簇索引 = 另一棵B+树,叶子节点只挂主键值,需要"回表"才能拿到完整数据。

相关推荐
sitellla1 小时前
MySQL 入门:最流行的开源关系型数据库介绍
数据库·mysql·其他·开源
2301_808414382 小时前
MySQL表的约束
数据库·mysql
小碗羊肉3 小时前
【MySQL | 第五篇】事务
数据库·mysql
@小柯555m3 小时前
MySql(高级操作符--高级操作符练习(1))
数据库·sql·mysql
bqq198610263 小时前
MySQL分库分表
数据结构·mysql
wang09073 小时前
Linux性能优化之磁盘基础介绍
linux·运维·性能优化
一直会游泳的小猫3 小时前
Claude Code 连 MySQL:保姆级教程
mysql·mcp·claude code
HalvmånEver3 小时前
MySQL的内置函数
linux·数据库·学习·mysql
小松加哲4 小时前
服务器LVM磁盘内部空闲空间无损扩容+挂载原理+MySQL Binlog自动清理完整实操
运维·mysql·服务器扩容