MySQL索引优化:被面试官问懵后,我终于搞懂了B+树和最左前缀原则

一名Java实习生的数据库性能优化实战笔记,从"这SQL怎么这么慢"到"优化只需0.01秒"的蜕变


前言:那次让我"社死"的面试

大家好,我是小明,一名刚毕业三个月的Java后端开发。今天想和大家分享一个让我既尴尬又成长的故事。

上个月,我去一家心仪的公司面试后端开发岗位。面试进行得很顺利,自我介绍、项目经验、Java基础都对答如流,面试官频频点头,我心里暗喜:这次稳了!

结果面试官话锋一转,问了我一个问题:

“小明同学,我看你的项目经验里写到了数据库优化。你能给我解释一下,为什么这条SQL查询需要0.5秒才能返回结果吗?”

说完,他在我面前写了一条SQL语句:

SELECT * FROM orders WHERE order_time LIKE '%2024-01%' ORDER BY update_time DESC LIMIT 100;

我盯着这条SQL,脑子里一片空白。什么?索引?我知道索引啊,数据库查询变快的就是那个东西对吧?但是具体怎么用、为什么这条SQL慢、我该怎么优化,我是真的不知道。

我支支吾吾地说:“可能是…没有建索引吧…”

面试官微微一笑,又问:“那你知道为什么加了索引之后,这条SQL依然很慢吗?”

我当时的表情,大概是这样的:😰

结果可想而知,面试官礼貌地送走了我。回到出租屋,我躺在床上,翻来覆去睡不着,心里一直想着那个问题:为什么加了索引还慢?

从那天起,我开始了为期两周的MySQL索引优化学习之旅。今天这篇文章,就是我这两周学习的总结,希望能帮助到和我一样曾经对索引一知半解的朋友们。


一、重新认识索引:它到底是什么?

在开始学习之前,我对索引的理解就是简单的"查询加速器"。面试回来后,我第一件事就是问自己:索引到底是什么?为什么它能让查询变快?

1.1 生活化的比喻

为了理解索引,我导师给我举了一个特别形象的比喻:

“小明啊,你可以把数据库想象成一个图书馆,里面有几十万本书(数据行)。没有索引的时候,你要找一本特定的书,比如《Java从入门到放弃》,管理员只能一本一本地翻给你看,这就是全表扫描。如果有索引呢?索引就像图书馆的图书分类目录,你可以先查目录知道这本书在第几排第几个书架,然后直接走过去拿。这就是索引的作用——减少查找的次数。”

这个比喻让我恍然大悟。索引的本质就是建立一种数据结构,能够快速定位到我们要找的数据,而不是一条一条地去遍历。

1.2 索引的物理结构

后来我了解到,在MySQL中,索引是实实在在存储在磁盘上的数据结构。不同的存储引擎实现方式不同,但我们最常用的InnoDB引擎使用的是B+树这种数据结构。

这里有个关键点需要理解:数据库的数据是存在磁盘上的,而磁盘IO的效率比内存慢得多。所以索引设计的核心目标就是用最少的磁盘IO次数找到我们需要的数据

如果没有索引,查询一条数据可能需要:

  1. 读取第一条记录,判断是否符合条件
  2. 读取第二条记录,判断是否符合条件
  3. 读取第100万条记录,终于找到了!

如果有索引,查询过程变成:

  1. 读取索引根节点
  2. 根据根节点的信息,读取下一层节点
  3. 定位到具体的数据行

想象一下,如果是100万条数据,没有索引可能需要IO 100万次,有索引可能只需要IO 4次(假设B+树高度为4),这个差距是巨大的。

1.3 索引的代价

但是,索引不是免费的午餐。我导师反复提醒我:索引虽然能加速查询,但会拖累写入操作

每次我们执行INSERT、UPDATE、DELETE操作时,数据库不仅要修改数据本身,还要维护对应的索引结构。这就像图书馆的图书分类目录:每次放入一本新书,不仅要把书放到书架上,还要在目录卡上增加一条记录。书越多,目录维护的成本就越高。

所以,索引不是越多越好。好的索引策略需要在查询速度写入速度之间找到平衡点。


二、B+树:为什么MySQL选择它?

