From 531d4b7d8c1ffe7e97c4bc757ed09dae78de3e57 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Thu, 21 May 2026 12:20:54 +0800 Subject: [PATCH] vault backup: 2026-05-21 12:20:54 --- hhs/MySQL/02-SQL核心/07-DML 增删改.md | 39 ++ hhs/MySQL/02-SQL核心/09-JOIN 原理与优化.md | 74 ++++ hhs/MySQL/02-SQL核心/10-子查询与派生表.md | 27 +- hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md | 111 +++++- .../03-索引与查询优化/12-B+Tree 索引原理.md | 59 ++- .../13-聚簇索引与二级索引.md | 41 +- .../14-联合索引与最左前缀.md | 236 +++++++++-- .../03-索引与查询优化/15-EXPLAIN 完全指南.md | 369 ++++++++++++++++-- 8 files changed, 867 insertions(+), 89 deletions(-) diff --git a/hhs/MySQL/02-SQL核心/07-DML 增删改.md b/hhs/MySQL/02-SQL核心/07-DML 增删改.md index 63584ba..957c33d 100644 --- a/hhs/MySQL/02-SQL核心/07-DML 增删改.md +++ b/hhs/MySQL/02-SQL核心/07-DML 增删改.md @@ -101,6 +101,45 @@ sequenceDiagram > > **优先用 `ON DUPLICATE KEY UPDATE`**,除非你确实需要完整的「删+插」语义。 +### INSERT ... SELECT + +从另一张表或查询结果批量插入数据——这是日常开发中比 `VALUES` 批量插入更高频的用法。 + +```sql +-- 基础:从查询结果插入 +INSERT INTO user_archive (id, username, email, archived_at) +SELECT id, username, email, NOW() +FROM users +WHERE status = 0 AND updated_at < '2025-01-01'; + +-- 搭配聚合:插入每日统计快照 +INSERT INTO daily_stats (stat_date, order_count, total_amount) +SELECT CURDATE(), COUNT(*), SUM(amount) +FROM orders +WHERE DATE(created_at) = CURDATE(); +``` + +> [!QUESTION] INSERT ... SELECT 会锁表吗? +> 这取决于事务隔离级别和索引情况: +> - 在 **RC(Read Committed)** 下:只锁定被插入的目标表,对源表加短暂的共享锁 +> - 在 **RR(Repeatable Read)** 下:源表的读取走一致性快照,**不会阻塞源表的写入** +> - 目标表:插入的行会加行锁,大批量插入时注意不要超长事务 + +> [!CAUTION] INSERT ... SELECT 的两个常见坑 +> 1. **SELECT 中不能引用正在被插入的目标表**(MySQL 会报 `Table is specified twice`)。解决办法:嵌套一层子查询 +> ```sql +> -- ❌ 报错:直接引用目标表 +> INSERT INTO orders_backup SELECT * FROM orders WHERE id IN (SELECT id FROM orders WHERE ...); +> -- ✅ 正确:用子查询隔离 +> INSERT INTO orders_backup SELECT * FROM orders WHERE id IN (SELECT t.id FROM (SELECT id FROM orders WHERE ...) t); +> ``` +> 2. **大批量插入时分批执行**,避免长事务持有过多锁。可以用 `LIMIT` + 循环分批: +> ```sql +> -- 每次只插入 5000 条,循环直到 affected_rows = 0 +> INSERT INTO user_archive +> SELECT * FROM users WHERE status = 0 LIMIT 5000; +> ``` + ## UPDATE UPDATE 的核心原则只有一条:**精准定位、最小影响**。先思考「哪些行需要改」,再写 SET 子句。 diff --git a/hhs/MySQL/02-SQL核心/09-JOIN 原理与优化.md b/hhs/MySQL/02-SQL核心/09-JOIN 原理与优化.md index fad9499..51953d0 100644 --- a/hhs/MySQL/02-SQL核心/09-JOIN 原理与优化.md +++ b/hhs/MySQL/02-SQL核心/09-JOIN 原理与优化.md @@ -136,6 +136,80 @@ SELECT a.name, b.name FROM table_a CROSS JOIN table_b; -- 结果 = |a| × |b| 行 ``` +## SELF JOIN(自连接) + +自连接是**同一张表与自身进行 JOIN**——表没有变,只是用两个不同的别名把它"当成两张表"来用。这是处理**层次结构**和**行间比较**的经典手法。 + +### 场景一:组织架构树(上下级关系) + +```sql +-- 找出每个员工及其直属上级的名字 +SELECT e.name AS employee, m.name AS manager +FROM employees e +LEFT JOIN employees m ON e.manager_id = m.id; + +-- 结果: +-- | employee | manager | +-- | Alice | NULL | ← CEO,无上级 +-- | Bob | Alice | +-- | Carol | Alice | +-- | David | Bob | +``` + +> [!NOTE] SELF JOIN vs 递归 CTE 的选择 +> - **只查一层关系**(直接上级/下级):SELF JOIN 简洁高效 +> - **需要遍历整棵树**(所有层级的汇报线):必须用递归 CTE——详见 [[hhs/MySQL/02-SQL核心/10-子查询与派生表]] + +### 场景二:查找同一组内的相邻行 + +```sql +-- 找出连续两天都有订单的用户(日环比分析) +SELECT DISTINCT a.user_id +FROM orders a +INNER JOIN orders b + ON a.user_id = b.user_id + AND DATEDIFF(b.created_at, a.created_at) = 1 +WHERE DATE(a.created_at) = '2026-05-20'; +``` + +### 场景三:去重——保留每组最新的一条 + +```sql +-- 删除同一用户的历史记录,只保留最新一条 +DELETE old FROM orders old +INNER JOIN orders new + ON old.user_id = new.user_id + AND old.created_at < new.created_at; +-- old 是"要删的旧记录",new 是"用来比较的新记录" +``` + +```mermaid +flowchart LR + subgraph "orders 表(别名 old)" + O1["user=1, 05-18"] + O2["user=1, 05-19"] + O3["user=1, 05-20"] + end + subgraph "orders 表(别名 new)" + N1["user=1, 05-18"] + N2["user=1, 05-19"] + N3["user=1, 05-20"] + end + O1 -->|"old.created_at < new.created_at"| N2 + O1 --> N3 + O2 --> N3 + O3 -.->|"无更早匹配"| N3 + + style O1 fill:#EE5A24,color:#fff + style O2 fill:#EE5A24,color:#fff + style O3 fill:#00D866,color:#fff +``` + +> [!TIP] 自连接的性能注意事项 +> - 自连接本质是同一张表扫描两次,如果表很大且没有索引,代价等同于两张大表的 BNLJ +> - **务必确保 JOIN 条件列有索引**(如 `manager_id`、`user_id + created_at`) +> - 如果只是做"每组取最新一条",`ROW_NUMBER()` 窗口函数通常比自连接 DELETE 更安全(详见 [[hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析]]) + ## JOIN 执行算法 MySQL InnoDB 在执行 JOIN 时,**本质上只有两种核心策略**:被驱动表有索引时用 **INLJ**(Index Nested-Loop Join),没有索引时退化到 **BNLJ**(Block Nested-Loop Join)。而 INLJ 内部又根据索引是否为唯一键进一步细分。 diff --git a/hhs/MySQL/02-SQL核心/10-子查询与派生表.md b/hhs/MySQL/02-SQL核心/10-子查询与派生表.md index 1451e91..0af3899 100644 --- a/hhs/MySQL/02-SQL核心/10-子查询与派生表.md +++ b/hhs/MySQL/02-SQL核心/10-子查询与派生表.md @@ -36,10 +36,13 @@ graph BT ```sql -- 用法 1:在 SELECT 中调用 -SELECT +SELECT username, (SELECT COUNT(*) FROM orders WHERE user_id = users.id) AS order_count FROM users; +-- 副作用:这个查询其实也是"相关子查询"的典型例子 +-- 因为内层的 user_id = users.id 引用了外层表的列 +-- 每一行 users 的记录都会触发一次内层查询 -- 用法 2:在 WHERE 中等价于常量 SELECT * FROM products @@ -50,6 +53,10 @@ INSERT INTO reports (month, order_total) VALUES ('2026-05', (SELECT SUM(amount) FROM orders WHERE MONTH(created_at) = 5)); ``` +> [!QUESTION] 上面用法 1 的子查询,每一行都要重新执行吗? +> 是的——但不是因为"放在 SELECT 里",而是因为它引用了外层的 `users.id`。 +> 这就引出了子查询最重要的分类维度:**相关 vs 无关**。 + ### 相关 vs 无关子查询 这是理解子查询性能的基石:**子查询是否引用了外层查询的列?** @@ -436,6 +443,14 @@ WITH RECURSIVE org_chain AS ( SELECT * FROM org_chain ORDER BY level, name; ``` +> [!QUESTION] 为什么叫「自我引用」?递归 CTE 到底怎么执行的? +> `org_chain` 这个名字在定义内部就被引用了——`FROM employees e INNER JOIN org_chain oc`。 +> 第一轮:只执行锚点成员,得到根节点(CEO); +> 第二轮:用第一轮的结果去 JOIN employees,得到 CEO 的直接下属; +> 第三轮:用第二轮的结果去 JOIN,得到下属的下属…… +> 直到某一轮 JOIN 结果为空,递归自动终止。 +> **关键:没有终止条件 → 无限循环 → 触发 `cte_max_recursion_depth` 报错。** + > [!WARNING] 递归 CTE 的注意事项 > - MySQL 默认递归深度上限为 **1000**(`cte_max_recursion_depth`),超出会报错 > - 务必设置合理的终止条件,否则会无限递归直到达到上限 @@ -466,6 +481,16 @@ SELECT * FROM org_chain ORDER BY level, name; > **子查询不是万能的——它首先是为了表达清晰,其次才是性能。** > 写完后务必跑 `EXPLAIN`,确认没有意料之外的临时表或多表扫描。当数据量上去后,能改写成 JOIN 的子查询就尽量改写,因为 JOIN 的执行路径对 Optimizer 更加透明。 +> [!TIP] 学习路径建议 +> 如果你刚接触子查询,建议按这个顺序学习: +> 1. **标量子查询** → 先理解「子查询就是一次独立的 SELECT」 +> 2. **相关 vs 无关** → 理解执行次数差异(本文核心概念) +> 3. **IN vs EXISTS** → 掌握两种过滤范式,重点理解 NOT IN 陷阱 +> 4. **派生表** → 子查询作为虚拟表,理解物化与 Flattening +> 5. **CTE** → 用现代语法重写派生表,最终挑战递归 CTE +> +> 每一步都要跑 `EXPLAIN` 看执行计划——**看懂 EXPLAIN 比背语法重要十倍**。 + ## 关联笔记 - [[hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析]] — SELECT 执行顺序与子查询的关系 diff --git a/hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md b/hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md index 17edd82..7846391 100644 --- a/hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md +++ b/hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md @@ -1,5 +1,5 @@ --- -tags: [MySQL, UNION, UNION ALL, 集合运算] +tags: [MySQL, UNION, UNION ALL, INTERSECT, EXCEPT, 集合运算] create time: 2026-05-16 00:00 --- @@ -47,6 +47,114 @@ flowchart LR > [!TIP] 首选 UNION ALL > 如果你能通过业务逻辑保证各部分结果不重复(比如按日期区间划分),**一律用 UNION ALL**。UNION 的去重操作需要 Sort/Dedup 阶段,在大结果集上是昂贵操作。 +## INTERSECT 与 EXCEPT(MySQL 8.0.22+) + +MySQL 8.0.22 开始支持标准 SQL 的 `INTERSECT`(交集)和 `EXCEPT`(差集),补齐了集合运算的最后一块拼图。 + +### 基本语法 + +```sql +-- INTERSECT:取两个查询的交集(两边都有的行) +SELECT city FROM customers_china +INTERSECT +SELECT city FROM customers_japan; +-- 结果:只返回同时存在于两个结果集中的城市 + +-- EXCEPT:取差集(左边有、右边没有的行) +SELECT city FROM customers_china +EXCEPT +SELECT city FROM customers_japan; +-- 结果:返回在中国有但日本没有的城市 +``` + +> [!QUESTION] INTERSECT vs IN / EXISTS? +> 两者在语义上等价,但 `INTERSECT` 更直观地表达了"取交集"的意图。实际执行中,Optimizer 通常会将 INTERSECT 转换为 Semi Join 或 Anti Join——因此性能差异不大,选择哪种取决于**可读性**。 + +### INTERSECT / EXCEPT vs UNION 行为对比 + +```mermaid +flowchart LR + A["查询 A: {1,2,3}"] --> OP + B["查询 B: {2,3,4}"] --> OP + + OP --> R1["UNION: {1,2,3,4}"] + OP --> R2["INTERSECT: {2,3}"] + OP --> R3["EXCEPT: {1}"] + + style R1 fill:#74C0FC,color:#000 + style R2 fill:#00D866,color:#fff + style R3 fill:#FF9F43,color:#000 +``` + +| 操作 | 语义 | 去重 | 对应集合论 | +|------|------|------|-----------| +| `UNION` | A ∪ B | ✅ 默认去重 | 并集 | +| `UNION ALL` | A ∪ B(含重复) | ❌ | 多重集并 | +| `INTERSECT` | A ∩ B | ✅ 默认去重 | 交集 | +| `EXCEPT` | A − B | ✅ 默认去重 | 差集 | + +> [!NOTE] 默认行为:去重 +> INTERSECT 和 EXCEPT 默认都会去重(等价于 DISTINCT)。如果想去重保留所有行,目前 **MySQL 不支持 `INTERSECT ALL` / `EXCEPT ALL`**,需要通过 UNION ALL + 计数的方式模拟。 + +### 实用场景 + +```sql +-- 场景一:找出"既买了 A 又买了 B"的用户(交集) +SELECT user_id FROM orders WHERE product_id = 'A' +INTERSECT +SELECT user_id FROM orders WHERE product_id = 'B'; + +-- 等价的 EXISTS 写法(对比可读性) +SELECT DISTINCT o1.user_id +FROM orders o1 +WHERE o1.product_id = 'A' + AND EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = o1.user_id AND o2.product_id = 'B'); + +-- 场景二:找出"注册了但从未下单"的用户(差集) +SELECT id FROM users +EXCEPT +SELECT DISTINCT user_id FROM orders; + +-- 等价的 NOT EXISTS 写法 +SELECT u.id FROM users u +WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id); +``` + +> [!TIP] 何时选 INTERSECT/EXCEPT? +> - **逻辑简单、需要取交集/差集**:`INTERSECT` / `EXCEPT` 语法最清晰 +> - **需要返回关联表的字段**:用 `EXISTS` / `NOT EXISTS` 或 JOIN,因为 INTERSECT/EXCEPT 只能操作整行或指定列 +> - **需要高性能**:大表场景下 EXPLAIN 确认执行计划——Optimizer 通常会转换为 Semi/Anti Join,性能与手写子查询一致 + +### 优先级与括号 + +当混合使用多个集合运算时,默认**从左到右**执行。需要用括号改变优先级: + +```sql +-- 先 UNION 再 INTERSECT(左到右) +SELECT id FROM t1 +UNION +SELECT id FROM t2 +INTERSECT +SELECT id FROM t3; +-- 实际含义:t1 UNION (t2 INTERSECT t3) ← 错误! +-- 实际执行:(t1 UNION t2) INTERSECT t3 ← 从左到右 + +-- ✅ 用括号明确意图 +(SELECT id FROM t1 UNION SELECT id FROM t2) +INTERSECT +SELECT id FROM t3; + +-- 也可以用 ORDER BY + LIMIT 修饰每个子句(需括号) +(SELECT id FROM t1 ORDER BY id LIMIT 10) +INTERSECT +(SELECT id FROM t2 ORDER BY id LIMIT 5); +``` + +> [!WARNING] 注意括号的必要性 +> 当 INTERSECT/EXCEPT 与 ORDER BY/LIMIT 混用时,不加括号可能产生歧义。**养成给每个子查询加括号的习惯**,避免优先级意外。 + +--- + ## 实战场景 ### 场景一:多表同构合并 @@ -348,6 +456,7 @@ flowchart TD | 主题 | 一句话 | |------|--------| | UNION ALL vs UNION | 能用 ALL 就绝不用 UNION — 去重的临时表+文件排序代价远超想象 | +| INTERSECT / EXCEPT | 8.0.22+ 原生支持交集与差集,语义比 IN/EXISTS 更直观,Optimizer 会转为 Semi/Anti Join | | ORDER BY/LIMIT 作用域 | 只在 UNION 最后一个 SELECT 生效;跨表排序必须外层包装子查询 | | 字段匹配规则 | 列数必须相同,类型应尽量兼容 — 列名取自第一个 SELECT | | Feed 流/多源合并 | UNION ALL 的经典场景,一次 DB 往返搞定多数据源混合 | diff --git a/hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理.md b/hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理.md index d611ca4..53f3c1c 100644 --- a/hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理.md +++ b/hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理.md @@ -132,7 +132,31 @@ sequenceDiagram ### 覆盖索引(Covering Index) -当查询需要的数据全部存在于某个索引的 B+ Tree 中时,就不需要再走聚簇索引了——这叫 **Index Only Scan**,在 `EXPLAIN` 中显示为 `Using index`。 +什么是**回表**?二级索引的叶子节点只存了**索引列 + 主键**,不存完整行数据。当查询需要的列不在索引中时,MySQL 必须拿着主键再去聚簇索引里找完整行——这个"再跳一次"的过程就叫回表(详见下方 [[#聚簇索引 vs 二级索引]])。每回表一次就是一次**随机 IO**,匹配 1000 行就是 1000 次随机跳转,代价非常高。 + +**覆盖索引的核心思想**:如果查询所需的**所有列**都存在于某个索引的叶子节点中,就省掉回表这一步——直接从二级索引中把数据读完。这在 `EXPLAIN` 中显示为 `Using index`,也叫 **Index Only Scan**。 + +```mermaid +flowchart LR + subgraph "回表路径(普通查询)" + A1["二级索引
叶子节点"] -->|"拿到主键 id"| A2["聚簇索引
再次定位"] + A2 -->|"随机 IO"| A3["完整行数据"] + end + + subgraph "覆盖索引路径(Index Only Scan)" + B1["二级索引
叶子节点"] -->|"所有列都在这里"| B2["直接返回"] + end + + style A1 fill:#FF9F43,color:#000 + style A2 fill:#EE5A24,color:#fff + style A3 fill:#FF9F43,color:#000 + style B1 fill:#00D866,color:#fff + style B2 fill:#00D866,color:#fff +``` + +> [!QUESTION] 为什么避免回表这么重要? +> 回表是**随机 IO**,而顺序 IO 和随机 IO 的性能差距是 **百倍到千倍** 级别(机械磁盘尤其明显,SSD 也有 10~50 倍差距)。 +> 当匹配行数较多时,覆盖索引的收益会非常显著。 ```sql CREATE TABLE users ( @@ -145,16 +169,39 @@ CREATE TABLE users ( -- ❌ 必须回表:email 在索引里,但 name 不在 SELECT name, email FROM users WHERE email = 'test@example.com'; --- ① 走 idx_email 找到 id → ② 回聚簇索引拿 name +-- ① 走 idx_email 找到 id → ② 回聚簇索引拿 name(随机 IO) -- ✅ 覆盖索引:不需要回表! SELECT email FROM users WHERE email = 'test@example.com'; --- 只需从 idx_email 叶子节点直接拿到 email +-- 只需从 idx_email 叶子节点直接拿到 email,零回表 ``` -> [!NOTE] Covering Index 的判断方法 -> - `Extra = Using index`(无 `Using where`)→ 完整覆盖,零回表 -> - `Extra = Using index condition` → 索引下推(ICP),部分过滤在索引层面完成 +#### 用联合索引构造覆盖索引 + +实际业务中,单列索引很难做到覆盖——查询通常需要多个字段。更常见的做法是**通过联合索引把查询涉及的列"包"进去**: + +```sql +-- 高频查询:根据 user_id 查该用户已完成的订单金额 +SELECT order_id, amount +FROM orders +WHERE user_id = 100 AND status = 'paid'; + +-- 方案 A:索引只有 (user_id) → 必须回表拿 status、order_id、amount +-- 方案 B:联合索引 (user_id, status, order_id, amount) +-- 叶子节点存了全部所需列 → 零回表 ✅ +ALTER TABLE orders ADD INDEX idx_user_cover(user_id, status, order_id, amount); +``` + +> [!NOTE] 设计覆盖索引的思路 +> 1. **先看高频查询**:`SELECT` 了哪些列?`WHERE` 和 `ORDER BY` 涉及哪些列? +> 2. **把 WHERE + ORDER BY + SELECT 涉及的列**按合理顺序放入联合索引 +> 3. 等值列在前、范围列在后(最左前缀原则),`SELECT` 中无法参与过滤的列放最后 +> +> 代价是索引更宽、占用更多空间、写入维护成本更高——需要权衡。 + +> [!NOTE] EXPLAIN 中怎么判断覆盖索引? +> - `Extra = Using index` → 完整覆盖,零回表 +> - `Extra = Using index condition` → 索引下推(ICP),部分过滤在索引层面完成,但仍需回表 > - Extra 中**没有** `Using index` → 触发了回表 > > 💡 更多细节(回表代价、ICP 原理、空间对比)详见 [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] diff --git a/hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引.md b/hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引.md index 15bdbb9..bcbc3dd 100644 --- a/hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引.md +++ b/hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引.md @@ -7,7 +7,13 @@ create time: 2026-05-20 14:00 ## 概述 -InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什么」的不同,分为两类:**聚簇索引**和**二级索引**。理解这两者的差异,是你设计高效查询和执行计划的起点。 +InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什么」的不同,分为两类:**聚簇索引**和**二级索引**。 + +打个比方:如果把数据库表想象成一本通讯录—— +- **聚簇索引**就是通讯录正文本身:按姓名排序,每个条目后面直接写上了电话、地址等所有信息。 +- **二级索引**就像附录的索引卡片:只写了「姓名 → 第几页」,想看完整信息还得翻回正文。 + +理解这两者的差异,是你设计高效查询和执行计划的起点。 为了方便说明,本文档将始终使用以下 `users` 表作为示例: @@ -24,8 +30,8 @@ CREATE TABLE users ( ``` 这张表中: -- **聚簇索引**:`id`(PRIMARY KEY) -- **二级索引**:`idx_email`(email)、`idx_status_created`(status, created_at) +- **聚簇索引**:`id`(PRIMARY KEY)—— 因为 InnoDB 的规则是:谁是 PRIMARY KEY,谁就是聚簇索引。你可以这样理解:InnoDB **根本不单独存"表数据"**,它把整张表的数据直接塞进了以 `id` 为键的 B+ Tree 的叶子节点里。所以 `id` 这棵 B+ Tree 既是索引,也是数据本身。(后面会详细展开这条规则。) +- **二级索引**:`idx_email`(email)、`idx_status_created`(status, created_at) —— 这两个索引的叶子节点里不存完整行数据,只存「索引列的值 + `id`」,所以叫"二级":它们是依附于聚簇索引的"小抄",查到 `id` 后还得回聚簇索引里拿完整数据。 > [!QUESTION] 思考 > 假设一张用户表按 `id` 排序存储在磁盘上—— @@ -69,7 +75,9 @@ block-beta ## 聚簇索引(Clustered Index) -聚簇索引就是数据本身。InnoDB 表中**只能有一个聚簇索引**。 +"聚簇"这个词听起来很抽象,但意思很简单:**数据按照索引的顺序"聚集"在一起存储**。在 InnoDB 里,聚簇索引的叶子节点不是"指向"数据,叶子节点**就是**数据——整行记录都直接存在里面。 + +正因为数据只有一份,所以一张表**只能有一个**聚簇索引。 ### 聚簇索引的形成规则 @@ -89,6 +97,8 @@ block-beta ### 聚簇索引的特性 +数据按主键顺序存储带来了两个直接好处:写入快(顺序追加)和范围查询快(连续扫描): + ```mermaid flowchart TD A["按主键排序存储数据"] --> B["数据页紧凑排列"] @@ -109,6 +119,15 @@ flowchart TD 除了聚簇索引外的所有索引都是二级索引。叶子节点存储的是:**索引列的值 + 主键值**。 +> [!QUESTION] 为什么二级索引要存主键? +> 因为二级索引本身没有完整数据,但它知道"这条记录在哪"——通过主键就能回到聚簇索引里找到完整行。主键就是二级索引回聚簇索引的"导航坐标"。 +> +> 整个过程分两步: +> 1. 在二级索引里找到匹配条件的记录,拿到主键 `id` +> 2. 拿着 `id` 去聚簇索引里找完整行数据 +> +> 这个"从二级索引跳回聚簇索引取数据"的过程,就叫 **回表**。 + ```mermaid flowchart TD subgraph "二级索引页
idx_status_created = (status, created_at)" @@ -131,6 +150,8 @@ flowchart TD ## 回表的代价 +刚才说了回表是"跳回聚簇索引取数据",但这个跳转是有代价的——它意味着额外的磁盘 I/O。下面这张图展示了具体过程: + ```mermaid sequenceDiagram participant App as 应用层 @@ -152,7 +173,9 @@ sequenceDiagram ### 减少回表的策略(Covering Index) -要减少回表,最直接的方法是让查询**完全在二级索引中完成**——这就是 Covering Index。 +回表既然是因为二级索引里"信息不够"才跳回聚簇索引,那最直接的解决办法就是:**让二级索引里包含查询需要的所有信息**,这样就不需要跳回去了。 + +这就是 Covering Index(覆盖索引)——索引"覆盖"了查询的全部需求,查完索引就够了,不用回表。 ```sql -- ❌ 差:Covering Index 未命中,需要回表拿 name 字段 @@ -220,11 +243,13 @@ flowchart TD ``` > [!HIGHLIGHT] ICP 的本质 -> 把 **Server 层**的过滤条件(如 `created_at > ...`)**下推到 Handler 层**,在读取二级索引页时就完成判断,命中了才回表。 +> 用生活化的类比来说:以前的做法是,快递员(存储引擎)在仓库(二级索引)里找到 1000 个包裹,全部搬出来,再由前台(Server 层)一个一个拆开检查是不是客户要的。ICP 的做法是,直接告诉快递员"只搬 status=1 且 created_at 在这个日期之后的",在仓库里就筛掉了 950 个,只搬出 50 个。 +> +> 技术上说,就是把过滤条件下推到存储引擎层,在读取二级索引页时就完成判断,命中了才回表。 > > **适用条件:** -> - 必须是二级索引(聚簇索引不走 ICP) -> - 过滤条件中能利用到的部分必须在索引列范围内 +> - 必须是二级索引(聚簇索引本身已经是完整数据,不存在"下推"的问题) +> - 过滤条件必须能利用到索引列(否则在索引里也没法判断) > - MySQL 5.6+ 默认开启,无需额外配置 ```sql diff --git a/hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀.md b/hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀.md index f1d9c91..f28ef57 100644 --- a/hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀.md +++ b/hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀.md @@ -9,23 +9,36 @@ create time: 2026-05-16 00:00 联合索引(Composite Index)是将多个列放在同一个 B+ Tree 中组织的索引。它的核心法则是**最左前缀匹配原则**——理解这一点就能避开 80% 的索引设计失误。 -## 联合索引的结构 +## 从"字典查词"理解联合索引 -``` -联合索引 idx(a, b, c) 的 B+ Tree 结构: +翻开一本中文词典,里面的词条按照**拼音 → 笔画 → 部首**的顺序排列: + +1. 先按拼音排序(a, b, c...) +2. 拼音相同时,按笔画排序 +3. 笔画也相同时,按部首排序 + +你想查"爱"字,先翻到拼音 `ai` 的区域(第一列),再找 10 画的(第二列),最后精确定位(第三列)——这就是**最左前缀**的直觉。 + +反过来,如果有人问"10 画的字在第几页?"——你没法回答,因为 10 画的字**分散在每个拼音区**里,没有连续的区域可以翻到。 + +> [!QUESTION] 思考 +> 联合索引 `idx(a, b, c)` 就像这本字典。条件必须从第一列开始依次匹配,不能"跳着查"。下面我们来看看它的具体结构。 + +## 联合索引的结构 叶子节点按 a 排序,a 相同时按 b 排序,a 和 b 都相同时按 c 排序: -[a=1, b=10, c=3] → data -[a=1, b=10, c=7] → data -[a=1, b=20, c=1] → data -[a=1, b=20, c=9] → data -[a=2, b=5, c=2] → data -[a=2, b=15, c=4] → data -[a=3, b=10, c=1] → data -[a=3, b=10, c=8] → data -[a=3, b=30, c=5] → data -``` +| a | b | c | data | +|---|---|---|------| +| 1 | 10 | 3 | row1 | +| 1 | 10 | 7 | row2 | +| 1 | 20 | 1 | row3 | +| 1 | 20 | 9 | row4 | +| 2 | 5 | 2 | row5 | +| 2 | 15 | 4 | row6 | +| 3 | 10 | 1 | row7 | +| 3 | 10 | 8 | row8 | +| 3 | 30 | 5 | row9 | > [!QUESTION] 这意味着什么? > 联合索引的本质是**多级排序**。你可以把它想象成 SQL 的 `ORDER BY a, b, c`。索引中的数据已经按照这个顺序排好了。 @@ -63,16 +76,35 @@ flowchart TD ### 为什么 b=2 单独查不了? -``` -查询 WHERE b=2 时,B+ Tree 的搜索路径是什么样的? +```mermaid +graph LR + subgraph SQ1["✅ WHERE a=1 二分查找定位"] + direction LR + A1["a=1 区域连续"] --> A2["直接定位起始行"] --> A3["顺序扫描即可"] + end -[b=20, c=1] 的位置在 [b=10, c=7] 之后,但它们之间穿插着 [b=5, c=2]... -数据在整个树中分散存放,没有连续的 b=2 区域 → 需要全树扫描 + subgraph SQ2["❌ WHERE b=2 需全表扫描"] + direction LR + B1["b=10 在 a=1 下"] --> B4["b=10 在 a=3 下"] --> B2["b=2 散落各处"] --> B3["无法定位起点"] + end -而 WHERE a=1 则不同: -[a=1, ...] 的所有记录都在树的左侧一段连续区域内 → 二分查找即可定位 + style SQ1 fill:#E8F8F5,stroke:#00D866,stroke-width:2px + style SQ2 fill:#FDEDEC,stroke:#EE5A24,stroke-width:2px + style A1 fill:#00D866,color:#fff + style A2 fill:#00D866,color:#fff + style A3 fill:#00D866,color:#fff + style B1 fill:#EE5A24,color:#fff + style B2 fill:#EE5A24,color:#fff + style B3 fill:#EE5A24,color:#fff + style B4 fill:#EE5A24,color:#fff ``` +B+ Tree 中的数据**先按 a 排序**,只有 a 相同时 b 才有序。单独查 `b=2` 时,满足条件的行分散在不同的 a 值下面,没有连续的 `b=2` 区域可供二分查找——只能全树扫描。 + +> [!QUESTION] 思考 +> 规律是什么?只有匹配了索引的**第一列**,才能继续利用第二列;匹配了前两列,才能利用第三列。条件必须从索引的最左列开始,依次匹配,不能跳过中间列。 +> 而对于 `WHERE a=1 AND c=3`,虽然 a 匹配了第一列,但跳过了 b,所以 c 无法走索引。不过别担心,MySQL 优化器足够聪明——它会自动调整 WHERE 条件的顺序来匹配索引,**SQL 中条件的书写顺序不影响索引的使用**。 + ## 范围查询后的断裂 联合索引遇到范围查询(`>`, `<`, `BETWEEN`, `LIKE 'prefix%'`)后,右侧列的索引失效。 @@ -91,14 +123,16 @@ SELECT * FROM orders WHERE status = 'paid' ``` > [!QUESTION] 为什么范围查询会打断后续列? -> 想象一下,当 `status='paid'` 锁定一个子树后,该子树内部按 `created_at` 升序排列。一旦用 `>` 做了范围扫描,得到的是一个 `created_at` 连续递增的数据段——这个段内的 `type` 值是乱序穿插的,无法再用二分查找定位 `type='online'`。 +> 一个生活类比:联合索引就像一本按**省份 → 城市 → 区县**排序的通讯录。你可以先翻到"广东省"(等值),再在里面翻"深圳市"(等值),最后找"南山区"——很快。但如果你只知道"某个日期之后创建的订单"(范围),你得到的是一个连续但内部无序的数据段,就像拿到"2026 年之后的广东省所有城市"的名单——这个名单里的区县是乱序的,你没法再按区县快速定位。 +> +> 简单说:**范围扫描得到的是一个"连续但内部无序"的区间**,后续列无法利用索引的有序性进行二分查找。 ```mermaid graph LR A["等值列 = 定位起点"] -->|"精确找到起始位置"| B B["范围列 = 确定终点"] -->|"划定扫描区间"| C C["右侧列 = 失效"] -->|"区间内无序"| D["退化为 WHERE 过滤"] - + style A fill:#00D866,color:#fff style B fill:#00B6BC,color:#fff style D fill:#EE5A24,color:#fff @@ -126,36 +160,115 @@ CREATE INDEX idx_status_time ON orders (status, created_at); -- 让每个索引专注于它的典型查询模式 ``` +### EXPLAIN 验证:从 range 到 ref + +用 EXPLAIN 对比两种索引的效果: + +```sql +-- ❌ 用 idx_sct(status, created_at, type) +EXPLAIN SELECT * FROM orders +WHERE status = 'paid' AND created_at > '2026-01-01' AND type = 'online'; +``` + +``` ++----+------+---------------+---------+-------+-----------------+------+----------+-------------+ +| id | type | possible_keys | key | ref | key_len | rows | filtered | Extra | ++----+------+---------------+---------+-------+-----------------+------+----------+-------------+ +| 1 | range| idx_sct | idx_sct | NULL | ... | 1280 | 33.33 | Using where | ++----+------+---------------+---------+-------+-----------------+------+----------+-------------+ +-- type=range:范围扫描后,type='online' 需要逐行过滤(filtered=33.33%) +``` + +```sql +-- ✅ 用 idx_stc(status, type, created_at) +EXPLAIN SELECT * FROM orders +WHERE status = 'paid' AND type = 'online' AND created_at > '2026-01-01'; +``` + +``` ++----+------+---------------+---------+------------------+---------+------+----------+-------+ +| id | type | possible_keys | key | ref | key_len | rows | filtered | Extra | ++----+------+---------------+---------+------------------+---------+------+----------+-------+ +| 1 | ref | idx_stc | idx_stc | const,const | ... | 42 | 100.00 | NULL | ++----+------+---------------+---------+------------------+---------+------+----------+-------+ +-- type=ref:等值列全部命中,范围扫描在最后一步,filtered=100%! +``` + > [!TIP] 联合索引列序黄金法则 > 1. **等值优先于范围**:等值列放在前面 > 2. **选择性高的列靠前**:区分度大的列(如 user_id)比区分度小的(如 gender)靠前 -> 3. **前缀长度要短**:如果需要 LIKE,尽量用较短的前缀 +> 3. **范围列放在最后**:让前面的等值列尽量多命中,范围列最后扫描 ## 索引失效的典型场景 -```sql --- ❌ 场景 1:隐式类型转换 -ALTER TABLE users ADD INDEX idx_phone (phone); --- phone 是 VARCHAR 类型 -SELECT * FROM users WHERE phone = 13800138000; -- 传入 INT! --- MySQL 会把 varchar 转成 int 再比较 → 函数作用于列 → 索引失效 +以下 5 个场景都会导致索引失效,逐一排查可解决大部分问题。 --- ❌ 场景 2:函数/表达式包裹索引列 -SELECT * FROM users WHERE YEAR(created_at) = 2026; --- 应对:改为范围查询 WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01' +> [!WARNING] 场景 1:隐式类型转换 +> ```sql +> ALTER TABLE users ADD INDEX idx_phone (phone); -- phone 是 VARCHAR 类型 +> SELECT * FROM users WHERE phone = 13800138000; -- 传入 INT! +> ``` +> MySQL 会把 varchar 列隐式转换为 int 再比较——函数作用于列,索引失效。 +> +> **解决**:传入字符串 `WHERE phone = '13800138000'`。 --- ❌ 场景 3:LIKE 以通配符开头 -SELECT * FROM users WHERE name LIKE '%abc%'; --- 应对:考虑全文索引 FT +> [!WARNING] 场景 2:函数/表达式包裹索引列 +> ```sql +> SELECT * FROM users WHERE YEAR(created_at) = 2026; +> ``` +> 索引列被函数包裹后,B+ Tree 无法直接定位。 +> +> **解决**:改为范围查询 `WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'`。 --- ❌ 场景 4:OR 条件中有列没有索引 -SELECT * FROM users WHERE email = 'a@x.com' OR phone = '138...'; --- 如果 email 有索引但 phone 没有,整个查询不走索引 --- 应对:给 phone 加索引,或用 UNION ALL 拆分 +> [!WARNING] 场景 3:LIKE 以通配符开头 +> ```sql +> SELECT * FROM users WHERE name LIKE '%abc%'; +> ``` +> 前缀未知,无法利用索引的有序性定位。 +> +> **解决**:使用前缀匹配 `LIKE 'abc%'`,或考虑全文索引。 --- ❌ 场景 5:字符集不一致导致隐式转换 --- 表 charset=utf8mb4,客户端 charset=gbk → 自动转换 --- 应对:确保连接字符集一致 SET NAMES utf8mb4 +> [!WARNING] 场景 4:OR 条件中有列没有索引 +> ```sql +> SELECT * FROM users WHERE email = 'a@x.com' OR phone = '138...'; +> ``` +> 如果 email 有索引但 phone 没有,整个查询不走索引。 +> +> **解决**:给 phone 加索引,或用 `UNION ALL` 拆分为两个独立查询。 + +> [!WARNING] 场景 5:字符集不一致导致隐式转换 +> ```sql +> -- 表 charset=utf8mb4,客户端 charset=gbk → 自动转换 +> ``` +> 跨字符集连接时,MySQL 需要逐行转换再比较,索引失效。 +> +> **解决**:确保连接字符集一致 `SET NAMES utf8mb4`。 + +### 索引失效决策图 + +```mermaid +flowchart TD + Q0{"查询条件是否包含索引最左列?"} + Q1{"最左列是否被函数/表达式包裹?"} + Q2{"列类型与传入值类型是否一致?"} + Q3{"OR 条件中所有列是否都有索引?"} + Q4{"字符集是否一致?"} + OK["✅ 索引生效"] + FAIL["❌ 索引失效,排查修复"] + + Q0 -->|是| Q1 + Q0 -->|否| FAIL + Q1 -->|否| Q2 + Q1 -->|是| FAIL + Q2 -->|是| Q3 + Q2 -->|否| FAIL + Q3 -->|是| Q4 + Q3 -->|否| FAIL + Q4 -->|是| OK + Q4 -->|否| FAIL + + style OK fill:#00D866,color:#fff + style FAIL fill:#EE5A24,color:#fff ``` ## 最左前缀的灵活应用 @@ -183,12 +296,53 @@ GROUP BY type; -- → 同上,子区间内 type 已有序,分组可以直接跳过 ``` +### Go 中利用索引的查询设计 + +在 Go 后端中,构建查询时应当有意识地按照索引列序组织条件: + +```go +// 按索引列序拼接查询条件,确保每个条件都能命中 idx_stc(status, type, created_at) +func buildOrderQuery(status string, orderType string, since time.Time) (string, []any) { + conditions := []string{"1=1"} + args := []any{} + + if status != "" { + conditions = append(conditions, "status = ?") + args = append(args, status) + } + if orderType != "" { + conditions = append(conditions, "type = ?") + args = append(args, orderType) + } + if !since.IsZero() { + conditions = append(conditions, "created_at > ?") // 范围列放最后 + args = append(args, since) + } + query := "SELECT * FROM orders WHERE " + strings.Join(conditions, " AND ") + return query, args +} +``` + > [!NOTE] 关键理解 > ORDER BY / GROUP BY 能复用联合索引,靠的不是"巧合",而是 B+ Tree **叶子节点本身有序**这一物理特性。只要 WHERE 过滤条件匹配了联合索引的左侧列,剩余列就是有序的——优化器只是利用了已有的顺序,并没有多做一次排序。 > [!TIP] 一索引多用 > 一个好的联合索引可以同时服务 WHERE、ORDER BY、GROUP BY 三种需求。在设计索引时要考虑查询的整体模式,而不是单一查询。 +## 联合索引列序决策表 + +| 查询模式 | 列序建议 | 示例索引 | 说明 | +|----------|----------|----------|------| +| 等值 + 等值 | 高选择性列在前 | `idx(user_id, status)` | 两个等值条件都能走 ref | +| 等值 + 范围 | 等值列在前,范围列最后 | `idx(status, created_at)` | 等值定位起点,范围最后扫描 | +| 等值 + 等值 + 范围 | 等值列全在前,范围列最后 | `idx(status, type, created_at)` | 让尽量多的等值列命中 | +| 等值 + ORDER BY | 等值列 → 排序列 | `idx(status, type)` | 排序列可复用索引有序性 | +| 等值 + GROUP BY | 同 ORDER BY | `idx(status, type)` | 分组复用索引有序性 | +| 范围 + 范围 | 选择性高的范围列在前 | `idx(created_at, amount)` | 两个范围条件,只能一个走索引 | + +> [!QUESTION] 什么时候该拆索引? +> 当两种查询模式的列序**互相矛盾**时(如一个需要 `(a, b, c)`,另一个需要 `(b, a, c)`),与其在同一个索引上做取舍,不如创建两个独立索引,让每个索引专注于它的典型查询模式。但要注意——索引越多,写入开销越大,需要在读写之间找到平衡。 + ## 关联笔记 - [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 聚簇索引本身就是特殊的联合索引 (PK) diff --git a/hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南.md b/hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南.md index bb06d17..138371f 100644 --- a/hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南.md +++ b/hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南.md @@ -12,6 +12,17 @@ create time: 2026-05-16 00:00 > [!QUESTION] 为什么不能直接靠"写了索引就一定能用到"? > 因为 MySQL 使用的是 **Cost-Based Optimizer (CBO)**:优化器会根据统计信息(行数、页大小、聚簇因子等)自行决定最优执行路径。有时优化器认为全表扫描比走索引更快(比如要查整张表 80% 的数据),这时即使有索引也不会使用。`EXPLAIN` 就是用来观察和优化器"想法"是否一致的镜子。 +> [!TIP] EXPLAIN 输出的阅读顺序 +> 面对一屏 EXPLAIN 输出,可以按"从大到小"的优先级快速判断: +> 1. **type** — 最快判断索引使用情况(ALL = 🔴 全表扫描) +> 2. **key** — 确认实际命中的索引名 +> 3. **rows** — 预估扫描行数,决定是否需要优化 +> 4. **Extra** — 有没有 filesort / temporary 等额外开销 +> 5. **key_len** — 联合索引用了几列?只用了前缀? +> 6. **ref** — 索引在跟什么做比较? +> +> 先扫 type + key 就能判断"这 SQL 有没有大问题",再看 rows + Extra 判断"问题有多严重"。 + ## 基本用法 ```sql @@ -27,15 +38,65 @@ EXPLAIN FORMAT=JSON SELECT * FROM users WHERE email = 'a@x.com'; EXPLAIN ANALYZE SELECT ...; -- MySQL 8.0.18+ ``` -## 关键字段速查 +## 输出字段全览 -| 字段 | 含义 | 关注点 | -|------|------|--------| -| **type** | JOIN 类型 | 是否走索引(ref/range 优,ALL 差)| -| **key** | 实际使用的索引 | NULL = 没用索引 | -| **rows** | 预估扫描行数 | 越小越好 | -| **Extra** | 附加信息 | 重点关注 Using where/index/filesort/temporary | -| **cost** | 预估成本(JSON 格式) | optimizer_cost 越低越好 | +> [!NOTE] 核心思路 +> EXPLAIN 输出的每一行对应一张表的访问方式。多表 JOIN 时会输出多行,**id 相同时,行号越小(越靠上)越先执行**——第一行是驱动表,后续行是被驱动表。 + +| 字段 | 含义 | 重要程度 | 关注点 | +|------|------|:--------:|--------| +| **id** | 查询编号 | ⚪ 了解 | 相同 id = 同层查询,不同 id = 嵌套子查询 | +| **select_type** | 查询类型 | 🟡 辅助 | SIMPLE/PRIMARY/SUBQUERY/DERIVED,判断复杂度 | +| **table** | 访问的表 | ⚪ 了解 | 可能是别名或 `` 物化表 | +| **type** | 访问类型 | 🔴 必看 | **索引使用的第一指标**(const > ref > range > ALL) | +| **possible_keys** | 可能用到的索引 | 🟡 辅助 | NULL = 没有候选索引,需创建 | +| **key** | 实际使用的索引 | 🔴 必看 | NULL = 优化器放弃索引,选了全表扫描 | +| **key_len** | 索引使用字节数 | 🟡 辅助 | 判断联合索引是否被充分利用 | +| **ref** | 索引比较对象 | 🟡 辅助 | const/列名/func,理解 JOIN 驱动关系 | +| **rows** | 预估扫描行数 | 🔴 必看 | 越小越好,`rows × filtered%` ≈ 进入下一步的行数 | +| **filtered** | WHERE 过滤率 (%) | 🟡 辅助 | 100% = 索引完全满足条件,越低说明回表后还要过滤 | +| **Extra** | 附加信息 | 🔴 必看 | filesort/temporary = 🔴;Using index = 🟢 覆盖索引 | + +## select_type:查询层级识别 + +`select_type` 告诉你这一行代表的是什么级别的查询——最外层、子查询、还是 UNION? + +| select_type | 含义 | 典型场景 | +|-------------|------|----------| +| **SIMPLE** | 简单查询(无子查询/UNION) | `SELECT * FROM t WHERE id = 1` | +| **PRIMARY** | 最外层查询 | 包含子查询时,外层标记为 PRIMARY | +| **SUBQUERY** | SELECT 列表或 WHERE 中的子查询 | `WHERE id IN (SELECT ...)` | +| **DEPENDENT SUBQUERY** | 依赖外层结果的关联子查询 | `WHERE EXISTS (SELECT ... FROM t2 WHERE t2.id = t1.id)` | +| **DERIVED** | FROM 子句中的派生表 | `FROM (SELECT ...) AS tmp` | +| **UNION** | UNION 中第二个及之后的 SELECT | `SELECT ... UNION SELECT ...` | +| **MATERIALIZED** | 物化子查询(8.0+) | 优化器将子查询结果存入临时表 | + +```sql +-- 一个包含子查询的查询 +EXPLAIN +SELECT u.username, order_count +FROM users u +JOIN ( + SELECT user_id, COUNT(*) AS order_count + FROM orders + GROUP BY user_id +) AS oc ON u.id = oc.user_id +WHERE u.id IN (SELECT user_id FROM vip_users); +``` + +``` ++----+-------------+------------+--------+---------------+---------+---------+------+------+-------+ +| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | ++----+-------------+------------+--------+---------------+---------+---------+------+------+-------+ +| 1 | PRIMARY | | ALL | NULL | NULL | NULL | NULL | 1000 | | ← FROM 子句的派生表 +| 1 | PRIMARY | u | eq_ref | PRIMARY | PRIMARY | 4 | oc.user_id | 1 | | +| 2 | DERIVED | orders | index | idx_user_id | idx_user_id | 4 | NULL | 100K | Using index | +| 3 | SUBQUERY | vip_users | ALL | NULL | NULL | NULL | NULL | 500 | | ++----+-------------+------------+--------+---------------+---------+---------+------+------+-------+ +``` + +> [!QUESTION] 为什么 DERIVED 表的 rows 是 1000? +> 因为派生表 `oc` 是先物化到临时表的,MySQL 对物化表的行数统计通常不精确。这也是为什么**尽量避免在大表上使用 FROM 子查询**——物化过程本身就有开销。MySQL 8.0 的 Merged Derived Table 优化可以将简单的派生表"合并"回外层查询,减少物化。 ## type 详解(最重要) @@ -72,6 +133,90 @@ flowchart LR > [!WARNING] 不要盲目追求 ref 以上级别 > `range` 在某些场景(如范围不大)完全可以接受。关键是看 `rows` 字段和 `Extra` 的组合。 +## key_len:索引使用了多少? + +`key_len` 表示 MySQL 在索引中实际使用的**字节数**。对于联合索引,它是判断"索引被用了几列"的最直接证据。 + +> [!QUESTION] 联合索引 `idx_a_b_c (a, b, c)`,EXPLAIN 显示 key_len = 8,用了几列? +> 需要知道各列的数据类型才能算出来。key_len 的计算规则如下: + +### 计算规则 + +| 类型 | 字节数 | 说明 | +|------|--------|------| +| `INT` | 4 | 固定 4 字节 | +| `BIGINT` | 8 | 固定 8 字节 | +| `CHAR(n)` | n × 字符集字节数 | utf8mb4 = 每字符 4 字节 | +| `VARCHAR(n)` | n × 字符集字节数 + **2** | 额外 2 字节存长度 | +| `DATE` | 3 | 固定 3 字节 | +| `TIMESTAMP/DATETIME` | 5/8 | 固定长度 | +| **NULLABLE 列** | +1 | 所有类型额外 +1 字节标记 NULL | + +> [!TIP] 速算公式 +> `key_len = 各列字节数之和`,其中每列 = **类型基础长度 + (VARCHAR 额外 2) + (可 NULL 额外 1)** +> +> 反过来看:如果 key_len 比你预期的短,说明联合索引**只用到了前缀几列**。 + +### 实战计算 + +```sql +CREATE TABLE users ( + id BIGINT PRIMARY KEY, + name VARCHAR(64) NOT NULL, -- utf8mb4 + age INT, + email VARCHAR(128) DEFAULT NULL, + INDEX idx_name_age_email (name, age, email) +); +``` + +| 使用方式 | key_len 计算 | 说明 | +|----------|-------------|------| +| `WHERE name = 'Tom'` | 64 × 4 + 2 = **258** | VARCHAR(64) utf8mb4,NOT NULL 无额外字节 | +| `WHERE name = 'Tom' AND age = 20` | 258 + 4 = **262** | age 是 INT,NOT NULL | +| `WHERE name = 'Tom' AND age = 20 AND email = 'a@b.c'` | 262 + 128 × 4 + 2 + 1 = **777** | email 可 NULL,额外 +1 | + +```sql +EXPLAIN SELECT * FROM users WHERE name = 'Tom' AND age = 20; +-- key: idx_name_age_email, key_len: 262 +-- → 说明索引只用到了前 2 列(name + age),没用到 email +``` + +> [!NOTE] 核心用法 +> 看到 `key_len` 后,对照表结构算一下"如果用满所有列应该是多少字节"。差得远 = 索引没充分利用,可能需要调整查询条件或索引顺序。 + +## ref 字段:索引在跟谁比? + +`ref` 显示索引的查找值来自哪里——是常量、函数还是另一张表的列。 + +| ref 值 | 含义 | 示例 | +|--------|------|------| +| **const** | 与常量比较 | `WHERE id = 1` | +| **func** | 与函数结果比较 | `WHERE created_at = NOW()` | +| **db.table.column** | 与另一张表的列比较(JOIN) | `ON u.id = o.user_id` → ref 显示 `o.user_id` | +| **NULL** | 无法使用等值比较 | range 扫描(如 `WHERE id > 100`)| + +```sql +EXPLAIN SELECT u.username, o.amount +FROM users u +JOIN orders o ON u.id = o.user_id +WHERE o.status = 'pending'; +``` + +``` ++----+-------------+-------+------+---------------+--------------+---------+------------------+------+-------+ +| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | ++----+-------------+-------+------+---------------+--------------+---------+------------------+------+-------+ +| 1 | SIMPLE | o | ref | idx_status | idx_status | 130 | const | 5000 | Using where | +| 1 | SIMPLE | u | eq_ref | PRIMARY | PRIMARY | 8 | test.o.user_id | 1 | | ++----+-------------+-------+------+---------------+--------------+---------+------------------+------+-------+ +``` + +- 第一行:`o` 表用 `idx_status` 索引,ref = `const`(`status = 'pending'` 是常量) +- 第二行:`u` 表用主键,ref = `test.o.user_id`(被驱动表的列来自 `o.user_id`) + +> [!TIP] ref 帮你理解 JOIN 驱动关系 +> 当 ref 显示另一张表的列名时,说明当前表是**被驱动表**。驱动表的行数(rows)越小,被驱动表的回表次数就越少——这就是为什么**小表驱动大表**的原则。 + ## Extra 关键字解读 > [!SUCCESS] Using index — 覆盖索引(Index Only Scan) @@ -85,6 +230,35 @@ flowchart LR > [!TIP] Using index condition — 索引下推(ICP) > MySQL 5.6+ 引入,在存储引擎层预过滤,减少回表次数 ✅ +#### ICP 深入理解:下推 vs 不下推 + +```sql +-- 假设有联合索引 idx_status_city (status, city) +EXPLAIN SELECT * FROM users +WHERE status = 'active' AND city LIKE '北京%'; +``` + +**Without ICP(MySQL 5.6 之前)**:存储引擎只用 `status` 过滤,拿到所有 active 用户的主键 → 逐行回表 → Server 层再过滤 `city LIKE '北京%'`。假设 active 用户有 10 万,就要回表 10 万次。 + +**With ICP(MySQL 5.6+)**:存储引擎在索引层**直接过滤** `city LIKE '北京%'`,只把满足两个条件的行回表。可能只回表 2000 次。 + +```mermaid +flowchart LR + A["存储引擎层
idx_status_city"] --> B{"ICP 可用?"} + + B -->|No ICP| C["只用 status 定位
回表 100K 行"] + C --> D["Server 层过滤 city
丢弃 98K 行 ⚠️"] + + B -->|With ICP| E["status 定位 + city 预过滤
索引层过滤后只剩 2K 行"] + E --> F["只回表 2K 行 ✅"] + + style D fill:#EE5A24,color:#fff + style F fill:#00D866,color:#fff +``` + +> [!NOTE] ICP 的适用条件 +> ICP 只在**联合索引**且 WHERE 条件包含**索引列但不满足最左前缀**时才有意义。比如 `idx(a, b)`,`WHERE a > 1 AND b = 2` 中 `b = 2` 就是 ICP 下推的候选条件。如果只查 `WHERE a = 1`,ICP 无从发挥。 + ### 其他 Extra 信息速查 | 关键词 | 含义 | 处理 | @@ -151,37 +325,115 @@ ORDER BY created_at DESC; ## 实战案例分析 +下面用一个完整的调优案例,把前面所有知识点串起来。 + +### 场景:订单列表页越来越慢 + ```sql --- 原始查询(慢) -EXPLAIN SELECT u.username, o.amount -FROM users u -JOIN orders o ON u.id = o.user_id -WHERE o.created_at >= '2026-01-01' -ORDER BY o.created_at DESC +-- 表结构 +CREATE TABLE orders ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + amount DECIMAL(10,2), + created_at DATETIME NOT NULL, + INDEX idx_user_id (user_id) +) ENGINE=InnoDB; +-- 表中有 100 万行数据 + +-- 慢查询:某用户最近的已支付订单 +EXPLAIN SELECT * +FROM orders +WHERE user_id = 42 + AND status = 'paid' + AND created_at >= '2026-01-01' +ORDER BY created_at DESC LIMIT 20; +``` --- 典型 bad result: --- type: ALL, rows: 1000000, Extra: Using where; Using filesort; Using temporary --- → 全表扫描 + 临时表 + 文件排序 +### Step 1:看 type + key — 全表扫描? --- 修复方案 --- 1. 创建复合索引 -ALTER TABLE orders ADD INDEX idx_created_user (created_at, user_id); +``` ++----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+ +| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | ++----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+ +| 1 | SIMPLE | orders | ref | idx_user_id | idx_user_id| 8 | const | 98000 | Using where; Using filesort | ++----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+ +``` --- 2. 查询改写(反向排序优化) --- 先用索引定位 top 20 的 pk,再 JOIN 拿数据 -SELECT u.username, o.amount -FROM orders o -JOIN users u ON u.id = o.user_id -WHERE o.created_at >= '2026-01-01' -ORDER BY o.created_at DESC +- type = ref(走了索引 ✅),但 rows = 98000(user_id=42 有 9.8 万条订单,太多) +- Extra = `Using where; Using filesort` 🔴 — 回表后还要过滤 status + created_at,再排序 + +> [!QUESTION] 问题出在哪? +> `idx_user_id` 是单列索引,只帮你定位 user_id。`status` 和 `created_at` 的过滤都在回表后才做,9.8 万次回表 + filesort = 慢。 + +### Step 2:创建联合索引 — 关注 key_len + +```sql +ALTER TABLE orders ADD INDEX idx_uid_status_created (user_id, status, created_at); +``` + +```sql +EXPLAIN SELECT * +FROM orders +WHERE user_id = 42 + AND status = 'paid' + AND created_at >= '2026-01-01' +ORDER BY created_at DESC LIMIT 20; +``` --- 新的 EXPLAIN: --- type: range on orders, ref on users --- key: idx_created_user --- Extra: Using index condition; Using where --- → 索引范围扫描 + 快速消除 filesort +``` ++----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+ +| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | ++----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+ +| 1 | SIMPLE | orders | range | idx_user_id, idx_uid_status_created | idx_uid_status_created | 97 | NULL | 1200 | Using where | ++----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+ +``` + +**逐字段分析**: + +| 字段 | 变化 | 说明 | +|------|------|------| +| **type** | ref → range | 用 created_at 范围扫描,精确了 | +| **key** | idx_user_id → idx_uid_status_created | 用上了新索引 ✅ | +| **key_len** | 8 → 97 | 8 = 只用了 user_id(BIGINT);97 = user_id(8) + status(VARCHAR(20)×4+2+1=83) + created_at(DATETIME=5+1=6) → **三列全用上** ✅ | +| **rows** | 98000 → 1200 | 索引直接过滤到 1200 行,减少了 **98.8%** | +| **Extra** | Using filesort 消失 ✅ | created_at 在索引末尾,DESC 排序直接倒序扫描 | + +> [!NOTE] 关键决策点 +> 这里 ORDER BY created_at DESC 恰好可以用到索引的倒序扫描(InnoDB 支持 Reverse Index Scan),所以 **filesort 消失了**。如果 ORDER BY 的列不在索引中,filesort 仍会出现。 + +### Step 3:EXPLAIN ANALYZE 确认 + +```sql +EXPLAIN ANALYZE +SELECT * +FROM orders +WHERE user_id = 42 + AND status = 'paid' + AND created_at >= '2026-01-01' +ORDER BY created_at DESC +LIMIT 20; +``` + +``` +-> Limit: 20 row(s) + -> Index range scan on orders using idx_uid_status_created + (actual time=0.089..0.142 rows=20 loops=1) +``` + +- **rows=20**:只扫了 20 行就满足 LIMIT,配合索引的有序性,几乎零开销 +- **actual time < 0.2ms**:从秒级优化到了亚毫秒级 🚀 + +```mermaid +flowchart LR + A["Step 1
rows: 98K
filesort 🔴"] --> B["Step 2
rows: 1.2K
无 filesort ✅"] + B --> C["Step 3
rows: 20
0.14ms 🚀"] + + style A fill:#EE5A24,color:#fff + style B fill:#FF9F43,color:#000 + style C fill:#00D866,color:#fff ``` ## EXPLAIN FORMAT=JSON 精读 @@ -244,6 +496,39 @@ flowchart LR style D fill:#C44569,color:#fff ``` +## FORMAT=TREE:现代可读格式 + +MySQL 8.0.16+ 引入了 `FORMAT=TREE`,输出**缩进树状结构**,比传统表格和 JSON 都更直观。 + +```sql +EXPLAIN FORMAT=TREE +SELECT u.username, o.amount +FROM users u +JOIN orders o ON u.id = o.user_id +WHERE o.created_at >= '2026-01-01' +ORDER BY o.created_at DESC +LIMIT 20; +``` + +``` +-> Limit: 20 row(s) + -> Nested loop inner join (cost=530 rows=20) + -> Index range scan on o using idx_created_user (cost=530 rows=5000) + -> Single-row index lookup on u using PRIMARY (id=o.user_id) (cost=0.25 rows=1) +``` + +### 三种格式怎么选? + +| 格式 | 适用场景 | 优点 | 缺点 | +|------|----------|------|------| +| **默认表格** | 快速筛查 | 一行一表,一目了然 | 缺少 cost、缺少嵌套信息 | +| **FORMAT=JSON** | 精细分析 | 信息最全,可编程解析 | 冗长,层级关系不直观 | +| **FORMAT=TREE** | 日常推荐 | **层级清晰 + 含 cost + 易读** | 无法看到 possible_keys | +| **ANALYZE** | 深度诊断 | 含真实执行时间 | 会实际执行 SQL | + +> [!TIP] 推荐工作流 +> 日常排查先用 `FORMAT=TREE` 快速定位瓶颈(哪一步 cost 最高),需要细节时切到 `FORMAT=JSON` 看 key_len、possible_keys 等。 + ## EXPLAIN ANALYZE:看到真实执行代价 `EXPLAIN ANALYZE` 是 MySQL 8.0.18+ 引入的功能——它会**真正执行一次 SQL**,然后返回实际运行时间、每行的实际扫描数等统计信息。 @@ -321,8 +606,28 @@ ANALYZE TABLE orders; SET GLOBAL innodb_stats_auto_recalc = ON; ``` +## 核心记忆点 + +> [!SUMMARY] EXPLAIN 六字口诀:型键行推排临 +> 1. **型**(type):判断索引使用等级,ALL 是底线问题 +> 2. **键**(key):确认命中哪个索引,NULL 需要警觉 +> 3. **行**(rows):预估扫描行数,rows × filtered 决定实际负载 +> 4. **推**(ICP):Using index condition = 存储引擎层预过滤,减少回表 +> 5. **排**(filesort):ORDER BY 无法利用索引排序时触发 +> 6. **临**(temporary):GROUP BY / DISTINCT 产生临时表 + +> [!CHECKLIST] EXPLAIN 审查清单 +> - [ ] `type` 不是 ALL(或 rows 可接受) +> - [ ] `key` 不是 NULL(有索引可用) +> - [ ] `key_len` 覆盖了联合索引的关键列 +> - [ ] `Extra` 没有 filesort(或 ORDER BY 有索引支撑) +> - [ ] `Extra` 没有 temporary(或 GROUP BY 走了索引) +> - [ ] `rows` × `filtered%` 在可接受范围 +> - [ ] 预估 rows 与实际偏差不大(必要时 `ANALYZE TABLE`) + ## 关联笔记 - [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — type=ALL vs type=range 的底层差异 - [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 如何用 pt-query-digest 配合 EXPLAIN - [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 索引设计不当导致的 EXPLAIN 问题 +- [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 回表原理与 Extra 中 Using index 的底层原因