1 执行计划:解码 EXPLAIN 核心指标

        在进行 SQL 调优前,必须掌握 EXPLAIN 工具的输出含义。它并非简单的查询预览,而是优化器(Optimizer)基于代价模型计算出的 “最优” 路径。

1.1 type 列:判断访问效率的准绳

        type 反映了数据库查找数据行的具体方式。从优到劣的常见排序如下:

  • system/const:表中仅有一行匹配,通常发生在对主键(Primary Key)或唯一索引(Unique Index)的等值查询。
  • eq_ref:联表查询时,被驱动表通过唯一索引进行关联。
  • ref:通过非唯一索引进行等值查询。
  • range:索引范围扫描,常见于使用 >、<、BETWEEN、IN 等操作。
  • index:全索引扫描(Full Index Scan),虽然避免了回表,但仍需遍历整棵索引树。
  • ALL:全表扫描(Full Table Scan),性能最差,通常是优化的首选目标。

1.2 Extra 列:性能优化的隐藏线索

        Extra 字段包含了一些影响性能的关键辅助信息:

  • Using index:触发了覆盖索引,查询直接在二级索引树上完成,无需回表。
  • Using filesort:无法利用索引完成排序,需要在内存或磁盘中进行文件排序,这是 CPU 密集型操作。
  • Using temporary:由于查询过于复杂(如某些 GROUP BY),需要创建临时表来处理数据。

2 隐式转换:失效的 B+ 树有序性

        索引失效最隐蔽的原因之一是隐式类型转换。B+ 树的检索依赖于字段的有序性,任何对索引列的 “加工” 都会导致检索退化。

2.1 字符串与数字的等值博弈

        当字段定义为 varchar 类型,而查询条件传入 int 时,MySQL 会调用内部函数将索引列转换为数字进行比较。

-- 表结构中 user_id 为 varchar(32)
-- 错误示范:传入数字 10086
EXPLAIN SELECT * FROM users WHERE user_id = 10086;

底层逻辑剖析:

        上述 SQL 在执行时等同于 WHERE CAST(user_id AS signed) = 10086。

        由于 CAST 函数作用于索引列,B+ 树无法预测函数计算后的结果分布,无法进行二分查找。优化器为了保证结果准确,只能选择 type: ALL 进行全表扫描。

2.2 字符集冲突导致的联表失效

        在分布式系统迁移或多版本迭代中,常出现 A 表使用 utf8,而 B 表使用 utf8mb4 的情况。在执行 JOIN 时,字符集的不一致同样会触发隐式转换。

-- 即使两个关联字段都有索引,只要字符集不同,被驱动表索引也会失效
SELECT a.name, b.order_no 
FROM table_a a 
INNER JOIN table_b b ON a.user_id = b.user_id;

解决方案:

        在 DML 层面应通过 CONVERT 手动处理非索引列,或在 DDL 层面统一全库字符集。推荐在 2026 年后的工程实践中强制统一使用 utf8mb4。


3 联合索引:最左匹配与寻址机制

        联合索引(Composite Index)的本质是 “多字段排序”。理解其底层的物理存储逻辑,是规避索引失效的关键。

3.1 复合索引的内部物理结构

        假设建立索引 KEY idx_a_b_c (a, b, c),在 B+ 树的叶子节点中,数据是严格按照 a 升序排序的;只有当 a 相等时,才会按 b 排序;以此类推。

3.2 字段跳变与范围查询的截断效应

        最左匹配原则要求查询条件必须从索引的最左列开始。

-- 场景 1:完全匹配(高效)
SELECT * FROM t WHERE a = 1 AND b = 2 AND c = 3;

-- 场景 2:中间跳变(部分失效)
-- 只能用到 a 的索引,b 与 c 将退化为在 a=1 结果集中的逻辑过滤
SELECT * FROM t WHERE a = 1 AND c = 3;

-- 场景 3:范围查询截断
-- a 可以用到索引,但由于 b 范围不确定,c 无法利用索引的有序性进行定位
SELECT * FROM t WHERE a = 1 AND b > 10 AND c = 3;

实战避坑指南:

        在设计索引时,务必将区分度高(Cardinality 大)且常作为等值查询的字段放在最左侧。对于包含范围查询(如时间戳)的 SQL,应尽量将其放在索引列的最后一位,以极大化提升前导列的过滤效率。


4 代价模型:优化器为何放弃索引

        MySQL 的优化器(CBO, Cost-Based Optimizer)在选择执行路径时,其核心准则并非 “有索引必用”,而是 “哪种方式成本最低”。