面试官问我的第二个问题是:"你知道为什么MySQL用B+树而不是其他数据结构做索引吗?"我当时完全答不上来,后来查了资料才明白,这里面大有学问。

2.1 常见数据结构的对比

在学习B+树之前,我先是了解了几种常见的数据结构:

二叉查找树(Binary Search Tree)
二叉查找树的查询效率是O(logN),看起来很不错。但问题是,二叉树会"偏科"——如果数据是有序的,二叉树会退化成链表,查询效率变成O(N)。更关键的是,二叉树的高度太高了。假设有100万条数据,二叉树的高度可能超过20层,这意味着一次查询需要20次磁盘IO。

平衡二叉树(AVL树)
AVL树通过旋转操作保持平衡,解决了二叉树退化成链表的问题。但AVL树的要求太严格了——任何节点的左右子树高度差不能超过1。这导致AVL树的插入操作需要大量的旋转来保持平衡,写入效率不高。而且20层的深度问题依然存在。

B树(Balance Tree)
B树是一种多路查找树,每个节点可以有多个子节点和多个数据项。假设每个节点可以存100个数据项,3层的B树就能存100万数据,查询只需要3次IO。这比二叉树强多了!但B树有个问题:它的数据既存在索引节点,也存在叶子节点。而且范围查询时,需要中序遍历,效率不够高。

B+树
B+树是B树的改进版,它的核心改进是:

  1. 非叶子节点只存索引(不存实际数据),这样每个节点能存更多的索引
  2. 所有数据都在叶子节点,叶子节点之间用双向链表连接

这样设计的好处是:

  • 非叶子节点更小,内存能缓存更多节点,减少IO次数
  • 叶子节点链表结构让范围查询非常高效
  • 查询路径稳定,IO次数可预测

2.2 B+树的可视化理解

为了理解B+树,我画了一个简单的示意图。假设我们有一个用户表,存储了用户ID和用户姓名,基于用户ID建立了主键索引:

                          [100 | 200 | 300]
                         /      |      \
           [1-100]    [101-200]    [201-300]   [301-400]
            /   \      /   \         /   \         /   \
         ...    ...   ...   ...     ...   ...      ...   ...
         
         叶子节点(双向链表连接):
         [1, 张三] → [2, 李四] → [3, 王五] → ... → [99, 赵六] → [100, 钱七]

这个图虽然简化了很多,但能说明几个关键点:

  1. 非叶子节点(索引层):存储的是索引值和指向下一层的指针,不存储实际数据
  2. 叶子节点:存储完整的数据行,并且按照索引值从小到大排列
  3. 链表结构:叶子节点之间有双向指针,范围查询时可以直接遍历链表

假设我们要查询ID为99的用户:

  1. 从根节点开始,发现99小于100,走左边的分支
  2. 到达中间层节点,发现99大于1且小于100,走中间分支
  3. 到达叶子节点,遍历链表找到ID为99的记录

整个过程只需要3次IO。

2.3 面试题敲黑板

经过学习,我总结了几个关于B+树的高频面试题:

问题一:为什么索引结构不用哈希表?
哈希表查询是O(1),看起来很快。但哈希表有两个致命缺陷:第一,不支持范围查询,比如"查询ID大于100的用户"就做不了;第二,哈希碰撞会导致性能退化。更重要的是,哈希表是无序的,无法支持ORDER BY操作。

问题二:B+树的高度通常是多少?
在InnoDB中,每个节点默认是16KB。假设索引键是8字节的bigint(主键ID),加上6字节的指针,每个节点大概能存1000个左右的键。3层B+树就能存10亿条数据。所以大多数情况下,B+树的高度是1-4层,查询只需要1-4次IO。

问题三:为什么建议主键自增?
主键自增的话,每次插入新记录都是追加到叶子节点的末尾,不需要移动已有数据,也不需要分裂节点。如果主键是无序的UUID,每次插入都可能需要找到合适的位置,甚至触发节点分裂,性能会差很多。


三、EXPLAIN:优化SQL的瑞士军刀

面试回来后,我导师(是的,我们公司也有导师制度)告诉我:想优化SQL,第一步就是学会用EXPLAIN。

3.1 EXPLAIN是什么?

