From 20fa8f4d5a6bccc5099e194c11fcce5b98d826e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E8=82=B2=E6=96=B0?= Date: Fri, 28 Aug 2026 16:48:16 +0800 Subject: [PATCH] =?UTF-8?q?docs(note):=20=E9=87=8D=E7=BB=84=E7=AC=94?= =?UTF-8?q?=E8=AE=B0=E7=9B=AE=E5=BD=95=E7=BB=93=E6=9E=84=E5=B9=B6=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20MySQL=20JOIN=20=E5=AE=9E=E9=AA=8C=E7=AC=94=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 随手记笔记按主题迁移至 note/资源/(DevOps、数据库、编程) - Windows使用、go errors 等笔记迁移时同步更名为规范标题 - 新增 MySQL 空表 JOIN 导致排序索引失效实验笔记 - 移除 go操作excel 笔记 --- .../DevOps/Linux}/ssh密钥登录.md | 0 .../{Windows使用.md => Windows使用技巧.md} | 0 note/{随手记 => 资源/DevOps}/正则表达式.md | 0 .../数据库/SQL}/mysql的字符集坑.md | 0 .../编程/Golang/标准库.md} | 0 .../MySQL空表JOIN导致排序索引失效-实验笔记.md | 286 ++++++++++++++++++ note/随手记/go操作excel.md | 83 ----- 7 files changed, 286 insertions(+), 83 deletions(-) rename note/{随手记 => 资源/DevOps/Linux}/ssh密钥登录.md (100%) rename note/资源/DevOps/{Windows使用.md => Windows使用技巧.md} (100%) rename note/{随手记 => 资源/DevOps}/正则表达式.md (100%) rename note/{随手记 => 资源/数据库/SQL}/mysql的字符集坑.md (100%) rename note/{随手记/go errors.md => 资源/编程/Golang/标准库.md} (100%) create mode 100644 note/随手记/MySQL空表JOIN导致排序索引失效-实验笔记.md delete mode 100644 note/随手记/go操作excel.md diff --git a/note/随手记/ssh密钥登录.md b/note/资源/DevOps/Linux/ssh密钥登录.md similarity index 100% rename from note/随手记/ssh密钥登录.md rename to note/资源/DevOps/Linux/ssh密钥登录.md diff --git a/note/资源/DevOps/Windows使用.md b/note/资源/DevOps/Windows使用技巧.md similarity index 100% rename from note/资源/DevOps/Windows使用.md rename to note/资源/DevOps/Windows使用技巧.md diff --git a/note/随手记/正则表达式.md b/note/资源/DevOps/正则表达式.md similarity index 100% rename from note/随手记/正则表达式.md rename to note/资源/DevOps/正则表达式.md diff --git a/note/随手记/mysql的字符集坑.md b/note/资源/数据库/SQL/mysql的字符集坑.md similarity index 100% rename from note/随手记/mysql的字符集坑.md rename to note/资源/数据库/SQL/mysql的字符集坑.md diff --git a/note/随手记/go errors.md b/note/资源/编程/Golang/标准库.md similarity index 100% rename from note/随手记/go errors.md rename to note/资源/编程/Golang/标准库.md diff --git a/note/随手记/MySQL空表JOIN导致排序索引失效-实验笔记.md b/note/随手记/MySQL空表JOIN导致排序索引失效-实验笔记.md new file mode 100644 index 0000000..b637fd8 --- /dev/null +++ b/note/随手记/MySQL空表JOIN导致排序索引失效-实验笔记.md @@ -0,0 +1,286 @@ +# 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)` 复合索引 + +```sql +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 列唯一索引 + +```sql +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 完全同构,只是**不插入任何数据** + +```sql +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 有数据的表 → 索引完美生效 + +```sql +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 空表 → 计划全面崩坏 + +```sql +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 把空表钉回点查 → 计划恢复 + +```sql +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 都是保序的嵌套循环点查,主表就能借索引序免排序。 + +### 实验四:空表填入数据 + 刷新统计信息 → 自愈 + +```sql +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 破坏保序计划,空表只是诱因之一": + +```sql +-- 有 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 值的计划仍在 + +--- + +## 七、清理 + +```sql +DROP TABLE IF EXISTS users, user_profiles, user_tags_empty, user_tags_noindex; +SET SESSION cte_max_recursion_depth = DEFAULT; +``` diff --git a/note/随手记/go操作excel.md b/note/随手记/go操作excel.md deleted file mode 100644 index 32eaa30..0000000 --- a/note/随手记/go操作excel.md +++ /dev/null @@ -1,83 +0,0 @@ -### excelize库 - -读写Excel的Go库 - -[github](https://github.com/qax-os/excelize) - -## 基本用法 - -#### 创建工作簿 - -```go - f := excelize.NewFile() - defer func() { - if err := f.Close(); err != nil { - fmt.Println(err) - } - }() - // 创建一个新的Sheet,返回的index为该工作簿的索引 - index, err := f.NewSheet("SheetName") - if err != nil { - return fmt.Errorf("创建Sheet失败: %w", err) - } -``` - -#### 打开文件 - -```go - f, err := excelize.OpenFile(filePath) - if err != nil { - return fmt.Errorf("打开 Excel失败: %w", err) - } - defer func() { - if err := f.Close(); err != nil { - fmt.Println(err) - } - }() -``` - -#### 写入cell值 - -```go - f.SetCellValue("SheetName", "B2", 100) -``` - -#### 设置工作簿默认活动工作表 - -```go -f.SetActiveSheet(index) -``` - -#### 创建或更新excel表 - -```go - if err := f.SaveAs("Book1.xlsx"); err != nil { - return fmt.Errorf("保存失败: %w", err) - } -``` - -#### 获取指定cell值 - -```go - cell, err := f.GetCellValue("Sheet1", "B2") - if err != nil { - return fmt.Errorf("读取cell失败: %w", err) - } - fmt.Println(cell) -``` - -#### 获取所有工作簿的cell值(二维数组) - -```go - rows, err := f.GetRows("Sheet1") - if err != nil { - return fmt.Errorf("读取失败: %w", err) - } - for _, row := range rows { - for _, colCell := range row { - fmt.Print(colCell, "\t") - } - fmt.Println() - } -``` -