4.1 I/O 成本与 CPU 成本的权衡

        优化器在估算成本时,主要考虑两个维度:

  • I/O 成本:从磁盘读取数据页到内存的开销。
  • CPU 成本:在内存中进行记录比较、排序等操作的开销。

        对于非聚簇索引查询,如果预估返回的记录行数占总行数的比例过高(通常阈值在 20% - 30% 之间),优化器会认为通过二级索引定位后再频繁回表(Random I/O)的成本,远高于直接进行全表扫描(Sequential I/O)的成本。

4.2 选择度与扫描区间的冲突

        选择度是指不重复的索引值(Cardinality)与表记录总数的比值。

-- 场景:状态字段只有 0 和 1 ,分布各占 50%
-- 优化器通常会放弃 idx_status ,直接执行全表扫描
SELECT * FROM orders WHERE status = 1;

底层逻辑剖析:

        当优化器发现 status = 1 的数据分布极其广泛时,它会判定利用二级索引进行 B+ 树搜索后再回表的代价过大。这种情况下,即使强制使用 FORCE INDEX,查询速度也往往不如全表扫描。


5 深度分页:回表风暴与 I/O 成本

        在海量数据场景下,简单的 LIMIT 分页会随着偏移量(Offset)的增加而产生严重的性能衰减。

5.1 偏移量的本质:扫描并丢弃

        当执行 LIMIT 1000000, 10 时,MySQL 并非直接跳到第 100 万行,而是从第一行开始顺序扫描 1,000,010 行记录,随后丢弃前 100 万行。

5.2 聚簇索引与二级索引的 I/O 博弈

        如果在分页查询中涉及非覆盖索引字段,每扫描一行记录都需要进行一次回表操作。100 万次的回表意味着大量的随机磁盘访问,这是导致 SQL 耗时从毫秒级拉升至秒级的根本原因。

5.3 延迟关联重构实践

        通过先在索引树上完成分页筛选,再回表获取完整字段,可以极大化减少 I/O 开销。

-- 原始写法:引发百万次级回表
SELECT * FROM orders WHERE user_id = 'A' ORDER BY id LIMIT 1000000, 10;

-- 优化写法:延迟关联
-- 为什么优化:子查询通过覆盖索引(Covering Index)仅扫描 ID ,不产生回表;
-- 外部查询通过主键进行常量级关联,回表次数被严格限制在 10 次。
SELECT o.* FROM orders o
JOIN (
    SELECT id FROM orders WHERE user_id = 'A' ORDER BY id LIMIT 1000000, 10
) AS tmp ON o.id = tmp.id;

6 函数操作:非等值匹配的反模式

        在 WHERE 子句中对索引列进行函数运算或表达式计算,是导致索引失效最常见的开发误区。

6.1 函数破坏 SARGable 特性

        SARG(Search ARGumentable)是指查询条件可以利用索引的有序性进行快速定位。

-- 错误示范:对日期列使用 MONTH 函数
-- 为什么失效:索引树存储的是原始日期,MONTH 函数会改变所有值的逻辑顺序,导致无法进行二分查找
SELECT * FROM orders WHERE MONTH(created_at) = 12;

-- 正确示范:转换为范围查询
-- 为什么有效:保持了索引列的原始性,利用 B+ 树的 Range Scan 快速定位
SELECT * FROM orders WHERE created_at >= '2025-12-01' AND created_at <= '2025-12-31';

6.2 表达式计算的逻辑偏差

        同样的逻辑也适用于算术运算:

-- 错误示范:索引列参与运算
SELECT * FROM products WHERE price * 0.9 < 100;

-- 正确示范:将运算移至等号右侧
-- 为什么这样做:确保等号左侧为纯净的索引列名,符合存储引擎的匹配规范
SELECT * FROM products WHERE price < 100 / 0.9;

6.3 模糊查询的前缀限制

        LIKE 模糊查询只有在 “前缀匹配” 时才能利用索引。

  • LIKE 'abc%':有效。能够利用 B+ 树的有序性定位以 abc 开头的范围。
  • LIKE '%abc':失效。索引树无法按照后缀进行排序,只能全索引扫描或全表扫描。
  • LIKE '%abc%':失效。

实战复盘建议:

        在必须进行全模糊匹配的业务场景中,应引入倒排索引(Inverted Index)机制,例如使用 Elasticsearch 或 MySQL 的 FULLTEXT 索引,而非在千万级 B+ 树上进行全量匹配。


7 范围查询:索引下推与间隙锁

        范围查询(Range Scan)是业务中最高频的操作之一,但其底层涉及的索引过滤机制与锁竞争逻辑最为复杂。

7.1 索引下推的减负效应

        在 MySQL 5.6 之前的版本中,存储引擎通过索引定位到基准数据后,必须将整行记录回表交给 Server 层进行后续过滤。索引下推(Index Condition Pushdown)将部分过滤动作下放到了存储引擎层。