EXPLAIN是MySQL提供的一个命令,它可以模拟优化器执行SQL查询,返回查询的执行计划。简单来说,EXPLAIN会告诉我们:这条SQL是怎么执行的、用了哪个索引、扫描了多少行数据。

使用方法超级简单:

EXPLAIN SELECT * FROM orders WHERE order_no = '202401150001';

执行后,MySQL会返回一张表,包含十几个字段。这些字段里藏着优化SQL的关键信息。

3.2 关键字段解析

我花了很长时间研究EXPLAIN返回的各个字段,最后发现最重要的就是这几个:

type字段:访问类型
type字段表示MySQL如何查找表中的行,按从好到坏排序:

type值 含义 说明
system 系统表 只有一行数据
const 常量匹配 最多匹配一行,比如主键查询
eq_ref 唯一索引查找 使用主键或唯一索引
ref 普通索引查找 使用普通索引,可能多行
range 范围查询 使用索引查一个范围
index 全索引扫描 遍历整个索引树
ALL 全表扫描 遍历整个数据表

好的SQL应该达到const或eq_ref,最差也要是index,如果是ALL就需要优化了

key字段:实际使用的索引
这个字段告诉我们查询到底用没用索引,用了哪个索引。如果显示NULL,说明没有使用索引,是全表扫描,需要重点优化。

rows字段:扫描的行数
这个字段是优化器预估的需要扫描的行数。注意是预估,不是实际。这个数字越小越好。如果一张表有100万行,rows显示100万,说明是全表扫描。

Extra字段:额外信息
这个字段包含很多重要信息:

  • Using filesort:排序没有使用索引,需要额外排序
  • Using temporary:使用了临时表,可能需要优化
  • Using index:覆盖索引,不需要回表查询
  • Using where:在存储引擎返回数据后,又用WHERE条件过滤

3.3 实战演示

让我用一个实际的例子来演示EXPLAIN的用法。假设我们有一张订单表:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(32) NOT NULL,
    user_id BIGINT NOT NULL,
    order_time DATETIME NOT NULL,
    status TINYINT NOT NULL DEFAULT 0,
    amount DECIMAL(10,2) NOT NULL,
    update_time DATETIME NOT NULL,
    INDEX idx_user_id (user_id),
    INDEX idx_order_time (order_time),
    INDEX idx_order_no (order_no)
) ENGINE=InnoDB;

现在执行几条SQL,看看EXPLAIN的结果:

案例一:主键查询

EXPLAIN SELECT * FROM orders WHERE id = 10086;

结果:

id select_type table type key key_len rows Extra
1 SIMPLE orders const PRIMARY 8 1 NULL

type是const,说明通过主键直接定位到唯一的一行,查询效率最高。

案例二:普通索引查询

EXPLAIN SELECT * FROM orders WHERE user_id = 100;

结果:

id select_type table type key key_len rows Extra
1 SIMPLE orders ref idx_user_id 8 156 NULL

type是ref,说明通过普通索引user_id查询,返回156行(预估)。这里有个问题:查询完后,MySQL需要通过主键回表查询完整数据,所以Extra显示NULL。

案例三:范围查询

EXPLAIN SELECT * FROM orders WHERE order_time > '2024-01-01' AND order_time < '2024-01-31';

结果:

id select_type table type key key_len rows Extra
1 SIMPLE orders range idx_order_time 5 5234 Using index condition

type是range,使用索引查询一个范围,rows显示5234行。Extra显示Using index condition,说明优化器用到了索引条件下推(ICP)功能。

案例四:最左前缀原则失效

EXPLAIN SELECT * FROM orders WHERE order_no LIKE '%20240115%';

结果:

id select_type table type key key_len rows Extra
1 SIMPLE orders ALL NULL NULL 125678 Using where

type是ALL!说明根本没有使用索引,是全表扫描。rows显示125678行,几乎扫描了整个表。这就是典型的索引失效场景。

面试官问我的那条SQL,就是这种情况。

3.4 面试题敲黑板

问题:怎么用EXPLAIN判断索引是否生效?
看key字段。如果key显示的是索引名,说明用到了索引;如果显示NULL,说明没有使用索引,是全表扫描。

