从执行计划排查 MySQL 慢查询

在 MySQL 8.4 的十万行倾斜数据上,对照联合索引前后的真实计划,解释扫描、排序、回表和深页分页,并说明一次查询变快之后还要验证什么。

·15 minMySQLPerformance
把应用部署到土耳其|BRNCHOST · 土耳其 VDS
云服务器,积分可续期|雨云 · 国内外节点 · 积分兑换权益
低价年付,大流量 VPS|RackNerd · SSD 存储 · 1Gbps 端口
香港轻量,搭个小站|晚安云 · 香港云服务器
香港 VPS,大带宽可选|野草云 · BGP 直连
大陆访问,精品线路|搬瓦工 · CN2 GIA / CTGNet 套餐
资料归档,交给 AI 整理|WorkBuddy · 本地文件处理
建站起步,先看应用镜像|腾讯云 · 轻量应用服务器
CN2 GIA,中国方向优化|DMIT · Premium 网络
双 ISP 原生住宅 IP|丽萨主机 · 美国 9929 精品线路
高频 CPU,多地部署|Evoxt · 云服务器 · 每周异地备份
京东云轻量云主机:129元/年,新人专享,限购1台

列表只显示 20 篇文章,数据库却读了十万行。这不矛盾:LIMIT 限制返回数量,前面的过滤和排序仍然可能处理整张表。排查时如果只看“用了哪个索引”,很容易错过真正消耗时间的节点。

这篇文章围绕一条多租户文章列表查询展开。先在没有合适索引的情况下运行,再增加一个同时服务过滤和排序的联合索引,最后改掉查询中的一个条件,观察优化为什么不再成立。数据、命令和计划都来自独立的 MySQL 8.4.11 测试容器,没有使用线上业务记录。

实验不是容量测试。使用合成数据,未构造稳定并发负载,也没有分别重复冷、暖缓存试验。下面保留实际行数与单次计时,帮助读计划;不能据此承诺某台生产服务器的响应时间。

先造一个容易暴露问题的数据分布

表有两个租户。租户 1 占绝大多数记录,租户 42 只占百分之一。每三条记录里有一条草稿,其余已发布。发布时间按 ID 每次增加一秒,标题保持较短。

CREATE TABLE posts (
  id bigint PRIMARY KEY,
  tenant_id int NOT NULL,
  status varchar(16) NOT NULL,
  published_at datetime(6) NOT NULL,
  title varchar(200) NOT NULL
);

CREATE TABLE digits (d int PRIMARY KEY);
INSERT INTO digits VALUES
(0),(1),(2),(3),(4),(5),(6),(7),(8),(9);

INSERT INTO posts
SELECT n,
       IF(n % 100 = 0, 42, 1),
       IF(n % 3 = 0, 'DRAFT', 'PUBLISHED'),
       TIMESTAMP('2026-01-01') + INTERVAL n SECOND,
       CONCAT('article-', n)
FROM (
  SELECT 1 + a.d + 10*b.d + 100*c.d + 1000*d.d + 10000*e.d AS n
  FROM digits a CROSS JOIN digits b CROSS JOIN digits c
  CROSS JOIN digits d CROSS JOIN digits e
) nums;

ANALYZE TABLE posts;

五张十行表的笛卡尔积生成十万条数据。它不是对业务分布的复刻,而是一个受控反例:同一条 SQL 模板,换一个租户参数,过滤后的规模相差近百倍。

租户 草稿 已发布
1 33,000 66,000
42 333 667

先核对这些数量,再看执行计划。如果数据生成就与假设不符,后面的优化解释没有意义。真实排查也一样:从慢日志取到 SQL 后,还要保留参数范围、采样时间、数据量和索引定义。只把租户 ID 换成一个方便测试的小租户,可能恰好避开原问题。

ANALYZE TABLE 更新统计信息,但不会让估计变成精确计数。统计采样、列之间的相关性和数据倾斜都会影响估计。看到计划中的 estimated rows 与实际行数不同,先把差异记下来,再判断它是否真的造成不合适的访问路径。

从叶子节点往上读,别把时间相加

查询某个租户的最新 20 篇已发布文章:

EXPLAIN ANALYZE
SELECT id, title, published_at
FROM posts
WHERE tenant_id = 42
  AND status = 'PUBLISHED'
ORDER BY published_at DESC, id DESC
LIMIT 20;