-- 假设存在复合索引 idx_name_age (name, age)
-- 查询条件包含前缀匹配和范围过滤
EXPLAIN SELECT * FROM employees WHERE name LIKE 'Zhang%' AND age > 25;

执行计划解读:

        如果在 Extra 列看到 Using index condition ,说明 ICP 已经生效。存储引擎在扫描 idx_name_age 索引树时,会直接判断 age > 25 是否成立,只有满足条件的记录才执行回表。这极大化减少了不必要的磁盘 I/O 开销。

7.2 可重复读下的间隙锁隐患

        在默认的 RR 隔离级别下,范围查询为了防止幻读(Phantom Read),会引入间隙锁。

-- 开启事务 A 执行范围更新
BEGIN;
UPDATE orders SET status = 2 WHERE id BETWEEN 10 AND 20;

并发风险评估:

        此时,存储引擎不仅锁定了 id 为 10 到 20 的现有记录,还锁定了这些 ID 之间的 “间隙”。若事务 B 此时尝试插入 id = 15 的新记录,将会陷入阻塞。在高频并发写入的场景下,范围查询极易引发大面积的锁等待甚至死锁。建议在业务层尽可能通过主键进行精确匹配,或缩减事务持有锁的时间。


8 联表查询:驱动表与 NLJ 算法

        MySQL 执行 JOIN 操作的本质是嵌套循环。理解驱动表(Driving Table)的选择逻辑,是优化多表关联的核心。

8.1 嵌套循环连接工作原理

        最基础的关联算法是 Index Nested-Loop Join 。它从驱动表中逐行读取记录,然后利用索引去被驱动表中定位匹配行。

        性能公式:总成本 ≈ 驱动表扫描成本 + (驱动表行数 × 被驱动表索引检索成本)

        由此可见,驱动表的数据量级直接决定了外层循环的次数。

8.2 “小表驱动大表” 的工程内幕

        优化器通常会自动选择结果集最小的表作为驱动表。但当过滤条件复杂时,优化器可能出现误判。

-- 通过 STRAIGHT_JOIN 强制指定驱动顺序进行性能测试
SELECT STRAIGHT_JOIN a.*, b.detail 
FROM small_table a 
INNER JOIN large_table b ON a.id = b.a_id 
WHERE a.status = 1;

优化策略:

  • 确保被驱动表有关联索引:如果被驱动表的关联字段没有索引,算法会退化为 Block Nested-Loop Join(BNL),将产生极其严重的 CPU 负载。
  • 过滤优先:在 JOIN 之前通过 WHERE 条件尽可能削减驱动表的结果集。

9 实战复盘:千万级慢查询重构

        本节复盘一个真实的生产环境案例:订单查询接口在数据量突破 3000 万后,响应时间从 200ms 飙升至 8s 。

9.1 问题定义:多维度条件导致的索引失效

        原始 SQL 逻辑如下,由于涉及时间范围、用户标识和模糊匹配,执行计划显示触发了全表扫描:

-- 原始慢 SQL
SELECT * FROM orders 
WHERE user_id = 'U12345' 
  AND created_at >= '2026-01-01' 
  AND remark LIKE '%urgent%' 
ORDER BY created_at DESC 
LIMIT 0, 20;

9.2 瓶颈分析

  • 索引选择偏差:由于 created_at 存在大量重复值,优化器判定回表成本过高,放弃了时间索引。
  • 模糊匹配无法利用索引:LIKE '%urgent%' 导致了全量数据行的加载。
  • 排序开销:由于没有合适的索引覆盖 ORDER BY 字段,触发了 Using filesort 。

9.3 重构方案:复合索引 + 延迟关联

        通过引入针对业务场景定制的复合索引,并采用延迟关联策略进行重构:

-- 1. 建立高辨识度复合索引
-- ALTER TABLE orders ADD INDEX idx_user_time (user_id, created_at);

-- 2. 重构 SQL 执行链路
SELECT o.* FROM orders o
INNER JOIN (
    -- 在覆盖索引中快速完成 ID 的筛选与排序
    SELECT id FROM orders 
    WHERE user_id = 'U12345' 
      AND created_at >= '2026-01-01' 
    ORDER BY created_at DESC 
    LIMIT 0, 20
) AS tmp ON o.id = tmp.id
WHERE o.remark LIKE '%urgent%'; -- 将模糊过滤放在最后的小结果集中处理

9.4 最终成果

  • 扫描行数:从 3000 万行骤减至 200 行。
  • 响应时间:从 8.2s 优化至 45ms 。
  • 技术债清理:通过延迟关联规避了深分页时的回表风暴,确保了系统在数据量持续增长下的稳定性。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