慢 SQL 优化完全指南:从一次"加了索引 EXPLAIN 也显示用了索引,查询却还是慢"看懂执行计划

2022 年我接手优化一个订单系统的慢查询运营后台有个订单列表页打开要十几秒怎么让它快起来这件事我压根没多想第一版我做得很顺手慢就是因为没索引嘛我看了一眼那条 SQLWHERE 条件里用到了 user_id 和 status 那我就给这两列各加一个索引改完一测真不错列表页快了我心里挺踏实SQL 优化嘛不就是看哪列没索引补上就行可等这优化上了生产数据量真正涨上来一串问题冒了出来第一种最先把我打懵我明明加了索引EXPLAIN 也显示这条查询用到了索引可它还是慢得离谱第二种最难缠同一条 SQL 在测试库上几十毫秒就回来了一模一样的语句放到生产库要跑七八秒第三种最头疼我给一个查询又加了一个索引本想让它更快结果它反而比加索引前更慢了第四种最莫名其妙我看 EXPLAINtype 那一列显示 ref 书上说 ref 是挺好的访问类型可这条查询就是慢再仔细一看 rows 那一列写着扫了三百多万行我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 SQL 慢就是因为没加索引加个索引就快了这句话把 SQL 性能理解成了一个有没有索引的二元开关可它根本不是开关你写下一条 SQL 只是描述了你要什么数据而具体怎么把这些数据捞出来全表扫还是走索引走哪个索引这一整套步骤是查询优化器在你看不见的地方根据它对表里数据的统计认知现场决定的这套被决定出来的步骤就叫执行计划SQL 快不快从来不取决于你加没加索引而取决于优化器最终为它选了一个什么样的执行计划而 EXPLAIN 这个命令干的就是把优化器在背后悄悄定好的那份执行计划完完整整地摊开摆到你面前真正把慢 SQL 调快核心不是挨个给列加索引而是认清 SQL 性能由优化器选的执行计划决定要用 EXPLAIN 把执行计划摊开看懂 type rows key Extra 这几个关键列理解索引为什么没生效或生效了还是慢以及优化器为什么会基于过时的统计信息选错计划本文从头梳理为什么加个索引就快了是个错觉EXPLAIN 怎么把优化器的决策摊开怎么读懂关键列索引为什么没生效或生效了还是慢优化器为什么会选错计划以及一些把慢 SQL 调优做扎实要避开的工程坑

2022 年我接手优化一个订单系统的慢查询:运营后台有个订单列表页,打开要十几秒。怎么让它快起来?这件事我压根没多想。第一版我做得很顺手:慢就是因为没索引嘛,我看了一眼那条 SQL,WHERE 条件里用到了 user_idstatus,那我就给这两列各加一个索引。就完事了。改完一测——真不错:列表页快了。我心里挺踏实:"SQL 优化嘛,不就是看哪列没索引、补上就行?"可等这优化上了生产、数据量真正涨上来,一串问题冒了出来。第一种最先把我打懵:我明明加了索引,EXPLAIN 也显示这条查询用到了索引,可它还是慢得离谱。第二种最难缠:同一条 SQL,在测试库上几十毫秒就回来了,一模一样的语句放到生产库,要跑七八秒。第三种最头疼:我给一个查询又加了一个索引,本想让它更快,结果它反而比加索引前更慢了。第四种最莫名其妙:我看 EXPLAIN,type 那一列显示 ref,书上说 ref 是挺好的访问类型,可这条查询就是慢——再仔细一看,rows 那一列写着扫了三百多万行。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"SQL 慢,就是因为没加索引,加个索引就快了"。这句话把 SQL 性能理解成了一个"有没有索引"的二元开关。可它根本不是开关我脑子里,数据库执行一条 SQL 是件机械的事:有索引就走索引、快;没索引就全表扫、慢。所以优化 SQL 就是个查漏补缺,看哪列没索引、补上。可这个想法,漏掉了数据库里一个真正做主的角色——查询优化器(query optimizer)。你写下一条 SQL,只是描述了"你要什么数据",而"具体怎么把这些数据捞出来"——全表扫还是走索引、走哪个索引、多张表按什么顺序连接、先过滤哪个条件——这一整套步骤,是优化器在你看不见的地方,根据它对表里数据的"统计认知"现场决定的。这套被决定出来的步骤,就叫执行计划(execution plan)。SQL 快不快,从来不取决于"你加没加索引",而取决于"优化器最终为它选了一个什么样的执行计划"。加索引,只是给优化器多提供了一个"可选项",至于它选不选、选了用得好不好,完全是另一回事:索引选择性太差,优化器会嫌它没用、宁可全表扫;索引选择性还行但要回表几百万次,优化器用了它反而更慢;统计信息过时,优化器会基于一个错误的数据分布认知,选出一个糟糕的计划。所以"加了索引、EXPLAIN 也显示用了索引、查询却还是慢",一点都不矛盾——用了索引,不等于用对了计划。而 EXPLAIN 这个命令,干的就是一件事:把优化器在背后悄悄定好的那份执行计划,完完整整地摊开摆到你面前。真正把慢 SQL 调快,核心不是"挨个给列加索引",而是认清 SQL 性能由优化器选的执行计划决定,要用 EXPLAIN 把执行计划摊开、看懂 type / rows / key / Extra 这几个关键列、理解索引为什么没生效或生效了还是慢、以及优化器为什么会基于过时的统计信息选错计划。这篇文章就把慢 SQL 执行计划这个坑梳理一遍:为什么"加个索引就快了"是个错觉、EXPLAIN 怎么把优化器的决策摊开、怎么读懂 type / rows / key / Extra 这几个关键列、索引为什么没生效或生效了还是慢、优化器为什么会选错计划,以及一些把慢 SQL 调优做扎实要避开的工程坑。