同一个发布时间下用 ID 打破并列,结果才有确定次序。生产接口使用参数绑定,不把用户输入拼进 SQL。EXPLAIN ANALYZE 会实际执行查询,不能当作没有代价的查看命令;对线上慢语句,先考虑副本、隔离数据和执行预算。需要只看估计计划时使用 EXPLAIN FORMAT=TREE。具体选项以 MySQL 8.4 EXPLAIN 文档为准,并记录会话的输出格式设置。

本次计划的关键部分如下,保留实际节点与行数,省略估计成本以便阅读:

Limit: 20 row(s)
  actual time=13.4..13.4 rows=20 loops=1
  Sort: posts.published_at DESC, posts.id DESC
    actual time=13.4..13.4 rows=20 loops=1
    Filter: status='PUBLISHED' and tenant_id=42
      actual time=0.0453..13.3 rows=667 loops=1
      Table scan on posts
        actual time=0.0111..8.6 rows=100000 loops=1

叶子节点扫描十万行,过滤保留 667 行,排序从中取出前 20 行,顶层再执行返回数量限制。这里的 Sort 节点虽然只向上输出 20 行,也不能据此认定只处理了 20 行。它必须接收下面提供的候选,才能知道哪几条应该排在前面;计划还提示采用有界的排序优化,但这不等于免除扫描和比较。

actual time=a..b 描述迭代器输出首行与完成输出的时间;父节点包含子节点的工作。把 13.4、13.3 和 8.6 相加,会重复计算。遇到 loops 大于 1 的连接查询,还要结合循环次数理解平均时间与平均行数,不能照着一列数字做总和。

先比较首行时间,能进一步解释用户为什么仍然等到扫描结束。Table scan 在 0.0111 毫秒就给出了首行,Filter 在 0.0453 毫秒给出第一个合格候选,但顶层直到 13.4 毫秒才输出第一行。候选很早出现,不代表它一定属于最新的二十篇;Sort 需要消耗输入才能作出判断。这里首行与末行时间在显示精度下相同,说明主要等待发生在结果开始输出之前,不说明二十条记录的网络传输耗时为零。

行数还能给出一份比毫秒更清楚的过滤账单。小租户查询扫描十万行,保留六百六十七行,意味着九万九千三百三十三行没有进入排序候选,保留比例为百分之零点六六七。这些数值是依据原输出做的算术推导。它说明过滤的淘汰量很大,但没有说明这些淘汰发生在索引里;当前计划恰恰是先扫描,再由 Filter 判断。因此“结果很少”仍然可能伴随大量上游工作。

也不要把 Filter 的 13.3 减去扫描的 8.6,直接命名为精确的谓词 CPU 时间。两个节点虽然有包含关系,测量还包括迭代器调度、取行调用和计时开销;相减最多帮助定位值得继续调查的层次。计划没有提供磁盘读取次数、每次回表延迟或独立排序 CPU 样本,不能从四个时间字段反推出这些没有测过的指标。

将参数改成租户 1,底层仍读十万行,但过滤保留 66,000 行,顶层单次计时为 18.1 毫秒。两次差异提醒我们:扫描量相同不代表后续工作相同。一个过滤条件选择性高的参数,不能代表所有调用。

如果实际计划显示数据库执行很短,用户却等了几秒,应沿连接池排队、事务锁等待、响应传输和上游调用继续找。给一条已经很快的 SQL 再加索引,未必改变用户等待的那段时间。

用一个索引同时解决范围和顺序

这条查询的前两个条件是等值,后面有固定排序。可以测试以下索引:

CREATE INDEX idx_tenant_status_time_id
ON posts (
  tenant_id,
  status,
  published_at DESC,
  id DESC
);
ANALYZE TABLE posts;

等值条件把租户 42、已发布状态限定在一段索引范围里,后两列让这段范围中的条目符合要求的倒序。数据库不必从全表找候选再排序,沿这一段读取到足够记录就可以停止。

本次小租户的新计划缩成两层:

Limit: 20 row(s)
  actual time=0.0692..0.0707 rows=20 loops=1
  Index lookup on posts using idx_tenant_status_time_id
    (tenant_id=42, status='PUBLISHED')
    actual time=0.0688..0.0697 rows=20 loops=1

大租户也只从访问节点向上提供了 20 行,顶层计时为 0.0633 毫秒。最有说服力的变化是访问节点从十万行降到满足本次返回所需的 20 行,而且 Sort 不再出现。单次计时会受缓存与调度影响,结构上的工作量减少才是后续容量评估的依据。

不要把这个索引称为覆盖索引:查询还取了 title,而索引定义里没有它。InnoDB 二级索引帮助定位记录,读取标题仍涉及聚簇记录。计划不一定单独画出一个“回表”节点,缺少该节点不代表没有读取表记录。

