Java 王者修炼手册【Mysql篇 - 索引+ SQL优化】:拆解 索引 + 执行计划 + SQL优化 核心原理

41 阅读16分钟

大家好,我是程序员强子。

又来提升英雄熟练度了~~ 今天专注提升 Mysql的索引 + Sql 优化相关~~

2037544.jpg

来看看今天有哪些知识点:

  • 索引

    • B+树 与红黑树 对比
    • 最左匹配原则/索引下推/前缀索引/索引合并机制..
    • 索引 与 排序
  • 执行计划

    • 字段详解
  • SQL优化实战

    • 子查询
    • 深度分页
    • count查询

来不及解释了,快点上车~

索引

B+树

层级结构

  • 根节点 / 枝节点

    • 仅存储索引键 + 下一层节点指针,不存实际数据,确保树的高度极低
    • 通常 2-4 层,支持千万级数据快速定位
  • 叶子节点

    • 所有叶子节点通过双向链表连接,方便范围查询和排序;
    • 存储内容分两种对应 聚簇 / 非聚簇索引

核心优势

  • 范围查询(如 BETWEEN)
  • 排序(如 ORDER BY)
  • 磁盘 I/O 次数固定,比较少,取决树的高度

为什么选 B+ 树而非红黑树?

  • 树的高度

    • 红黑树是二叉树,数据量越大,树的高度越高(千万级数据可能达 30 层),每次查询需多次磁盘 I/O;
    • B+ 树是多路平衡树,高度固定在 2-4 层,I/O 次数极少
  • 范围查询能力

    • 红黑树需遍历整棵树才能完成范围查询;
    • B+ 树叶子节点是有序链表,直接遍历链表即可,效率远超红黑树
  • 数据存储密度

    • B+ 树非叶子节点仅存索引键指针,单节点可存储更多索引项,进一步降低树高;
    • 红黑树每个节点存完整数据,存储密度低

为什么B+非叶子节点不保存整行数据?

InnoDB每页 16KB 固定,假设 主键 + 指针≈14 字节,数据≈100 字节;

  • B 树(非叶子存数据):一页只能存≈16384÷(14+100)≈143 个条目;
  • B + 树(非叶子不存数据):一页能存≈16384÷14≈1170 个条目。

B + 树的 目录层 能塞更多索引,树更矮(2-3 层就能存千万行)

分类

聚簇索引(主键索引)

  • 核心特点:以主键为索引键,叶子节点直接存储完整数据记录,整个表的物理存储顺序与索引顺序一致
  • 注意:一张表只能有一个聚簇索引,若未显式定义主键,InnoDB 会选择唯一非空索引,若无则生成隐藏主键(row_id)

非聚簇索引(二级索引)

  • 核心特点:以非主键字段为索引键,叶子节点仅存储 索引键 + 主键值 ,查询时需通过主键回查聚簇索引获取完整数据(即 “回表”)

使用规则

最左匹配原则

联合索引的索引键按定义顺序排序,查询时必须从左到右匹配,中间不能中断,否则无法命中索引。

案例:联合索引 idx_a_b_c(a, b, c)

  • 命中索引

    • WHERE a=1
    • WHERE a=1 AND b=2
    • WHERE a=1 AND b=2 AND c=3
  • 未命中索引

    • WHERE b=2(跳过 a)
    • WHERE a=1 AND c=3(中断 b)

覆盖索引

查询的所有字段都包含在索引中,无需回表

示例:

假设学生表student有字段:id(主键)、nameagescore,创建联合索引idx_age_score(age, score):

  • 普通查询(需回表)

    • SELECT id, name FROM student WHERE age=18;
    • name不在索引中,需先查idx_age_score得主键id,再查聚簇索引拿name。
  • 覆盖索引查询(无需回表)

    • SELECT age, score FROM student WHERE age=18;
    • 查询字段age、score均在idx_age_score中,直接从辅助索引返回数据