问题背景

这个坑普遍,是因为"慢就加索引"这条经验,在小数据量下几乎永远是对的——它给了无数人一种虚假的安全感,让人以为 SQL 优化就这么简单,从不去看优化器和执行计划这一层。它错得隐蔽,是因为测试库永远测不出来:测试库往往只有几千几万行数据,这种规模下,无论优化器选了多烂的计划,几毫秒也就跑完了,你根本看不出"计划好不好"的区别。它只在生产的真实数据量、真实数据分布下才暴露——那时一个烂计划要扫几百万行,而你还以为"我索引都加了,能有什么问题"。

把这个现象拆开,错误认知和真相是这样对应的:

  • 现象:加了索引 EXPLAIN 也显示用了索引却还是慢;同一条 SQL 测试库快生产库慢;加索引反而更慢;type 看着体面 rows 却扫了几百万行。
  • 错误认知一:以为 SQL 性能是"有没有索引"的二元开关。真相是性能由优化器选的执行计划决定,索引只是一个可选项。
  • 错误认知二:以为"用了索引"就等于"查询快了"。真相是用了索引不等于用对了计划,选择性差、回表多照样慢。
  • 错误认知三:以为优化器永远会选最优计划。真相是它依赖统计信息估算成本,统计信息过时就会选错。
  • 真相:调慢 SQL 的正路是用 EXPLAIN 摊开执行计划,读懂关键列,对症下药,而不是盲目给列加索引。

一、为什么"加个索引就快了"是个错觉

先把第一版那条慢 SQL 和我的处理摆出来。它就是订单列表页背后那条查询,运营按用户和状态筛订单:

-- 第一版那条慢 SQL:订单列表页,运营按用户和状态筛订单(反面教材)
SELECT * FROM orders
WHERE user_id = 10086
  AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;

-- 我的第一反应:WHERE 里有 user_id 和 status,那就各加一个索引
ALTER TABLE orders ADD INDEX idx_user (user_id);
ALTER TABLE orders ADD INDEX idx_status (status);

这套处理"看起来"无懈可击:WHERE 里的列都加上索引了,还能有什么问题?可它上线后照样慢,而且慢得没规律。问题在于,我做的事是"给列加索引",而我以为我做的事是"让查询变快"——这两件事之间,隔着一个我从没意识到它存在的角色:优化器。给 orders 表加了 idx_useridx_status 两个索引之后,优化器面对这条查询,手里其实有好几个选项:用 idx_user、用 idx_status、两个索引都用一下再合并、或者干脆全表扫。它到底选哪个,取决于它对数据的估算。如果这个用户 10086 名下有几十万个订单,而 status='paid' 的订单占了全表的一大半,那么这两个索引在优化器眼里都不够"挑"——走任何一个,都要先捞出海量的行,再一行行回表、过滤、排序。这种情况下,优化器可能直接放弃索引、选择全表扫,也可能用了索引却扫了三百万行。

