vault backup: 2026-05-21 12:20:51

This commit is contained in:
hhs
2026-05-21 12:20:51 +08:00
parent e176563c0b
commit 2e84683d9c
69 changed files with 2008 additions and 6155 deletions
@@ -0,0 +1,407 @@
---
tags: [MySQL, B+ Tree, 索引原理, Clustered Index]
create time: 2026-05-16 00:00
---
# B+ Tree 索引原理
## 概述
MySQL InnoDB 的索引默认使用 B+ Tree(Balance Plus Tree)。理解它的工作方式是掌握所有索引优化技巧的前提。
## 为什么用 B+ Tree?
```mermaid
flowchart LR
BT["B+ Tree<br/>多路平衡树"] --> W1["胜出的原因"]
BT2["B Tree<br/>传统平衡树"] --> W2["缺点"]
HT["Hash<br/>哈希表"] --> W3["缺陷"]
BST["RBTree<br/>二叉平衡树"] --> W4["不适合磁盘"]
W1 -.->|"范围扫描 + 磁盘友好 + 叶子链表串联"| WINNER["✅ B+ Tree 胜出"]
W2 -.->|"非叶子也存数据 → IO 更多"| REASON
W3 -.->|"无范围查询能力"| REASON
W4 -.->|"树太高 IO 频繁"| REASON
style WINNER fill:#00D866,color:#fff
style BT fill:#00D866,color:#fff
```
### 关键差异点
| 特性 | B+ Tree | B Tree | Hash | 红黑树 |
|------|---------|--------|------|--------|
| **范围查询** | ✅ 叶子节点链表遍历 | ❌ 需回溯祖先 | ❌ | ❌ |
| **全表扫描** | ✅ 顺序扫描叶子 | ❌ 需要层序遍历 | — | ❌ |
| **磁盘 IO 次数** | 低(阶数高,树矮) | 高 | O(1) 但仅等值 | 极高 |
| **插入删除稳定性** | ✅ 分裂/合并均衡 | ✅ | ❌ 缩容重哈希 | ✅ |
| **查询性能可预测** | ✅ 始终 O(logₘn) | ✅ | ⚠️ 冲突时退化 | ✅ |
## B+ Tree 结构
```mermaid
flowchart TD
ROOT["根节点<br/>键: 15, 30, 45"] --> N1["内部节点<br/>键: 5, 10"]
ROOT --> N2["内部节点<br/>键: 20, 25"]
ROOT --> N3["内部节点<br/>键: 35, 40"]
ROOT --> N4["内部节点<br/>键: 50, 55"]
N1 --> LEAF1["[1][3][5][8][10]<br/>叶子 · 存数据 + 双向链表"]
N2 --> LEAF2["[12][15][20][22][25]"]
N3 --> LEAF3["[28][32][35][40][45]"]
N4 --> LEAF4["[48][50][52][55][60]"]
LEAF1 -.-> LEAF2 -.-> LEAF3 -.-> LEAF4
style ROOT fill:#4FC08D,color:#fff
style N1 fill:#A0AEC0,color:#fff
style N2 fill:#A0AEC0,color:#fff
style N3 fill:#A0AEC0,color:#fff
style N4 fill:#A0AEC0,color:#fff
style LEAF1 fill:#00B6BC,color:#fff
style LEAF2 fill:#00B6BC,color:#fff
style LEAF3 fill:#00B6BC,color:#fff
style LEAF4 fill:#00B6BC,color:#fff
```
### 核心特征
1. **非叶子节点只存储键(Key)和指针**,不存储完整数据行 —— 一个页可以放更多键,树更矮
2. **所有数据存储在叶子节点**,形成有序链表 —— 支持范围扫描
3. **叶子节点之间通过双向链表连接** —— 相邻页面不用回溯父节点
4. **所有叶子节点在同一深度** —— 查询性能稳定
## 为什么树这么矮?
InnoDB 一页 16KB(可配置),扣除页头 / 页尾等元数据开销后,**实际可用空间约 14~15 KB**。假设:
- 主键 BIGINT = 8 bytes
- 指针(Page Pointer)= 6 bytes
- 每个内部节点 Entry(Key + Pointer + 冗余信息)≈ **16 bytes**
- 一页可存 ≈ 14.5 KB / 16 bytes ≈ **900 ~ 1170 个子节点**(取保守值 1170)
```mermaid
flowchart LR
D1["1层<br/>1,170 行"] --> D2["2层<br/>约 137 万行"]
D2 --> D3["3层<br/>约 16 亿行"]
D3 --> D4["4层<br/>约 1900 亿行"]
style D1 fill:#A0AEC0,color:#fff
style D2 fill:#FF9F43,color:#000
style D3 fill:#00D866,color:#fff
style D4 fill:#EE5A24,color:#fff
```
> [!TIP] 这意味着什么?
> 即使是一张有 1 亿行的表,查找任意一条记录也只需要 **3~4 次磁盘 IO**(每次读取一个页)。这就是 B+ Tree 在磁盘介质上无可替代的原因。
> [!QUESTION] 思考一下
> 如果每个内部节点只存一个键,那这棵树会变成什么样子?
> ——退化成一棵二叉树(红黑树的高度)。所以 B+ Tree 的**阶数越高,树越矮,IO 越少**。
>
> [!TIP] 直观感受
> 通过 `information_schema.statistics` 查看索引基数,关注 CARDINALITY(基数)——
> 基数接近总行数说明区分度高,索引效果好:
> ```sql
> -- 查看表的索引基数统计
> SELECT INDEX_NAME, NON_UNIQUE, CARDINALITY
> FROM information_schema.statistics
> WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users';
> ```
## 索引查找过程
```mermaid
sequenceDiagram
participant Q as "查询"
participant R as "根节点"
participant I as "内部节点"
participant L as "叶子节点"
participant D as "数据行"
Q->>R: "id > 30 → 右分支"
R->>I: "定位到第二层右子节点"
I->>L: "命中对应叶子节点"
L->>D: "读取完整行数据"
Note over Q,D:"总共 3 次随机 IO"
```
> [!QUESTION] 思考一下
> 如果二级索引也存整行数据,还需要回表吗?
> ——不需要了。这就是**覆盖索引(Covering Index)**的核心思想。
### 覆盖索引(Covering Index)
当查询需要的数据全部存在于某个索引的 B+ Tree 中时,就不需要再走聚簇索引了——这叫 **Index Only Scan**,在 `EXPLAIN` 中显示为 `Using index`。
```sql
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255),
name VARCHAR(100),
age INT,
INDEX idx_email(email)
);
-- ❌ 必须回表:email 在索引里,但 name 不在
SELECT name, email FROM users WHERE email = 'test@example.com';
-- ① 走 idx_email 找到 id → ② 回聚簇索引拿 name
-- ✅ 覆盖索引:不需要回表!
SELECT email FROM users WHERE email = 'test@example.com';
-- 只需从 idx_email 叶子节点直接拿到 email
```
> [!NOTE] Covering Index 的判断方法
> - `Extra = Using index`(无 `Using where`)→ 完整覆盖,零回表
> - `Extra = Using index condition` → 索引下推(ICP),部分过滤在索引层面完成
> - Extra 中**没有** `Using index` → 触发了回表
>
> 💡 更多细节(回表代价、ICP 原理、空间对比)详见 [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]]
## 聚簇索引 vs 二级索引
这是本节最重要的概念延伸,也是理解后续所有索引优化的基础。
```mermaid
flowchart TB
subgraph "聚簇索引 = 数据本身"
C1["id=1 · 整行数据"]
C2["id=2 · 整行数据"]
C3["id=3 · 整行数据"]
end
subgraph "二级索引 idx_email"
S1["email='a' → id=1"]
S2["email='b' → id=2"]
S3["email='c' → id=3"]
end
S1 -.回表.-> C1
S2 -.回表.-> C2
S3 -.回表.-> C3
style C1 fill:#00D866,color:#fff
style C2 fill:#00D866,color:#fff
style C3 fill:#00D866,color:#fff
style S1 fill:#FF9F43,color:#000
style S2 fill:#FF9F43,color:#000
style S3 fill:#FF9F43,color:#000
```
> [!QUESTION] 什么是回表?
> 当用二级索引 `idx_email` 查 `WHERE email = 'a'` 时:
> 1. 先在二级索引中找到 `email='a'` → 得到主键 `id=1`
> 2. 再用 `id=1` 去聚簇索引中查找完整行数据
> 这个两步过程叫**回表(Table Lookup)**。
>
> **优化方向**:让查询条件直接走聚簇索引,或者用 Covering Index 避免回表。
## MySQL 索引类型总览
在 B+ Tree 的基础上,InnoDB 支持多种索引类型。理解它们的选择时机也是面试和实战的常考点。
| 索引类型 | 底层结构 | 适用场景 | 等值查找 | 范围查找 | 是否有序 |
|----------|----------|----------|----------|----------|----------|
| **聚簇索引 (Clustered)** | B+ Tree 叶子存整行数据 | 主键查询(默认自带) | ✅ O(log n) | ✅ 顺序扫描叶子 | ✅ |
| **二级索引 (Secondary)** | B+ Tree 叶子存 `索引列 + 主键` | 非主键字段查询 | ✅ O(log n) | ✅ | ✅ |
| **联合索引 (Composite)** | 单个 B+ Tree,多列组合排序 | 前缀匹配的多字段过滤 | ✅ | ✅ | ✅ |
| **唯一索引 (Unique)** | 基于 B+ Tree 加约束 | 保证列值唯一(如手机号) | ✅ | ✅ | ✅ |
| **前缀索引 (Prefix)** | B+ Tree 只取字符前 N 位 | 长字符串字段(如 URL) | ✅ | ⚠️ 精度降低 | ✅ |
| **全文索引 (Fulltext)** | InnoDB 使用倒排索引 | 文本搜索 (`MATCH...AGAINST`) | — | — | ❌ |
| **空间索引 (Spatial)** | R-Tree | GIS 地理空间数据 | — | — | ❌ |
> [!NOTE] 重点记忆
> 除 **全文索引** 和 **空间索引** 外,其余全部依赖 B+ Tree。所以本文的核心内容覆盖了 InnoDB 90% 以上的日常使用场景。
### 联合索引的最左前缀原则
联合索引 `(a, b, c)` 的本质是:**先按 a 排序,a 相同时按 b 排序,a、b 都相同时按 c 排序**。
```mermaid
graph TD
subgraph "B+ Tree 叶子节点中数据的实际存储顺序"
R1["(10, 'pending')"]
R2["(10, 'paid')"]
R3["(10, 'shipped')"]
R4["(20, 'pending')"]
R5["(20, 'paid')"]
R6["(30, 'pending')"]
end
Q1["WHERE user_id = 10<br/>✅ 精确匹配一层"] --> S1["命中: R1, R2, R3"]
Q2["WHERE user_id = 10 AND status = 'paid'<br/>✅ 精确匹配两层"] --> S2["命中: R2"]
Q3["WHERE status = 'paid'<br/>❌ 缺少最左列 user_id"] --> S3["全表扫描"]
Q4["WHERE user_id = 10 AND created_at > ...<br/>⚠️ 只能用到 user_id"] --> S4["R1,R2,R3 + 逐行过滤"]
style S1 fill:#00D866,color:#fff
style S2 fill:#00D866,color:#fff
style S3 fill:#EE5A24,color:#fff
style S4 fill:#FF9F43,color:#000
```
> [!NOTE] 核心直觉
> - 联合索引的 B+ Tree **不是按列独立排序**的,而是把所有列拼成一条记录整体排序。
> - 跳过最左列 → 相当于跳过了目录的第一层,直接翻到第二层找,找不到就退化全表扫描。
```sql
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id INT,
status VARCHAR(20),
created_at DATETIME,
INDEX idx_user_status(user_id, status) -- 联合索引
);
-- 以下可以命中索引
SELECT * FROM orders WHERE user_id = 1; -- ✅ (user_id)
SELECT * FROM orders WHERE user_id = 1 AND status = 'paid'; -- ✅ (user_id, status)
-- 以下无法完全命中联合索引
SELECT * FROM orders WHERE status = 'paid'; -- ❌ 缺少最左列 user_id
SELECT * FROM orders WHERE user_id = 1 AND created_at > ...; -- ⚠️ 只能用到 user_id
```
> [!TIP] 设计联合索引的黄金法则
> 1. **把区分度最高的列放在最左边**——能最大程度缩小搜索范围
> 2. **等值匹配的列排在范围查询之前**
> 3. **覆盖最常出现的查询模式**,而不是把所有可能的列堆在一起
## 实战常见陷阱:索引为什么不生效?
B+ Tree 再优秀,也用不好 SQL。以下是最常见的索引失效场景:
### 1. 函数 / 运算包裹了索引列
```sql
-- ❌ 索引失效 —— 每行都要计算 DATE(created_at)
SELECT * FROM orders WHERE DATE(created_at) = '2025-05-01';
-- ✅ 改用范围查询,利用 B+ Tree 的范围扫描能力
SELECT * FROM orders
WHERE created_at >= '2025-05-01 00:00:00'
AND created_at < '2025-05-02 00:00:00';
```
> [!NOTE] 原理
> B+ Tree 按原始值排序,对 `created_at` 建了索引但查的是 `DATE(created_at)`,相当于换了个"钥匙"去找"锁",树根目录里根本找不到这个新钥匙。
### 2. LIKE 以通配符开头
```sql
-- ❌ 走全表扫描
SELECT * FROM users WHERE name LIKE '%abc%';
-- ✅ 前缀匹配仍然走索引
SELECT * FROM users WHERE name LIKE 'abc%';
```
### 3. OR 条件中有一列无索引
```sql
-- ❌ 即使 id 有索引、email 也有索引,但一旦 email 没索引,整个 OR 就可能不走索引
SELECT * FROM users WHERE id = 1 OR email = 'test@x.com';
```
### 4. 隐式类型转换
```sql
-- 假设 phone 是 VARCHAR 类型并建有索引
-- ❌ 传入的是数字类型,MySQL 要对每行做 CAST(phone AS SIGNED)
SELECT * FROM users WHERE phone = 13800138000;
-- ✅ 保持类型一致
SELECT * FROM users WHERE phone = '13800138000';
```
### 5. 回表太多导致 Optimizer 选择全表扫描
```sql
-- 当二级索引需要回表的行数占总行数很大比例时(通常 > 20%~30%),
-- Optimizer 会认为走索引反而更慢(频繁随机 IO),主动选择全表扫描。
-- 这是正常行为,强行用 FORCE INDEX 往往适得其反。
```
> [!TIP] 怎么判断要不要加索引?
> - 先用 `EXPLAIN` 看执行计划
> - `type` 从 `ALL → index → range → ref → const` 依次越来越优
> - 如果已经是 `ref` 或 `range` 且 Extra 没有异常提示,说明索引已经在工作
## 索引的设计权衡
好索引带来快查询,但也带来慢写入。设计时需要权衡以下代价:
### 写入放大(Write Amplification)
聚簇索引只需维护一个 B+ Tree,但**每个二级索引都是独立的 B+ Tree**。每插入一行数据:
```sql
INSERT INTO users (id, email, name, age) VALUES (1, 'a@x.com', 'Tom', 25);
-- InnoDB 需要同时更新:
-- ① 聚簇索引 idx__PRIMARY → 1 次 IO(顺序插入在末尾)
-- ② 二级索引 idx_email → 1 次 IO(定位 + 可能的页分裂)
-- ③ 每多一个二级索引 → 各加 1 次 IO
-- 总写入成本 = 1(聚簇)+ N(N 个二级索引)
```
> [!NOTE] 直观理解
> 假设有 5 个二级索引,插入一条记录就要写 **6 棵 B+ Tree**。
> 删除、更新同理——所有相关索引都要同步修改。这就是为什么**索引越多,写越慢**。
### 空间占用
```sql
-- 一张表 100 GB,建了 4 个二级索引:
-- 聚簇索引 ≈ 100 GB(就是数据本身)
-- 二级索引 × 4 ≈ 30~50 GB(取决于索引列的宽度)
-- 总磁盘用量 ≈ 130~150 GB
```
> [!QUESTION] 思考一下
> 如果你有一张日增百万行的日志表,你会给它建几个二级索引?
> ——答案通常是:**少而精**。先分析最慢的几个查询,针对性加索引,而不是"以防万一全加上"。
### 维护开销
| 操作 | 聚簇索引影响 | 二级索引影响 |
|------|-------------|-------------|
| **INSERT** | 叶子节点末尾追加(顺序 IO) | 需定位正确位置 + 可能的页分裂(随机 IO) |
| **UPDATE** | 若主键不变则无影响;变化则删除+重建 | 所有涉及列变化的索引都需要更新 |
| **DELETE** | 标记删除或合并页 | 同上,且可能有页合并开销 |
| **页分裂** | 顺序写满一页后可能触发 | 频率更高(写入定位分散 + 热点行密集) |
> [!TIP] 经验法则
> - 写密集型系统(如日志、订单创建),索引数量控制在 **3 个以内**
> - 读密集型系统(如报表查询),可以放宽到 **5 个左右**
> - 超过 10 个索引几乎必然影响写入性能,应审慎评估
## 最佳实践清单
结合上述原理与实战经验,整理一份日常工作中可以直接使用的检查清单:
> [!CHECKLIST] 索引设计与审查清单
>
> **[建表阶段]**
> - [ ] 主键选择合理(自增 BIGINT / UUID 替代方案考虑过?)
> - [ ] 高频查询字段已建索引
> - [ ] 联合索引区分度最高的列放在最左
> - [ ] 避免过度索引(写多读少的表控制在 3 个以内)
>
> **[上线前]**
> - [ ] 所有慢查询 `EXPLAIN` 后 type 不是 `ALL`
> - [ ] 覆盖索引场景用 `Using index` 确认不走回表
> - [ ] 范围查询列不要和其他等值列反序排列
> - [ ] VARCHAR 长字符串考虑使用前缀索引
>
> **[线上巡检]**
> - [ ] `SHOW INDEXES` 检查 Cardinality 接近行数
> - [ ] 无用索引定期清理(使用 pt-duplicate-key-checker 等工具)
> - [ ] 大表 DDL 使用 `ALGORITHM=INPLACE, LOCK=NONE` 避免锁表
> - [ ] 监控慢查询日志,按需调整索引策略
## 关联笔记
- [[hhs/GORM/02-模型定义]] — GORM 创建索引的 struct tag 映射到 B+ Tree
- [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 深入解析聚簇索引的物理存储结构与二级索引的回表优化
- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 联合索引的设计原则与实践
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — type=index / range 的物理含义与索引执行计划解读
@@ -0,0 +1,299 @@
---
tags: [MySQL, 聚簇索引, 二级索引, Covering Index, 回表]
create time: 2026-05-20 14:00
---
# 13-聚簇索引与二级索引
## 概述
InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什么」的不同,分为两类:**聚簇索引**和**二级索引**。理解这两者的差异,是你设计高效查询和执行计划的起点。
为了方便说明,本文档将始终使用以下 `users` 表作为示例:
```sql
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
email VARCHAR(255) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB;
```
这张表中:
- **聚簇索引**:`id`(PRIMARY KEY)
- **二级索引**:`idx_email`(email)、`idx_status_created`(status, created_at)
> [!QUESTION] 思考
> 假设一张用户表按 `id` 排序存储在磁盘上——
> 现在要查 `WHERE email = 'alice@test.com'`,数据库需要做什么?
> (提示:email 不是存储排序的依据。)
带着这个问题往下看。
## 一张图看懂索引全貌
在深入细节之前,先看这张示意图——它展示了同一个表上**聚簇索引**和**二级索引**如何共存:
```mermaid
block-beta
columns 2
block:ClusteredIndex["聚簇索引 — idx_id (PK = id)"]
columns 1
CI_L1["枝节点: id=5 | id=18"]
CI_L2["枝节点: id=12 | id=25"]
CI_L3["叶子节点: 整行数据(id=5, name=Bob, email=bob@x.com, …)"]
end
block:SecondaryIndex["二级索引 — idx_email (email)"]
columns 1
SI_L1["枝节点: email < 'k'"]
SI_L2["枝节点: email >= 'k'"]
SI_L3["叶子节点: (email='alice@x.com' | PK=12)"]
SI_L4["叶子节点: (email='bob@x.com' | PK=5)"]
end
style CI_L3 fill:#00B6BC,color:#fff
style SI_L3 fill:#E8DFF5,color:#333
style SI_L4 fill:#E8DFF5,color:#333
```
> [!HIGHLIGHT] 核心区别一目了然
> - **聚簇索引叶子层**存的是**完整行数据** → 查到就结束
> - **二级索引叶子层**存的是**「索引列 + 主键」** → 拿到主键后还要去聚簇索引里再查一次(回表)
> - 所有索引共享同一份数据(存储在主键聚簇索引中),二级索引只是"额外的查找路径"
## 聚簇索引(Clustered Index)
聚簇索引就是数据本身。InnoDB 表中**只能有一个聚簇索引**。
### 聚簇索引的形成规则
| 优先级 | 条件 | 说明 |
|--------|------|------|
| ① | **PRIMARY KEY** | 如果有显式 PK,它自动成为聚簇索引 |
| ② | **UNIQUE + NOT NULL** | 没有 PK 但有唯一非空列,用它 |
| ③ | **隐藏 row_id** | 都没有时,InnoDB 自动生成隐藏的 6-byte row_id |
> [!WARNING] 隐藏的 row_id 是个坑
> 如果你的表既没 PK 也没有 UNIQUE NOT NULL 列,InnoDB 会自动生成隐藏主键。这时:
> - 你自定义的任何索引都会变成二级索引
> - 二级索引回表时需要额外跳转 → 回表代价更高
> - 外键引用不可靠(ID 对用户透明)
>
> **结论:每张表必须有显式 PRIMARY KEY。**
### 聚簇索引的特性
```mermaid
flowchart TD
A["按主键排序存储数据"] --> B["数据页紧凑排列"]
B --> C["连续主键 = 顺序写入"]
C --> D["最小化页分裂"]
D --> E["最佳写入性能"]
A --> F["范围查询极快<br/>WHERE id BETWEEN 100 AND 200<br/>只需扫描一段连续的叶子页"]
F --> G["顺序 I/O 而非随机 I/O"]
style E fill:#00D866,color:#fff
style G fill:#00B6BC,color:#fff
```
理解了聚簇索引为什么快,我们反过来想——如果不是按主键查,而是通过其他字段(比如 `email`)查询,又会发生什么?这就是二级索引的故事。
## 二级索引(Secondary Index)
除了聚簇索引外的所有索引都是二级索引。叶子节点存储的是:**索引列的值 + 主键值**。
```mermaid
flowchart TD
subgraph "二级索引页<br/>idx_status_created = (status, created_at)"
direction LR
R1["status=1 \| created_at=01 → PK=5"]
R2["status=1 \| created_at=02 → PK=12"]
R3["status=1 \| created_at=03 → PK=18"]
R4["status=0 \| created_at=01 → PK=3"]
R5["status=0 \| created_at=04 → PK=25"]
end
style R1 fill:#E8DFF5,color:#333
style R3 fill:#00B6BC,color:#fff
```
> [!NOTE] 关键观察
> - 每行都包含 **索引列值**(用于匹配查询条件)和 **主键值**(用于回表定位完整行数据)
> - 二级索引本身是 B+ Tree,按索引列排序;叶子层每一行指向聚簇索引中的对应数据
> - 如果只需要索引中的列(如 `SELECT status WHERE status=1`),就 **无需回表**——这就是 Covering Index
## 回表的代价
```mermaid
sequenceDiagram
participant App as 应用层
participant SI as 二级索引(idx_email)
participant CI as 聚簇索引(PK=id)
participant DB as 数据盘
App->>SI: 查找 email='alice@test.com'
SI-->>App: 找到 → id=12345
App->>CI: 用 id=12345 查找
CI->>DB: 定位叶子页 (随机 IO #1)
DB-->>CI: 返回完整行
CI-->>App: 返回完整行
Note over SI,DB: 至少 2 次 IO:1次二级索引 + 1次回表
```
### 减少回表的策略(Covering Index)
要减少回表,最直接的方法是让查询**完全在二级索引中完成**——这就是 Covering Index。
```sql
-- ❌ 差:Covering Index 未命中,需要回表拿 name 字段
SELECT name, email FROM users WHERE email = 'alice@test.com';
-- idx_email 能匹配 WHERE,但 name 不在索引里 → 需要回表
EXPLAIN SELECT name, email FROM users WHERE email = 'alice@test.com'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: users
type: ref ← 通过 idx_email 找到匹配行
possible_keys: idx_email
key: idx_email
key_len: 769
ref: const
rows: 1
filtered: 100.00
Extra: Using where ← 注意:无 "Using index",说明走了回表
-- ✅ 好:覆盖索引,无需回表
ALTER TABLE users ADD INDEX idx_email_name (email, name);
EXPLAIN SELECT name, email FROM users WHERE email = 'alice@test.com'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: users
type: ref
possible_keys: idx_email,idx_email_name
key: idx_email_name ← 优化器选择了更优的联合索引
key_len: 769
ref: const
rows: 1
filtered: 100.00
Extra: Using index ← 完美!Index Only Scan,无需回表
```
> [!SUCCESS] Covering Index 的黄金法则
> **把 SELECT 中的列 + WHERE 中的列放到同一个联合索引中**,就能实现 Index Only Scan。
> - 适合:高频查询、固定列选择
> - 不适合:`SELECT *`(永远无法覆盖)、列变化频繁的查询
### 当 Covering Index 不够用时:索引下推(ICP)
并非所有查询都能被覆盖索引解决。比如 `SELECT *` 必须回表,或者部分过滤条件无法放入索引中。这时 MySQL 5.6 引入的 **索引下推**(Index Condition Pushdown)能进一步减少无效回表。
假设 `users` 表有联合索引 `idx_status_created (status, created_at)`:
```sql
-- 查询:WHERE status = 1 AND created_at > '2024-01-01'
SELECT id, name FROM users WHERE status = 1 AND created_at > '2024-01-01';
```
```mermaid
flowchart TD
N["无 ICP"] --> A["在 idx_status_created<br/>查到 1000 条 status=1 的记录"]
A --> B["1000 次回表检查 created_at"]
B --> C["最终只有 50 条满足时间条件"]
Y["有 ICP"] --> D["在二级索引中直接判断<br/>created_at > '2024-01-01'"]
D --> E["1000 条中筛出 50 条"]
E --> F["仅 50 次回表"]
style B fill:#EE5A24,color:#fff
style F fill:#00D866,color:#fff
```
> [!HIGHLIGHT] ICP 的本质
> 把 **Server 层**的过滤条件(如 `created_at > ...`)**下推到 Handler 层**,在读取二级索引页时就完成判断,命中了才回表。
>
> **适用条件:**
> - 必须是二级索引(聚簇索引不走 ICP)
> - 过滤条件中能利用到的部分必须在索引列范围内
> - MySQL 5.6+ 默认开启,无需额外配置
```sql
-- 验证 ICP 是否生效
EXPLAIN SELECT * FROM users WHERE status = 1 AND created_at > '2024-01-01'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: users
type: range
possible_keys: idx_status_created
key: idx_status_created
key_len: 5 -- TINYINT(1) + DATETIME(5)
ref: NULL
rows: 1000
filtered: 10.00
Extra: Using where ← 注意这里
-- 如果开启了 ICP,实际执行时会先判断 created_at,减少回表次数
-- 可以通过开关观察差异
SET optimizer_switch = 'index_condition_pushdown=off';
-- EXPLAIN Extra 仍为 "Using where",但执行计划变差(更多回表)
SET optimizer_switch = 'index_condition_pushdown=on';
```
## 两种索引的空间对比
```mermaid
graph TB
subgraph Clustered["聚簇索引 — 100 万行"]
CI["每页约 200 行 | 16KB / 80 bytes"]
CI2["总页数 ≈ 5000 页"]
end
subgraph Secondary["二级索引 — idx_email VARCHAR(255)"]
SI["每页约 80 行 | 16KB / 200 bytes"]
SI2["总页数 ≈ 12500 页"]
end
CI --> CI2
SI --> SI2
style CI2 fill:#00B6BC,color:#fff
style SI2 fill:#C44569,color:#fff
```
> [!NOTE] 二级索引通常比聚簇索引大得多
> 因为二级索引存的是「索引列 + 主键」,而聚簇索引存的是「整行」。如果索引列很长(如 VARCHAR(255)),二级索引会膨胀得很厉害。这也是为什么长字符串列做索引时要限制长度:`(name(50))`。
## 两种索引的对比总结
| 维度 | 聚簇索引 (Clustered) | 二级索引 (Secondary) |
|------|---------------------|---------------------|
| **数量** | 每张表仅一个 | 可以有多个 |
| **叶子节点存储** | 整行数据 | 索引列值 + 主键值 |
| **形成依据** | PK / 唯一非空列 / 隐藏 row_id | `CREATE INDEX` 或 `UNIQUE KEY` |
| **范围查询** | 极快(顺序扫描连续页) | 需要回表,代价高 |
| **覆盖索引** | 天然覆盖(本身就是数据) | 仅当所需列都在索引中时生效 |
| **空间占用** | 基准大小 | 通常更大(多一列主键 + 膨胀风险) |
| **写入代价** | 插入可能触发页分裂 | 更新索引列需改索引 + 回表改数据 |
> [!SUMMARY] 核心记忆点
> 1. 聚簇索引 = 数据本身,**一张表只能有一个**
> 2. 二级索引 = 「索引列 + 主键」,查数据要**回表**
> 3. 能用 Covering Index 的场景永远优于回表
> 4. ICP 是 MySQL 5.6 对回表的温和优化——能省则省
## 关联笔记
- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — B+ Tree 作为底层数据结构的工作原理
- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 联合索引的搜索模式对回表的影响
- [[hhs/GORM/15-性能优化]] — GORM 场景下的 Covering Index 实践
@@ -0,0 +1,197 @@
---
tags: [MySQL, 联合索引, 最左前缀, 索引失效]
create time: 2026-05-16 00:00
---
# 联合索引与最左前缀
## 概述
联合索引(Composite Index)是将多个列放在同一个 B+ Tree 中组织的索引。它的核心法则是**最左前缀匹配原则**——理解这一点就能避开 80% 的索引设计失误。
## 联合索引的结构
```
联合索引 idx(a, b, c) 的 B+ Tree 结构:
叶子节点按 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
```
> [!QUESTION] 这意味着什么?
> 联合索引的本质是**多级排序**。你可以把它想象成 SQL 的 `ORDER BY a, b, c`。索引中的数据已经按照这个顺序排好了。
## 最左前缀法则详解
```mermaid
flowchart TD
IDX["联合索引 idx(a, b, c)"]
IDX --> U1["✅ WHERE a=1"]
IDX --> U2["✅ WHERE a=1 AND b=2"]
IDX --> U3["✅ WHERE a=1 AND b=2 AND c=3"]
IDX --> X1["❌ WHERE b=2"]
IDX --> X2["❌ WHERE c=3"]
IDX --> X3["❌ WHERE b=2 AND c=3"]
IDX --> U4["⚠️ WHERE a=1 AND c=3"]
U1 -. "用 1 列" .-> _u1
U2 -. "用 2 列" .-> _u2
U3 -. "用全部" .-> _u3
X1 -. "无效" .-> _x1
X2 -. "无效" .-> _x2
X3 -. "无效" .-> _x3
U4 -. "仅用 a" .-> _u4
style U1 fill:#00D866,color:#fff
style U2 fill:#00D866,color:#fff
style U3 fill:#00D866,color:#fff
style X1 fill:#EE5A24,color:#fff
style X2 fill:#EE5A24,color:#fff
style X3 fill:#EE5A24,color:#fff
style U4 fill:#FF9F43,color:#000
```
### 为什么 b=2 单独查不了?
```
查询 WHERE b=2 时,B+ Tree 的搜索路径是什么样的?
[b=20, c=1] 的位置在 [b=10, c=7] 之后,但它们之间穿插着 [b=5, c=2]...
数据在整个树中分散存放,没有连续的 b=2 区域 → 需要全树扫描
而 WHERE a=1 则不同:
[a=1, ...] 的所有记录都在树的左侧一段连续区域内 → 二分查找即可定位
```
## 范围查询后的断裂
联合索引遇到范围查询(`>`, `<`, `BETWEEN`, `LIKE 'prefix%'`)后,右侧列的索引失效。
```sql
-- 假设已有联合索引 idx(status, created_at, type)
-- ⚠️ 看起来像用上了 3 个列,实际上 type 不会走索引!
SELECT * FROM orders WHERE status = 'paid'
AND created_at > '2026-01-01' AND type = 'online';
-- 逐列分析:
-- status = 'paid' → ✅ 等值匹配,精确定位起始行
-- created_at > ... → ✅ 范围扫描,划定区间终点
-- type = 'online' → ❌ 区间内数据未按 type 排序 → 退化为内存过滤
```
> [!QUESTION] 为什么范围查询会打断后续列?
> 想象一下,当 `status='paid'` 锁定一个子树后,该子树内部按 `created_at` 升序排列。一旦用 `>` 做了范围扫描,得到的是一个 `created_at` 连续递增的数据段——这个段内的 `type` 值是乱序穿插的,无法再用二分查找定位 `type='online'`。
```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
```
### 实战:调整联合索引的顺序
```sql
-- 场景:订单表常用查询——按状态筛选 + 按时间范围 + 按类型过滤
-- ❌ 原始索引:范围列在中间,后续等值列无法使用
CREATE INDEX idx_sct ON orders (status, created_at, type);
-- 查询 A: WHERE status=? AND created_at>? AND type=?
-- → type 无法用索引(created_at 的范围扫描打断了后续列)
-- ✅ 优化方案 1:将等值列提前
CREATE INDEX idx_stc ON orders (status, type, created_at);
-- 查询 B: WHERE status=? AND type=? AND created_at>?
-- → 三个列全部用上!status 定位起点,type 进一步缩小,created_at 做范围
-- 💡 注意:即使 SQL 写的是 created_at 在前,优化器也会自动调整顺序匹配索引
-- ✅ 优化方案 2:如果两个查询频率差不多,拆成两个索引
CREATE INDEX idx_status ON orders (status);
CREATE INDEX idx_status_time ON orders (status, created_at);
-- 让每个索引专注于它的典型查询模式
```
> [!TIP] 联合索引列序黄金法则
> 1. **等值优先于范围**:等值列放在前面
> 2. **选择性高的列靠前**:区分度大的列(如 user_id)比区分度小的(如 gender)靠前
> 3. **前缀长度要短**:如果需要 LIKE,尽量用较短的前缀
## 索引失效的典型场景
```sql
-- ❌ 场景 1:隐式类型转换
ALTER TABLE users ADD INDEX idx_phone (phone);
-- phone 是 VARCHAR 类型
SELECT * FROM users WHERE phone = 13800138000; -- 传入 INT!
-- MySQL 会把 varchar 转成 int 再比较 → 函数作用于列 → 索引失效
-- ❌ 场景 2:函数/表达式包裹索引列
SELECT * FROM users WHERE YEAR(created_at) = 2026;
-- 应对:改为范围查询 WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
-- ❌ 场景 3:LIKE 以通配符开头
SELECT * FROM users WHERE name LIKE '%abc%';
-- 应对:考虑全文索引 FT
-- ❌ 场景 4:OR 条件中有列没有索引
SELECT * FROM users WHERE email = 'a@x.com' OR phone = '138...';
-- 如果 email 有索引但 phone 没有,整个查询不走索引
-- 应对:给 phone 加索引,或用 UNION ALL 拆分
-- ❌ 场景 5:字符集不一致导致隐式转换
-- 表 charset=utf8mb4,客户端 charset=gbk → 自动转换
-- 应对:确保连接字符集一致 SET NAMES utf8mb4
```
## 最左前缀的灵活应用
一个设计良好的联合索引可以**同时服务 WHERE、ORDER BY、GROUP BY 三种需求**。
```sql
-- 建索引:idx(status, type, created_at)
CREATE INDEX idx_status_type_time ON orders (status, type, created_at);
-- ── 查询 1:等值 + 范围 ──
SELECT * FROM orders WHERE status = 'pending'
AND created_at > '2026-01-01';
-- → status 等值定位起点,created_at 范围扫描。type 虽在中间但没用到,不影响前两列生效
-- ── 查询 2:利用 ORDER BY ──
SELECT * FROM orders WHERE status = 'pending'
ORDER BY type ASC;
-- → WHERE 条件锁定了 status='pending' 的子区间,该区间内数据天然按 type 排序
-- → 无需额外 filesort!
-- ── 查询 3:利用 GROUP BY ──
SELECT type, COUNT(*) FROM orders WHERE status = 'pending'
GROUP BY type;
-- → 同上,子区间内 type 已有序,分组可以直接跳过
```
> [!NOTE] 关键理解
> ORDER BY / GROUP BY 能复用联合索引,靠的不是"巧合",而是 B+ Tree **叶子节点本身有序**这一物理特性。只要 WHERE 过滤条件匹配了联合索引的左侧列,剩余列就是有序的——优化器只是利用了已有的顺序,并没有多做一次排序。
> [!TIP] 一索引多用
> 一个好的联合索引可以同时服务 WHERE、ORDER BY、GROUP BY 三种需求。在设计索引时要考虑查询的整体模式,而不是单一查询。
## 关联笔记
- [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 聚簇索引本身就是特殊的联合索引 (PK)
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 用 EXPLAIN 验证联合索引是否按预期生效
- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 如何从 slow log 中识别索引未命中
- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] — 将失效的查询改写为可利用索引的形式
@@ -0,0 +1,328 @@
---
tags: [MySQL, EXPLAIN, 执行计划, 性能分析]
create time: 2026-05-16 00:00
---
# EXPLAIN 完全指南
## 概述
`EXPLAIN` 是 MySQL 性能分析的入口工具——它展示 SQL 语句的执行计划,告诉你优化器打算怎么用索引、怎么 JOIN、会不会用临时表和文件排序。
> [!QUESTION] 为什么不能直接靠"写了索引就一定能用到"?
> 因为 MySQL 使用的是 **Cost-Based Optimizer (CBO)**:优化器会根据统计信息(行数、页大小、聚簇因子等)自行决定最优执行路径。有时优化器认为全表扫描比走索引更快(比如要查整张表 80% 的数据),这时即使有索引也不会使用。`EXPLAIN` 就是用来观察和优化器"想法"是否一致的镜子。
## 基本用法
```sql
-- 标准 EXPLAIN
EXPLAIN SELECT * FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = 1 AND o.amount > 100;
-- JSON 格式(信息最全,推荐)
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 越低越好 |
## type 详解(最重要)
```mermaid
flowchart LR
system["system<br/>只有1行"] --> const["const<br/>常量级访问"]
const --> eq_ref["eq_ref<br/>唯一索引 × 驱动行"]
eq_ref --> ref["ref<br/>等值匹配多行"]
ref --> range["range<br/>索引范围"]
range --> index["index<br/>全索引扫描"]
index --> ALL["ALL<br/>全表扫描 🔴"]
style system fill:#00D866,color:#fff
style const fill:#00D866,color:#fff
style eq_ref fill:#00D866,color:#fff
style ref fill:#00B6BC,color:#fff
style range fill:#FF9F43,color:#000
style index fill:#C44569,color:#fff
style ALL fill:#EE5A24,color:#fff
```
### 各级别详解
| 级别 | 名称 | 含义 | 示例 |
|------|------|------|------|
| **system** | 系统表 | 表只有一行(MyISAM 等引擎)| `SELECT * FROM (SELECT 1) t`|
| **const** | 常量 | 最多一行匹配 | `WHERE primary_key = 1`|
| **eq_ref** | 唯一索引 | 唯一索引扫描,每行匹配被驱动表一行 | `JOIN ON pk = ?` |
| **ref** | 索引查找 | 等值匹配多行(非唯一索引 / 最左前缀的部分匹配)| `WHERE email_prefix = 'a@'` |
| **range** | 范围扫描 | 索引上的范围查询 | `WHERE id > 100` |
| **index** | 索引全扫 | 扫描整个索引树 | `SELECT COUNT(*)` |
| **ALL** | 全表扫描 | 无可用索引,逐行扫描 | **需要优化** |
> [!WARNING] 不要盲目追求 ref 以上级别
> `range` 在某些场景(如范围不大)完全可以接受。关键是看 `rows` 字段和 `Extra` 的组合。
## Extra 关键字解读
> [!SUCCESS] Using index — 覆盖索引(Index Only Scan)
> 数据在索引中全部找到,**无需回表**。这是最优的 Extra 信息。
> [!INFO] Using where — 服务器层过滤
> 读完索引后还需 WHERE 条件过滤。**正常现象**,不代表性能问题。
> - **Using where + Using index** = 覆盖索引 + WHERE 过滤 → 最佳实践 ✅
> - **Using where 无 Using index** = 回表后才过滤 → 可考虑加索引优化 ⚠️
> [!TIP] Using index condition — 索引下推(ICP)
> MySQL 5.6+ 引入,在存储引擎层预过滤,减少回表次数 ✅
### 其他 Extra 信息速查
| 关键词 | 含义 | 处理 |
|--------|------|------|
| `Impossible where` | WHERE 永远为假(如 `status = 1 AND status = 2`)| 检查 SQL 逻辑是否正确 |
| `Select tables optimized` | 优化器发现子查询可展开 | 无需处理,已自动优化 ✅ |
| `Using distinct` | 内部去重,类似 DISTINCT | 看能否改用 GROUP BY + 索引 |
| `Scan & filter` | **InnoDB** 特有:全索引扫描后逐行过滤 | 考虑加更精确的索引 |
### Using temporary:何时出现
```sql
-- 常见触发场景
EXPLAIN SELECT city, AVG(age) FROM users GROUP BY city;
-- Extra: Using temporary; Using filesort
-- 需要用临时表存储每个 city 的聚合结果
EXPLAIN SELECT DISTINCT city FROM users;
-- Extra: Using temporary
-- DISTINCT 内部用临时表去重
```
> [!WARNING] Using temporary + Using filesort
> 当 GROUP BY 的分组列和 ORDER BY 的排序列不一致时,MySQL 会先建临时表再额外排序。
> **解决思路**:让索引同时满足 GROUP BY + ORDER BY 的顺序要求。
### Using filesort:深度分析
```mermaid
flowchart TD
A["WHERE status='pending'"] --> B["idx_status 索引扫描<br/>拿到所有 pending 订单的 pk"]
B --> C{"created_at 能在索引中找到吗?"}
C -->|能:<br/>联合索引 idx_status_created| D["直接有序返回<br/>No filesort ✅"]
C -->|不能:<br/>单列索引 idx_status| E["取 pk 列表 → 回表拿完整行<br/>→ 内存中排序<br/>Filesort ⚠️"]
D --> F["可能的解决方案"]
E --> F
F --> F1["添加联合索引 (status, created_at)"]
F --> F2["调整 WHERE 条件使索引生效"]
F --> F3["加大 sort_buffer_size"]
style D fill:#00D866,color:#fff
style E fill:#EE5A24,color:#fff
```
> [!QUESTION] 思考:ORDER BY 一定会触发 filesort 吗?
> 不一定!如果查询使用了索引且排序字段与索引顺序一致,MySQL 可以直接按索引序扫描,跳过排序步骤。关键看 **索引是否天然有序**。
```sql
-- ✅ No filesort — 走联合索引天然有序
EXPLAIN SELECT * FROM orders
WHERE status = 'pending'
ORDER BY status, created_at;
-- key: idx_status_created, Extra: Using where
-- ❌ Using filesort — WHERE 用了另一个索引,无法利用排序
EXPLAIN SELECT * FROM orders
WHERE user_id = 42
ORDER BY created_at DESC;
-- key: idx_user_id, Extra: Using where; Using filesort
```
## 实战案例分析
```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
LIMIT 20;
-- 典型 bad result:
-- type: ALL, rows: 1000000, Extra: Using where; Using filesort; Using temporary
-- → 全表扫描 + 临时表 + 文件排序
-- 修复方案
-- 1. 创建复合索引
ALTER TABLE orders ADD INDEX idx_created_user (created_at, user_id);
-- 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
LIMIT 20;
-- 新的 EXPLAIN:
-- type: range on orders, ref on users
-- key: idx_created_user
-- Extra: Using index condition; Using where
-- → 索引范围扫描 + 快速消除 filesort
```
## EXPLAIN FORMAT=JSON 精读
`FORMAT=JSON` 输出最完整的执行计划,包含嵌套子查询、Cost 信息、索引选择细节。
```json
{
"query_block": {
"select_id": 1,
"table": {
"table_name": "orders",
"access_type": "range",
"possible_keys": ["idx_created_user"],
"key": "idx_created_user",
"key_length": "8",
"rows": 5000,
"filtered": 100.0,
"index_condition": "orders.created_at >= '2026-01-01'",
"cost_information": {
"total_cost": "53000", ← 总成本(越小越好)
"optimizer_cost": "53000" ← 优化器计算的成本值
}
}
}
}
```
### 关键字段说明
| JSON 字段 | 含义 | 实战要点 |
|-----------|------|----------|
| **access_type** | 与 `type` 等价 | range/ref 优先,ALL 需关注 |
| **key_length** | 实际使用索引的字节数 | 越短说明用的列越少,可优化 |
| **rows** | 预估扫描行数 | 估算值,可能与实际情况偏差 |
| **filtered** | WHERE 筛选率 (%) | rows × filtered% = 进入下一步的行数 |
| **index_condition** | ICP 预过滤条件 | 有 = 使用了索引下推 ✅ |
| **using_temporary** / **using_filesort** | 布尔值 | true = 会触发对应操作 |
### Cost 分析:如何判断查询是否健康?
> [!TIP] Cost 评估经验法则
> - **cost < 1000**:通常没问题 ✅
> - **cost 1000 ~ 10000**:中等负载可接受,关注高频 SQL ⚠️
> - **cost > 10000**:大概率需要优化 🔴
关键点:**optimizer_cost 是绝对值而非相对值**,不同版本 MySQL 的计算方式可能变化。更有价值的是 **对比两种方案的 cost 差值**——比如加了一个索引后 cost 从 50000 降到 5300,这就是有效的索引设计。
```mermaid
flowchart LR
A["编写慢 SQL"] --> B["EXPLAIN 看执行计划"]
B --> C{"access_type?"}
C -->|ALL 全表扫描| D["检查 possible_keys<br/>添加合适的索引"]
C -->|range/ref/optimal| E{"Extra 中有<br/>filesort/temporary?"}
E -->|无 → 健康✅| F["无需额外处理"]
E -->|有 → 优化⚠️| G["调整索引顺序或改写 SQL"]
style F fill:#00D866,color:#fff
style G fill:#FF9F43,color:#000
style D fill:#C44569,color:#fff
```
## EXPLAIN ANALYZE:看到真实执行代价
`EXPLAIN ANALYZE` 是 MySQL 8.0.18+ 引入的功能——它会**真正执行一次 SQL**,然后返回实际运行时间、每行的实际扫描数等统计信息。
> [!WARNING] 注意事项
> EXPLAIN ANALYZE **会执行 SQL**。如果有写入操作(如 INSERT/UPDATE),需要谨慎;但对于纯 SELECT 查询可以放心使用。
```sql
-- 传统方式 vs 现代方式
EXPLAIN SELECT * FROM orders WHERE user_id = 42;
-- → 只有预估数据,可能不准
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;
-- → 预估 + 实际运行结果
-- Extra: Rows examined: 42 (vs. rows estimate: 100)
```
### 典型输出解读
```
-> Index lookup on orders using idx_user_id (user_id=42)
(actual time=0.034..0.156 rows=42 loops=1)
```
- **actual time**:首行耗时 .. 总耗时(微秒级)
- **rows**:实际扫描行数(与预估 `rows` 对比,差距大说明统计信息过时)
- **loops**:循环次数,JOIN 场景尤其重要
> [!QUESTION] 什么时候用 EXPLAIN vs EXPLAIN ANALYZE?
> - **EXPLAIN**:快速预览,不执行 SQL,适合批量排查和 CI 门禁
> - **EXPLAIN ANALYZE**:精准诊断,需要实际执行,适合深入分析某个慢查询
> - 日常开发建议先用 `EXPLAIN` 快速筛查,再对问题 SQL 用 `EXPLAIN ANALYZE` 定位根因
## 优化策略速查
遇到问题 SQL,按以下步骤排查:
```mermaid
flowchart TD
A["拿到慢 SQL"] --> B["EXPLAIN 看执行计划"]
B --> C{"type = ALL?"}
C -->|是| D["检查 possible_keys → 加索引"]
C -->|否| E{"Extra 有 filesort?"}
E -->|是| F["调整索引顺序,覆盖 ORDER BY"]
E -->|否| G{"Extra 有 temporary?"}
G -->|是| H["让 GROUP/DISTINCT 走索引"]
G -->|否| I{"rows 很大?"}
I -->|是→ rows × filtered% 仍大| J["优化 WHERE 条件或加更精确的索引"]
I -->|否| K["查询健康 ✅"]
style K fill:#00D866,color:#fff
style D fill:#C44569,color:#fff
style F fill:#FF9F43,color:#000
style H fill:#FF9F43,color:#000
style J fill:#FF9F43,color:#000
```
### 常见场景与对策
| 症状 | 根因 | 对策 |
|------|------|------|
| `type=ALL, Extra=Using where` | 无可用索引 | 添加 WHERE 列的索引 |
| `Using filesort` | ORDER BY 字段不在可用索引中 | 创建 (WHERE列, ORDER BY列) 联合索引 |
| `Using temporary; Using filesort` | GROUP BY 和 ORDER BY 不一致 | 索引顺序同时满足两者 |
| `rows 预估 >> 实际行数` | 统计信息过时 | `ANALYZE TABLE 表名` 更新统计信息 |
| `possible_keys` 有但 `key` 为 NULL | 优化器选了全表扫描 | 用 `FORCE INDEX` 强制指定,或改写查询 |
### 别忘了维护统计信息
```sql
-- 当 EXPLAIN 预估严重偏离实际时
ANALYZE TABLE orders;
-- InnoDB 支持自动分析,但大批量写入后建议手动触发
SET GLOBAL innodb_stats_auto_recalc = ON;
```
## 关联笔记
- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — type=ALL vs type=range 的底层差异
- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 如何用 pt-query-digest 配合 EXPLAIN
- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 索引设计不当导致的 EXPLAIN 问题
@@ -0,0 +1,366 @@
---
tags: [MySQL, 慢查询日志, pt-query-digest, 性能分析, EXPLAIN]
create time: 2026-05-16 10:30
---
# 慢查询日志分析
## 概述
慢查询日志(Slow Query Log)是 MySQL 自带的性能诊断工具,记录超过指定时间的 SQL 语句。配合 `pt-query-digest` 等专业工具,可以系统地识别和优化慢查询,定位数据库性能瓶颈。
> [!TIP] 核心思路
> 慢查询日志本身只是"记录仪"——真正的价值在于**归因分析**。将日志中的原始数据聚合、分类、排名,才能把噪音变成可行动的信号。
## 配置慢查询日志
```ini
# my.cnf
[mysqld]
slow_query_log = 1 # 开启
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2 # 阈值(秒)
min_examined_row_limit = 100 # 只记录扫描了至少 N 行的查询
log_queries_not_using_indexes = 1 # 记录未使用索引的查询
# 可选:实时捕获到表
log_output = FILE,TABLE # 同时写入文件和 mysql.slow_log 表
```
```sql
-- 运行时动态开启(无需重启)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'ON';
-- 查看当前配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
```
> [!WARNING] 生产环境注意事项
> - 不要盲目设置极低的 `long_query_time`(如 0.1s),否则会产生大量噪声。建议从 **1~5 秒**开始逐步下调。
> - 开启 `log_queries_not_using_indexes` 会让所有无索引查询都被记录,即使它们只需要 0.01s。**谨慎启用**,最好配合 `min_examined_row_limit` 过滤无害查询。
> - `TABLE` 模式会往 `mysql.slow_log` 写数据,需要评估写入开销。如果已有 FILE 模式,优先选 FILE。
### min_examined_row_limit
```sql
-- 设置 1000 意味着:只扫描了 < 1000 行的查询不会被记录
-- 过滤掉大量无害查询,让慢日志专注于真正有问题的 SQL
SET GLOBAL min_examined_row_limit = 1000;
```
> [!NOTE] 为什么需要这个参数?
> 一个看似"快"的查询可能扫描了大量行才找到目标数据——比如一次全表扫描回查 50 万行最终返回 1 条结果。这样的查询单看执行时间并不长(缓存命中时不到 0.1s),但放在高并发下就是 CPU 杀手。`min_examined_row_limit` 就是从**扫描行数**维度额外加了一层拦截。
## 慢查询日志格式
每一条慢查询由**元数据头 + 时间戳锚点 + SQL 体**三部分组成:
```
# Time: 2026-05-16T10:30:45.123456Z ← 执行时间(UTC)
# User@Host: app_user[app_user] @ app-server-01 [10.0.1.50] Id: 12345 ← 哪个用户、哪台机器、连接 ID
# Query_time: 3.456789 Lock_time: 0.000123 Rows_sent: 1 Rows_examined: 985432 ← 核心指标
# Rows_affected: 0 ← 影响的行数(DML)
# Bytes_received: 512 Bytes_sent: 65432 ← 网络传输量
# Thread_id: 12345 Schema: app_db ← 线程 ID、所属数据库
SET timestamp=1715848245; ← 锚点:SQL 实际执行时的 Unix 时间戳
SELECT o.*, u.username FROM orders o ← SQL 语句本体
JOIN users u ON o.user_id = u.id
WHERE o.status = 'pending' AND o.amount > 100
ORDER BY o.created_at DESC LIMIT 20;
```
### 关键字段含义
| 字段 | 含义 | 关注点 |
|------|------|--------|
| **Query_time** | 从接收请求到返回结果的总耗时 | > `long_query_time` 即触发记录 |
| **Lock_time** | 等待行锁/表锁的时间 | 占比过高 → 存在锁竞争 |
| **Rows_sent** | 返回给客户端的行数 | 应用层真正收到的结果 |
| **Rows_examined** | InnoDB 引擎扫描的索引+数据行数 | 与 `Rows_sent` 差距越大越危险 |
| **Rows_affected** | INSERT/UPDATE/DELETE 实际变更的行数 | DML 操作的副作用评估 |
> [!QUESTION] 如何判断查询效率是否健康?
> ```
> Rows_examined / Rows_sent < 10 → ✅ 合理——每条扫描的行大部分都返回了
> Rows_examined / Rows_sent > 100 → ⚠️ 可疑——可能是全索引扫描或回查过多
> Rows_examined / Rows_sent > 1000 → 🔴 严重低效——典型的全表扫描或错误的 WHERE 条件
> ```
> 理想情况是比值接近 1。但需注意:**对范围查询来说,这个比值高一些是正常的**(因为 B+Tree 范围内每页都要读)。
#### Lock_time 专项解读
```
Lock_time 占 Query_time 的比例 诊断方向
──────────────────────────────────────────
< 1% 正常,无锁问题
1% ~ 10% 轻度竞争,可接受
> 10% 需要排查:是否有长事务或未命中索引
> 50% 紧急:大概率死锁或锁升级
```
## mysqldumpslow 内置工具
MySQL 自带的轻量级日志分析工具,适合快速查看 Top N:
```bash
# 最常用的参数组合:按执行时间排序,取前 10 条
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# -s t: 按 Query_time 排序;-t 10: 取前 10 条
# 按扫描行数排序(关注"杀 CPU"的查询)
mysqldumpslow -s r -t 10 /var/log/mysql/slow.log
# 按出现频率排序(关注高频小额查询累积成的大问题)
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
# 只看包含特定关键词的
mysqldumpslow -s t -t 20 -g "order" /var/log/mysql/slow.log
```
### 输出解读
```
Count: 150 Time=3.50s (525s) Lock=0.00s (0s) Rows=1.0 (150), app_user@app-server-01
SELECT o.*, u.username FROM orders o JOIN users u ON o.user_id=u.id
WHERE o.status='pending' AND o.amount>100 ORDER BY o.created_at DESC LIMIT N
```
| 字段 | 含义 |
|------|------|
| **Count: 150** | 该模式在日志中出现了 150 次 |
| **Time=3.50s** | 单次平均执行 3.5 秒 |
| **(525s)** | 150 次累计占用 525 秒——这才是对生产真正造成伤害的数字 |
| **Rows=1.0 (150)** | 每次返回约 1 行,累计 150 行 |
> [!NOTE] Count × Average = Total 的意义
> `Time=3.50s (525s)` 揭示了一个关键思路:**优化一个频繁执行的中等耗时查询,往往比优化一个极端耗时的罕见查询收益更大**。这就是为什么 `-s c`(按频率排序)有时能发现更隐蔽的性能问题。
### 使用限制
`mysqldumpslow` 的输出较为粗糙,它只能做**聚合统计**,无法提供:
- SQL 指纹归因的详细分组(哪些参数值不同但 SQL 结构相同)
- 趋势对比(和上次报告相比变化了多少)
- HTML 可视化报告
对于系统性诊断,推荐使用 `pt-query-digest`。
## pt-query-digest(专业分析工具)
Percona Toolkit 中最强大的 MySQL 分析工具,支持日志文件、Performance Schema、甚至直连生产库实时采样:
```bash
# 基本用法:输出完整分析报告
pt-query-digest /var/log/mysql/slow.log
# 按响应时间分组排名
pt-query-digest --group-by latency /var/log/mysql/slow.log
# 只分析报告 Top 5% 的查询(保留细节,去掉噪声)
pt-query-digest --limit 5% /var/log/mysql/slow.log
# 与历史基线对比(需提前用 --review/--history 采集数据)
pt-query-digest --review D=perflive,h=localhost \
--history D=perflive,h=localhost \
/var/log/mysql/slow.log
# 生成 HTML 可视化报告
pt-query-digest --output=slowreport.html /var/log/mysql/slow.log
# 分析最近 1 小时的慢查询
pt-query-digest --since 1h /var/log/mysql/slow.log
# 分析最近 30 分钟且执行时间 > 1s 的查询
pt-query-digest --since 30m --filter '$event->{qt} > 1000000' /var/log/mysql/slow.log
```
### 输出解读框架
`pt-query-digest` 的报告分为四个核心区域:
```
===== Profile (整体概况)
Rank Query ID Response time Calls R/Call V/M Item
==== ================= ============== ===== ====== ===== ===========
1 0xA1B2C3D4 525.1234 50.0% 150 3.5008 0.00 SELECT orders
2 0xE5F6A7B8 180.5678 17.2% 50 3.6114 0.00 SELECT users
===== Query 1: Hash = 0xA1B2C3D4
# 该查询模式的详细统计(P95, Q3, Q1 等百分位)
# Count: 150 → 出现次数
# Exec time: 1~8s → 单次执行时间范围
# Lock time: ... → 锁等待分布
# Rows sent: 1 avg → 返回行数(平均值/中位数/最大最小)
# EXPLAIN output → 执行计划(最关键部分!)
# The query is above that you can use EXPLAIN to analyze
```
| 区域 | 作用 |
|------|------|
| **Profile** | 快速了解哪些查询占了大部分资源——通常是 Pareto 20/80 规律 |
| **Query N** | 单个查询模式的详细统计,包含执行计划 |
| **Flattened** | SQL 归一化后的指纹,展示参数替换前后的差异 |
> [!TIP] 标准诊断流程
> 拿到 `pt-query-digest` 报告后:**先看 Profile 找出 Top 3 耗时查询 → 再逐条看其 EXPLAIN 输出 → 最后决定优化方向**。不要一开始就陷入某一条 SQL 的细节。
### 从实时 Performance Schema 分析
当慢查询日志未开启或已关闭时,可以直接从 MySQL 内部采集:
```sql
-- MySQL 5.7+ 启用 performance_schema
SET GLOBAL performance_schema = ON;
-- 清理已有数据(可选)
TRUNCATE performance_schema.events_statements_summary_by_digest;
-- 等业务跑一会儿后,用 pt-query-digest 直接分析
pt-query-digest --processlist D=localhost,U=root \
--no-report /var/log/mysql/slow.log
-- 或者直接用下面的方式从 Performance Schema 提取
pt-query-digest --type processlist D=host:port,user,password
```
## 常见慢查询反模式
即使有索引,SQL 写法不当也会导致索引失效。以下是生产中最常见的几种反模式:
| 反模式 | 示例 | 为什么慢 | 优化方向 |
|--------|------|----------|----------|
| **前导通配符** | `WHERE name LIKE '%abc'` | 无法使用 B+Tree 前缀匹配,全表扫描 | 改用全文检索 (FULLTEXT) |
| **隐式类型转换** | `WHERE phone = 13800138000` (phone 是 VARCHAR) | 字符型字段与数字比较触发隐式转换,索引失效 | 传参类型与列类型一致 |
| **OR 条件未覆盖** | `WHERE idx_col = 1 OR other_col = 2` | 只有第一个字段有索引 | 拆分 UNION ALL 或用 BITMAP |
| **函数包裹列名** | `WHERE YEAR(created_at) = 2025` | 对列计算函数导致索引失效 | 改为范围扫描:`created_at >= '2025-01-01' AND created_at < '2026-01-01'` |
| **NOT IN / <>** | `WHERE id NOT IN (SELECT id FROM ...)`)` | 子查询难以走索引 | 改写为 LEFT JOIN ... IS NULL |
| **SELECT \*** | `SELECT * FROM orders WHERE ...` | 回表额外开销 + 网络传输浪费 | 只查需要的列 |
> [!QUESTION] 为什么 `SELECT *` 会拖慢查询?
> 有两个原因:
> 1. **回表**:如果用的是二级索引(非聚簇),`SELECT *` 需要拿着二级索引的值去聚簇索引回表取出所有列数据;而如果只 SELECT 了索引中包含的列,则可以直接"覆盖索引"取数,无需回表。
> 2. **带宽浪费**:即使通过聚簇索引取数,多取的每列也会占用更多内存缓冲和网路传输时间。在高频查询场景下,每次省 2KB,一万次就是 20MB 的额外开销。
## 慢查询优化工作流
从发现慢查询到完成优化的完整闭环:
```mermaid
flowchart TD
A["收集慢查询日志"] --> B["分类归因<br/>pt-query-digest / mysqldumpslow"]
B --> C{"定位 Top 瓶颈 SQL"}
C --> D["EXPLAIN 执行计划分析"]
D --> E{问题类型?}
E -- "type=ALL" --> F["缺索引 → ADD INDEX"]
E -- "Extra: Using filesort" --> G["调整联合索引顺序 / 覆盖索引"]
E -- "Extra: Using temporary" --> H["改写 SQL,避免 GROUP BY 临时表"]
E -- "rows 过大但 sent 很小" --> I["检查 WHERE 条件是否命中索引"]
E -- "无锁等待但耗时高" --> J["排查深层原因:深分页/大事务/函数包裹列"]
E -- "Lock_time 占比高" --> K["排查死锁 / 长事务 / 行锁竞争"]
F --> L["优化后验证"]
G --> L
H --> L
I --> L
J --> L
K --> L
L --> M{"效果达标?"}
M -- "是" --> N["🎉 上线 + 接入监控告警"]
M -- "否" --> D
```
### 步骤详解
#### Step 1: 收集与分组
通过 `pt-query-digest` 将原始日志中的千条记录压缩为几十条"SQL 指纹"。每一条指纹代表一个查询模式,忽略参数差异(如 `'123'` vs `'456'`),聚焦结构。
#### Step 2: 定位 TOP N
按以下优先级排序关注对象:
| 场景 | 排序方式 | 适用情况 |
|------|----------|----------|
| **总耗时最大** | `-s t`(总时间) | 找最拖后腿的 SQL |
| **最常见** | `-s c`(频率) | 高频小额查询累积成痛 |
| **扫描行数最多** | `-s r`(Rows_examined) | CPU 杀手型查询 |
#### Step 3: EXPLAIN 诊断
对 Top 3 的查询逐条执行 `EXPLAIN FORMAT=JSON ...` 获取结构化执行计划:
```sql
EXPLAIN FORMAT=JSON SELECT * FROM orders
WHERE user_id = 100 AND status = 'pending'
ORDER BY created_at DESC LIMIT 10;
```
重点关注 JSON 输出中的:
- `table->access_type`(扫描方式:ALL / range / ref / index)
- `table->key`(实际使用的索引)
- `table->rows`(预估扫描行数)
- `query_block->filesort` / `temporary_table` 是否存在
更详细的 EXPLAIN 解读见 [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]]。
#### Step 4: 针对性优化
根据诊断结果选择优化策略:
```mermaid
flowchart LR
subgraph "索引类优化"
A1["补充缺失索引"] --> A3["验证:type 从 ALL→ref/range"]
A2["调整联合索引顺序"] --> A3
A4["创建覆盖索引"] --> A5["Extra 去掉 Using filesort/index"]
end
subgraph "SQL 改写"
B1["LIKE '%xxx' → FULLTEXT"] --> B3["验证:rows↓, time↓"]
B2["YEAR(col) → range 扫描"] --> B3
B3["NOT IN → LEFT JOIN IS NULL"] --> B3
end
subgraph "架构类优化"
C1["深分页 OFFSET → 延迟关联"] --> C3["验证:P95 latency ↓"]
C2["大事务拆小"] --> C3
C3["读写分离 / 缓存"] --> C3
end
```
详见:
- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] — 常见 SQL 改写方案
- [[hhs/MySQL/03-索引与查询优化/18-深分页优化]] — OFFSET 深分页的替代方案
- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 索引设计原则
#### Step 5: 回归验证 & 监控接入
优化不是一次性动作,需要建立持续监控机制:
> [!TIP] 最小可落地的监控方案
> ```sql
> -- 定期从 Performance Schema 拉取 Top SQL
> SELECT DIGEST_TEXT,
> ROUND(SUM_TIMER_WAIT/1e12, 2) AS total_ms,
> COUNT_STAR AS calls,
> ROUND(AVG_TIMER_WAIT/1e12, 2) AS avg_ms
> FROM performance_schema.events_statements_summary_by_digest
> ORDER BY total_ms DESC
> LIMIT 20;
> ```
> 将此查询放入定时任务(如每 5 分钟),用 Grafana 或 Prometheus 做可视化告警。
## 关联笔记
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — EXPLAIN 各字段的详细解读与执行计划分析
- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] — SQL 反模式改写方案
- [[hhs/MySQL/03-索引与查询优化/18-深分页优化]] — OFFSET 深分页的替代方案
- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — 索引底层结构,理解为何某些写法会让索引失效
- [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — 行锁/表锁/间隙锁,排查 Lock_time 偏高问题
- [[hhs/MySQL/08-工程实践/40-常见踩坑]] — MySQL 日常使用中的典型陷阱
@@ -0,0 +1,335 @@
---
tags: [MySQL, 查询优化, 重写, 性能调优]
create time: 2026-05-16 00:00
---
# 查询改写技巧
## 概述
同样的业务需求可能有多种 SQL 写法,性能差异可达几十倍。本章总结常用的 SQL 改写模式,帮助你将「能跑的 SQL」变成「跑得快的 SQL」。
## 1. OR → UNION ALL
```sql
-- ❌ 差:两个列各自独立,OR 会导致两边都无法走索引
SELECT * FROM users
WHERE email = 'alice@example.com' OR phone = '13800138000';
-- EXPLAIN: type=ALL, Rows=1000000 → 全表扫描
-- ✅ 好:拆成两次索引查询 + UNION ALL
SELECT * FROM users WHERE email = 'alice@example.com'
UNION ALL
SELECT * FROM users WHERE phone = '13800138000';
-- 两次独立的 ref 查询,各走一个索引
```
> [!TIP] 什么时候不该改?
> - 如果 OR 两边的列在**同一个联合索引**中,原写法可以利用最左前缀
> - 如果结果集非常小(几行),优化器可能自行选择最优方案
## 2. JOIN → EXISTS
```sql
-- ❌ 存在重复行的风险
SELECT DISTINCT u.* FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.amount > 1000;
-- 一个用户有多张大额订单时会重复,DISTINCT 又带来排序开销
-- ✅ 改用 EXISTS(找到第一条就停止)
SELECT u.* FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.amount > 1000
);
```
> [!NOTE] MySQL Optimizer 的智能
> 现代 MySQL(8.0+)的优化器已经足够聪明,经常能把 JOIN 和 EXISTS 转换为相同的执行计划。但显式使用 EXISTS 语义更清晰,且在某些复杂场景下确实能引导优化器选更好的路径。
## 3. 子查询 → JOIN
```sql
-- ❌ 标量子查询可能导致 N+1
SELECT u.username,
(SELECT SUM(amount) FROM orders WHERE user_id = u.id) AS total_spent
FROM users u;
-- ✅ 换成 GROUP BY + JOIN
SELECT u.username, COALESCE(SUB.total_spent, 0) AS total_spent
FROM users u
LEFT JOIN (
SELECT user_id, SUM(amount) AS total_spent
FROM orders GROUP BY user_id
) SUB ON u.id = SUB.user_id;
```
```mermaid
flowchart TD
subgraph "子查询方案"
A1["10万用户"] --> A2["每个用户查一次 SUM()"]
A2 --> A3["总计 10万次扫描 orders 表"]
end
subgraph "JOIN 方案"
B1["1次 GROUP BY orders"] --> B2["1000个汇总结果"]
B2 --> B3["10万次 LEFT JOIN 1000行<br/>成本极低"]
end
style A3 fill:#EE5A24,color:#fff
style B3 fill:#00D866,color:#fff
```
## 4. 避免函数包裹索引列
```sql
-- ❌ YEAR() / DATE() 包裹索引列 → 索引失效
SELECT * FROM orders
WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 5;
-- EXPLAIN: type=ALL, Rows=1000000
-- ✅ 改用范围查询 → 走索引
SELECT * FROM orders
WHERE created_at >= '2026-05-01'
AND created_at < '2026-06-01';
```
```mermaid
flowchart LR
Func["WHERE YEAR(col) = 2026"] --> F1["每一行都要计算 YEAR()"]
F1 --> F2["无法利用 B+ Tree 二分查找"]
F2 --> F3["全表扫描 ALL"]
Range["WHERE col >= '2026-01-01' AND col < '2027-01-01'"] --> R1["直接在 B+ Tree 中定位范围"]
R1 --> R2["索引范围扫描 range"]
R2 --> R3["O(log N) 复杂度"]
style F3 fill:#EE5A24,color:#fff
style R3 fill:#00D866,color:#fff
```
## 5. 分页优化:延迟关联 + 游标分页
### 5.1 延迟关联(Deferred Join)
```sql
-- ❌ 深分页灾难
SELECT * FROM articles
WHERE status = 1
ORDER BY created_at DESC
LIMIT 999990, 10;
-- 扫描聚簇索引跳过 999990 行,每行都拉完整数据
-- ✅ 延迟关联:子查询只扫主键,外层精准 JOIN
SELECT a.* FROM articles a
INNER JOIN (
SELECT id FROM articles
WHERE status = 1
ORDER BY created_at DESC
LIMIT 999990, 10
) tmp ON a.id = tmp.id;
-- 子查询只扫主键(极紧凑),外层精准 JOIN 10 条
```
```mermaid
flowchart LR
subgraph "传统方式"
A1["扫描 999990 行完整数据"] --> A2["丢弃 999990 行"]
A2 --> A3["返回 10 行"]
end
subgraph "延迟关联"
B1["子查询扫 999990 行<br/>仅提取主键(8 bytes)"] --> B2["精准 JOIN 10 个主键"]
B2 --> B3["返回 10 行完整数据"]
end
A3 ---|"I/O 减少 90%+"| B3
style A1 fill:#EE5A24,color:#fff
style B1 fill:#00B6BC,color:#fff
style B3 fill:#00D866,color:#fff
```
### 5.2 游标分页(Keyset Pagination)
延迟关联能缓解,但**不是根治方案**。当 offset 极大时仍要遍历大量行。更优解是**基于上一页最后一个 ID 定位下一页起点**。
```sql
-- ❌ offset 越大越慢,且并发翻页可能丢数据
SELECT * FROM articles ORDER BY id DESC LIMIT 100000, 10;
-- ✅ 游标分页:记住上一页最后一条的 id
SELECT * FROM articles
WHERE id < 999980 -- 上一页最后一条的 id
ORDER BY id DESC
LIMIT 10;
```
> [!TIP] Go 应用层写法
> ```go
> // 第一页:无游标
> db.Order("id DESC").Limit(10).Find(&articles)
>
> // 后续页:用最后一条的 ID 做游标
> lastID := articles[len(articles)-1].ID
> db.Where("id < ?", lastID).Order("id DESC").Limit(10).Find(&articles)
> ```
> [!WARNING] 注意事项
> - 要求排序列有**唯一索引**(如自增主键)。如果排序字段不唯一(如 `created_at`),需加辅助条件:`WHERE (created_at, id) < (?, ?)` 利用联合索引最左前缀。
> - 前端无法直接跳到第 N 页——这是合理的 UX 约束。现代平台(Twitter、Instagram)均采用「加载更多」而非页码导航。
## 6. 避免 SELECT * 陷阱
```sql
-- ❌ 拉取不需要的列:浪费网络带宽 + 无法使用 Covering Index
SELECT * FROM orders WHERE user_id = 42;
-- ✅ 只查需要的列 → 可能变成 Covering Index(完全不碰数据行)
SELECT id, amount, status FROM orders WHERE user_id = 42;
```
> [!TIP] 为什么指定列更快?
> - InnoDB 聚簇索引叶子节点存整行数据。如果查询的列都在辅助索引中,MySQL 直接从辅助索引取数(**Using index**),不需要回表。
> - 网络传输的数据量也显著降低——去掉 TEXT/BLOB 列可能从 MB 级降到 KB 级。
## 7. LIKE 与全文搜索
```sql
-- ❌ 左模糊匹配导致全表扫描
SELECT * FROM articles WHERE title LIKE '%MySQL%';
-- EXPLAIN: type=ALL
-- ✅ 前缀模糊走索引
SELECT * FROM articles WHERE title LIKE 'MySQL%';
-- ✅ 大文本搜索用全文索引(FULLTEXT)
ALTER TABLE articles ADD FULLTEXT INDEX ft_title (title);
SELECT * FROM articles
WHERE MATCH(title) AGAINST('MySQL' IN NATURAL LANGUAGE MODE);
```
> [!NOTE] LIKE vs FULLTEXT
> - `LIKE 'prefix%'` 走 B+ Tree 范围扫描,适合精确前缀匹配
> - `FULLTEXT` 支持分词、相关性排序( relevance ranking),适合搜索引擎场景
> - MySQL 的 FULLTEXT 对中文效果有限(空格分隔分词器不适用于中文),中文场景建议对接 Elasticsearch
## 8. 近似聚合:SUM(COL) vs SUM(1)
> [!QUESTION] 下面两行 SQL 有什么区别?
> ```sql
> -- Q1: 这两行结果一样吗?哪个更快?
> SELECT SUM(status = 1) FROM orders;
> SELECT SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) FROM orders;
> ```
> **答案**: 结果相同,但写法 1 更简洁且执行效率略高。MySQL 将布尔表达式视为 0/1 整数,`SUM(TRUE)` 等价于计数。
```sql
-- ✅ 简洁写法:布尔表达式直接参与算术
SELECT
SUM(status = 'pending') AS pending,
SUM(status = 'shipped') AS shipped,
SUM(status = 'completed') AS completed
FROM orders;
-- 等价于多 CASE WHEN 写法,但更短、可读性更好
```
> [!WARNING] NULL 处理差异
> - `SUM(col = val)` 在条件不满足时返回 0,在列为 NULL 时也返回 NULL(即 0+NULL=NULL)
> - 如果需要对 NULL 安全计数,用 `SUM(CASE WHEN col = val THEN 1 ELSE 0 END)` 或加 `COALESCE`
## 9. COUNT 优化策略
### 9.1 利用索引覆盖计数
```sql
-- ❌ 无索引:全表扫描
SELECT COUNT(*) FROM large_table WHERE status = 1;
-- EXPLAIN: type=ALL → 遍历每一行判断
-- ✅ 建索引后:只扫索引树
ALTER TABLE large_table ADD INDEX idx_status (status);
SELECT COUNT(*) FROM large_table WHERE status = 1;
-- EXPLAIN: type=range, key=idx_status, Extra=Using where; Using index
```
### 9.2 近似计数(可容忍误差)
| 方案 | 精度 | 适用场景 |
|------|------|----------|
| `SHOW TABLE STATUS` | 低(缓存导致偏差) | MyISAM 快速估算 |
| 随机采样 × 膨胀系数 | 中 | 运营后台 Dashboard |
| `EXPLAIN table WHERE ...` | 较高 | 预估行数范围 |
> [!NOTE] InnoDB 的 COUNT(*)
> InnoDB 没有维护表级别行数(因为有 MVCC,不同事务看到的行数可能不同)。`COUNT(*)` 需要实际扫描。对于高并发大表,考虑**异步统计表**或**Redis 计数器**。
## 10. INSERT 批量优化
```sql
-- ❌ 逐条插入(N 次网络往返 + N 次事务提交)
INSERT INTO orders (...) VALUES (...);
INSERT INTO orders (...) VALUES (...);
INSERT INTO orders (...) VALUES (...);
-- ✅ 批量 INSERT(1 次网络往返 + 1 次事务)
INSERT INTO orders (...) VALUES (...), (...), (...);
-- ✅ Go 中分批次提交
for i := 0; i < len(items); i += 500 {
batch := items[i : min(i+500, len(items))]
db.Model(&Order{}).Create(batch)
}
```
## 11. CASE WHEN 代替多次查询
```sql
-- ❌ 应用层多次查询
countPending = db.Where("status='pending'").Count()
countShipped = db.Where("status='shipped'").Count()
countCompleted = db.Where("status='completed'").Count()
-- ✅ 单次查询 + 服务端处理
SELECT
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END) AS shipped,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed
FROM orders;
```
## 改写原则总结
```mermaid
mindmap
root((SQL 改写<br/>核心原则))
早过滤
WHERE 先行
减少中间结果
走索引
避免函数包裹列
OR 拆 UNION ALL
LIKE 前缀匹配
少回表
Covering Index
SELECT 指定列(非*)
分批量
批量 INSERT
延迟关联
游标分页
换思路
EXISTS vs JOIN
CASE WHEN vs 多次查询
子查询转 JOIN
```
## 关联笔记
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 改写后验证效果的必备工具
- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 从慢日志中发现需要改写的 SQL
- [[hhs/MySQL/03-索引与查询优化/18-深分页优化]] — 游标分页的专项深入
- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — 理解索引走与否的根本原因
- [[hhs/MySQL/08-工程实践/40-常见踩坑]] — 生产环境中的典型错误写法合集
- [[hhs/GORM/15-性能优化]] — GORM 层的批量操作优化
@@ -0,0 +1,281 @@
---
tags: [MySQL, 分页优化, 游标分页, 延迟关联]
create time: 2026-05-16 00:00
---
# 深分页优化
## 概述
当 OFFSET 达到几十万、百万级别时,`LIMIT offset, size` 的性能会急剧下降。这不仅是 MySQL 的问题,而是所有关系型数据库的通病。本章提供三种成熟的解决方案。
## 问题复现
```sql
-- 典型的深分页查询
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 999990, 20;
-- 发生了什么?
-- 1. MySQL 从聚簇索引开始扫描
-- 2. 逐行读取并排序(或使用 order by 索引)
-- 3. 跳过前 999990 行
-- 4. 取出接下来的 20 行
-- 5. 丢弃前面跳过的所有行
```
```mermaid
flowchart LR
A["Page 1: LIMIT 0, 20"] -->|"扫描 20 行,取 20 行"| T1["✅ 很快"]
B["Page 1000: LIMIT 19980, 20"] -->|"扫描 20000 行,取 20 行"| T2["⚠️ 慢"]
C["Page 50000: LIMIT 999990, 20"] -->|"扫描 ~100万行,取 20 行"| T3["🔴 极慢"]
style T1 fill:#00D866,color:#fff
style T2 fill:#FF9F43,color:#000
style T3 fill:#EE5A24,color:#fff
```
> [!QUESTION] 为什么不能像编程语言那样直接从数组索引取值?
> 数据库不是内存数组。每一次 `LIMIT offset, N` 都需要从 B+ Tree 根部重新定位到第 offset 行——这是一次完整的扫描 + 排序操作。偏移量越大,越像是在浩瀚星海中逐粒计数沙子。
## 方案一:延迟关联(Deferred Join)
```sql
-- 核心思路:先用紧凑的主键索引定位 ID,再回表查数据
-- 关键:必须使用 (created_at, id) 联合索引,确保排序可覆盖
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders
ORDER BY created_at DESC, id DESC -- 用主键做稳定排序的 tie-breaker
LIMIT 999990, 20
) tmp ON o.id = tmp.id;
```
> [!QUESTION] 为什么需要 `id DESC` 作为第二排序条件?
> 如果多条记录具有相同的 `created_at`(比如同一秒内多个用户下单),仅按 `created_at` 排序的结果是不确定的——每次查询行顺序可能不同。加上主键作为 tie-breaker 可以**保证排序稳定性**,避免翻页时出现重复或遗漏的数据。
> [!TIP] 索引前提
> - 子查询必须是**覆盖索引扫描**:`(created_at, id)` 联合索引足以满足 `ORDER BY` + `LIMIT`,无需回表
> - 如果没有此索引,子查询本身也会退化为慢查询
```mermaid
sequenceDiagram
participant SQ as 子查询
participant PK as 聚簇索引(id)
participant Main as 主查询
SQ->>PK: LIMIT 999990, 20 只取 id
PK-->>SQ: 返回 20 个 ID(各 8 bytes)
loop 20 个 ID
Main->>PK: SELECT * WHERE id = ?
PK-->>Main: 精准返回完整行
end
```
### 性能对比
| 指标 | 传统方式 | 延迟关联 |
|------|---------|---------|
| 扫描行数 | ~1,000,000 行完整数据 | 1,000,000 行仅主键 (8 bytes) |
| 回表次数 | 1,000,000 次(隐式) | 20 次(精准) |
| 内存占用 | 1,000,000 × 行大小 | 20 × 8 bytes |
| 典型耗时 | 3~10 秒 | 0.1~0.3 秒 |
```go
// Go 中实现延迟关联的分页 helper
func PaginatedQuery(db *gorm.DB, page, pageSize int) ([]Order, error) {
offset := (page - 1) * pageSize
// 子查询:只查主键(覆盖索引扫描)
var ids []int64
if err := db.Model(&Order{}).
Select("id").
Order("created_at DESC, id DESC").
Limit(pageSize).
Offset(offset).
Pluck("id", &ids).Error; err != nil {
return nil, err
}
if len(ids) == 0 {
return []Order{}, nil
}
// 主查询:IN 精确查完整数据
var results []Order
if err := db.Where("id IN ?", ids).Find(&results).Error; err != nil {
return nil, err
}
return results, nil
}
```
> [!WARNING] 延迟关联的限制
> - `ORDER BY` 必须能用索引覆盖,否则子查询本身就很慢
> - `IN` 列表过大时(比如 > 1000 条)也会退化
> - 如果每行数据很小(< 50 bytes),收益有限
## 方案二:游标分页(Seek Method / Keyset Pagination)
```sql
-- 首次查询(第一页)
SELECT * FROM orders
ORDER BY id ASC
LIMIT 20;
-- 下一页:取上一页最后一条记录的 id
SELECT * FROM orders
WHERE id > 987654 -- 上一页最后一条的 id
ORDER BY id ASC
LIMIT 20;
-- 再下一页
SELECT * FROM orders
WHERE id > 987674 -- 这次最后一条的 id
ORDER BY id ASC
LIMIT 20;
```
```mermaid
flowchart TD
Page1["第一页: WHERE id > 0 LIMIT 20<br/>获取最后一条 id = 10001"] --> Page2
Page2["第二页: WHERE id > 10001 LIMIT 20<br/>获取最后一条 id = 10021"] --> Page3
Page3["第三页: WHERE id > 10021 LIMIT 20"]
Page1 -->|"O(1) 索引范围扫描"| R1["✅ 恒定速度"]
Page2 -->|"O(1) 索引范围扫描"| R2["✅ 恒定速度"]
Page3 -->|"O(1) 索引范围扫描"| R3["✅ 恒定速度"]
style R1 fill:#00D866,color:#fff
style R2 fill:#00D866,color:#fff
style R3 fill:#00D866,color:#fff
```
### Go 实现
```go
// Cursor-based pagination with stable sort
type CursorResult struct {
Items []Order
NextCursor *string // nil = 最后一页
PrevCursor *string // nil = 第一页
}
func GetOrdersByCursor(db *gorm.DB, afterID, beforeID *int64, limit int, desc bool) (*CursorResult, error) {
q := db.Model(&Order{}).Limit(limit + 1)
// 稳定排序:主键确保顺序一致
if desc {
q.Order("id DESC")
if afterID != nil {
q = q.Where("id < ?", *afterID) // 上一页最后一条的 id
}
} else {
q.Order("id ASC")
if afterID != nil {
q = q.Where("id > ?", *afterID)
}
}
var items []Order
if err := q.Find(&items).Error; err != nil {
return nil, err
}
result := &CursorResult{}
hasNext := len(items) > limit
hasPrev := true // 查了 limit+1 条就说明前面还有数据
if hasNext {
items = items[:limit]
} else {
hasPrev = len(items) > limit/2 // 半经验判断:不足半页说明接近头部
}
// 生成 cursor token
if len(items) > 0 {
lastID := items[len(items)-1].ID
firstID := items[0].ID
if hasNext {
nextVal := fmt.Sprintf("%d", lastID)
result.NextCursor = &nextVal
}
if hasPrev && (beforeID == nil || *beforeID == 0) {
prevVal := fmt.Sprintf("%d", firstID)
result.PrevCursor = &prevVal
}
}
result.Items = items
return result, nil
}
```
> [!TIP] 游标的高级用法
> - **复合排序**:当需要按非唯一字段排序时,用 `WHERE (created_at, id) > (?, ?)` 实现 tuple 比较——MySQL 支持行值的字典序比较
> - **双向翻页**:同时返回 `nextCursor` 和 `prevCursor`,前端无需维护额外状态
> - **Token 编码**:生产环境中建议将 cursor 加密或签名(如 JWT),防止用户篡改
```sql
-- 按创建时间 + ID 排序的游标翻页(tuple 比较)
-- 上一页最后一条: created_at = '2026-05-15', id = 987654
SELECT * FROM orders
WHERE (created_at, id) < ('2026-05-15 00:00:00', 987654)
ORDER BY created_at DESC, id DESC
LIMIT 20;
```
### 优缺点对比
| | 游标分页 | 延迟关联 | OFFSET 分页 |
|--|---------|---------|------------|
| 性能 | 🏆 恒定 O(1) | ⭐ 好 | 🔴 随偏移量变差 |
| 支持跳页 | ❌ 不支持 | ✅ 支持 | ✅ 支持 |
| 前端改造 | 传 cursor | 传 offset | 传 offset |
| ORDER BY 要求 | 必须是有序主键/唯一键 | 可接受普通索引 | 任何 ORDER BY |
| 适用场景 | 无限滚动 / 瀑布流 | 后台管理 / Excel 导出 | 小偏移量 (< 1万) |
## 方案三:限制最大页码
最朴素的方案——从业务层面限制深度,从根本上消除深分页问题:
```go
// 前端 UI 限制:瀑布流最多加载 100 页
const MaxPage = 100
func PaginatedQuery(w http.ResponseWriter, r *http.Request) {
page := parsePage(r)
if page > MaxPage {
respondError(w, 400, "结果太多,请添加筛选条件缩小范围")
return
}
// ...正常查询
}
```
```mermaid
flowchart TD
A["请求翻页"] --> B{page <= MAX?}
B -->|是| C["正常查询"]
B -->|否| D["提示: 请添加筛选条件"]
D --> E["用户加筛选 ↓"]
E --> B
style C fill:#00D866,color:#fff
style D fill:#FF9F43,color:#000
```
> [!TIP] 实际工程建议
> - **前台展示**(商品列表、文章列表):用游标分页,用户体验最好
> - **后台管理**(运营后台、报表导出):允许 OFFSET 但限制最大页码 + 提供筛选条件
> - **定时任务**(数据同步):用主键范围扫描 `WHERE id > last_id AND id <= last_id + 1000`
> - **缓存策略**:热点分页数据可配合 Redis SET 或 Bloom Filter 预计算页码边界
>
> 核心思想:**不要让 OFFSET 成为决定性能的唯一变量**。最好的方案往往是结合业务场景的组合拳。
## 关联笔记
- [[hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析]] — LIMIT 基础语法与 OFFSET 的定义
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 分页查询的 EXPLAIN 分析
- [[hhs/GORM/06-排序与分页]] — GORM 分页 API 与各方案的 Go 实现
@@ -0,0 +1,26 @@
---
tags: [MySQL, 索引, B+Tree, EXPLAIN, 查询优化]
create time: 2026-05-20 23:55
---
# 三、索引与查询优化
## 概述
学会写 SQL 之后,下一步是让 SQL 跑得快。本章从 B+ Tree 底层原理讲起,覆盖聚簇索引、联合索引、EXPLAIN 执行计划分析、慢查询排查、查询改写和深分页优化——这些是解决线上性能问题的核心技能。
## 本章文档
| # | 主题 | 说明 |
|---|------|------|
| 12 | [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] | MySQL 为什么选 B+ Tree、它比 Hash / 红黑树好在哪里 |
| 13 | [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] | 数据存在哪棵树上、查二级索引为什么要"回表" |
| 14 | [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] | 多个字段一起建索引怎么用、为什么跳列就用不上了 |
| 15 | [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] | 怎么看 SQL 的执行计划、type 从优到差排哪些 |
| 16 | [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] | 抓出执行慢的 SQL、用工具分析根因 |
| 17 | [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] | OR → UNION ALL、JOIN → EXISTS,同样的结果写法影响性能 |
| 18 | [[hhs/MySQL/03-索引与查询优化/18-深分页优化]] | `LIMIT 1000000, 20` 为什么慢、怎么优化 |
## 关联笔记
- [[hhs/MySQL/README]] — MySQL 知识库总目录