简单来说:直接 绕过回表,查询字段全在索引中;

索引下推

目的就是为了极少 回表次数

存储引擎在遍历索引时,先过滤索引中能匹配的条件,再将结果返回给服务器层

示例

以上面student表的联合索引idx_name_age(name, age)为例

SELECT * FROM student WHERE name LIKE '岑%' AND age=18;

如果是无索引下推:

  • 存储引擎先找出所有name以 “张” 开头的索引记录,返回对应的主键(比如 100 个)
  • 服务器层拿着这 100 个主键查聚簇索引(回表) ,逐一判断age=18,最终可能只留 10 个符合的

有索引下推:

  • 存储引擎遍历索引时,同时检查age=18
  • 仅返回符合name LIKE '张%' AND age=18的主键(比如 10 个)
  • 服务器层只需回表 10 次,效率大幅提升

有适用条件:

  • 仅适用于辅助索引(聚簇索引叶子节点是数据,无需下推);
  • 支持联合索引的后续字段过滤、like前缀匹配(%张不行,因为无法走索引)、数值比较等条件。

简单来说: 减少回表次数,先过滤索引中的条件再回表

前缀索引

长字符串字段(如手机号、邮箱),仅取前 N 个字符建立索引

案例:对手机号字段建立前缀索引

CREATE INDEX idx_phone_prefix ON user(phone(11)); -- 手机号固定 11 位,取完整长度
-- 若为邮箱,可根据分布取前 6 位:CREATE INDEX idx_email_prefix ON user(email(6));

范围条件后的字段无法命中

若联合索引中某字段用范围条件(>、<、BETWEEN 等)

该字段右侧的索引字段无法被利用

案例:联合索引 idx_a_b_c(a,b,c)

SELECT * FROM t WHERE a=1 AND b>2 AND c=3;
  • 实际命中:仅 a=1 和 b>2 部分,c=3 无法利用索引(因 b 是范围条件,后续字段顺序被打乱)
  • 优化:若 c 条件频繁使用,可调整索引顺序为 (a,c,b)

查询命中多个独立索引

当查询条件涉及多个字段,且每个字段都有独立索引

比如 a 有索引 idx_a,b 有索引 idx_b)

MySQL 可能会触发索引合并(Index Merge)

那什么是索引合并呢?来来来,跟强子仔细研究一下

索引合并

交集合并

多条件用 AND 连接,取多个索引结果的交集

需满足条件:每个索引都能精准匹配,如 =、IN

案例:WHERE a=1 AND b=2(a 和 b 各有单列索引)

分别用 idx_a 查 a=1 的主键列表

用 idx_b 查 b=2 的主键列表

取两个列表的交集,再回表查数据

并集合并

多条件用 OR 连接,取多个索引结果的并集

需满足条件:每个索引都能匹配部分条件,且无范围查询

案例:WHERE a=1 OR b=2(a 和 b 各有单列索引)

分别用 idx_a 和 idx_b 查结果,合并去重后回表

排序合并

多条件用 OR 连接,但包含范围查询

需先排序再合并(效率较低)

案例:WHERE a>1 OR b>2(a 和 b 各有单列索引)

局限性

  • 效率低:需扫描多个索引,再合并结果(涉及排序、去重),成本高于扫描单个联合索引。
  • 适用场景窄:仅支持简单条件(AND/OR 连接的等值或范围查询),复杂条件(如 GROUP BY、ORDER BY)无法合并。
  • 回表次数多:多个索引的结果需分别回表,比联合索引的单次回表成本高

常见索引失效场景

索引字段用函数 / 表达式

WHERE SUBSTR(phone,1,3)='138'

无法命中 phone 索引

隐式类型转换

WHERE phone=13800138000

phone 是字符串,与数字比较

模糊查询

WHERE name LIKE '%张三'

前缀模糊无法命中索引

联合索引中断匹配

上文案例: idx_a_b_c 中跳过中间字段