这里要建立的第一个、也是最重要的认知是:你写的 SQL,和数据库实际执行的步骤,是两样东西,中间隔着一个翻译官——优化器。SQL 是一种"声明式"语言,这是它和你平时写的 Python、Java 那种"命令式"代码最根本的区别。命令式代码里,你写下的每一行,就是机器照做的每一步,执行顺序完全由你掌控。但 SQL 不是——你用 SQL 写下的,只是"我想要哪些数据"这个目标,你没有、也无法规定数据库"按什么步骤"去拿。从目标到步骤的这段翻译,被数据库完全接管了,接管它的就是优化器。这意味着一件很多人没意识到的事:你对一条 SQL 性能的掌控,是间接的。你不能像调命令式代码那样,直接命令它"走这个索引、按这个顺序连表";你能做的,只是影响优化器的决策——给它提供更好的索引选项、给它更新的统计信息、把 SQL 改写得让它更容易选对。所以"加索引就快了"这个错觉的根子,是把一个间接的影响关系,误当成了直接的因果关系:以为"加索引"这个动作的输出就是"变快"。真相是,"加索引"的输出只是"多了一个选项",这个选项会不会变成"变快",中间还隔着优化器的一道决策。要真正掌控 SQL 性能,第一步就是承认这个翻译官的存在,然后想办法看见它的决策——而这,正是 EXPLAIN 的全部意义。

二、EXPLAIN:把优化器的决策摊开看

既然慢的根子在优化器选的执行计划,那调优的第一步,就不该是"加索引",而该是"把优化器为这条 SQL 定的计划调出来看"。干这件事的命令,就是 EXPLAIN。用法极简单——在任何一条 SELECT 前面加上它:

-- EXPLAIN:在 SELECT 前面加上它,数据库不会真的执行这条查询,
-- 而是把优化器为它定好的"执行计划"返回给你
EXPLAIN
SELECT * FROM orders
WHERE user_id = 10086
  AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;

-- 想看"实际"跑了多久、实际扫了多少行,用 EXPLAIN ANALYZE(MySQL 8.0+)
-- 它会真的执行查询,并把"预估值"和"实际值"一起给你
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 10086 AND status = 'paid';

EXPLAIN 返回的不是查询结果,而是一张描述"这条 SQL 打算怎么执行"的表。它有很多列,刚上手时不用全看懂,先死死盯住四列:type(怎么访问表)、key(实际用了哪个索引)、rows(估计要扫多少行)、Extra(还有什么额外的、往往很贵的动作)。第一版那条慢 SQL,它的 EXPLAIN 大概长这样:

EXPLAIN 输出的几个关键列(MySQL 为例):

  id  table   type   key        rows      Extra
  1   orders  ref    idx_user   3200000   Using where; Using filesort

  逐列看:
    type  = ref       访问类型,ref 表示用非唯一索引做等值查找
    key   = idx_user  优化器最终决定用的索引(从几个候选里挑的)
    rows  = 3200000   优化器"估计"要扫描的行数 —— 这一列是慢的元凶
    Extra = ...       额外动作,Using filesort 表示要单独排序,很贵

  关键:type=ref 看着体面,但 rows 扫了 320 万行 —— 这条计划其实很烂

这张表一摊开,前面那几个"莫名其妙"的现象,瞬间就有了答案。"加了索引却还是慢"——你看 key 列,确实用了 idx_user,索引没白加;可你再看 rows,它要扫 320 万行,慢就慢在这里。"type=ref 看着挺好却很慢"——type 只告诉你"用什么方式访问",不告诉你"要访问多少",真正决定耗时的是 rows。优化器选 idx_user 是因为这个用户订单极多、这个索引"挑"不出几条,但它已经是矮子里的将军了。