索引后的小租户,顶层首行时间是 0.0692 毫秒,完成二十行是 0.0707 毫秒。相较原计划,最显著的改变是无需等全部候选进入排序才开始提供结果;有序访问可以按需产出。两者相差的 0.0015 毫秒只是这个节点在本次输出中首行到末行的时间差,不能当成每条文章的稳定读取成本,也不包括浏览器收到和渲染文章的过程。

回表需要与过滤位置一起理解。现有索引包含 tenant_id 和 status,查询可以先定位满足这两个等值条件的条目,再取得标题;它不需要为了判断这两个条件,先把整个租户的标题读出来。但 title 不在二级索引里,获取它仍需访问聚簇记录。这里“访问节点输出二十行”描述向上游交付的记录,不是“只读二十个磁盘页”的同义词:多个记录可能共享页面,页面也可能已经在缓冲池中。

如果后来新增“标题包含某词”的过滤,原索引不能直接从自身内容判断标题,访问过程可能读出多个聚簇记录,筛掉不匹配者,直到凑够二十条。这是查询形状变化的推导,本文没有运行该变体。它解释了为什么不能把当前的二十行计划复用为所有列表筛选的成本承诺,也提示排查时应区分定位条件、剩余过滤条件和最终投影字段。

是否把标题加进索引,要看读取节省能否抵消索引变大、写入变重和缓存利用率下降。当前路径只返回 20 条,回表代价可能完全可以接受。若文章标题经常很长,把长字符串放进每个索引条目,还会影响索引层级和页面数量。不能因为“覆盖”听起来更先进就默认扩列。

索引设计还依赖主键和排序方向。这里显式包含倒序 ID,是为了让同一发布时间的倒序规则清楚对应索引,不能从“InnoDB 二级索引带主键”推断任意隐含顺序都满足要求。上线前用实际 DDL 和计划确认,不用口头简化代替验证。联合索引说明可以帮助理解访问前缀,但具体查询仍需单独观察。

删掉一个条件,排序为什么又回来了

产品增加“查看全部状态”的筛选,开发者只删除 status='PUBLISHED',仍然按时间倒序:

EXPLAIN ANALYZE
SELECT id, title, published_at
FROM posts
WHERE tenant_id = 42
ORDER BY published_at DESC, id DESC
LIMIT 20;

索引里,同租户记录先按 status 分组,再在各组内部按时间排列。放开 status 后,数据库面对多个分别有序的组,不能直接把一组后面接另一组当作全局时间顺序。

本次计划访问该租户的 1,000 行,随后重新排序:

Limit: 20 row(s)
  Sort: posts.published_at DESC, posts.id DESC
    Index lookup using idx_tenant_status_time_id (tenant_id=42)
      actual rows=1000 loops=1

这不是刚建的索引失效。它仍然把全表缩小到了一个租户,只是不再同时满足排序。接下来有几种合理选择:全部状态查询使用独立的 (tenant_id, published_at DESC, id DESC) 索引;如果调用很少且数据量可控,接受额外排序;或者调整产品入口,让主要路径保持明确状态。

选择取决于查询频率、每租户规模和写入成本。为每种筛选组合都建一条索引,最终可能把查询问题转移成写入问题。应该收集主要 SQL 形状,优先覆盖频繁且影响用户的路径,再处理长尾。

把 status 从等值改成多个值,或者在排序列前加入一个范围条件,也需要重新读计划。索引字段顺序是一种对访问方式的承诺;查询形状变化后,原解释不会自动继续成立。

深页仍然会让数据库白走很多步

合适索引解决了首屏,不代表第几千页同样轻。实验对大租户使用六万条偏移:

EXPLAIN ANALYZE
SELECT id, title, published_at
FROM posts
WHERE tenant_id = 1 AND status = 'PUBLISHED'
ORDER BY published_at DESC, id DESC
LIMIT 60000, 20;

底层索引节点实际输出 60,020 行,顶层丢弃前 60,000 行,再返回 20 行。这里没有 Sort,但数据库仍做了大量遍历;本次顶层计时为 26.2 毫秒。把“没有 filesort”当作查询已经足够好的结论,会漏掉这种情况。

按上一页末尾继续读取,可以把偏移改成范围。实验取第 60,000 条作为上一页末尾,它的时间为 2026-01-01 02:31:31.000000,ID 为 9091。下一页条件为:

SELECT id, title, published_at
FROM posts
WHERE tenant_id = 1
  AND status = 'PUBLISHED'
  AND (
    published_at < '2026-01-01 02:31:31.000000'
    OR (
      published_at = '2026-01-01 02:31:31.000000'
      AND id < 9091
    )
  )
