上一篇我们梳理了事务隔离与日志流转,但在实际的计费与投放业务中,即便锁冲突与复制延迟已被抚平,线上照样会冒出「一条 SQL 把 CPU 打满」的险情:报表导出卡在深分页、配置后台一条 IN 圈选扫穿整张投放表、优化器放着覆盖索引不用偏走全表。本篇我们将视线从存储引擎上移,站在内核优化器与执行计划一侧,讲清 EXPLAIN 看什么、优化器怎么估价、常见失效与 JOIN/分页怎么改——这是守住广告配置库与报表库性能边界的日常兵器。
本文是 MySQL 内核 系列的第 4 篇(执行计划与慢查询优化)。 全系列 5 篇:
- MySQL 内核深挖 · InnoDB 存储结构与 B+ 树索引
- MySQL 内核深挖 · 事务隔离级别、MVCC 与行锁
- MySQL 内核深挖 · redo、undo 与 binlog 机制
- MySQL 内核深挖 · EXPLAIN 计划与慢查询优化(本篇)
- MySQL 内核深挖 · 主从复制链路与 HA 高可用
一句话定位:慢查询治理 = 读懂执行计划 + 让谓词与索引形状匹配 + 控制扫描行数与排序/临时表;先量(慢日志 /
EXPLAIN ANALYZE),再改,最后才谈硬件。
TL;DR
- 执行路径:SQL → 解析 → 优化器(代价模型) → 执行计划 → 执行器,
EXPLAIN呈现的正是优化器基于统计信息的估算结论。 - 审视四列:
type决定访问层级(从const到ALL),key反映实际命中索引,rows估算扫描规模,Extra暴露filesort或临时表等高危操作。 - 防范失效:避免在索引列上施加函数或引发隐式类型转换,警惕最左前缀断裂与
%前导模糊查询,关注统计信息过期导致的选错索引。 - 关联优化:遵循「小结果集驱动大表、被驱动表走索引」原则,利用 Index Nested-Loop 提效,MySQL 8.0 针对大表等值连接可启用 Hash Join。
- 化解深分页:大
OFFSET会因沿叶子链表范围扫描后大量丢弃而拖垮系统,改用 keyset(WHERE id > ?)或延迟关联先取主键进行规避。 - 治理闭环:通过慢日志聚合 Top N 开启分析,结合
EXPLAIN ANALYZE确认真实瓶颈,优化后务必回归校验扫描行与 Buffer Pool 命中率。
Table of contents
Open Table of contents
1. 优化器在干什么
在我们讨论过的 InnoDB 层,核心职责是存与取 16KB 的内存页,而位于 Server 层的优化器,则负责在多条「都能算出正确答案」的执行路径里,挑出估算代价最低的那一条。究竟是走聚簇索引还是二级索引,表连接的顺序如何排布,以及是否需要物化临时表或触发文件排序,都由它结合多维度指标来定夺。这种决策机制主要依赖三类基准:表与索引的基数 / 直方图等统计数据,评估 IO 与 CPU 消耗的代价常数,以及常量传播、条件消除等启发式重写规则。
正因如此,统计信息一旦过期,rows 的估算就会大幅偏离,导致索引自然选错。例如在广告配置表中发生剧烈的分批下线操作后,业务偶发「执行计划飘了」的告警,根因往往就在于引擎与实际数据分布状态不一致。此时,与其盲目改写 SQL,不如优先通过 ANALYZE TABLE 刷新统计数据或开启持久化统计来兜底。
EXPLAIN 给出的仅是基于代价模型的估算,唯有 EXPLAIN ANALYZE 才能揭示各个算子真实的耗时与实际输出行数(MySQL 8.0.18+ 引入)。
2. EXPLAIN 实战读法
既然优化器掌控着全局路线图,下一步便是学会读懂它的输出结论——EXPLAIN,从而透视 SQL 在底层的流转脉络。
2.1 type:从好到差的直觉
type 字段刻画了访问数据源的具体方式,按性能由优到劣大致排列为:system / const → eq_ref → ref → range → index → ALL。
- const / eq_ref:基于主键或唯一键的等值查询,属于理想的精确打击;
- ref:基于非唯一索引的等值匹配,这在广告业务的
advertiser_id=?条件中最常见; - range:基于索引的范围扫描或
IN列表圈选; - index:沿叶子链表范围扫描整棵索引树,虽然比扫表快,但通常意味着缺乏精准过滤;
- ALL:全表扫描,在千万级大表上出现此标志必须拉响警报。
2.2 key / possible_keys / rows
possible_keys 代表优化器眼中的备选方案,而 key 是最终拍板的索引;若 key=NULL,就必须优先排查谓词是否失效。rows 给出的是估算扫描行数,我们还需结合 filtered 字段来推演有效结果集的过滤比例。当估算值远大于真实数据量,或反之远小于实际规模时,都强烈提示统计信息滞后或谓词设计存在逻辑缺陷。
以广告配置表为例,status 字段往往呈现极度倾斜(海量历史下线记录,少量实时在线数据)。优化器一旦低估或高估其数据选择性,就可能弃用原本「完美匹配」的联合索引。针对这种场景,主动更新统计信息、引入直方图,或者把「永远只要在线」这一条件剥离成生成列并配合冷热分区,往往比强加 FORCE INDEX 更具长期健壮性。
2.3 Extra 危险信号
Extra 字段承载了执行过程中的附加动作,往往是排查隐蔽性能杀手的关键:
Using filesort:表示需要额外在内存或磁盘排序,面对大结果集时代价极为惨痛;Using temporary:意味着被迫创建临时表来容纳中间结果,极易打爆内存并引发落盘;Using where:说明存储引擎返回数据后,Server 层仍需进行二次过滤(这本身不一定是坏事);Using index:标志着查询所需列已全部在索引树上找到,通过覆盖索引直接返回,这是最值得追求的优异状态;Using join buffer:表明被驱动表缺乏合适索引,引擎只能通过内存块缓存来勉强补救。
看完 EXPLAIN 后,我们能快速建立一条研判准则:若 type 层级太差,优先造索引或重写谓词;若 key 未命中,重点查隐式转换与最左前缀;若 rows 巨大且 filtered 极低,说明过滤条件未能有效下推;遭遇 filesort 或 temporary 时,则需考察能否利用索引天然的有序性来消除额外开销;若是各项指标堪称完美却依然缓慢,那就果断祭出 EXPLAIN ANALYZE 去追踪真实耗时算子,瓶颈往往藏在海量回表的随机 IO 或网络传输中。
3. 索引失效与选错
读懂了执行计划的各列指标后,我们在实战中真正高频遭遇的性能坠崖,往往是因为精心设计的索引被非标准的谓词「绕开」,或是被优化器的代价模型错选。由于 B+ 树的有序性建立在原始值之上,一旦我们在条件中嵌套函数,整棵树的搜索机制便会瞬间瘫痪。
在撰写 SQL 时应当反复自省:过滤条件能否「干净地」贴合在索引列上,还是被函数调用与隐式类型转换严严实实地包裹住了。
针对广告投放流水库等高并发场景,补充几点极易踩坑的写法:
-- 坏:函数包住列,导致只能全表扫描
WHERE DATE(created_at) = '2026-07-14'
-- 好:转化为区间范围查询,完美利用索引有序性
WHERE created_at >= '2026-07-14' AND created_at < '2026-07-15'
-- 坏:varchar 列与数字比较,触发隐式转换而导致索引失效
WHERE campaign_code = 10086
-- 好:保持类型严格匹配
WHERE campaign_code = '10086'
此外,最左前缀原则一旦断裂,就会毫无悬念地出现「明明建了联合索引却退化为 type=ALL」的窘境:例如 idx(a,b) 面对仅包含 WHERE b=? 的查询时只能爱莫能助(除非触发索引下推等特例且效果受限)。当发现优化器因统计偏差选错索引时,短期内固然可以通过 FORCE INDEX 来止血,但长期来看,必须回到梳理统计数据与重构索引设计的正轨上。
4. JOIN:算法与原则
单表的索引失效尚可通过改写 SQL 来挽救,而一旦踏入多表关联的深水区,执行计划的崩塌往往牵一发而动全身。
多表关联的铁律:永远坚持小结果集驱动大表,并确保被驱动表的连接条件能够精准走索引。
在 MySQL 的演进历程中,处理 JOIN 的底层算法也在不断丰富:
- Index Nested-Loop:针对外表输出的每一行,利用索引前往内表进行点查,这是广告维表关联配置信息时最为主力的提效利器;
- Block Nested-Loop:通过 join buffer 划分内存块来缓存外表数据,以此批量比对,从而显著摊薄内表全表扫描带来的随机 IO 代价;
- Hash Join(MySQL 8.0+ 引入):专攻等值连接场景,通过对内表数据在内存中构建哈希表来加速匹配,极其适合双方均为大表且缺乏优质关联索引的报表跑批场景。
在处理多张计费维表的复杂关联时,务必保障驱动顺序的合理性:应当优先通过谓词过滤出极小的 campaign 结果集,再以此为基础去关联海量的创意表,绝对要杜绝先引发局部笛卡尔积再执行 WHERE 过滤的粗暴作法。虽然 STRAIGHT_JOIN 能够强行锁定连接顺序,但它仅适合应急救火;长治久安之策依然是依托精准的统计信息与索引结构,让优化器心甘情愿地选对路径。需要强调的是,老版本中子查询极易被物化成劣质计划,尽管 MySQL 8.0 在此有了长足进步,仍强烈建议针对头部慢 SQL 运用 EXPLAIN ANALYZE,精准定位时间到底损耗在了哪一层循环。
另一方面,超长 IN 列表的破坏力同样不容小觑:当 SQL 强行圈选成百上千个 campaign_id 时,不仅会让优化器的成本估算彻底失真,甚至会迫使其倒戈选择全表扫描。面对广告定向圈选这类典型场景,将其转化为临时表 JOIN、预先构建 ID 映射表或是分批次打平处理,远比将巨大的 IN 集合硬塞进单一语句更契合数据库的运行心智。
5. 深分页与慢日志治理
将 JOIN 顺序与单表索引理顺之后,深分页查询与慢日志的常态化治理,便是彻底守住库表稳定性的收尾两道关。
5.1 OFFSET 的代价
执行诸如 LIMIT 100000, 20 的翻页语句,本质上意味着引擎必须沿着叶子链表范围扫描足足 100020 行数据,在承受了大量回表读取聚簇索引的 IO 代价后,最终却在 Server 层被无情地丢弃掉前 100000 行。这种越往后翻越慢的机制,使得运营后台一次随意的「跳页到末尾」操作,就能直接打穿数据库的 CPU 与 IO 资源。应对之策在于彻底改写分页模型:
-- 推荐方案 1:keyset / seek 分页(记住上一页的边界)
SELECT * FROM creative
WHERE advertiser_id = ? AND id > ?
ORDER BY id
LIMIT 20;
-- 推荐方案 2:延迟关联(先利用覆盖索引极速取下 id,再精准回表)
SELECT c.*
FROM (
SELECT id FROM creative WHERE advertiser_id=? ORDER BY id LIMIT 100000, 20
) t
JOIN creative c ON c.id = t.id;
5.2 慢查询流水线
治理慢查询不应停留在单次救火,而必须构建一条完整的自动化流水线:
- 开启慢日志采集(或深度整合 Performance Schema);
- 依托
pt-query-digest或自研聚合平台,精准捕捉消耗资源的 Top N 语句; - 挂载
EXPLAIN与EXPLAIN ANALYZE剖析真实执行代价; - 推进 SQL 重构、索引补全或历史数据归档;
- 发布后严密回归观测扫描行数波动与锁等待状态,绝不能仅凭「体感变快了」就草草结案。
关于慢日志的记录阈值,建议根据业务场景的严苛程度灵活定制——承接高并发点查的决策库大可设定在 100ms,而跑批分析为主的报表库放宽至 1s 亦无不可。关键在于建立持续观测 Top N 的排查机制,而非等到库表雪崩才仓促翻看日志。将「新增慢 SQL 检测」无缝接入发布门禁,在变更后自动对比 digest 摘要,能够从源头拦住一大批性能倒退。
这也解释了为什么我们要严格划定系统边界:将高频配置变更留在主库,而将重度的聚合报表引流至从库或专业 OLAP 引擎。这不仅是 读写分离 与 列存 OLAP 的分水岭,更是为了确保分析型大查询与决策型点查永远不要去竞争同一块 Buffer Pool 的内存红利。
6. 一张「改之前先自问」清单
为了避免在复杂的查询面前迷失焦点,我们将前面这些零散的实战经验收敛成一张可复用的自问清单。在动手修改任何可疑 SQL 之前,请严格按顺序拷问自己:
- 有没有合适索引?谓词列是否精准覆盖、排列顺序是否契合、能否利用覆盖索引直接返回?
- 谓词有没有破坏索引?是否存在隐式类型转换、函数包裹、或是前导
%引发的沿叶子链表范围扫描? - 扫描行是否合理?
EXPLAIN估算的rows与EXPLAIN ANALYZE的实际触碰行数究竟存在多大鸿沟? - 要不要排序/临时表?能否巧妙利用 B+ 树索引天然的有序性,直接将高昂的 filesort 开销彻底消灭?
- JOIN 驱动顺序?是否坚守了小结果集在前、且被驱动表能够通过 ref/eq_ref 极速匹配?
- 分页是否在深翻?面对大 OFFSET 场景,能否转换为 keyset 或延迟关联机制?
- 是否不该打 OLTP 库?当查询涉及海量聚合时,是否早该将这部分重负载剥离至从库、ClickHouse 或 Doris 中?
以一段典型的创意列表慢查询为例,我们可以清晰地看到不同决策下的性能鸿沟:
-- 慢:承受巨大 OFFSET 开销 + 非覆盖排序导致的 filesort
SELECT * FROM creative
WHERE advertiser_id = 42
ORDER BY updated_at DESC
LIMIT 50000, 50;
-- 快:游标分页 + 仅提取必备列,完美吃满索引红利
SELECT id, title, status, bid, updated_at
FROM creative
WHERE advertiser_id = 42
AND (updated_at, id) < (?, ?) -- 锁定上一页最后一条的边界值
ORDER BY updated_at DESC, id DESC
LIMIT 50;
-- 配合此查询的最佳索引形态:KEY(advertiser_id, updated_at, id, title, status, bid)
在线上排查陷入僵局时,我们还有备用武器:利用 optimizer_trace 揪出优化器放弃某条索引的真实动因,或通过直方图(MySQL 8.0)大幅改善数据严重倾斜列(例如海量的 status=0)的代价估算精度。务必铭记,短期的 FORCE INDEX 只能充当止血绷带,绝不能作为长治久安的技术方案。
此外,针对计数类查询也需保持敬畏:在 InnoDB 引擎中,SELECT COUNT(*) 从来就不是「免费的元数据读取」,大表上的精确计数需要付出高昂的遍历代价。在广告报表这类仅需知晓「大概有多少创意」的场景下,引入近似计数、维护汇总表或旁路至 OLAP,才是避免拖垮 OLTP 主库的明智之举。
慢查询排障口诀:看 type 优劣;查 key 命中;对 rows 估算;灭 filesort;EXPLAIN ANALYZE 探底耗时。
理解了优化器如何挑选路径,接下来就必须直面它最终将数据落盘并分发到整个集群的过程。一旦单机遭遇瓶颈,我们又该如何利用 主从复制与高可用 架构,将这些优化后的读写流量平滑摊薄。
参考
- MySQL 官方文档. EXPLAIN Output Format:逐列含义与取值。
- MySQL 官方文档. Optimizing SELECT Statements:SELECT 优化总览。
- MySQL 官方文档. Nested-Loop Join Algorithms:NLJ/BNL/Hash Join 算法。
- MySQL 8.0. EXPLAIN ANALYZE:真实执行耗时与行数。
- MySQL 官方文档. Controlling the Query Optimizer:hints、优化器开关。
- 《高性能 MySQL》查询性能优化章节。