这里要建立的认知是:EXPLAIN 之于慢 SQL,等同于一切排障工作里"让黑盒变白盒"的那个动作——它的价值,不在于它本身多复杂,而在于它把一个你原本只能靠猜的过程,变成了一个你可以直接观察的过程。第一版的我,面对一条慢 SQL,处境其实是"盲调":我不知道优化器选了什么计划,不知道它扫了多少行,不知道它有没有用我加的索引,我唯一的反馈信号就是"快了还是没快"。在这种盲调状态下,加索引这个动作的本质,是碰运气:加对了,蒙中;加错了,甚至加了个让优化器选得更糟的索引,反而更慢——而我连"为什么更慢"都无从知道。EXPLAIN 做的,就是把这盏灯打开:它让优化器的每一个决策——访问方式、用的索引、估算的行数、额外的排序——全部显形。一旦决策显形,调优就从"碰运气"变成了"看着问题改":rows 太大就去想办法减少扫描行数,出现 filesort 就去想办法让排序走索引,type 是 ALL 就去给 WHERE 列建索引。这里要建立的通用认知是:任何性能问题,在你能"看见"它之前,你做的所有优化都只是猜测;排障的第一步,永远不是动手改,而是先找到那个能把黑盒照亮的工具——对慢 SQL 来说,这个工具就是 EXPLAIN。先看见,再动手,这个顺序不能反。

三、读懂关键列:type、rows、key、Extra

EXPLAIN 能调出来了,接下来得真看懂它。最该花时间的是 type 这一列——它直接告诉你这条 SQL 是用什么"姿势"访问表的,而不同姿势之间是数量级的差距:

type 列:这条 SQL 是怎么访问表的,从快到慢排个序

  system / const   最快   主键或唯一索引等值匹配,最多命中 1 行
  eq_ref           很快   多表连接时,被连的表用主键/唯一索引精确匹配
  ref              较快   用非唯一索引做等值查找,可能命中多行
  range            中等   索引范围扫描,WHERE 里有 BETWEEN、IN、范围比较
  index            偏慢   扫描整棵索引树,只比全表扫略好一点
  ALL              最慢   全表扫描,一行一行翻完整张表

  经验阈值:线上关键查询,type 至少要到 range;
           出现 ALL 或 index,几乎一定要优化

看懂 type 之后,要把它和 rows 配着看——这是新手最容易栽的地方:type 体面不代表查询快。一条 type=ref 的查询,如果 rows 是 300 万,它比一条 type=rangerows 只有 200 的查询慢得多。type 描述的是"访问的姿势",rows 描述的是"访问的体量",体量才是耗时的主导项Extra 列则藏着另一类陷阱:Using index 是好消息(覆盖索引、不用回表),但 Using filesort(要单独做一次排序)、Using temporary(要建临时表)都是很贵的额外动作,看到它们,优化的空间往往就在那里。拿到一份 EXPLAIN,可以照下面这个顺序去判断它健不健康:

[mermaid]
flowchart TD
A[拿到一条慢 SQL 的 EXPLAIN] --> B{type 是 ALL 吗}
B -->|是 全表扫描| C[看 WHERE 列能不能建索引]
B -->|不是| D{rows 估计值大不大}
D -->|很大| E[索引选择性差 或 统计信息过时]
D -->|不大| F{Extra 里有 filesort 或 temporary 吗}
F -->|有| G[排序或分组没走索引 调整索引设计]
F -->|没有| H[执行计划基本健康]

这里要建立的认知是:读 EXPLAIN 这件事,真正要练的不是"背下每个列的含义",而是养成一种"看指标要看一组、不看单个"的思维习惯。第一版的我栽在 type=ref 上,根子就是用单个指标下了结论:我看到 type 是 ref,书上说 ref 不错,我就认定"这条查询没问题"——我让一个孤立的指标,替我做了整体判断。可任何一个单独的指标,都只描述了系统的一个侧面,它天然是片面的。type 只回答"用什么方式访问",它压根不回答"要访问多少"和"访问完还要不要排序"。只有把 type、rows、Extra 这三个指标摆在一起看,一条 SQL 的真实代价才会浮现:type=ref + rows=200 + 无 filesort,这是真的快;type=ref + rows=320万 + Using filesort,这是个披着体面外衣的烂计划。这个"指标要成组看"的道理,远不止用在读 EXPLAIN 上——看服务器,不能只看 CPU 不看内存和 IO;看接口,不能只看平均延迟不看 P99 和错误率;看一个人的工作,不能只看产出数量不看质量。任何时候,当你想用一个单一指标去给一个复杂对象下"好"或"坏"的结论时,都该先警惕一下:这个指标覆盖的,是不是只是这个对象的一个侧面?真正可靠的判断,来自一组互相补充、能彼此校验的指标,而不是某一个看着顺眼的数字。

