Mysql 从数据结构到执行计划
索引是 MySQL 查询性能的核心杠杆。理解索引在磁盘上如何存储、聚簇索引与二级索引的区别、多列索引的最左前缀规则,再结合 EXPLAIN 读懂执行计划,才能在开发中写出高效 SQL,也能在慢查询排查时快速定位问题。
示例表
后文统一使用下面这张 orders 表举例:
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
order_no VARCHAR(32) NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已取消
amount DECIMAL(10,2) NOT NULL,
city VARCHAR(50),
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_order_no (order_no),
KEY idx_user_status (user_id, status),
KEY idx_city_created (city, created_at),
KEY idx_status_amount (status, amount)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
假设表中有 100 万行数据, id 自增, user_id 分布较均匀, status 中 0 占 80%。
索引的存储结构
B+ 树
InnoDB 的索引底层都是 B+ 树 (不是 B 树):
- 所有数据都存储在 叶子节点 ,叶子节点之间通过双向链表连接,适合范围扫描
- 非叶子节点只存索引键和子节点指针,一个节点通常对应一个 页(Page,默认 16KB)
- 树的高度通常只有 3~4 层:一层非叶子 + 两层叶子就能索引千万级数据
一次索引查找 ≈ 几次磁盘随机 I/O(每层一次)。树越矮,随机 I/O 越少,查询越快。
文件系统中的存储格式
InnoDB 不会为每个索引单独建一个文件, 所有索引(聚簇 + 二级)和数据行都存放在同一个表空间文件里 。
-
表空间文件(.ibd)
开启 innodb_file_per_table=ON (MySQL 8.0 默认)时,每张表对应一个独立文件:
/var/lib/mysql/mydb/orders.ibd ← orders 表的数据 + 全部索引文件 内容 orders.ibd 聚簇索引 B+ 树 + 所有二级索引 B+ 树,共享同一表空间 ibdata1 系统表空间(数据字典、undo、change buffer 等,非单表数据) undo_.ibu* undo 日志(MySQL 8.0 独立 undo 表空间) #ib_16384_.ibd* MySQL 8.0 部分系统表 查看表空间与行格式:
SELECT NAME, SPACE_TYPE, FILE_FORMAT FROM information_schema.INNODB_TABLESPACES WHERE NAME LIKE '%orders%'; SHOW TABLE STATUS LIKE 'orders'\G -- Row_format: Dynamic(MySQL 8.0 默认)一个表的所有数据和索引确实都属于同一个 .ibd 文件,但 .ibd 内部不是一个连续的大数组,而是由很多页(Page)组成的。
一个索引就是一棵 B+ 树,这些 B+ 树的节点分散存储在同一个 .ibd 的不同 Page 中。每个 Page 在 表空间文件中有固定的位置(偏移范围),可快速提取。
-
页(Page)—— 磁盘 I/O 的最小单位
表空间文件由固定大小的 页 顺序拼接而成,默认 16KB ( innodb_page_size ,安装后不可改)。
-
二级索引页与聚簇索引页的差异
页类型 非叶子节点内容 叶子节点内容 聚簇索引(主键) 主键值 + 子页号 *完整行记录*(按主键排序) 二级索引 索引键 + 子页号 *索引列值 + 主键*(按索引键排序) idx_user_status (user_id, status) 叶子页中的一条记录: [user_id=42][status=1][id=10086] ↑ 主键,用于回表定位聚簇索引页 回表时: 用 id=10086 在聚簇索引 B+ 树中再查一次,读到完整行。同一棵 B+ 树的叶子页通过 PAGE_NEXT / PAGE_PREV 指针组成双向链表,所以 WHERE id > 100 ORDER BY id 的范围扫描只需沿链表顺序读页,无需反复从根查找。
-
从文件到查询的路径(串联)
SELECT * FROM orders WHERE user_id = 42 AND status = 1; 1. 优化器选择 idx_user_status 2. 从该索引根页(某个 16KB FIL_PAGE_INDEX)开始向下查找 3. 到达叶子页,在 User Records 链表中找到 (42, 1, id) 记录 4. 对每个 id 回表:在聚簇索引 B+ 树叶子页读取完整行 5. 若该行在 Buffer Pool 中已缓存,则跳过磁盘 I/Oinnodb_page_size 只能是 4K / 8K / 16K / 32K / 64K,且必须在初始化时设定。
生产环境默认 16KB;更大的页适合顺序扫描,更小的页适合点查,但 16KB 是综合最优的通用选择。
聚簇索引(Clustered Index)
InnoDB 中, 表数据本身按主键顺序存储在一棵 B+ 树上 ,这棵树的叶子节点存放完整行记录,称为聚簇索引。
| 特点 | 说明 |
|---|---|
| 一个表只有一个 | 必须有主键;没有显式主键时 InnoDB 会选第一个非空唯一索引,都没有则隐式生成 6 字节 row_id |
| 叶子节点 = 数据页 | 按主键顺序物理排列,范围查询 id BETWEEN 1 AND 1000 效率高 |
| 插入影响 | 主键自增时顺序插入,页分裂少;随机主键(如 UUID)会导致频繁页分裂和碎片 |
-- 主键查找:直接走聚簇索引,type = const
SELECT * FROM orders WHERE id = 10086;
EXPLAIN SELECT * FROM orders WHERE id = 10086;
-- type: const, key: PRIMARY, rows: 1
聚簇索引结构示意(简化):
非叶子节点: [10 | 50 | 100 | 200]
↓ ↓ ↓ ↓
叶子节点: [1..9] [10..49] [50..99] [100..199] [200..]
每片叶子存完整行: (id, user_id, order_no, status, ...)
二级索引(Secondary Index / 普通索引)
除聚簇索引外创建的索引都是二级索引( UNIQUE KEY 、 KEY 、 INDEX 等)。
| 特点 | 说明 |
|---|---|
| 叶子节点不存完整行 | 只存 索引列的值 + 主键值 |
| 回表 | 通过二级索引找到主键后,再到聚簇索引查完整行,这一步叫 回表 |
| 覆盖索引 | 若查询列全部在索引中,无需回表,性能最好 |
-- 二级索引 idx_user_status 的叶子节点存: (user_id, status, id)
SELECT * FROM orders WHERE user_id = 42 AND status = 1;
EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 1;
-- type: ref, key: idx_user_status, key_len: 9, rows: ~少量
-- Extra: 可能含 Using index condition
查找过程:
1. 在 idx_user_status 的 B+ 树上定位 user_id=42, status=1
2. 叶子节点拿到主键 id 列表: [101, 205, 388, ...]
3. 对每个 id 回聚簇索引取完整行(若 SELECT * 则需要回表)
多列索引(联合索引 / Composite Index)
多列索引是将多个列按 定义顺序 组合成一棵 B+ 树,遵循 *最左前缀原则*。
KEY idx_user_status (user_id, status)
-- 等价于为以下前缀都建立了有序结构:
-- (user_id)
-- (user_id, status)
-- 但不为单独 status 建立有序结构
| 查询条件 | 能否用上 idx_user_status | 原因 |
|---|---|---|
| *WHERE user_id * 1= | ✅ 完全使用 | 命中最左列 |
| WHERE user_id = 1 AND status = 0 | ✅ 完全使用 | 两列都命中 |
| WHERE status = 0 | ❌ 不能用 | 跳过了最左列 user_id |
| WHERE user_id > 100 AND status = 0 | ⚠️ 部分使用 | user_id 走 range,status 通常只能做索引条件下推(ICP) |
-- ✅ 能用索引
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- key: idx_user_status, key_len: 8 (仅 user_id 部分)
-- ✅ 能用索引
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 1;
-- key: idx_user_status, key_len: 9 (user_id + status)
-- ❌ 不能用该索引(会全表扫描或换别的索引)
EXPLAIN SELECT * FROM orders WHERE status = 1;
-- key: NULL 或 idx_status_amount(若优化器认为更优)
-- type: ALL 或 range
idx_city_created (city, created_at) 适合 WHERE city = ’北京’ ORDER BY created_at ,
因为同一 city 下 created_at 已有序,可避免额外排序( Using filesort )。
聚簇索引 vs 二级索引 vs 多列索引 对比
| 类型 | 定义 | 叶子节点内容 | 数量 | 典型场景 |
|---|---|---|---|---|
| 聚簇索引 | 主键 | 完整行数据 | 1 个 | 主键点查、主键范围扫描 |
| 二级索引 | 单列非主键索引 | 索引列 + 主键 | 多个 | 按 order_no、email 等唯一/高频列查询 |
| 多列索引 | 多列组合索引 | 联合列 + 主键 | 多个 | 多条件组合查询、排序、覆盖索引 |
索引使用方式
何时建索引
| 适合建 | 不适合建 |
|---|---|
| WHERE / JOIN / ORDER BY 高频列 | 低选择性列(如性别、布尔值)单独建索引收益低 |
| 区分度高的列 | 频繁 UPDATE 的列(维护索引有写开销) |
| 外键关联列 | 小表(几百行,全表扫描更快) |
| 组合查询的多列(联合索引) | 重复/冗余索引 |
-- 查看列区分度(Cardinality 越高越好)
SELECT
COUNT(DISTINCT status) / COUNT(*) AS status_selectivity,
COUNT(DISTINCT city) / COUNT(*) AS city_selectivity
FROM orders;
-- status 若只有 0/1/2 三值,区分度低;city 若上百个城市则适合索引
覆盖索引(Covering Index)
查询所需列全部在索引中,优化器直接从索引返回结果,*不回表* 。
-- idx_user_status 含 (user_id, status, id),id 是主键自动附加在二级索引叶子中
EXPLAIN SELECT user_id, status FROM orders WHERE user_id = 42;
-- Extra: Using index ← 覆盖索引,不回表
EXPLAIN SELECT user_id, status, amount FROM orders WHERE user_id = 42;
-- Extra: 无 Using index,需要回表取 amount
若该查询极频繁,可建覆盖索引:
ALTER TABLE orders ADD KEY idx_user_status_amount (user_id, status, amount);
-- 再查 user_id, status, amount 就会出现 Using index
索引列顺序设计
联合索引列顺序的一般原则:
-
等值查询列放前面 ,范围查询列放后面
-
区分度高的列放前面 (优化器更容易缩小扫描范围)
-
若查询含 ORDER BY ,把排序列放在索引合适位置
– 查询: WHERE status = 1 AND amount > 100 ORDER BY amount – idx_status_amount (status, amount) 是合理设计 EXPLAIN SELECT * FROM orders WHERE status = 1 AND amount > 100 ORDER BY amount; – type: range, key: idx_status_amount – Extra: Using index condition(可能)
– 反例:若索引是 (amount, status) – status 在范围列 amount 之后,status 条件难以高效利用索引
导致索引失效的常见写法
-
对索引列使用函数或运算
-- ❌ 索引失效 EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2026; -- key: NULL, type: ALL -- ✅ 改写为范围查询 EXPLAIN SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'; -- 可走 idx_city_created 或其他含 created_at 的索引 -
隐式类型转换
-- order_no 是 VARCHAR,传入数字会触发转换,索引失效 EXPLAIN SELECT * FROM orders WHERE order_no = 20260101001; -- 可能 type: ALL -- ✅ 使用字符串 EXPLAIN SELECT * FROM orders WHERE order_no = '20260101001'; -- type: const, key: uk_order_no -
前导模糊匹配
-- ❌ LIKE '%ABC' 无法用 B+ 树有序性 EXPLAIN SELECT * FROM orders WHERE order_no LIKE '%ABC'; -- ✅ 后导模糊可用索引 EXPLAIN SELECT * FROM orders WHERE order_no LIKE 'ORD2026%'; -- type: range, key: uk_order_no -
OR 连接不同列
-- 两个列各走各的索引,可能 Index Merge;也可能直接全表扫描 EXPLAIN SELECT * FROM orders WHERE user_id = 1 OR status = 2; -- ✅ 若业务允许,改写为 UNION EXPLAIN SELECT * FROM orders WHERE user_id = 1 UNION SELECT * FROM orders WHERE status = 2; -
不等于、NOT IN、IS NULL(视情况)
-- != 和 <> 通常导致范围扫描或放弃索引(优化器根据选择性判断) EXPLAIN SELECT * FROM orders WHERE status != 0; -- IS NULL:MySQL 8.0 对索引列 IS NULL 可以走 ref/range(取决于版本和统计信息) EXPLAIN SELECT * FROM orders WHERE city IS NULL;
索引下推(ICP, Index Condition Pushdown)
MySQL 5.6+ 特性:在 存储引擎层 先过滤索引条件,减少回表次数。
-- idx_user_status (user_id, status)
EXPLAIN SELECT * FROM orders WHERE user_id > 1000 AND status = 1;
-- Extra: Using index condition
-- 含义:在索引层先按 user_id 范围扫描,同时用 status=1 过滤,再回表
索引合并(Index Merge)
当单个索引无法覆盖查询,但多个索引各覆盖一部分条件时,优化器可能合并结果。
EXPLAIN SELECT * FROM orders WHERE user_id = 42 OR order_no = 'ORD001';
-- type: index_merge
-- key: idx_user_status,uk_order_no
-- Extra: Using union(idx_user_status,uk_order_no)
Index Merge 不一定比全表扫描快,可用 optimizer_switch 或 USE INDEX hint 对比测试。
执行计划详解(EXPLAIN)
基本用法
EXPLAIN SELECT * FROM orders WHERE user_id = 42
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE user_id = 42
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42 -- MySQL 8.0.18+,真实执行
| 命令 | 作用 |
|---|---|
| EXPLAIN | 只看优化器 计划 ,不真正执行(除子查询物化等少数情况) |
| EXPLAIN FORMAT=JSON | 结构化输出,含 cost 估算 |
| EXPLAIN ANALYZE | 真正执行 并报告实际耗时、实际行数(排查利器) |
核心字段解读
| 字段 | 含义 | 关注要点 |
|---|---|---|
| id | 查询中 SELECT 的序号 | 相同 id 可能表示 UNION 或子查询 |
| select_type | 查询类型 | SIMPLE / PRIMARY / SUBQUERY / DERIVED 等 |
| table | 当前访问的表 | 派生表显示 <derivedN> |
| type | 访问类型 ,性能关键 | 见下表,从左到右越来越差 |
| possible_keys | 可能用到的索引 | 仅供参考 |
| key | 实际选用的索引 | NULL 表示未用索引 |
| key_len | 索引使用的字节长度 | 可判断联合索引用了几列 |
| ref | 与索引比较的列或常量 | const 、列名等 |
| rows | 预估扫描行数 | 越小越好(统计信息会影响准确度) |
| filtered | 按表条件过滤后的行百分比 | 100 表示全部命中 |
| Extra | 额外信息 | Using index / Using filesort / Using temporary 等 |
type 访问类型(由好到坏)
| type | 含义 | 示例 |
|---|---|---|
| system | 表仅一行(系统表) | — |
| const | 主键或唯一索引等值,最多一行 | WHERE id = 1 |
| eq_ref | JOIN 时主键/唯一索引等值匹配 | 关联表主键 |
| ref | 非唯一索引等值 | WHERE user_id = 42 |
| range | 索引范围扫描 | BETWEEN=、=>、=LIKE ’abc%’ |
| index | 全索引扫描 | *SELECT id FROM orders*(扫整个二级索引) |
| ALL | 全表扫描 | 无可用索引或优化器认为全表更快 |
生产环境应尽量避免 type: ALL 且 rows 很大的组合。 index 全索引扫描有时比全表扫描还慢(二级索引叶子还要回表)。
Extra 常见值
| Extra | 含义 | 是否理想 |
|---|---|---|
| Using index | 覆盖索引 | ✅ 好 |
| Using index condition | 索引下推 | ✅ 通常好 |
| Using where | 存储引擎返回后 Server 层再过滤 | ⚠️ 视情况 |
| Using filesort | 额外排序,未利用索引顺序 | ⚠️ 大数据量需优化 |
| Using temporary | 使用临时表(GROUP BY / DISTINCT 等) | ❌ 大结果集需优化 |
| Using join buffer | JOIN 未走索引,用缓冲区嵌套循环 | ❌ 检查 JOIN 列索引 |
索引下推是指:除了用于索引定位的列之外,还有其他索引列可以用于过滤来减少回表的情况。
key_len 计算示例
idx_user_status (user_id BIGINT, status TINYINT) :
| 列 | 类型 | key_len |
|---|---|---|
| 仅 user_id | BIGINT NOT NULL | 8 |
| user_id + status | BIGINT + TINYINT | 8 + 1 = 9 |
EXPLAIN SELECT * FROM orders WHERE user_id = 1;
-- key_len: 8
EXPLAIN SELECT * FROM orders WHERE user_id = 1 AND status = 0;
-- key_len: 9
VARCHAR 列还需加上长度前缀(1 或 2 字节)及是否允许 NULL(+1)。
执行计划实战举例
例 1:主键点查 — const
EXPLAIN SELECT * FROM orders WHERE id = 50000
id: 1
select_type: SIMPLE
table: orders
type: const
possible_keys: PRIMARY
key: PRIMARY
key_len: 8
ref: const
rows: 1
Extra: NULL
最优路径:聚簇索引直接定位一行。
例 2:唯一索引 — const
EXPLAIN SELECT * FROM orders WHERE order_no = 'ORD20260709001'
type: const
key: uk_order_no
rows: 1
例 3:联合索引等值 — ref
EXPLAIN SELECT * FROM orders
WHERE user_id = 100 AND status = 1
type: ref
key: idx_user_status
key_len: 9
ref: const,const
rows: 50 -- 预估该用户已支付订单约 50 条
Extra: NULL
例 4:范围查询 — range
EXPLAIN SELECT * FROM orders
WHERE created_at BETWEEN '2026-01-01' AND '2026-06-30'
AND city = '上海'
type: range
key: idx_city_created
key_len: 203 -- city 全用 + created_at 部分
rows: 12000
Extra: Using index condition
例 5:索引失效 — 全表扫描
EXPLAIN SELECT * FROM orders WHERE amount > 500
type: ALL
key: NULL
rows: 1000000
Extra: Using where
amount 单独无索引;即使有 idx_status_amount ,条件未带最左列 status 时通常用不上。若此类查询频繁,考虑单独建 KEY idx_amount (amount) 或调整业务查询。
例 6:覆盖索引 — Using index
EXPLAIN SELECT user_id, status, id FROM orders WHERE user_id = 200
type: ref
key: idx_user_status
Extra: Using index
无需回表,只扫二级索引叶子。
例 7:排序优化 — 避免 filesort
-- ✅ 利用索引有序性
EXPLAIN SELECT * FROM orders
WHERE city = '北京'
ORDER BY created_at DESC
LIMIT 20
key: idx_city_created
Extra: NULL -- 无 Using filesort
-- ❌ 排序列与索引不一致
EXPLAIN SELECT * FROM orders
WHERE city = '北京'
ORDER BY amount DESC
LIMIT 20
Extra: Using filesort
例 8:GROUP BY 与临时表
EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status
type: index
key: idx_status_amount
rows: 1000000
Extra: Using index
若 GROUP BY 列无索引或无法利用索引顺序,Extra 可能出现 Using temporary; Using filesort 。
例 9:JOIN 执行计划
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(50)
);
EXPLAIN SELECT o.order_no, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 1
-- orders 表(驱动表或被驱动表由优化器决定)
table: o
type: ref
key: idx_user_status 或 idx_status_amount
-- users 表
table: u
type: eq_ref
key: PRIMARY
ref: o.user_id
JOIN 列 o.user_id 应有索引;被关联表 users.id 是主键, eq_ref 最优。
例 10:子查询与派生表
EXPLAIN SELECT * FROM orders
WHERE user_id IN (
SELECT id FROM users WHERE name LIKE '张%'
)
MySQL 8.0 可能对 IN 子查询做 半连接(semi-join) 优化,*select_type* 可能显示 SUBQUERY 或合并为 JOIN。用 EXPLAIN FORMAT=JSON 可看 attached_condition 等细节。
例 11:EXPLAIN ANALYZE 对比预估与实际
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE user_id = 42 AND status = 0
-> Index lookup on orders using idx_user_status (user_id=42, status=0)
(cost=120 rows=800) (actual time=0.05..2.1 rows=750 loops=1)
若 rows 预估 800 实际 750,说明统计信息较准;若预估 50 实际 50000,需 ANALYZE TABLE orders 更新统计信息。
排查与优化流程
标准排查步骤
-
开启慢查询日志,抓取慢 SQL
-
EXPLAIN / EXPLAIN ANALYZE 看 type*、*key*、*rows*、*Extra
-
确认是否回表、是否 filesort / temporary
-
检查索引是否缺失、联合索引顺序是否合理、是否有隐式转换
-
SHOW INDEX FROM orders 查看索引 cardinality
-
ANALYZE TABLE orders 更新统计信息后再对比
SHOW INDEX FROM orders; SHOW TABLE STATUS LIKE ‘orders’
– 查看优化器跟踪(MySQL 5.6+) SET optimizer_trace=‘enabled=on’; SELECT * FROM orders WHERE status = 1 AND amount > 100 LIMIT 1; SELECT * FROM information_schema.optimizer_trace SET optimizer_trace=‘enabled=off’;
实用原则小结
| 原则 | 说明 |
|---|---|
| 最左前缀 | 联合索引 (a,b,c) 支持 a、ab、abc 条件,不支持单独 b、c |
| 覆盖索引优先 | 高频查询尽量让索引包含 SELECT 列,避免回表 |
| 等值在前,范围在后 | 联合索引列顺序影响能否同时用于过滤和排序 |
| 小表不必强上索引 | 优化器可能正确选择全表扫描 |
| 用执行计划验证 | 不要凭感觉,*EXPLAIN ANALYZE* 看真实代价 |
| 定期 ANALYZE | 数据量变化大后统计信息过期会导致错误计划 |
索引不是越多越好:每个索引增加写入成本(INSERT/UPDATE/DELETE 要维护 B+ 树),
且占用磁盘。只为 真实查询模式 设计索引,并用执行计划验证收益。