使用 OR 连接非索引字段

WHERE a=1 OR b=2

若 b 无索引,整个查询无法命中 a 的索引

不知道大家会不会遇到这个问题:

  • 明明有索引却走全表
  • 明明看着索引A更符合条件,却走了索引B

可用 trace 工具查看优化器的 “决策过程”,分析成本计算、索引筛选逻辑~

那什么是trace工具呢? 接下来跟强子仔细研究一下~

trace工具

核心作用

  • 查看优化器如何计算 全表扫描成本索引扫描成本
  • 定位为什么优化器不选预期的索引,比如统计信息过时成本估算偏差

使用步骤

步骤 1:开启 trace

-- 开启 trace,设置格式为 JSON,大小限制 102400
SET optimizer_trace = "enabled=on", end_markers_in_json=on;
SET optimizer_trace_max_mem_size = 102400;

步骤 2:执行目标 SQL(如慢查询)

-- 执行选错索引的 SQL
SELECT * FROM user WHERE age BETWEEN 20 AND 30;

步骤 3:查看 trace 结果

-- 查询 trace 日志,重点看 join_optimization 阶段
SELECT * FROM information_schema.OPTIMIZER_TRACE\G;

步骤 4:分析关键信息

在 trace 结果的 join_optimization → considered_execution_plans 中,可看到:

  • cost:全表扫描成本(如 1000)与索引扫描成本(如 1200),优化器会选成本低的。
  • index:优化器考虑的索引列表(如 idx_age),若索引未被考虑,可能是统计信息过时。

步骤 5:关闭 trace

SET optimizer_trace = "enabled=off";

排序

MySQL 排序分为 索引排序文件排序,两者性能差异极大

索引排序

利用索引本身的有序性,直接从索引中获取有序数据

无需额外排序操作

ORDER BY 的字段顺序、排序方向(ASC/DESC)与索引完全一致,且满足最左匹配原则。

案例:表 user 有联合索引 idx_age_name(age ASC, name ASC),执行查询

SELECT age, name FROM user ORDER BY age ASC, name ASC;

文件排序

当无法利用索引排序时,MySQL 需要先从表中读取数据

再在内存或磁盘中对结果集进行排序

内存排序有什么特点?

当待排序数据量 ≤sort_buffer_size(默认 256KB)时,直接在内存的 排序缓冲区中完成排序

步骤:

  • 从表中读取满足 WHERE 条件的记录,将 ORDER BY 字段和主键(用于回表取完整数据)存入排序缓冲区
  • 在排序缓冲区中按 ORDER BY 字段排序
  • 按排序结果的主键回表,获取完整数据并返回

磁盘排序有什么特点?

当待排序数据量 > sort_buffer_size 时,需借助临时文件分块排序,再合并结果

步骤:

  • 分块读取数据,每块大小不超过 sort_buffer_size,在内存中排序后写入临时文件
  • 对所有临时文件进行 归并排序(多路合并),得到全局有序的结果;
  • 按排序结果的主键回表,返回完整数据

执行计划

EXPLAIN 输出共 8 个核心字段(id、select_type、table、type、possible_keys、key、rows、Extra)

