大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。
上周三凌晨,一个订单查询接口的 P99 从 40 毫秒掉到了 3 秒。告警响的时候我正在改另一篇稿子。我把慢日志里那条 SQL 抄出来,翻来覆去看了一个小时。有索引,条件不复杂,没子查询,没排序,我找不出毛病。后来我拉了两个数出来看。一个是执行器向存储引擎要数据的次数,那条 SQL 只返回 20 行,引擎层却被问了上百万次。另一个是优化器的决策记录,它选的路径也不是我预期的那条。
那一刻我才明白,我一直在 SQL 文本里找答案。答案不在文本里,在它被执行的这条路上。
一条 SQL 要过七道关
我刚转行那会儿,以为 SQL 发出去,数据库就直接去磁盘翻数据了。不是这么回事。它得先被认出来,被检查,被改写,被规划,最后才由执行器一行一行去向存储引擎要数。
我把它拆成七段。前六段在 Server 层,最后一段在引擎层。这个分层最有用的一点是,你看到的慢,可能压根还没碰到数据。
| 阶段 | 干什么 | 出问题的症状 |
|---|---|---|
| 连接器 | 握手、认证、授权 | 连接数爆满、认证慢 |
| 查询缓存 | 按 SQL 文本查缓存(8.0 已移除) | 现在是历史包袱 |
| 解析器 | 词法语法分析,生成语法树 | 报错指向某个 token |
| 预处理器 | 查表、列、权限,展开视图 | 表不存在、无权限 |
| 优化器 | 选访问路径、选 JOIN 顺序 | 执行计划突变、索引不走 |
| 执行器 | 调引擎接口,逐行取数 | 取数次数爆炸 |
| 存储引擎 | 索引定位、Buffer Pool、回表、加锁 | 锁等待、刷脏、IO 打满 |
前面四段:SQL 还没开始跑
连接器是唯一跟 SQL 内容没关系的一段。TCP 握手,验证账号密码,从权限表读权限缓存起来。这个连接从此独占一个线程,直到断开。
权限缓存这件事值得记一笔。你在会话中途改了权限,有时候不生效,得重新连一次才认。长连接还会攒内存。我现在的习惯是让连接池配好最大存活时间,别让一个连接活到天荒地老。
第二段是查询缓存。它在 MySQL 8.0 被整个删掉了。我刚知道的时候有点意外,毕竟听着挺划算。后来想明白了,它按整张表失效。这张表只要有任意一次写入,表上所有缓存全废。对写多读少的业务来说,维护成本比省下来的还高。而且每次查缓存都要加锁。删得对。这件事也提醒我,缓存的失效粒度比缓存本身更值得琢磨。
第三段解析器干两件事。词法分析把一长串字符切成一个个 token。语法分析再按规则把它们拼成语法树。你写错语法时看到的那个 near 'xxx',就是它卡住的位置。第四段预处理器拿着语法树去数据字典核对,确认表和列真的存在,你真有权限。
这两段快到几乎零成本。但它们说明一件事,SQL 进数据库后的第一步不是执行,是理解。
第五段:优化器,路真正分岔的地方
前四段是确定性的,同一句 SQL 每次走的路一样。到优化器就变了。它要在好几条能走通的路里挑一条。挑什么?全表扫还是走索引,走哪个索引,多表 JOIN 先连哪两张,用哪种连接算法。挑的依据是代价估算,估算靠统计信息和代价常量。
我那个告警的答案就在这儿。我打开 optimizer_trace,看到了它的决策过程。它比了三条访问路径,选了一条跟我预期完全不同的。原因是它算错了这张表要返回多少行。
为什么算错,因为统计信息是随机采样出来的。采样页数有限,数据一倾斜它就看不准。这块内容够单独写一篇,今天先记住一个结论:优化器每次选路都基于估算,不基于事实。它每次都可能改主意。
-- 打开优化器追踪,只对当前会话生效
SET optimizer_trace = 'enabled=on';
SET optimizer_trace_max_mem_size = 1048576;
-- 把要查的 SQL 原样跑一遍
SELECT id, order_no, amount
FROM orders
WHERE user_id = 1024 AND status = 3
ORDER BY created_at DESC
LIMIT 20;
-- 看优化器的完整决策记录
SELECT TRACE FROM information_schema.optimizer_trace\G
第六、七段:逐行的真相
拿到执行计划以后,执行器开始干活。它会再判一次权限。然后按计划里的算子一层层往下走,向存储引擎要数据。
关键在"逐行"两个字。它不是一批一批地拿。它打开接口以后一行一行地取,取够条件才往上交。所以一条只返回 20 行的 SQL,引擎层可能被调了上百万次。大部分行在半路就被丢掉了。
-- 看执行器和引擎之间到底交互了多少次
SELECT VARIABLE_NAME, VARIABLE_VALUE
FROM performance_schema.session_status
WHERE VARIABLE_NAME IN (
'Handler_read_first', 'Handler_read_key',
'Handler_read_next', 'Handler_read_rnd_next'
);
第七段进 InnoDB。走主键索引直接定位到页。走二级索引就先拿主键再回表,除非是覆盖索引。Buffer Pool 命中就不用读盘,没命中才从磁盘读页。锁也在这一层加,行锁在引擎层,MDL 在 Server 层。
我以前排障是一上来就翻慢日志。现在中间会插一步,先看一眼 Handler_read 这几个变量。交互次数远远超过返回行数,那问题多半在"取了很多又丢掉",往执行计划的方向查。次数正常但还是很慢,那更可能是锁等待或者 IO,得换条路查。
避坑清单
optimizer_trace 有内存上限,超了就截断,结尾看不到。它还是会话级的,换个连接就没了。如果想在事后复现,需要在问题复现时当场开着。
SHOW PROFILE 这两个词网上教程到处都是。但它在较新的版本里已经不是推荐做法了,performance_schema 才是正经路子。具体到你手上的版本还支不支持,动手前先确认,别照着老文章抄。
最后一条是我自己搞错的。我以前看 EXPLAIN 只看 type 列,看到 ref 或者 range 就觉得稳了,从来不看 rows。后来才知道 rows 是估算值,那个数字本身可能就是优化器算偏的结果。现在我习惯把 rows 和 EXPLAIN ANALYZE 出来的实际行数放一起比。EXPLAIN ANALYZE 会真正执行 SQL,生产环境慎用,建议在测试环境或只读副本上操作。两者差得离谱,就先别急着调 SQL,去治统计信息。
写在最后
这次告警教我一件事,定位 SQL 慢不能只盯着 SQL,得知道它此刻走在哪一段。卡在连接,卡在锁,还是卡在选路上,这三类的排查手法完全不一样。一上来就翻慢日志,等于把七个病人塞进一个诊室。
我不是说慢日志没用。它是入口,不是答案。我现在的顺序是先看慢日志拿到 SQL,再用 EXPLAIN 看计划,用 optimizer_trace 看优化器怎么想的,用 Handler 变量看执行器怎么要数的,最后才去猜引擎层。
一条 SQL 跑得慢,从来不是 SQL 自己的错。它只是老老实实走完了这七段路,把每一段的耗时加总还给你。你要做的,是找出是哪一段。
你上一次排查慢 SQL,卡在哪一段?评论区聊聊。
我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