ORDER BY published_at DESC, id DESC
LIMIT 20;

本次走 Index range scan,向上提供 20 行,顶层计时为 0.0883 毫秒。与偏移查询得到的 20 个 ID 逐项相同,从 9089、9088、9086 开始,到 9061 结束。对照结果集合很重要:一条写错比较符但返回更少结果的查询,也可能看起来更快。

这里的游标位置也能从数据生成规则独立核算。ID 大于 9091 的共有 90909 个,其中 30303 个是三的倍数,先剩 60606 个已发布候选;再排除其中属于租户 42 的 607 个,正好留下 59999 个租户 1 的已发布记录。因此 9091 是倒序的第 60000 条,下一条 9090 因为是草稿被跳过,第一页游标之后从 9089 开始。这个算术核对与原输出相符,没有新增执行计划。

同一发布时间的边界如何手算

下面是人为构造的四条记录,用来检查谓词,不是数据库实测。假定它们属于同一租户且都已发布:A 的时间为 10:00:00.123456、ID 为 105;B 的时间相同、ID 为 103;C 的时间仍相同、ID 为 101;D 的时间为 10:00:00.123455、ID 为 110。先按时间倒序,再按 ID 倒序,顺序就是 A、B、C、D。D 的 ID 最大也不能提前,因为时间是第一排序键。

若每页两条,上一页结束在 B,游标必须同时携带它的时间和 ID。C 通过“时间等于游标且 ID 小于 103”这一分支,D 通过“时间小于游标”这一分支;两者构成下一页。只写时间小于游标,会漏掉 C;把同时间的 ID 比较写成小于等于,会重复 B;只比较 ID,又会把 ID 为 110 的 D 错误排除。拆开的两个分支正好对应倒序排列中游标后方的两种情况。

原十万行数据的时间随 ID 每次增加一秒,无法实际触发同时间的第二分支。两组二十个 ID 相同证明这份数据的分页等价,手算则解释另一个分支为何必要,两种证据不能互相冒充。如果实现层把 B 的微秒截断为 10:00:00.123000,C 和 D 的真实时间都大于这个截断值,下一页条件会把它们排除。因此精度损失不是单纯的展示差异,而会改变范围边界。

时间戳要保持数据库精度,不能经过 JavaScript 日期格式化后丢掉微秒。将时间和 ID 编入游标时,还要绑定租户、筛选条件和排序版本;服务端的租户权限来自会话,而不来自客户端游标。参数不匹配就拒绝或从第一页重查,不悄悄套用旧游标。

游标查询并没有创造跨请求快照。用户翻页期间文章修改发布时间或状态,记录可能跨越游标位置。普通阅读列表通常允许这种变化;财务导出、审批名单等需要固定集合的任务,要设计独立的快照或物化名单。不要把浏览器分页的体验优化写成一致性保证。

同样,游标不擅长跳到任意页。产品若坚持直接访问第 500 页,可以保留偏移并接受成本,或维护额外导航信息。技术实现需要回应真实交互,而不是为了用某个优化技巧改变用户没有同意的行为。

把实验结论带回生产前,还差哪些信息

这次实验回答了一个窄问题:在给定数据与查询条件下,联合索引减少了扫描和排序,游标范围减少了深页遍历。它没有测量长标题、热点更新、并发发布、复制延迟,也没有覆盖数据库缓冲池被其他表挤占的情况。

下一步应从真实请求中挑选代表参数,保留脱敏后的分布,再在可控环境比较。至少分别观察首屏、深页、全部状态和大租户,不要把它们合成一条平均耗时。记录用户侧延迟时,也要保留连接池等待和 SQL 时间,确认优化触达了原来的慢环节。

新增索引需要安排上线过程。建索引要占用额外空间,可能产生复制积压,也可能等待元数据锁。即使版本支持在线操作,开始和结束阶段的锁行为仍值得关注。长事务没有释放,DDL 排队可能影响后来请求;发布手册应写清取消条件和负责人,而不是只有一条 CREATE INDEX。

回退也不一定是立即删索引。发现新索引让另一个重要查询换成差计划时,先保留证据,再考虑查询调整、优化器可见性或撤回索引。直接把线上状态来回切换,会让诊断混入更多变量。

最后,把这次的表结构、数据生成、参数、完整计划和结果校验留在同一份实验里。下一位工程师遇到“只返回 20 条为什么还慢”,可以从扫描行数开始检查,不必重新猜测那个曾经很漂亮的毫秒数是怎么测出来的。