11-SQL优化与执行计划
慢 SQL 怎样优化?从查询语义到执行计划证据
EXPLAIN 显示使用索引,接口依然很慢;增加一个索引后,排序好了,返回大字段又变慢。索引只是访问路径的一部分,优化要回答究竟减少了哪些扫描、回表、排序或等待。
具体 SQL 观察沿用原有 MySQL 8.0.29 示例。不同实验先执行自己的数据准备,执行计划和锁行为不被推广成所有版本或负载的保证。
1. 先固定查询语义与比较条件
优化不能改变租户、时间边界和结果集合。
先写清查询的业务语义
目标是统计租户 3 在 2026 年 1 月 10 日产生的日志数量。原 SQL 为:
SELECT COUNT(*) FROM event_log
WHERE tenant_id=3 AND DATE(created_at)='2026-01-10';
这里需要的是一个自然日的全部记录,不是前 100 条,也不是省略租户条件后的全站数量。任何改写都必须保留这些条件。
如果数据库保存 UTC,而产品按用户时区定义“某一天”,应先算出该业务日对应的起止时间,再作为参数传入。本文使用 DATETIME 和固定时间值,不进行时区转换,避免把时间语义问题伪装成索引优化。
完整数据准备
在新实验库执行下面 SQL;同名库存在时选择新名字:
CREATE DATABASE blog_slow_sql_lab;
USE blog_slow_sql_lab;
CREATE TABLE event_log (
id BIGINT PRIMARY KEY,
tenant_id INT NOT NULL,
created_at DATETIME NOT NULL,
payload VARCHAR(200) NOT NULL,
KEY idx_time(created_at)
) ENGINE=InnoDB;
CREATE TABLE digit(n INT PRIMARY KEY);
INSERT INTO digit VALUES(0),(1),(2),(3),(4),(5),(6),(7),(8),(9);
INSERT INTO event_log
SELECT n+1, MOD(n,10),
DATE_ADD('2026-01-01',INTERVAL n MINUTE), REPEAT('x',100)
FROM (
SELECT a.n+10*b.n+100*d.n+1000*e.n+10000*f.n AS n
FROM digit a CROSS JOIN digit b CROSS JOIN digit d CROSS JOIN digit e CROSS JOIN digit f
) numbers WHERE n<40000;
ANALYZE TABLE event_log;
共有 10 个租户,每分钟生成一条日志,目标日期内总共有 1440 条,租户 3 占其中 144 条。
建立基线,区分估计与实际
执行:
EXPLAIN
SELECT COUNT(*) FROM event_log
WHERE tenant_id=3 AND DATE(created_at)='2026-01-10';
EXPLAIN ANALYZE
SELECT COUNT(*) FROM event_log
WHERE tenant_id=3 AND DATE(created_at)='2026-01-10';
SELECT COUNT(*) FROM event_log
WHERE tenant_id=3 AND DATE(created_at)='2026-01-10';
本次 type 为 ALL,key 为空。EXPLAIN ANALYZE 中 Table scan 实际返回 40000 行,上层 Filter 保留 144 行,COUNT 最终返回 144。
普通 EXPLAIN 的 rows 是估计值,可能与 40000 不一致;不能把它当成实测扫描计数。EXPLAIN ANALYZE 会执行查询,观察的是实际迭代过程,也会产生数据库负载。EXPLAIN 文档
从完整查询出发
业务要查询某租户处于指定状态的最新 20 篇文章:
SELECT id, created_at
FROM article
WHERE tenant_id = 3 AND status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;
这里同时有租户过滤、状态过滤、时间排序和数量限制。id 作为次级排序字段,能让同一时间的记录得到明确顺序。单独给每个字段建索引,不等于优化器能把它们拼成最合适的有序访问路径。
联合索引 (tenant_id, status, created_at, id) 是候选方案:前两列在本次查询中都是等值条件,后面的列提供需要的顺序。
联合索引为何强调左侧列?
可以把联合索引理解为按多个字段依次排列的目录:先按 tenant_id 排列,同一租户内再按 status 排列,然后是时间与 id。
当左侧列被等值条件固定时,剩余索引区间中的顺序更容易用于查询和排序。但如果缺少租户条件,或把 status 改成范围,后续时间列就不能简单视为整个结果集的全局顺序。
“范围之后索引完全失效”也不准确。后续列仍可能用于过滤、索引条件下推或覆盖查询,只是不能把筛选、范围定位和排序能力混成同一件事。具体是否启用这些方式,应看执行计划。
SQL 中 WHERE 条件的书写先后,也不直接决定联合索引列顺序。调整 status=1 AND tenant_id=3 的文本顺序,并不会把索引变成另一种排列。MySQL 联合索引说明
两个等值列应该如何排序?
本例两列都被等值限制,因此不能只凭这条查询断言租户列放前一定更快。还应考虑其他查询是否只有租户条件、租户数据隔离方式、状态分布和索引维护成本。
常见的“区分度高的列永远放前面”过于简单。列顺序要服务查询组合;索引只覆盖一条 SQL,还是还要支持同租户的其他列表,会影响选择。
索引也有代价。每次插入、修改相关列或删除,都需要维护索引,索引本身消耗空间与缓存。验证新索引有效后,还要检查旧索引是否真的冗余,不能仅因为列名重叠就直接删除。
2. 一步步减少实际访问成本
先改条件,再调整索引;每一步保留可比较的计划。
第一步:将函数条件改成半开区间
改写为:
EXPLAIN ANALYZE
SELECT COUNT(*) FROM event_log
WHERE tenant_id=3
AND created_at>='2026-01-10'
AND created_at<'2026-01-11';
SELECT COUNT(*) FROM event_log
WHERE tenant_id=3
AND created_at>='2026-01-10'
AND created_at<'2026-01-11';
左闭右开的范围表达“当天起点到次日起点之前”。它避免把终点写成 23:59:59 后漏掉更高精度的时间,也避免 BETWEEN 把次日零点包含进来。
本次优化器使用 idx_time,实际索引范围读取 1440 行,再过滤租户,COUNT 仍为 144。索引现在能直接定位时间范围,但该范围仍包含全部租户。
不要把经验概括成“任何函数都会使所有索引失效”。本例普通 created_at 索引没有为 DATE 表达式提供所需范围;函数索引、生成列或其他优化路径需要另行判断。范围优化
第二步:让租户条件也参与定位
加入候选索引并核对计划:
CREATE INDEX idx_tenant_time ON event_log(tenant_id,created_at);
ANALYZE TABLE event_log;
EXPLAIN
SELECT COUNT(*) FROM event_log
WHERE tenant_id=3
AND created_at>='2026-01-10'
AND created_at<'2026-01-11';
EXPLAIN ANALYZE
SELECT COUNT(*) FROM event_log
WHERE tenant_id=3
AND created_at>='2026-01-10'
AND created_at<'2026-01-11';
本次使用 idx_tenant_time,先限定租户,再定位时间范围。执行树出现 Covering index range scan,实际读取 144 行。
| 阶段 | 实际底层迭代行数 | COUNT 结果 | 访问方式 |
|---|---|---|---|
| DATE 条件 | 40000 | 144 | 表扫描 |
| 时间半开区间 | 1440 | 144 | 时间索引范围 |
| 租户与时间联合索引 | 144 | 144 | 覆盖索引范围 |
这个对比证明了本组数据下工作量的变化。它没有证明真实系统的响应时间按同样比例改善,也没有覆盖并发、冷缓存、网络与连接池开销。
优化结果还需要哪些验证?
首先核对结果。这里三种查询都返回 144;真实业务还应测试边界时刻、空结果和不同时区日期。
其次使用业务真实 SQL。COUNT 查询覆盖成功,不代表返回 payload 的列表查询也不回表;修改返回列、排序或连接表,都要重新检查。
然后检查新索引对写入和其他查询的影响。idx_time 支持跨租户时间范围,idx_tenant_time 未必能同样替代它。是否移除旧索引,需要核对使用场景与实际负载。
最后决定优化是否值得上线。数据库改动涉及构建耗时、资源与版本支持,应评估表规模与变更窗口。本文完成本地 SQL 验证,没有把实验库建索引等同于生产发布方案。
3. 为什么走索引仍然不够
覆盖、统计和选择性影响最终成本。
索引存在、索引被选中、查询高效是三件事
索引存在,只说明数据库有这个访问结构。优化器可能不选择它;即使选择了,也可能需要读取大量索引记录并回表。
假设 2 万个任务中,1.9 万个状态为 0。查 status=0 本来就需要返回大部分记录,不能期待一个状态索引把业务需要的 1.9 万条数据变成 20 条。
选择性讨论应落到条件匹配比例。全列只有两种状态,不代表两种值匹配同样多;数据倾斜会让相同 SQL 在不同参数下承担不同工作量。
优化器怎么估计行数?
成本选择依赖统计信息。持久化索引统计包含采样估计,表变化后可能通过自动重算或 ANALYZE TABLE 更新。估计值与实际值存在差异,并不表示表中记录丢失。InnoDB 持久化统计
本次 status=0 的 EXPLAIN rows 估计为 8108,而实际迭代为 19000。这个数字用于展示估计与实测的区别,不应要求每次重新建库都得到 8108。
统计不准确时,先检查数据分布、统计更新时间与采样条件,再决定是否分析表或调整采样。ANALYZE TABLE 不是把所有查询成本都变成精确计数,也不是任何慢查询的通用修复。
还有哪些“走索引但慢”的原因?
有些索引只满足前缀过滤,剩余条件还要筛选大量候选;有些查询要额外排序;有些连接表中的内层查询被执行很多次。查看 EXPLAIN ANALYZE 时,不仅看单次 rows,还要看 loops。
深 OFFSET、返回宽字段、N+1 查询,以及锁等待也会增加总耗时。全表扫描在读取大部分记录时可能是合理选择,不能把 ALL 当成任何情况下都必须消除的错误。
定位时,可以按“结果本来多不多 → 候选扫描多不多 → 是否回表或排序 → 估计是否偏离 → 是否存在等待”的顺序收集证据。性能结论最终还需要与真实数据、参数和负载结合。
4. 真实慢请求还可能主要在等待
区分 SQL 执行、连接池与锁等待,避免对错地方优化。
面对真实慢查询,先排除等待问题
查询耗时长,不一定只因为扫描多。锁等待、磁盘压力、连接池排队和网络传输也会增加端到端耗时。
先确认慢发生在哪一层,再检查执行计划。数据库执行本身只用少量时间,接口却等待很久时,单纯继续加索引可能找错方向。
记录优化过程时,保留输入参数、数据量、原始结果、计划变化与验证范围,比单独写“加索引后变快了”更能说明你理解问题。
5. 设计判断与适用边界
优化报告应该包含查询、数据条件、改动前后计划和结果一致性。估计行数不能当作实际访问次数;一个索引被选中,也不能证明它比其他路径更便宜。
查询增加字段、范围或排序时,重新检查计划。索引也消耗写入与空间成本,不按接口数量机械增加。原有实验数据只说明指定条件下的观察,新业务仍需要相近数据分布与负载验证。
6. 问答
问:created_at 有索引,DATE(created_at) 为什么仍可能扫描全表?
普通列索引与表达式条件不一定形成可定位的范围。本例实测是表扫描,改成原列半开区间后才使用时间范围。
问:为什么不用 BETWEEN?
半开区间更直接表达完整日期且不包含次日起点,避免时间精度边界错误。
问:查询走索引就一定快吗?
不一定。扫描输入可能很多,可能需要排序和回表。还要看实际处理量与负载。
问:WHERE 条件写在前面,就应该放在索引前面吗?
不是。SQL 文本顺序不能直接决定索引排列,应根据查询组合、等值与范围条件、排序和数据分布设计。