Files
lab/note/资源/数据库/SQL/避坑/MySQL空表JOIN导致排序索引失效-实验笔记.md
T

11 KiB
Raw Blame History

MySQL 空表 JOIN 导致 ORDER BY 索引失效——实验笔记

环境:MySQL 8.0.18+(hash join 自 8.0.18 引入,实测 8.0.28) 现象:一条 ORDER BY ... LIMIT 查询原本走主表复合索引(免排序),LEFT JOIN 一张空表后,执行计划退化为全量读取 + Using temporary; Using filesort,主表索引"失效"。


一、一句话结论

不是空表禁用了别的表的索引,而是空表让优化器为该 JOIN 选择了 hash join;hash join 不保序,导致"借主表索引序完成 ORDER BY"的整个计划作废,主表随之退化为全量读取 + 临时表排序。


二、原理

1. 为什么嵌套循环(Nested Loop Join)能"免排序"

按 idx(created_at, id) 倒序逐行读主表 → 每行对被 join 表做索引点查(eq_ref)→ 输出
  • 嵌套循环的输出顺序 = 驱动表(主表)的访问顺序
  • 主表走 (created_at, id) 复合索引倒序扫描,输出天然满足 ORDER BY created_at DESC, id DESC
  • LIMIT 10 可以提前终止:读到 10 行即停,ORDER BY 零成本(EXPLAIN 中体现为 type=index + Backward index scan,无 temporary/filesort)

2. hash join 为什么会破坏这一切

  • hash join 把被 join 表整棵读进内存哈希表,再用驱动表逐行探测,输出顺序不再对应任何一方的索引序
  • 一旦计划中任何一个 JOIN 环节是 hash join,结果集都必须整体重排
  • 优化器权衡:"反正要排序,主表走哪个访问路径都无所谓" → 放弃排序专用索引,选最便宜的读取方式(全表扫或全索引扫,见第四节变体)

3. 为什么偏偏是空表触发 hash join(反直觉)

  • 空表没有统计信息,优化器估算行数 ≈ 1
  • 估算"把空表整棵读进哈希表(≈1 次 IO)再探测" 比 "对主表每行做一次 B+ 树点查(N 次树遍历)" 更便宜
  • 于是选了 hash join —— 对空表本身这是正确的成本决策(0 行数据怎么访问都对),副作用是拖垮了主表的排序计划
  • 表有了真实数据后,统计信息更新,优化器改回索引点查(eq_ref),保序计划自动恢复

三、建表与造数(可直接执行)

1. 主表:10 万行,带 (created_at, id) 复合索引

DROP TABLE IF EXISTS users, user_profiles, user_tags_empty, user_tags_noindex;

CREATE TABLE users (
  id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  username   VARCHAR(64) NOT NULL,
  nickname   VARCHAR(64) NOT NULL DEFAULT '',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  KEY idx_created_id (created_at, id)
) ENGINE=InnoDB;

-- 递归 CTE 造数需要先放开深度限制(仅当前会话)
SET SESSION cte_max_recursion_depth = 100001;

INSERT INTO users (username, nickname, created_at)
WITH RECURSIVE seq AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM seq WHERE n < 100000
)
SELECT
  CONCAT('user_', n),
  CONCAT('nick_', n),
  DATE_ADD('2025-01-01 00:00:00', INTERVAL FLOOR(RAND() * 31536000) SECOND)
FROM seq;

2. 对照用被 join 表:有数据,join 列唯一索引

CREATE TABLE user_profiles (
  id      BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT UNSIGNED NOT NULL,
  city    VARCHAR(32) NOT NULL DEFAULT '',
  UNIQUE KEY uk_user_id (user_id)
) ENGINE=InnoDB;

INSERT INTO user_profiles (user_id, city)
WITH RECURSIVE seq AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM seq WHERE n < 100000
)
SELECT n, CONCAT('city_', FLOOR(RAND() * 100)) FROM seq;

3. "罪魁"空表:结构与 user_profiles 完全同构,只是不插入任何数据