问题:Extra显示Using filesort怎么优化?
Using filesort说明排序没有走索引。需要检查ORDER BY的字段是否在索引中,或者调整索引的顺序,让排序字段符合最左前缀原则。


四、最左前缀原则:索引设计的核心法则

如果说B+树是索引的"内功",那么最左前缀原则就是索引的"招式"。这是面试中经常被问到的问题,也是实际开发中最需要掌握的内容。

4.1 什么是最左前缀原则?

最左前缀原则的意思是:在创建联合索引时,索引字段的顺序非常重要,查询时必须从左到右使用索引字段,才能命中索引

举个例子,我们创建一个联合索引:

INDEX idx_user_time (user_id, order_time, status)

这个索引包含三个字段:user_id、order_time、status。

根据最左前缀原则,以下查询能使用索引:

-- 能使用索引(使用第1个字段)
SELECT * FROM orders WHERE user_id = 100;

-- 能使用索引(使用第1、2个字段)
SELECT * FROM orders WHERE user_id = 100 AND order_time > '2024-01-01';

-- 能使用索引(使用全部三个字段)
SELECT * FROM orders WHERE user_id = 100 AND order_time > '2024-01-01' AND status = 1;

以下查询不能使用索引:

-- 不能使用索引(跳过第1个字段,直接查第2个)
SELECT * FROM orders WHERE order_time > '2024-01-01';

-- 不能使用索引(跳过第1、2个字段,直接查第3个)
SELECT * FROM orders WHERE status = 1;

-- 不能使用索引(范围查询后,字段失效)
SELECT * FROM orders WHERE user_id < 100 AND order_time > '2024-01-01';

第三个例子需要特别说明:如果user_id使用了范围查询(比如<、>、BETWEEN),那么order_time字段的索引就失效了。这是因为B+树的索引结构决定的:范围查询会找到user_id<100的所有节点,但这些节点的order_time是乱序的,无法再使用索引快速查找。

4.2 索引失效的常见场景

除了最左前缀原则,还有几种常见的索引失效场景:

场景一:在索引列上使用函数

-- 索引失效
SELECT * FROM orders WHERE YEAR(order_time) = 2024;

-- 索引有效
SELECT * FROM orders WHERE order_time >= '2024-01-01' AND order_time < '2024-02-01';

原因:在索引列上使用函数,会破坏索引的有序性,MySQL无法使用B+树的有序特性。

场景二:字符串不加引号

-- 假设order_no是varchar类型
-- 索引可能失效
SELECT * FROM orders WHERE order_no = 202401150001;

-- 索引有效
SELECT * FROM orders WHERE order_no = '202401150001';

原因:类型转换会破坏索引的有序性。

场景三:LIKE以百分号开头

-- 索引失效
SELECT * FROM orders WHERE order_no LIKE '%20240115';

-- 索引有效
SELECT * FROM orders WHERE order_no LIKE '20240115%';

-- 索引有效(只查索引列)
SELECT order_no FROM orders WHERE order_no LIKE '20240115%';

原因:LIKE '%abc’是从右向左匹配的,无法利用索引的有序性。

场景四:使用OR连接

-- 假设order_time有索引,user_id有索引
-- 可能索引失效
SELECT * FROM orders WHERE order_time > '2024-01-01' OR user_id = 100;

-- 改用AND,索引有效
SELECT * FROM orders WHERE order_time > '2024-01-01' AND user_id = 100;

原因:OR会导致只要有一个条件没索引,就使用全表扫描。

4.3 如何设计高效的联合索引?

知道了最左前缀原则,就能明白联合索引的设计是有讲究的。我总结了一个口诀:区分度高的放左边,范围查询放右边

区分度:指的是字段的唯一值数量占总行数的比例。比例越高,区分度越好。比如user_id的区分度通常比status高(因为用户ID唯一,状态只有几种)。

设计原则

  1. 等值查询的字段放左边:因为等值查询可以精确匹配,不会导致后续字段失效
  2. 区分度高的放左边:这样过滤掉的数据多,后续查询的数据量小
  3. 范围查询的字段放右边:范围查询会导致后续字段失效,所以放在最后

举个例子,如果我们的查询经常是:

SELECT * FROM orders WHERE user_id = ? AND order_time > ? AND status = ? ORDER BY order_time DESC;

