索引原理

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)等