CREATE TABLE user_tags_empty (
  id      BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT UNSIGNED NOT NULL,
  tag     VARCHAR(32) NOT NULL DEFAULT '',
  UNIQUE KEY uk_user_id (user_id)
) ENGINE=InnoDB;
-- 故意不插数据

四、对照实验

每组实验观察四处:主表 users 的 type / key / rows / Extra,以及被 join 表的访问方式。 注意:实验 SQL 故意 SELECT 了 username/nickname 等索引外列,原因见实验二的"变体"说明。

实验一(对照组):只 JOIN 有数据的表 → 索引完美生效

EXPLAIN
SELECT u.id, u.username, u.nickname, u.created_at, p.city
FROM users u
LEFT JOIN user_profiles p ON p.user_id = u.id
ORDER BY u.created_at DESC, u.id DESC
LIMIT 10;

预期结果:

table type key rows Extra
u index idx_created_id 10 Backward index scan
p eq_ref uk_user_id 1 (空)

主表倒序走复合索引只读 10 行(LIMIT 提前终止),被 join 表逐行点查,无排序开销。保序计划下 SELECT 索引外列不影响走索引(逐行回表而已)。

实验二:仅追加一个 LEFT JOIN 空表 → 计划全面崩坏

EXPLAIN
SELECT u.id, u.username, u.nickname, u.created_at, p.city, t.tag
FROM users u
LEFT JOIN user_profiles p ON p.user_id = u.id
LEFT JOIN user_tags_empty t ON t.user_id = u.id
ORDER BY u.created_at DESC, u.id DESC
LIMIT 10;

预期结果(SELECT 含索引外列时):

table type key rows Extra
u ALL NULL 100003 Using temporary; Using filesort
p eq_ref uk_user_id 1 (空)
t ALL NULL 1 Using where; Using join buffer (hash join)

三个观察点:

  1. 空表 t 的索引 uk_user_id 明明存在,却 type=ALL + hash join(空表上这是合理的成本选择)
  2. 主表 u 的 idx_created_id 被整个放弃:全表扫描 + 临时表 + 文件排序,rows 从 10 涨到全量(LIMIT 提前终止失效)
  3. 注意 p 的 eq_ref 没有受影响——别的表的索引并没有被禁用,坏掉的是主表的"排序专用访问路径"

变体:主表退化的两种形态(重要,实测踩过的坑)

如果 SELECT 只查索引内的列(如仅 u.id, u.created_at),主表退化后不是 ALL,而是:

u: type=index, key=idx_created_id, rows=100003, Extra: Using index; Using temporary; Using filesort

机制:排序计划作废后优化器选"最便宜的读取方式"——查询列全在索引里时,扫窄的二级索引(覆盖扫描,Using index 免回表)比扫宽的聚簇索引(ALL)更便宜。type=index 全索引扫描与 ALL 全表扫本质相同:都是全量读取 + 显式排序,同样没有 LIMIT 提前终止。

判断"是否退化"的可靠标志是 rows 是否涨到全量、Using temporary; Using filesort 是否出现,而不是 type 显示 ALL 还是 index。生产查询通常 SELECT 大量业务列(覆盖不成立),多表现为 ALL 形态;本实验因此故意带上索引外列。

加 FORMAT=TREE 可以更直观看到 Left (hash join) 节点: EXPLAIN FORMAT=TREE <实验二的查询>;

实验三:FORCE INDEX 把空表钉回点查 → 计划恢复

EXPLAIN
SELECT u.id, u.username, u.nickname, u.created_at, p.city, t.tag
FROM users u
LEFT JOIN user_profiles p ON p.user_id = u.id
LEFT JOIN user_tags_empty t FORCE INDEX (uk_user_id) ON t.user_id = u.id
ORDER BY u.created_at DESC, u.id DESC
LIMIT 10;

预期结果: t 变为 eq_ref,主表 u 恢复 index + Backward index scan, rows=10,temporary/filesort 消失。

结论:只要所有 JOIN 都是保序的嵌套循环点查,主表就能借索引序免排序。

实验四:空表填入数据 + 刷新统计信息 → 自愈

INSERT INTO user_tags_empty (user_id, tag)
WITH RECURSIVE seq AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM seq WHERE n < 100000
)
SELECT n, 'vip' FROM seq;