四、索引为什么没生效,或者生效了还是慢

看懂了 EXPLAIN,就能回头治第一版那条 SQL 了。它的病根有两个,都和索引设计有关。第一个:idx_useridx_status两个独立的单列索引,优化器一条查询通常只能高效地用其中一个,用了 idx_user 就得在捞出的海量行里再逐行过滤 status。正确的做法是建一个复合索引,把多个条件做进同一个索引里——而复合索引的列顺序大有讲究,等值条件的列要放在前面,排序的列放在最后:

-- 关键纠正:user_id 和 status 不该建成两个独立索引,
-- 而该建成一个复合索引(顺序有讲究:等值列在前,排序列在后)
ALTER TABLE orders DROP INDEX idx_user;
ALTER TABLE orders DROP INDEX idx_status;
ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, status, created_at);

-- 这个查询能完整吃到复合索引:user_id 等值 + status 等值 + created_at 排序
SELECT * FROM orders
WHERE user_id = 10086 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20;

-- 但这个查询用不到这个索引:它跳过了最左列 user_id,直接用 status 过滤
SELECT * FROM orders WHERE status = 'paid';

这就是最左前缀原则:复合索引 (user_id, status, created_at) 像一本先按 user_id、再按 status、最后按 created_at 排好序的字典,你必须从最左的 user_id 开始查,才能用上这个排序;只给 status 不给 user_id,这本字典就帮不上忙了。第二个病根是回表:二级索引里只存了索引列和主键,SELECT * 要取的其他列不在里面,数据库得拿主键再回主表查一次——一行回一次。如果列表页只展示固定几列,把这几列也塞进索引,就能覆盖索引、完全不回表:

-- 回表:走 idx_user_status_time 找到 20 个订单的主键后,
-- 还要拿这 20 个主键回主表取 SELECT * 要的其它列 —— 这一步就叫"回表"

-- 如果列表页只需要展示订单号、金额、时间这几列,
-- 把它们也塞进索引,查询就能"覆盖索引",一次回表都不用
ALTER TABLE orders
  ADD INDEX idx_cover (user_id, status, created_at, order_no, amount);

-- 这条查询的 Extra 会出现 Using index —— 代表覆盖索引、零回表
SELECT order_no, amount, created_at FROM orders
WHERE user_id = 10086 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20;

这里要建立的认知是:索引这件事,最反直觉、也最值得记住的一点是——索引不是"加了就有用"的,它的有用程度,是一个连续的、强烈依赖上下文的变量,而不是一个"有/无"的开关。决定一个索引值不值钱的,是它的选择性:索引列上不同的值越多、每个值对应的行越少,这个索引就越"挑",越值钱。给 status 这种只有"已付款/已发货/已完成"几个值的列单独建索引,选择性极差——一个值就对应了全表三分之一的行,优化器扫这个索引和扫全表没差多少,它自然会嫌弃这个索引。这就解释了为什么"加了索引优化器却不用":不是优化器犯傻,是这个索引真的不值得用。而复合索引、覆盖索引这些设计,本质上都是在提升索引的有用程度:复合索引 (user_id, status) 的选择性,比单独的 status 高得多,因为"某个用户的某个状态"能挑出的行,远少于"所有人的某个状态";覆盖索引则更进一步,它让索引不只能用来"定位",还能直接"供数据",把回表这个隐藏的大开销彻底省掉。这里要建立的通用认知是:不要满足于"我做了这个动作",要追问"我这个动作的质量如何"。加索引、加缓存、加监控——这些动作本身都不产生价值,产生价值的是它们做得好不好:一个低选择性的索引,和没加几乎一样;一个命中率极低的缓存,和没加几乎一样。把事情"做了",和把事情"做对、做到位",中间隔着的就是这份对质量的较真。