最佳索引应该是:

INDEX idx_user_status_time (user_id, status, order_time)

而不是:

INDEX idx_user_time_status (user_id, order_time, status)

因为如果order_time放在中间,status的索引就失效了。


五、覆盖索引:让查询飞起来

在研究EXPLAIN的时候,我发现Extra字段经常出现"Using index",这是什么意思呢?

5.1 什么是回表?

在InnoDB中,有两种索引:聚簇索引(Clustered Index)二级索引(Secondary Index)

聚簇索引:就是主键索引,叶子节点存储的是完整的数据行。

二级索引:也叫非聚簇索引,叶子节点存储的是索引键和主键值,不存储完整数据。

当我们通过二级索引查询时,过程是这样的:

  1. 在二级索引树中找到对应的记录,获取主键ID
  2. 通过主键ID回到聚簇索引,查找完整数据
  3. 返回完整数据

这个过程叫做回表。回表意味着需要两次索引查找,性能比只查一次索引要差。

5.2 什么是覆盖索引?

覆盖索引的意思是:查询的所有数据都能在索引中找到,不需要回表

比如我们有一个索引是INDEX idx_order_no (order_no),如果查询的是:

SELECT order_no, user_id FROM orders WHERE order_no = '202401150001';

这条SQL的order_no和user_id都能在idx_order_no索引中找到(因为user_id是主键,会被包含在二级索引的叶子节点中),所以不需要回表。

如果查询的是:

SELECT * FROM orders WHERE order_no = '202401150001';

这就需要回表了,因为status、amount等字段不在索引中。

5.3 覆盖索引的实战应用

怎么利用覆盖索引来优化SQL呢?

优化前(需要回表):

SELECT id, order_no, update_time FROM orders WHERE order_no LIKE '202401%' LIMIT 100;

这条SQL虽然用了索引,但因为LIKE以百分号开头,索引失效了。

优化后(覆盖索引):

SELECT id, order_no, update_time FROM orders 
WHERE order_no >= '20240100000000' AND order_no < '20240200000000' 
LIMIT 100;

这样写可以利用order_no的索引,而且查询的字段都在索引中,不需要回表。

另一个例子:

优化前

SELECT * FROM orders WHERE status = 1 ORDER BY order_time DESC LIMIT 100;

Extra显示Using filesort,说明排序没有走索引。

添加覆盖索引

ALTER TABLE orders ADD INDEX idx_status_time (status, order_time);

优化后

SELECT * FROM orders WHERE status = 1 ORDER BY order_time DESC LIMIT 100;

Extra显示Using index,说明使用了覆盖索引,不需要回表,也不需要额外排序。


六、性能优化:常见场景与解决方案

学完了理论知识,我用几个实际的优化案例来练练手。这些案例都是我在工作中或者模拟环境中遇到的。

6.1 案例一:分页查询优化

分页查询是开发中非常常见的场景。当offset很大时,查询会越来越慢:

-- 传统分页
SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 20;

这条SQL的原理是:先扫描前100万条记录,扔掉它们,然后取20条。这100万条记录的扫描是实实在在发生的,性能当然差。

优化方案一:基于ID的分页

-- 上一页最后一条记录的ID是1000999
SELECT * FROM orders WHERE id > 1000999 ORDER BY id ASC LIMIT 20;

这种方法利用主键索引,查询20条数据只需要20次IO。

优化方案二:延迟关联

SELECT o.* FROM orders o 
INNER JOIN (SELECT id FROM orders ORDER BY create_time DESC LIMIT 1000000, 20) t 
ON o.id = t.id;

这个查询分两步:第一步只查主键,Using index很快;第二步用主键关联查完整数据。这种方法比直接查快很多。

6.2 案例二:COUNT(*)优化

COUNT(*)也是经常被优化的对象:

-- 慢
SELECT COUNT(*) FROM orders WHERE status = 1;

如果status没有索引,这条SQL会全表扫描。

优化方案:添加索引

ALTER TABLE orders ADD INDEX idx_status (status);

这样COUNT(*)只需要扫描索引树,不需要扫描数据行。

注意:COUNT()和COUNT(column)是不同的。COUNT()统计所有行,包括NULL;COUNT(column)只统计非NULL的行。

