索引原理
1. B+ Tree 索引原理
1.1 B+ Tree 结构特点
B+ Tree 是 MySQL 中 InnoDB 存储引擎默认使用的索引结构。它是一种自平衡的多路搜索树,所有数据都存储在叶子节点,非叶子节点只存储索引键。
flowchart TD
subgraph Root[根节点]
R["[20 40 60 80]"]
end
subgraph Level1[第一层节点]
L1_1["[10 15]"]
L1_2["[20 30]"]
L1_3["[40 50]"]
L1_4["[60 70]"]
L1_5["[80 90]"]
end
subgraph Leaves[叶子节点层]
L2_1["[1,5,8]
→data1"]
L2_2["[10,12,15]
→data2"]
L2_3["[20,25,30]
→data3"]
L2_4["[40,45,50]
→data4"]
L2_5["[60,65,70]
→data5"]
L2_6["[80,85,90]
→data6"]
end
R --> L1_1 & L1_2 & L1_3 & L1_4 & L1_5
L1_1 --> L2_1 & L2_2
L1_2 --> L2_2 & L2_3
L1_3 --> L2_3 & L2_4
L1_4 --> L2_4 & L2_5
L1_5 --> L2_5 & L2_6
L2_1 -.->|双向链表 | L2_2
L2_2 -.->|双向链表 | L2_3
L2_3 -.->|双向链表 | L2_4
L2_4 -.->|双向链表 | L2_5
L2_5 -.->|双向链表 | L2_6
style R fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style L1_1 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style L1_2 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style L1_3 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style L1_4 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style L1_5 fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style L2_1 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style L2_2 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style L2_3 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style L2_4 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style L2_5 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style L2_6 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
图示说明:
- 非叶子节点(索引页,仅存储键值)
- 叶子节点(数据页,存储完整数据)
- ⟷ 叶子节点间的双向链表连接
1.2 B+ Tree 优缺点分析
| 分类 | 特点 | 说明 |
|---|---|---|
| 优点 | 查询稳定 | 时间复杂度 O(log n),路径长度相同 |
| 范围查询高效 | 叶子节点双向链表,顺序访问快 | |
| 磁盘 IO 少 | 非叶子节点不存数据,每页可存 1365 个键 | |
| 适合统计 | ORDER BY、GROUP BY 效率高 | |
| 缓存命中高 | 非叶子节点可缓存在内存 | |
| 缺点 | 写入性能差 | 需维护树平衡,可能触发页分裂/合并 |
| 空间占用大 | 叶子节点存储重复主键值 | |
| 唯一性维护 | 需额外维护唯一性约束 | |
| 更新成本高 | 索引列值变化需更新索引 |
1.3 B+ Tree 性能数据
| 数据量 | B+ Tree 高度 | 磁盘 IO 次数 | 查询性能 |
|---|---|---|---|
| 1 万条 | 2 层 | 2 次 | 毫秒级 |
| 100 万条 | 3 层 | 3 次 | 毫秒级 |
| 1000 万条 | 3 层 | 3 次 | 毫秒级 |
| 1 亿条 | 4 层 | 4 次 | 10 毫秒级 |
| 10 亿条 | 4 层 | 4 次 | 10-50 毫秒 |
1.4 不同数据量的索引设计建议
| 数据量 | 建议 | 示例 |
|---|---|---|
| < 10 万 | 简单索引,优先考虑查询便利性 | CREATE INDEX idx_name ON users(name); |
| 10 万 - 1000 万 | 复合索引覆盖查询,关注索引选择性 | CREATE INDEX idx_user_status ON orders(user_id, status); |
| > 1000 万 | 分区表、覆盖索引、定期分析效率 | CREATE INDEX idx_cover ON logs(device_id, event_type, created_at); |
| > 1 亿 | 分库分表、近似查询、考虑 ES/ClickHouse | 分布式方案或多数据库组合 |
2. 聚簇索引 vs 非聚簇索引
| 特性 | 聚簇索引 | 非聚簇索引 |
|---|---|---|
| 数据存储 | 数据行存储在叶子节点 | 叶子节点存储索引键 + 主键 |
| 数量限制 | 每个表只能有 1 个 | 每个表可以有多个 |
| 查询效率 | 查找主键直接返回数据 | 需要回表获取完整数据 |
| 磁盘顺序 | 数据按索引键物理排序 | 不影响数据物理顺序 |
| 适用场景 | 主键查询、范围查询 | 辅助查询、联合查询 |
flowchart TD
A[SQL 查询:SELECT * FROM users WHERE age = 25] --> B{使用哪个索引?}
B -->|聚簇索引 PK| C[在主键索引中查找 id=1]
C --> D[叶子节点包含完整数据]
D --> E[直接返回结果]
B -->|非聚簇索引 idx_age| F[在 idx_age 中查找 age=25]
F --> G[获取主键 id=1]
G --> H[回表:在聚簇索引中查找 id=1]
H --> I[获取完整数据]
I --> E
style A fill:#fff3e0,stroke:#e65100,stroke-width:2px
style D fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style E fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style I fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
2.1 聚簇索引(Clustered Index)
聚簇索引决定了数据的物理存储顺序。每个表只能有一个聚簇索引,因为数据只能按一种方式物理排序。通常主键就是聚簇索引。
-- InnoDB 聚簇索引特点:
-- 1. 数据行直接存储在叶子节点中
-- 2. 主键索引就是聚簇索引
-- 3. 如果没有主键,使用第一个唯一非空索引
-- 4. 如果没有唯一索引,InnoDB 会生成隐藏的 ROW_ID
-- 聚簇索引结构:
-- 叶子节点存储完整的数据行
-- +--------+-------------+-----+-----+
-- | id (PK)| name | age | ... |
-- +--------+-------------+-----+-----+
-- | 1 | zhangsan | 25 | ... |
-- | 2 | lisi | 30 | ... |
-- | 3 | wangwu | 28 | ... |
-- +--------+-------------+-----+-----+
CREATE TABLE users (
id INT PRIMARY KEY, -- 聚簇索引
name VARCHAR(100),
age INT,
INDEX idx_age (age) -- 非聚簇索引
);
2.2 非聚簇索引(Secondary Index)
-- 非聚簇索引(辅助索引)特点:
-- 1. 叶子节点存储索引键 + 主键值
-- 2. 一个表可以有多个非聚簇索引
-- 3. 查询时需要先通过非聚簇索引找到主键
-- 4. 再通过主键到聚簇索引中获取完整数据(回表)
-- 非聚簇索引 idx_age 结构:
-- +--------+--------+
-- | age | id(PK) |
-- +--------+--------+
-- | 25 | 1 |
-- | 28 | 3 |
-- | 30 | 2 |
-- +--------+--------+
-- 查询 SELECT * FROM users WHERE age = 25:
-- 1. 在 idx_age 中找到 age=25 的记录,获取 id=1
-- 2. 在聚簇索引中找到 id=1 的完整数据
-- 3. 返回完整数据行
2.3 覆盖索引避免回表
-- 覆盖索引:索引包含查询所需的所有列
-- 不需要回表,直接从索引中返回数据
-- 创建覆盖索引
CREATE INDEX idx_age_name ON users(age, name);
-- 使用覆盖索引(不需要回表)
SELECT age, name FROM users WHERE age > 25;
-- EXPLAIN: Extra = Using index
-- 需要回表的查询
SELECT * FROM users WHERE age > 25;
-- EXPLAIN: Extra = Using index condition
-- 验证是否使用覆盖索引
EXPLAIN SELECT age, name FROM users WHERE age = 25;
-- type=ref, key=idx_age_name, Extra=Using index
2.4 回表原理详解
回表是指查询过程中,先通过非聚簇索引找到主键值,再根据主键值到聚簇索引中查找完整数据行的过程。这是 InnoDB 存储引擎的重要特性。
-- 回表的完整过程:
-- 1. 在非聚簇索引树中查找
-- 2. 获取满足条件的主键值
-- 3. 使用主键值在聚簇索引中查找完整数据
-- 4. 返回完整的用户数据
-- 示例表结构:
CREATE TABLE orders (
id INT PRIMARY KEY, -- 聚簇索引
order_no VARCHAR(20), -- 唯一索引
customer_id INT,
amount DECIMAL(10,2),
status VARCHAR(10),
created_at DATETIME,
INDEX idx_customer (customer_id) -- 非聚簇索引
);
-- 执行 SELECT * FROM orders WHERE customer_id = 100
-- 1. 在 idx_customer 中找到 customer_id=100 的记录
-- 2. 获取主键 id(如 id=500)
-- 3. 在聚簇索引中查找 id=500 的完整记录
-- 4. 返回所有列数据
EXPLAIN SELECT * FROM orders WHERE customer_id = 100;
-- type: ref, key: idx_customer
-- Extra: Using index condition (需要回表)
2.5 回表性能影响
-- 回表的性能影响:
-- 1. 增加磁盘 IO:每次回表可能产生一次磁盘读取
-- 2. 随机 IO:聚簇索引中查找是随机 IO
-- 3. 回表次数 = 索引命中的记录数
-- 回表次数过多的例子
SELECT * FROM orders WHERE customer_id > 100;
-- 假设 customer_id > 100 有 10000 条记录
-- 需要回表 10000 次,性能很差
EXPLAIN SELECT * FROM orders WHERE customer_id > 100;
-- type: range, key: idx_customer
-- rows: 10000 (预计回表次数)
-- 优化方案:使用覆盖索引
CREATE INDEX idx_cover ON orders(customer_id, order_no, amount, status);
-- 如果查询的列都在索引中,不需要回表
SELECT customer_id, order_no, amount, status
FROM orders WHERE customer_id > 100;
EXPLAIN SELECT customer_id, order_no, amount, status
FROM orders WHERE customer_id > 100;
-- Extra: Using index (覆盖索引,无需回表)
2.6 减少回表的方法
-- 方法 1:使用覆盖索引
-- 将需要查询的列都包含在索引中
-- 方法 2:优化查询条件
-- 避免返回大量数据
SELECT id, order_no FROM orders WHERE customer_id = 100
LIMIT 100; -- 限制返回数量
-- 方法 3:使用主键查询
SELECT * FROM orders WHERE id = 500;
-- 直接在聚簇索引中查找,不需要回表
-- 方法 4:分页优化
-- 避免 OFFSET 过大导致的深度分页问题
-- 低效(回表次数多):
SELECT * FROM orders ORDER BY id LIMIT 10000, 10;
-- 高效(基于主键范围):
SELECT * FROM orders WHERE id > 10000 LIMIT 10;
-- 上一页最后一条记录的 id 作为起点
-- 方法 5:使用延迟关联
SELECT o.*
FROM orders o
INNER JOIN (
SELECT id FROM orders WHERE customer_id = 100
LIMIT 100
) t ON o.id = t.id;
3. 索引存储结构
3.1 InnoDB 页面结构
-- InnoDB 数据页结构(默认 16KB):
-- +------------------+
-- | File Header | 38 bytes - 文件头
-- +------------------+
-- | Page Header | 56 bytes - 页头
-- +------------------+
-- | Infimum + Supremum| 26 bytes - 虚拟行
-- +------------------+
-- | User Records | 变长 - 用户数据
-- +------------------+
-- | Free Space | 变长 - 空闲空间
-- +------------------+
-- | Page Directory | 变长 - 页目录
-- +------------------+
-- | File Trailer | 8 bytes - 文件尾
-- +------------------+
-- 页内记录存储:
-- 1. 记录头信息(变长字段长度、NULL 标志等)
-- 2. 记录实际数据
-- 3. 记录通过 Page Directory 实现二分查找
3.2 索引页结构
-- B+ Tree 索引页结构:
-- +------------------+
-- | File Header | 38 bytes
-- +------------------+
-- | Page Header | 56 bytes
-- +------------------+
-- | User Records | 索引记录
-- | (非叶子节点) | (最小键,指向子页指针)
-- | (叶子节点) | (键值,主键/行指针)
-- +------------------+
-- | Free Space | 空闲空间
-- +------------------+
-- | Page Directory | 槽位目录(记录分组)
-- +------------------+
-- | File Trailer | 8 bytes
-- +------------------+
-- 非叶子节点记录格式:
-- | 记录头 | 索引键值 | 页面指针 |
-- 叶子节点记录格式(聚簇索引):
-- | 记录头 | 主键值 | 其他列数据 |
-- 叶子节点记录格式(非聚簇索引):
-- | 记录头 | 索引键值 | 主键值 |
4. 索引优化原理
4.1 最左前缀原则
-- 复合索引 (a, b, c) 的使用规则:
WHERE a = 1 -- ✅ 使用索引 a
WHERE a = 1 AND b = 2 -- ✅ 使用索引 a, b
WHERE a = 1 AND b = 2 AND c = 3 -- ✅ 使用索引 a, b, c
WHERE b = 2 -- ❌ 不使用索引
WHERE b = 2 AND c = 3 -- ❌ 不使用索引
WHERE a = 1 AND c = 3 -- ⚠️ 只使用索引 a
-- 原理:复合索引按从左到右的顺序构建 B+ Tree
-- 必须先使用前面的列,才能利用后续列的索引
4.2 索引下推(Index Condition Pushdown)
-- 索引下推原理:
-- 在 MySQL 5.6+ 之前:
-- 1. 通过索引找到满足 a=1 的主键
-- 2. 回表获取完整数据
-- 3. 在 server 层过滤 b>100 的条件
-- MySQL 5.6+ 索引下推:
-- 1. 通过索引找到满足 a=1 的主键
-- 2. 在存储引擎层直接过滤 b>100 的条件
-- 3. 只对满足所有条件的记录回表
-- 示例索引:(a, b)
SELECT * FROM t WHERE a = 1 AND b > 100;
-- 验证索引下推
EXPLAIN SELECT * FROM t WHERE a = 1 AND b > 100;
-- Extra: Using index condition (使用了索引下推)
4.3 索引合并(Index Merge)
-- 索引合并原理:
-- MySQL 可以同时使用多个索引
-- 然后合并结果集
-- 索引合并类型:
-- 1. intersection(交集)- 多个索引结果取交集
-- 2. union(并集)- 多个索引结果取并集
-- 3. sort_union(排序并集)- 先排序再取并集
-- 示例:
SELECT * FROM users WHERE age = 25 AND name = 'zhangsan';
EXPLAIN SELECT * FROM users WHERE age = 25 AND name = 'zhangsan';
-- type: index_merge
-- key: idx_age, idx_name
-- Extra: Using intersect(idx_age,idx_name)
5. 索引实践分析
5.1 使用 EXPLAIN 分析索引
-- EXPLAIN 关键字段:
-- type: 连接类型(从好到差)
-- system > const > eq_ref > ref > range > index > ALL
-- const: 主键/唯一索引等值查询
-- ref: 非唯一索引等值查询
-- range: 索引范围查询
-- index: 全索引扫描
-- ALL: 全表扫描(需要优化)
-- key: 实际使用的索引
-- rows: 预计扫描的行数
-- Extra: 额外信息
-- Using index: 使用覆盖索引
-- Using where: 使用 WHERE 条件过滤
-- Using index condition: 使用索引下推
-- Using filesort: 需要额外排序
-- Using temporary: 使用临时表
EXPLAIN SELECT * FROM users WHERE age = 25;
-- 分析结果:
-- type: ref (使用了非唯一索引)
-- key: idx_age
-- rows: 10 (预计扫描 10 行)
5.2 索引设计最佳实践
-- 索引设计原则:
-- 1. 区分度高的列放在前面
CREATE INDEX idx_status_user ON orders(user_id, status);
-- ✅ user_id 区分度高,放前面
-- 2. 考虑查询的 WHERE 条件顺序
CREATE INDEX idx_age_name ON users(age, name);
-- 适合:WHERE age = ? 或 WHERE age = ? AND name = ?
-- 3. 控制索引数量
-- - 索引占用空间
-- - 影响 INSERT/UPDATE/DELETE 性能
-- - 建议单个表索引数 < 5
-- 4. 使用覆盖索引减少回表
CREATE INDEX idx_user_cover ON orders(user_id, status, created_at);
-- 覆盖:SELECT user_id, status FROM orders WHERE user_id = ?
-- 5. 避免在区分度低的列上建索引
-- 如:性别、状态(0/1)等