五、优化器为什么会选错:统计信息与成本估算

到这里还剩一个最诡异的现象没解释:同一条 SQL、同样的索引,测试库飞快、生产库慢。索引都一样,凭什么计划会不一样?答案藏在优化器做决策的依据里——统计信息。优化器选计划,靠的是对每张表的"统计认知":这张表大概有多少行、某个索引大概有多少个不同的值、某个值大概对应多少行。它拿这些数字去估算每个候选计划的成本,挑成本最低的。可统计信息不是实时的,它是定期采样、缓存下来的——一旦表经历了大批量的增删改,缓存的统计信息就过时了,优化器会基于一份错误的数据画像去估算,自然选错:

-- 测试库飞快、生产库慢,十有八九是"统计信息"在作怪。
-- 优化器靠表的统计信息(行数、索引值的分布)来估算成本,
-- 大批量增删改之后统计信息会过时,优化器就会基于错误认知选错计划。

-- 手动重新采集统计信息,让优化器看到表当前真实的样子
ANALYZE TABLE orders;

-- 看某个索引的"基数"(cardinality:不同值的个数,越大选择性越好)
SHOW INDEX FROM orders;

-- 如果优化器铁了心选错索引,可以用 FORCE INDEX 强制(应作为最后手段)
SELECT * FROM orders FORCE INDEX (idx_user_status_time)
WHERE user_id = 10086 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20;

怎么确认优化器是不是被过时的统计信息坑了?用 EXPLAIN ANALYZE——它会把优化器的"估计"和实际跑出来的"真实值"摆在一起,一对比就露馅:

EXPLAIN ANALYZE:把"优化器的估计"和"实际跑的结果"摆在一起对

  -> Index lookup on orders using idx_user (user_id=10086)
       (cost=2.5 rows=120)                          <- 优化器估计:扫 120 行
       (actual time=0.1..980 rows=2100000 loops=1)  <- 实际:扫了 210 万行

  估计 120、实际 210 万 —— 差了上万倍。
  这种巨大偏差,几乎一定是统计信息过时:
  优化器是基于一份错的数据画像,做的成本估算。

这里要建立的认知是:优化器这个设计,藏着一个对所有工程师都极有价值的模型——任何一个"自动做决策"的智能系统,它的决策质量,都不可能超过它所依赖的那份"输入信息"的质量。优化器是个挺聪明的东西,它会枚举多个计划、会算成本、会挑最优,但它再聪明,也只是在它以为的那个世界里做最优决策。如果它以为这个用户只有 120 个订单(过时的统计信息),而实际上有 210 万个,那它精心算出来的"最优计划",在真实世界里就是个灾难。问题不出在它的决策逻辑上——它的逻辑可能完全正确;问题出在喂给它的那张地图,是过期的。这个模型一旦你装进脑子,你看待很多系统会立刻清醒:一个再先进的推荐算法,喂给它的是脏数据,它推的就是垃圾;一个再缜密的风控规则,基于的是过时的画像,它拦的就是错的人;一个再理性的人,他掌握的信息是错的,他做的决定也必然是错的。所以当一个自动决策系统给出明显离谱的结果时,你的第一反应,不该是去怀疑、去推翻它的决策逻辑(那往往很难、也常常不是真凶),而该是回头去审查它的输入:它依据的那些信息,还新鲜吗?还准确吗?还完整吗?对优化器来说,这份输入就是统计信息,而你能做的、最有效的一个动作,就是一句 ANALYZE TABLE——给它换一张新地图。先查输入,再疑逻辑,这个排查顺序,能帮你省下大量南辕北辙的功夫。

六、工程里那些慢 SQL 的坑