ANALYZE TABLE user_tags_empty;

-- 重跑实验二的 EXPLAIN

预期结果: 与实验一/三相同,t 走 eq_ref,主表走 idx_created_id,无需任何 hint。

补充观察:只插入少量行(比如 50 行)时优化器可能仍选 hash join——对几十行的表,hash join 确实可能更便宜,这属于合理决策而非问题;关注点应是"数据量与计划是否匹配",而不是"必须永远走索引"。

进阶实验五:与空无关——任何原因导致的 hash join 都会触发同样的退化

证明"根因是 hash join 破坏保序计划,空表只是诱因之一":

-- 有 10 万行、但 join 列上【没有索引】的表
CREATE TABLE user_tags_noindex (
  id      BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT UNSIGNED NOT NULL,
  tag     VARCHAR(32) NOT NULL DEFAULT ''
) ENGINE=InnoDB;

INSERT INTO user_tags_noindex (user_id, tag)
WITH RECURSIVE seq AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM seq WHERE n < 100000
)
SELECT n, 'new' FROM seq;

EXPLAIN
SELECT u.id, u.username, u.nickname, u.created_at, p.city, t.tag
FROM users u
LEFT JOIN user_profiles p ON p.user_id = u.id
LEFT JOIN user_tags_noindex t ON t.user_id = u.id
ORDER BY u.created_at DESC, u.id DESC
LIMIT 10;

预期结果: 与实验二相同的崩坏形态(t 为 ALL + hash join,主表全量读取 + temporary + filesort)。

这说明日常更常见的场景——join 列漏建索引——和空表是同一个病:hash join 一出现,依赖主表索引序的 ORDER BY + LIMIT 计划就没了。


五、机制小结(对照表)

JOIN 实现方式 输出是否保序 LIMIT 能否提前终止 对主表排序索引的影响
嵌套循环 + eq_ref 点查 ✅ 顺序=驱动表访问序 ✅ 读够即停 可借索引序免排序
hash join ❌ 哈希探测序 ❌ 需物化后整体排序 排序计划作废,主表退化为全量读取

主表退化后的具体形态由 SELECT 列集合决定:

SELECT 列 退化形态 特征
含索引外列(生产常态) type=ALL 全表扫 key=NULL
全在索引内 type=index 全索引扫 key=该索引,Using index(覆盖免回表)

两种形态共同点:rows ≈ 全量、Using temporary; Using filesort、LIMIT 无法提前终止。

优化器的行为链条:

被 join 表选了 hash join
  → 结果集顺序不可保证
  → ORDER BY 必须显式排序(temporary + filesort)
  → "主表走排序专用索引"失去意义
  → 主表改用最便宜的读取方式(ALL 或覆盖索引全扫)

六、实践建议

  1. 排查口诀:ORDER BY ... LIMIT 查询变慢时,先看 EXPLAIN 里是否出现 Using join buffer (hash join)——它常常是主表索引"无故失效"的信号
  2. 判断退化看 rows 和 temporary/filesort,不要只盯 type:type=index 也可能是全索引扫描的退化形态(对比正常时 rows 应接近 LIMIT 值)
  3. 根治:给被 join 表的 join 列建索引(唯一列建唯一索引,非唯一建普通索引),让点查比 hash join 更便宜
  4. 空表/新表场景(刚上线、还没数据的表):如果线上已出现该退化且影响明显,可用 FORCE INDEX 过渡;表有数据后可移除 hint(低频后台查询甚至可以不处理)
  5. 不要盲信"必须走索引":空表/极小表走全表扫本身是正确的成本决策,要修的是"hash join 破坏排序计划"这个副作用,而不是全表扫本身
  6. 改动 join 结构后(新增关联表、调整关联条件)重新 EXPLAIN 一次带排序的查询,确认 Backward index scan / rows 接近 LIMIT 值的计划仍在

七、清理

DROP TABLE IF EXISTS users, user_profiles, user_tags_empty, user_tags_noindex;
SET SESSION cte_max_recursion_depth = DEFAULT;