字段名核心作用
id标记查询的执行顺序(id 相同按从上到下执行,id 越大越先执行,如子查询)
select_type区分查询类型(如 SIMPLE 简单查询、SUBQUERY 子查询、DERIVED 派生表)
table当前行操作的表名(可能是表别名或派生表编号,如 <font style="color:rgb(0, 0, 0);"><derived2></font>
type访问类型(性能优先级:const > eq_ref > ref > range > index > ALL)
possible_keys优化器认为 “可能适用” 的索引(不一定实际使用)
key实际使用的索引(NULL 表示没走任何索引,需重点关注)
rows预估扫描的行数(数值越小越好,直接反映查询的 “范围大小”)
Extra额外执行信息(包含关键优化点,如 Using index、Using filesort)

接着跟着强子脚步,重点字段解读~

type

type 代表 MySQL 查找数据的方式,优先级从高到低对应性能从快到慢

type适用场景案例
const主键 / 唯一索引的等值查询(仅查 1 条数据,速度最快)SELECT * FROM user WHERE id=1; id 是主键
eq_ref多表 JOIN 时,关联字段是主键 / 唯一索引(每张表仅匹配 1 条)SELECT * FROM user u JOIN order o ON u.id=o.user_id; o.user_id 是主键
ref普通索引的等值查询(匹配多条,但比全表快)SELECT * FROM user WHERE phone='13800138000'; phone 是普通索引
range索引的范围查询(如 BETWEEN、>、IN 等)SELECT * FROM user WHERE age BETWEEN 20 AND 30; age 是索引
index扫描整个索引树(比全表快,但仍需遍历所有索引项)SELECT COUNT(*) FROM user; 有索引 idx_age,扫描整个索引计数
ALL全表扫描(遍历所有数据行,性能最差,必须优化)

key

实际使用的索引

  • 若 key=NULL:说明没走任何索引,大概率是慢 SQL(除非表数据极少,如几十条)。

  • 若 key≠possible_keys

    • 优化器从 可能的索引 中选了一个更优的
    • 比如统计信息显示某索引扫描行数更少

案例:表 user 有索引 idx_phone(phone)和 idx_email(email)

执行 SELECT * FROM user WHERE phone='13800138000';

  • possible_keys

    • idx_phone,idx_email
    • 优化器认为两个索引都可能用,但实际只需要 idx_phone
  • key:idx_phone(实际使用的索引)

rows

预估扫描行数

是优化器根据统计信息估算的 需要扫描的行数,数值越小,查询范围越精准

  • 全表扫描(type=ALL):rows 接近表的总数据量(如 10 万条)。
  • 索引命中(type=ref):rows 可能只有几十或几百条(如查 phone='13800138000',rows=1)

rows 是 预估值

若统计信息过时(如刚批量插入数据未更新统计),可能与实际行数偏差大

需用 ANALYZE TABLE 表名 刷新统计

Extra

Extra 包含 SQL 执行的细节

Extra 信息含义与影响案例
Using index覆盖索引 查询字段均在索引中,无需回表,性能优SELECT id,phone FROM user WHERE phone='13800138000'; 索引 idx_phone 包含 id 和 phone
Using filesort文件排序 无法用索引排序,需在内存 / 磁盘排序,性能差SELECT * FROM user ORDER BY age; age 无索引
Using temporary临时表 需创建临时表存储中间结果,如 GROUP BY 非索引字段,性能差SELECT name,COUNT(*) FROM user GROUP BY name; name 无索引
Using where过滤条件在 执行器处理 索引仅用于定位数据,需再过滤,无性能问题SELECT * FROM user WHERE age>20; age 是索引,先查索引再过滤
Using join buffer多表 JOIN 未走索引,用内存缓冲区暂存数据 性能差,需加关联索引

总结

  1. type:优先保证 type 是 const/ref/range,避免 ALL/index。
  2. key:key 不为 NULL 是基础,尽量用联合索引覆盖多条件。
  3. rows:预估行数越小越好,超过 1000 需检查索引是否精准。
  4. Extra:杜绝 Using filesort/Using temporary,追求 Using index。

SQL优化

子查询优化

子查询(尤其是关联子查询)容易产生临时表(Using temporary),导致频繁磁盘 I/O;

子查询会将中间结果存入临时表,若子查询结果量大,临时表的创建和读取会严重拖慢性能

JOIN 可通过索引直接关联,效率更高

案例

-- 查“有订单的用户”信息,子查询获取有订单的 user_id
SELECT id, name FROM user 
WHERE id IN (SELECT user_id FROM `orderWHERE status = 1);

执行结果

  • 子查询生成临时表存储 user_id,再与 user 表匹配,耗时 300ms,
  • EXPLAIN 显示 Extra=Using temporary

优化后

-- 用 INNER JOIN 关联,order.user_id 加索引
SELECT DISTINCT u.id, u.name 
FROM user u
INNER JOIN `order` o ON u.id = o.user_id 
WHERE o.status = 1;

-- 建索引:关联字段加索引(关键)
CREATE INDEX idx_order_user_id ON `order`(user_id, status); -- 覆盖 status 过滤条件

执行结果

  • 通过索引直接关联两张表,无临时表,耗时 50ms
  • EXPLAIN 显示 type=ref

深度分页优化

LIMIT 1000000, 10 会扫描前 1000010 条数据,再丢弃前 1000000 条,效率极低;

优化核心是 用索引定位起始位置

案例

SELECT id, name, create_time FROM user 
ORDER BY create_time DESC 
LIMIT 100000010;

执行结果

  • 扫描 1000010 条数据,耗时 800ms
  • EXPLAIN 显示 Using filesort

书签法(游标分页)

通过 唯一索引字段(如 id、create_time+id) 记录上次查询的最后一个值

直接跳转到起始位置,避免扫描无用数据

-- 1. 先查上一页最后一条数据的 create_time 和 id(假设上一页最后一条 id=1000000,create_time='2024-01-01')
-- 2. 用“create_time < 上一页时间 AND id > 上一页 id”定位起始位置(避免时间重复导致漏数据)
SELECT id, name, create_time FROM user 
WHERE create_time < '2024-01-01' AND id > 1000000
ORDER BY create_time DESC, id DESC 
LIMIT 10;

-- 建索引:覆盖排序和过滤条件(关键)
CREATE INDEX idx_user_create_id ON user(create_time DESC, id DESC);

执行结果

  • 直接扫描 10 条数据,耗时 20ms
  • EXPLAIN 显示 type=range、Extra=Using index

count查询

优化核心是 避免每次扫表统计

通过 缓存统计表 提前存储结果

方案 1:用 Redis 缓存计数

增删数据时同步更新 Redis 的计数 key,查询时直接读 Redis,避免扫表。

// 1. 新增用户时,Redis 计数+1
redisTemplate.opsForValue().increment("user:total:count", 1);

// 2. 删除用户时,Redis 计数-1
redisTemplate.opsForValue().decrement("user:total:count", 1);

// 3. 查询总行数,直接读 Redis
Long total = redisTemplate.opsForValue().get("user:total:count");

方案 2:用统计表存储计数

建一张专门的计数表,记录各表的总行数,增删数据时更新计数表,查询时读计数表

-- 1. 建计数表
CREATE TABLE table_count (
  table_name VARCHAR(50PRIMARY KEY, -- 表名
  total_count BIGINT NOT NULL         -- 总行数
);

-- 2. 初始化 user 表计数
INSERT INTO table_count VALUES ('user', (SELECT COUNT(*FROM user));

-- 3. 新增用户时,更新计数表
INSERT INTO user (name, phone) VALUES ('张三''13800138000');
UPDATE table_count SET total_count = total_count + 1 WHERE table_name = 'user';

-- 4. 查询 user 表总行数,直接读计数表
SELECT total_count FROM table_count WHERE table_name = 'user';

总结

今天强子带我们本文围绕 MySQL 性能优化核心方向展开

  • 先对比了 B + 树与红黑树在索引场景的适配差异,
  • 详解最左匹配原则、索引下推等关键索引机制,
  • 厘清了索引与排序的内在关联;
  • 再拆解执行计划核心字段,为优化提供判断依据;
  • 最后落地到 SQL 实战优化,针对子查询、深度分页、count 查询这三大高频痛点给出解决方案

这些知识点既是面试高频考点,也是日常开发中提升系统性能的关键~

熟练度刷不停,知识点吃透稳,下期接着练~