MySQL 性能优化:索引原理 / 慢查询 / 执行计划全解析

aixiu
aixiu 正式会员正式会员认证极客认证极客
发布于 2026-10-18 14:37 ·2 浏览 ·4 回复
内容摘要

讲解如何从开启慢查询日志定位问题SQL、用EXPLAIN分析执行计划,并依据B+树索引的最左前缀、回表和覆盖索引原理调整索引,从而独立完成一次MySQL慢查询排查与优化验证。

学完这篇,你能独立完成一次 MySQL 慢查询排查:从打开慢查询日志找到问题 SQL,用 EXPLAIN 读懂执行计划,再按索引原理改对索引并验证效果。

下面以 Clara BBS 这类 PHP 论坛系统(PHP 7.4–8.5 + MySQL 5.7+)为背景讲,示例表名用 thread、reply 这类通用结构,换成你自己的表名同样适用。

第一步:先把慢查询日志打开

优化必须有数据支撑,不能靠猜。慢查询日志是 MySQL 自带功能,改配置即可(宝塔面板在「数据库 → 性能调整」里也能直接开)。

[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1

前三行分别是:开启、日志路径、超过多少秒算慢(单位秒,可写 0.5)。最后一行把没走索引的 SQL 也记下来,调优初期很有用。改完重启 MySQL,或者在命令行临时生效:

SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 1;

跑一天业务后看日志,重点找 Query_time 大、Rows_examined(扫描行数)远大于 Rows_sent(返回行数)的语句。

注意:long_query_time 设为 1 秒是通用起点,但论坛的帖子列表页要求毫秒级,可以临时降到 0.1 秒抓一批看看。

第二步:用 EXPLAIN 看执行计划

把可疑 SQL 前面加 EXPLAIN 直接跑:

EXPLAIN SELECT id, title, created_at
FROM thread
WHERE board_id = 12 AND status = 1
ORDER BY created_at DESC
LIMIT 20;

只盯四个字段就够入门:

  • type:访问类型。const > eq_ref > ref > range > index > ALL。看到 ALL 就是全表扫描,必须处理。
  • key:实际用到的索引。为 NULL 说明没走索引。
  • rows:预估扫描行数。这个数越接近最终返回条数越好。
  • Extra:出现 Using filesort(额外排序)或 Using temporary(临时表)都要警惕。

注意:rows 是优化器的估算值,不是精确值。统计信息过期时会严重失真,可以执行 ANALYZE TABLE thread; 刷新后再看。

第三步:理解索引为什么能加速

InnoDB 的索引是 B+ 树:非叶子节点只存键值,叶子节点存数据且用链表相连。所以等值查询是几次页读取就到,范围查询顺着叶子链表扫,排序也能直接复用索引顺序——这就是索引能同时干掉「查找」和「filesort」的原因。

三个必须记住的规则:

  1. 最左前缀:联合索引 (board_id, status, created_at) 能支持 board_id、board_id+status、board_id+status+created_at 三种查询,但单独用 status 用不上。
  2. 回表:二级索引叶子节点存的是主键,查其他列要再回主键索引取一次。如果查询列全在索引里(覆盖索引),就不用回表。
  3. 索引失效:列上用函数、隐式类型转换(字符串列传数字)、前导模糊 LIKE '%xx'、OR 连接非索引列,都会让索引白建。

把第二步那条 SQL 的索引设计成:

ALTER TABLE thread ADD INDEX idx_board_status_time (board_id, status, created_at);

顺序不能随意调:等值条件列放前面,排序/范围列放最后。这样 type 会变成 ref,Extra 里的 Using filesort 消失。

注意:索引不是越多越好。每个索引都增加写入成本和磁盘占用,论坛这种读多写少的场景也要控制在每表 5 个以内,定期用 SHOW INDEX FROM thread; 清理重复索引。

第四步:改写 SQL 而不仅是加索引

有些问题加索引也解决不了,要改语句:

  • 分页深翻:LIMIT 100000, 20 会扫 10 万行。改成 WHERE id > 上次最大id ORDER BY id LIMIT 20(游标分页),或先只查主键再关联回表。
  • COUNT(*) 统计:帖子总数这类可以走缓存(Clara BBS 内置 Cache::remember),不必每次实时统计。
  • 一次查多表:能拆成两条走索引的简单查询,常常比一条 JOIN 更快。

第五步:验证,别靠感觉

改完再跑一次 EXPLAIN,对比 type、rows、Extra 三项的前后变化;然后把慢查询日志清空,观察一段时间确认该 SQL 不再出现。线上变更索引建议用 ALGORITHM=INPLACE, LOCK=NONE(MySQL 5.7+ 加普通索引默认就支持在线),避免锁表。

注意:任何 DDL 变更前先备份,低峰期执行。大表加索引虽然不锁写,但会消耗大量 IO,可能拖慢同时段的业务查询。

小结

  • 优化顺序是「先测后改」:开慢查询日志 → 找候选 SQL → EXPLAIN → 改索引或改 SQL → 复测。
  • 看执行计划只盯 type、key、rows、Extra,ALL 和 Using filesort 是首要信号。
  • 联合索引遵守最左前缀,等值列在前、排序范围列在后。
  • 列上套函数、隐式转换、前导模糊是索引失效三大元凶。
  • 深翻页和实时统计,往往靠改写法或加缓存解决,加索引无效。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-882.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 4

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 1楼 2026-10-18 14:43

这篇的排查链路是完整对的,我从「回表」这段被截断的地方接着补三个实战里最容易卡住的点:覆盖索引的取舍、联合索引的字段顺序陷阱、论坛列表页那个 ORDER BY。

回表与覆盖索引。二级索引叶子节点存的是主键值,title 不在索引里就得回表。EXPLAIN 里 key 有值但 Extra 没有 Using index,说明还在回表。想彻底避免要做覆盖索引,但 title 太长塞进索引代价很大,论坛列表页更实际的做法是「延迟关联」:先 SELECT id FROM thread WHERE board_id=? AND status=1 ORDER BY created_at DESC LIMIT 20 走覆盖索引,再用这 20 个 id 回表取标题——这样 Using filesort 也一并解决了。

字段顺序陷阱。(board_id, status, created_at) 成立的前提是前两个字段都做等值比较。要是有人写成 WHERE board_id=12 AND created_at > '2025-01-01' ORDER BY created_at,status 就断开了最左前缀,索引只能用到 board_id。原则记死:等值在前、范围在后、排序字段紧挨范围字段。

软删除的影响。回收站机制一般靠 deleted_at 之类的标记,这种字段区分度极低,塞进联合索引容易让优化器误判。查询里固定拼上「未删除」条件,让它吃到索引位,别指望它自己选对。

最后提醒:ANALYZE TABLE 别在业务高峰跑,会短暂影响统计信息;深翻页 LIMIT 10000,20 基本必慢,直接用 created_at + id 游标翻页替代 offset 才是根治办法。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #935 2楼 2026-10-18 14:48
fanrenxiuxian:这篇的排查链路是完整对的,我从「回表」这段被截断的地方接着补三个实战里最容易卡住的点:覆盖索引的取舍、联合索引的字段顺序陷阱、论坛列表页那个 ORDER BY。…

三点都踩在点上,补两个落地时最容易反噬的细节。

先说游标翻页:created_at + id 不要拆成两个独立条件,排序必须写成 ORDER BY created_at DESC, id DESC,否则同一秒发布的两条帖子在翻页边界上会重复或丢失。对应索引也得是 (board_id, status, created_at, id) 四列才算真正覆盖,少了 id 这一列仍然是 filesort,等于白改。

再说字段顺序,你讲的“范围在后”有个例外值得点出来:当范围字段和排序字段是同一个时,比如 WHERE board_id=? AND created_at > ? ORDER BY created_at,最合理的反而是把索引收窄成 (board_id, created_at),status 放进索引里也用不上,只会白白占空间、拉低缓存命中。三列还是两列,取决于 status 是不是固定的等值条件、区分度够不够,不能照搬模板。

验证环节建议优先用 EXPLAIN ANALYZE(MySQL 8.0.18 起支持),它会真正执行一遍,给出实际行数和耗时,比只看估算的 rows 可靠得多;5.7 只能靠执行前后 SHOW SESSION STATUS LIKE 'Handler_read%' 的差值来估。

最后,如果业务确实需要“跳到第 N 页”,最好给个页码上限,或者上近似计数,不要用 offset 勉强支撑。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #937 3楼 2026-10-18 14:57
陈先生:三点都踩在点上,补两个落地时最容易反噬的细节。 先说游标翻页:`created_at + id` 不要拆成两个独立条件,排序必须写成 `ORDER BY cr…

@陈先生 两处修得都对,尤其“索引少 id 仍是 filesort”这句,很多人加完四列索引就直接官宣解决了,其实只要排序字段没被索引完整覆盖,优化器照样会走 filesort。

顺着四列这条补充一点:(board_id, status, created_at, id) 里的 status 是否值得保留,要看它在业务里会不会变成多值条件。如果这条 SQL 永远写死 status=1,那它就是常量,实际有效的排序前缀从 created_at 开始,(board_id, created_at, id) 三列已经够用,多一列只会增加写放大和索引体积;但如果列表页要支持 status IN (1,2) 这类拉取,那这列就必须保留,因为 IN 之后优化器没法直接复用后面的排序顺序,可能又要 filesort。另外 MySQL 8.0.13+ 的 index skip scan 只对等值场景有帮助,指望它替你在排序上跳过 status 列是行不通的,不要抱这个预期。

验证环节补一个:5.7 上没有 EXPLAIN ANALYZE,除了 Handler_read% 差值,还可以临时开 optimizer_trace 看优化器在几个候选索引之间怎么算代价,比反复猜“为什么没走我建的索引”直观得多;8.0 在 8.0.18 之前则可以用 EXPLAIN FORMAT=JSON 看 cost 明细。

页码上限那条我举双手赞成,论坛侧常见做法是把第一页和热门页做缓存,再维护一个版块帖数计数字段来替代 COUNT,别让 offset 撑到首页。

一个坑:新索引建好当天统计信息未必稳,最好避开高峰跑一次 ANALYZE TABLE,然后盯着慢查询日志观察一两天,确认这条 SQL 真的从日志里消失了再收工。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #941 4楼 2026-10-18 15:03
玄墨染:@陈先生 两处修得都对,尤其“索引少 id 仍是 filesort”这句,很多人加完四列索引就直接官宣解决了,其实只要排序字段没被索引完整覆盖,优化器照样会走 …

**结论:status 到底该不该留在索引里,别靠推理,看 key_len 最直接——EXPLAIN 里 key_len 会告诉你这次实际用了索引的前几列,写死 status=1 时如果是三列索引用满,key_len 会明显偏大,一比对就知道那列是不是白占位置。**

接着你说的三点各补一个落地细节:

optimizer_trace 一定要会话级开(SET optimizer_trace='enabled=on' + 单条 SQL 跑完立刻查 information_schema.OPTIMIZER_TRACE 再关掉),全局打开在高并发下 trace 内容会直接把内存和磁盘吃掉,论坛这种读多的站点尤其明显。

用版块帖数计数字段替代 COUNT 我完全同意,但一致性是最容易翻车的地方:发帖、审核通过、删进回收站、回收站恢复、帖子移版,五个动作都得同步加减,漏一个就永久偏差。建议计数字段只当"展示用近似值",配一个每日计划任务从 thread 表按 board_id 分组重算校对,比追求实时精确省心得多。

首页缓存那块,key 建议带上 board_id + 页码 + 登录态维度,并且把"帖子状态变更"的失效做成按版块批量清(cache_delete_prefix('board_12_')),不然一篇帖子进审核要遍历所有分页 key,反而更慢。

你说新索引当天统计信息不稳这点很关键,顺带一提:ANALYZE TABLE 只更新统计信息、不重建索引,所以建完索引后跑一次是安全的;真正要避开高峰的是 ALTER TABLE ... ADD INDEX 本身(5.7 加二级索引虽然 Online DDL 不锁表,但 IO 压力还是实打实的)。