执行计划这条主线理顺了,落地时还有几个工程细节反复咬人。第一个,WHERE 列上别套函数、别让它发生隐式类型转换。一旦你写了 WHERE DATE(created_at) = '2022-01-01',或者 user_id 是字符串列你却传了数字,索引会当场失效——因为索引是按列的原始值排序的,你对列做了运算,索引那套排序就对不上了。第二个,索引不是越多越好。每加一个索引,表的每次 INSERTUPDATEDELETE 都要多维护一份索引,写入会变慢、磁盘会变大;只读多写的表可以多建,写入频繁的表要克制。第三个,分页深了要用游标分页LIMIT 1000000, 20 这种深分页,数据库要先扫过前一百万行再丢掉,极慢——改成"记住上一页最后一条的 created_at,下一页用 WHERE created_at < ?"的游标写法。第四个,EXPLAIN 的 rows 是估计值,不是真值。它来自统计信息,可能有偏差;要拿准数,用会真实执行的 EXPLAIN ANALYZE。第五个,把慢查询日志打开——很多慢 SQL 你平时根本不知道它存在,只有日志会替你逐条抓住它们:

慢 SQL 治理必须盯死的几个信号:

  slow_query_log         慢查询日志开关,务必打开
  long_query_time        慢查询阈值,线上一般设 1 秒,关键库可更低
  rows_examined          单条 SQL 实际扫描的行数,远大于返回行数就是坏味道
  Handler_read_rnd_next  全表扫描的累计计数,持续猛涨说明有查询在裸扫
  查询响应 P99            别只看平均耗时,慢 SQL 往往藏在长尾的 P99 里

这里要建立的认知是:把这一节的坑串起来看,会浮现一个关于"慢 SQL 治理"的总体判断——它根本不是一件"出了问题再去优化"的事,而是一件需要常态化、有抓手地去经营的事。第一版的我,对待慢 SQL 是纯被动的:运营来报"页面卡",我才去看那一条 SQL。这种被动模式有个致命缺陷——它只能让你看见"有人抱怨了的"那几条慢 SQL,而那些没人抱怨、却在后台默默拖慢数据库、默默吃着资源的慢查询,你永远不会知道它们的存在。等到它们多到让整个库都喘不过气,你面对的就不是"优化一条 SQL",而是"救火"。这一节讲的每一个工程动作,本质上都是在把这件事从"被动救火"扭转成"主动经营":打开慢查询日志,是给自己装一双能持续看见慢 SQL 的眼睛,让它们无所遁形;把 rows_examined、P99 接进监控,是给"慢"这件事立一个能持续度量的标尺;克制索引数量、警惕深分页,是在写代码的当下就预防慢 SQL 的产生,而不是等它长出来。这里要建立的通用认知是:任何一类会随着系统长大而不断滋生的问题——慢 SQL、技术债、安全漏洞、性能退化——你都不能用"等它爆发了再处理"的被动姿态去面对,因为被动姿态下你看到的永远只是冰山一角。你必须为它建立常态化的机制:一双能持续观察的眼睛(日志与监控),一把能持续度量的尺子(指标与阈值),和一套能在源头预防的规范(代码评审与设计约束)。把问题管在它还小的时候,代价是日常的、可控的;把问题拖到它爆发,代价是灾难性的、甚至不可逆的。

关键概念速查

概念 说明 关键点
查询优化器 把声明式 SQL 翻译成具体执行步骤的组件 SQL 快慢由它选的执行计划决定
执行计划 优化器定下的访问表 用索引 连接顺序等步骤 EXPLAIN 就是把它完整摊开看
EXPLAIN 在 SELECT 前加它 返回执行计划而不真执行 调慢 SQL 的第一步 先看见再动手
type 访问类型 表示怎么访问表 从 const 到 ALL 线上关键查询至少要到 range
rows 优化器估计这条 SQL 要扫描的行数 远大于返回行数就是坏味道
Extra 额外动作 Using index filesort temporary 等 Using index 是好 filesort 偏贵
复合索引最左前缀 多列索引必须从最左列开始用起 跳过最左列索引就整个失效
回表与覆盖索引 二级索引取主键再回主表取数据叫回表 把所需列塞进索引可消除回表
索引选择性 索引列不同值的占比 越高越值钱 选择性差的索引优化器会弃用
统计信息 优化器估算成本的数据画像 过时会选错计划 ANALYZE TABLE 刷新