6.3 案例三:JOIN优化

多表JOIN是性能问题的重灾区:

SELECT o.*, u.name, p.product_name 
FROM orders o 
JOIN users u ON o.user_id = u.id 
JOIN products p ON o.product_id = p.id 
WHERE o.status = 1 
AND u.register_time > '2024-01-01';

这条SQL涉及三张表,JOIN顺序不对可能导致灾难性的性能问题。

优化方案一:确保JOIN的字段有索引

-- orders表的user_id和product_id应该有索引
-- users表的id是主键,products表的id是主键

优化方案二:用小表驱动大表

-- 先查符合条件的用户(数据量小)
SELECT id, name FROM users WHERE register_time > '2024-01-01';

-- 再查这些用户的订单
SELECT o.*, u.name, p.product_name 
FROM (SELECT id, name FROM users WHERE register_time > '2024-01-01') u
JOIN orders o ON u.id = o.user_id
JOIN products p ON o.product_id = p.id
WHERE o.status = 1;

优化方案三:关闭不必要的JOIN

-- 如果只需要订单信息,不需要用户和产品详情
-- 只查orders表,加个索引
SELECT * FROM orders WHERE status = 1;

6.4 面试题敲黑板

问题:怎么优化深分页?
方案一:基于ID的游标分页,查询条件是id > last_id
方案二:延迟关联,先查主键再关联
方案三:记录上一页的某个字段值,用WHERE字段 > value来分页

问题:COUNT(*)慢怎么办?
方案一:确认COUNT的字段有索引
方案二:考虑是否真的需要精确COUNT,可以用近似值
方案三:使用缓存计数,但要注意数据一致性


七、总结:索引优化的检查清单

经过这两周的学习,我总结了一份索引优化的检查清单,每次写SQL或者优化SQL时都可以对照一下:

7.1 索引创建检查

检查项 说明 是否通过
主键是否自增 自增主键插入效率高
常用查询字段是否建索引 WHERE、JOIN、ORDER BY的字段
区分度低的字段是否单独建索引 区分度低的字段不适合做联合索引的引导列
联合索引顺序是否正确 最左前缀原则,区分度高的放左边
是否有多余的索引 重复索引、冗余索引要删除

7.2 SQL编写检查

检查项 说明 是否通过
是否命中索引 EXPLAIN的key字段不为NULL
是否避免索引失效 没有函数、没有隐式类型转换、LIKE不以%开头
是否覆盖索引 查询字段都在索引中,不需要回表
是否避免SELECT * 只查询需要的字段
分页是否高效 深分页用了合适的优化方案

7.3 性能监控检查

检查项 说明 是否通过
EXPLAIN的type是否良好 至少是index,不要是ALL
扫描行数是否合理 rows字段是否在可接受范围
是否出现Using filesort 排序是否走索引
是否出现Using temporary 是否避免了临时表

八、后记:从"小白"到"入门"的感悟

写完这篇文章,我不禁想起一个月前面试时那个尴尬的场景。当时的我,面对面试官的问题,只能支支吾吾地说"可能没有建索引"。

现在回想起来,当时的自己确实太浮躁了。以为会写几个SQL查询,会用MyBatis就懂数据库了。其实数据库是一个博大精深的领域,里面的水很深。

这两周的学习,让我明白了几个道理:

第一,原理比用法更重要。知道了B+树的结构,才能理解最左前缀原则;理解了最左前缀原则,才能设计出高效的索引。只知道"索引能让查询变快"是远远不够的。

第二,工具要用对地方。EXPLAIN是优化SQL的神器,但如果不知道怎么看、看什么,再好的工具也是摆设。

第三,实践出真知。看再多的理论,也不如实际跑几条SQL、看几个EXPLAIN结果来得深刻。

最后,我想对和我一样正在学习后端开发的同学们说:技术路很长,不要怕犯错,不要怕丢脸。每一次被问倒,都是成长的机会。就像我这次面试,虽然失败了,但让我真正开始认真对待数据库优化这个领域。

希望这篇文章能对你有所帮助。如果觉得有用,记得点赞、收藏、关注哦!你们的支持是我继续创作的最大动力!

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