避坑清单

  1. 别一看慢就盲目加索引,先 EXPLAIN 看优化器到底选了什么执行计划。
  2. type 出现 ALL 或 index 几乎一定要优化,线上关键查询的 type 至少要到 range。
  3. rows 估计值远大于实际返回行数,说明计划在裸扫,要查索引设计和统计信息。
  4. 多个等值条件别建多个单列索引,建一个复合索引,等值列在前、排序列在后。
  5. 复合索引遵守最左前缀,查询一旦跳过最左列,这个索引就整个用不上。
  6. 列表页只取固定几列时做覆盖索引,把这几列塞进索引,消除回表开销。
  7. 测试库快生产库慢先 ANALYZE TABLE,十有八九是统计信息过时让优化器选错。
  8. WHERE 列上别套函数、别触发隐式类型转换,否则索引会当场失效。
  9. 索引不是越多越好,写入频繁的表要克制,每个索引都会拖慢增删改。
  10. 打开慢查询日志并把 rows_examined、P99 接进监控,主动经营而非被动救火。

总结

回头看,第一版栽的跟头,根子是一个认知误判:我以为 SQL 性能是个"有没有索引"的二元开关,优化就是挨个给 WHERE 列补索引。可一条 SQL 快不快,从来不由"你加没加索引"直接决定,而由一个你看不见的角色——查询优化器——为它选的执行计划决定。加索引,只是给优化器多递了一个可选项;它选不选、选了用得好不好,是另一回事。我盲目加索引,等于在一个看不见的黑盒上碰运气,加对了是蒙中,加错了连为什么更慢都不知道。

真正把慢 SQL 调快,工作量不在"加多少索引",而在一次视角的转变:别再盯着 SQL 的字面,去看优化器为它定的那份执行计划。一旦你愿意用 EXPLAIN 把这份计划摊开,该做的事就都浮现出来了——type 太差就改访问方式,rows 太大就提升索引选择性、建复合索引,Extra 里有 filesort 就让排序走索引,测试快生产慢就 ANALYZE TABLE 刷新统计信息。每一步都不复杂,难的是先承认:你能优化的从来不只是你写下的那行 SQL,还有那个在背后替你做决策、却被你忽略了很久的优化器。

我后来常拿手机导航来想这件事。你在导航里输入目的地,这就像写下一条 SQL——你只说了"要去哪",没说"走哪条路"。真正规划路线的是导航软件,它就是那个优化器;它根据自己掌握的路况数据,算出一条它认为最快的路线,这条路线就是执行计划。加索引,像是城市里新修了一条路:路修好了,但导航走不走它,要看它划不划算——新路要是绕远、要是正堵着,导航照样不走。而最关键的是导航依赖的那份路况数据:如果它用的是一张过期地图,上面那条早就拆了的路还在、那条新通车的高架还没标,那它再聪明,规划出的路线也必然糟糕。统计信息,就是优化器的那张地图;一句 ANALYZE TABLE,就是给它更新地图。

这类问题最咬人的地方,在于它在开发测试时几乎永远是"对"的:测试库里就那么几千几万行数据,无论优化器选了多烂的计划,几毫秒也跑完了,你根本分不出"计划好"和"计划烂"的区别,慢 SQL 在你眼皮底下安然过关。它只在生产那真实的数据量、真实的数据分布下才露出獠牙——一个烂计划要扫几百万行,而你还以为索引都加了能有什么事。所以别等运营来报"页面卡死"、别等数据库被慢查询拖到喘不过气,才想起去看执行计划:写下每一条会上生产的关键 SQL 时,就该顺手 EXPLAIN 一下,看一眼优化器给它选的是什么计划——这不该是一个"以后慢了再说"的补救项,而该是你写 SQL 时就养成的、和写对业务逻辑同等重要的习惯。把执行计划这件事在一开始就看清楚,你才算真正跳出了那个人人都在埋头加索引、却没人抬头看一眼优化器的坑。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

AI Agent 调试完全指南:从一次"Agent 演示时好好的,一上线就胡乱调用工具"看懂 trace 与决策定位

2026-5-22 18:33:52

技术教程

HNSW 向量索引调优完全指南:从一次"库里明明有那条文档,语义检索却死活召回不到"看懂召回率与延迟权衡

2026-5-22 18:49:12

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索