强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

MySQL 完全指南 / 第 7 章:索引详解

第 7 章:索引详解

索引是数据库性能优化的核心武器。理解 B+Tree 的原理,才能用好索引。


7.1 索引的本质

索引是帮助 MySQL 高效获取数据的有序数据结构。可以类比为书的目录。

没有索引:全表扫描(翻遍整本书找内容)
    O(n) → 100 万行就需要扫描 100 万次

有索引:通过索引定位(先查目录,再翻到对应页)
    O(log n) → 100 万行只需要约 20 次比较

7.2 B+Tree 数据结构

7.2.1 B+Tree 原理

InnoDB 使用 B+Tree 作为索引的数据结构:

                    [30 | 60]                    ← 根节点(非叶子)
                   /    |    \
            [10|20]   [40|50]   [70|80|90]       ← 内部节点(非叶子)
           /  |  \   /  |  \   /  |   |  \
         [数据页] [数据页] ... [数据页] [数据页]  ← 叶子节点(存数据)
          ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔ ↔   ← 叶子节点双向链表

B+Tree 的特点:

特性说明
多路平衡每个节点可以有多个子节点(通常 1000+)
叶子存储数据非叶子节点只存键值,叶子节点存完整数据
叶子双向链表叶子节点通过指针相连,支持范围查询
树高很低通常 2-4 层,亿级数据也只需 3-4 次磁盘 I/O

7.2.2 为什么 B+Tree 而不是 B-Tree 或 Hash?

数据结构等值查询范围查询排序适用场景
B+TreeO(log n)✅ 高效✅ 天然有序InnoDB 默认
B-TreeO(log n)较差支持但效率不如 B+Tree
HashO(1)❌ 不支持❌ 不支持Memory 引擎、自适应哈希索引
二叉搜索树O(log n)支持支持树高太高,磁盘 I/O 多
红黑树O(log n)支持支持树高太高,不适合磁盘存储

7.3 InnoDB 索引类型

7.3.1 聚簇索引(Clustered Index)

聚簇索引的叶子节点存储完整的行数据。InnoDB 表有且仅有一个聚簇索引。

选择规则:

1. 如果有 PRIMARY KEY → 主键作为聚簇索引
2. 如果没有主键,有 UNIQUE NOT NULL → 第一个 UNIQUE NOT NULL 作为聚簇索引
3. 如果都没有 → InnoDB 自动生成一个隐藏的 6 字节 ROW_ID
-- 聚簇索引结构示意:
-- 主键索引叶子节点存储:
-- | PK(id) | col1 | col2 | col3 | ... | 完整行数据 |

-- 查询走主键:直接从聚簇索引叶子节点获取数据(最快)
SELECT * FROM users WHERE id = 100;

7.3.2 二级索引(Secondary Index)

二级索引的叶子节点存储的是主键值(而不是行数据的物理地址)。

-- 二级索引 idx_email 的结构:
-- 叶子节点存储:| email | id(主键) |

-- 查询过程:
SELECT * FROM users WHERE email = 'zhangsan@example.com';
-- 1. 在 idx_email B+Tree 中找到 email='zhangsan@example.com' → 获取 id=5
-- 2. 用 id=5 去聚簇索引(主键索引)中查找完整行 → 这个过程叫"回表"
二级索引 idx_email           聚簇索引(主键)
┌──────────────────┐        ┌──────────────────┐
│ a@ex.com → id=1  │        │ id=1 | 完整行数据 │
│ b@ex.com → id=2  │  ────→ │ id=2 | 完整行数据 │
│ c@ex.com → id=3  │ 回表   │ id=3 | 完整行数据 │
└──────────────────┘        └──────────────────┘

7.3.3 覆盖索引(Covering Index)

如果查询的列全部包含在索引中,就不需要回表,这就是覆盖索引。

-- 假设有索引:INDEX idx_email_status (email, status)

-- 覆盖索引(不回表)
SELECT email, status FROM users WHERE email = 'zhangsan@example.com';
-- 索引叶子节点已包含 email 和 status,直接返回

-- 非覆盖索引(需要回表)
SELECT email, status, username FROM users WHERE email = 'zhangsan@example.com';
-- 索引中没有 username,需要回表到聚簇索引获取
-- EXPLAIN 中的 Using index 表示覆盖索引
EXPLAIN SELECT email, status FROM users WHERE email = 'zhangsan@example.com';
-- Extra: Using index ← 使用了覆盖索引

💡 覆盖索引优化:在设计联合索引时,将 SELECT 中常用的列包含进去,避免回表。

7.3.4 联合索引(Composite Index)

联合索引是多个列组成的索引,遵循最左前缀原则

-- 联合索引:INDEX idx_abc (a, b, c)
CREATE TABLE test_index (
    id INT PRIMARY KEY,
    a INT,
    b INT,
    c INT,
    d INT,
    INDEX idx_abc (a, b, c)
);

最左前缀匹配规则

查询条件是否使用索引 idx_abc说明
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❌ 不使用缺少最左列 a
WHERE b = 2 AND c = 3❌ 不使用缺少最左列 a
WHERE c = 3❌ 不使用缺少最左列 a
WHERE a = 1 AND c = 3⚠️ 部分使用 (a)c 无法使用索引(b 缺失)
WHERE a = 1 ORDER BY b✅ 使用 (a, b)a 等值 + b 排序
WHERE a > 1 AND b = 2⚠️ 部分使用 (a)a 范围后 b 无法使用索引

⚠️ 关键规则

  • 索引列的顺序非常重要
  • 范围查询(>, <, BETWEEN, LIKE ‘xx%’) 之后的列无法使用索引
  • 将等值查询的列放在前面,范围查询的列放在后面

7.3.5 前缀索引(Prefix Index)

对长字符串列,只索引前 N 个字符,节省空间。

-- 为 email 列创建前缀索引(只索引前 10 个字符)
ALTER TABLE users ADD INDEX idx_email_prefix (email(10));

-- 如何选择合适的前缀长度?
-- 目标:选择性(distinct 比例)接近全列
SELECT 
    COUNT(DISTINCT LEFT(email, 5)) / COUNT(*) AS sel_5,
    COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS sel_10,
    COUNT(DISTINCT LEFT(email, 15)) / COUNT(*) AS sel_15,
    COUNT(DISTINCT email) / COUNT(*) AS sel_full
FROM users;
-- 选择最接近 sel_full 且值稳定的前缀长度
前缀长度选择性推荐
50.85太短
100.95可以考虑
150.99推荐
全列1.00最好但占空间

⚠️ 前缀索引的限制

  • 不能用于 ORDER BY 和 GROUP BY
  • 不能做覆盖索引
  • 只能用于等值查询和前缀匹配

7.4 索引分类总结

分类说明示例
主键索引聚簇索引,叶子节点存完整行PRIMARY KEY (id)
唯一索引值唯一,允许 NULLUNIQUE INDEX uk_email (email)
普通索引最基本的索引INDEX idx_name (name)
前缀索引索引列的前 N 个字符INDEX idx_email (email(10))
联合索引多列组合INDEX idx_abc (a, b, c)
全文索引全文搜索FULLTEXT INDEX ft_content (content)
空间索引地理空间数据SPATIAL INDEX idx_geo (position)
降序索引8.0+ 支持INDEX idx_time (created_at DESC)
不可见索引8.0+ 支持,优化器不使用ALTER TABLE t ALTER INDEX idx INVISIBLE
函数索引8.0+ 支持INDEX idx_upper ((UPPER(name)))

7.5 索引设计原则

7.5.1 何时创建索引

-- ✅ 适合创建索引的场景
-- 1. WHERE 条件频繁使用的列
SELECT * FROM orders WHERE user_id = 100;      -- INDEX idx_user_id

-- 2. JOIN 关联列
SELECT * FROM orders o JOIN users u ON o.user_id = u.id;  -- orders.user_id 需要索引

-- 3. ORDER BY / GROUP BY 列
SELECT * FROM orders ORDER BY created_at DESC;  -- INDEX idx_created_at

-- 4. 高选择性的列(distinct 值多)
-- email、手机号 适合建索引
-- status、gender 不适合单独建索引(选择性低)

-- 5. 覆盖索引(查询列都在索引中)

7.5.2 索引设计口诀

等值查询放前面,范围查询放后面
选择性高的放前面,选择性低的放后面
查询需要的列放索引里(覆盖索引)
不要过度索引(每个索引都有写入代价)

7.5.3 联合索引设计实战

-- 场景:电商订单查询
-- 查询模式:
-- 1. WHERE user_id = ? AND status = ? ORDER BY created_at DESC
-- 2. WHERE status = ? AND created_at BETWEEN ? AND ?
-- 3. WHERE user_id = ? ORDER BY created_at DESC

-- 推荐索引设计
ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, status, created_at);
ALTER TABLE orders ADD INDEX idx_status_time (status, created_at);

7.6 索引管理

7.6.1 查看索引

-- 查看表的所有索引
SHOW INDEX FROM orders;

-- 更详细的索引信息
SELECT 
    INDEX_NAME,
    GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns,
    NON_UNIQUE,
    INDEX_TYPE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'myapp' AND TABLE_NAME = 'orders'
GROUP BY INDEX_NAME, NON_UNIQUE, INDEX_TYPE;

7.6.2 创建/删除索引

-- 创建普通索引
ALTER TABLE orders ADD INDEX idx_user_id (user_id);

-- 创建唯一索引
ALTER TABLE users ADD UNIQUE INDEX uk_email (email);

-- 创建联合索引
ALTER TABLE orders ADD INDEX idx_user_status (user_id, status);

-- 创建降序索引
ALTER TABLE orders ADD INDEX idx_created_desc (created_at DESC);

-- 创建不可见索引(测试索引是否有用)
ALTER TABLE orders ADD INDEX idx_test (column_name) INVISIBLE;

-- 将索引设为不可见(优化器不使用,但保留索引结构)
ALTER TABLE orders ALTER INDEX idx_test INVISIBLE;

-- 将索引设为可见
ALTER TABLE orders ALTER INDEX idx_test VISIBLE;

-- 删除索引
ALTER TABLE orders DROP INDEX idx_user_id;

-- 使用 DROP INDEX 语法
DROP INDEX idx_user_id ON orders;

7.6.3 索引监控

-- 查看索引使用情况(需要开启 Performance Schema)
SELECT 
    OBJECT_SCHEMA,
    OBJECT_NAME,
    INDEX_NAME,
    COUNT_READ,
    COUNT_WRITE,
    COUNT_FETCH
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'myapp'
ORDER BY COUNT_READ DESC;

-- 查找从未使用过的索引(可以考虑删除)
SELECT 
    OBJECT_SCHEMA,
    OBJECT_NAME,
    INDEX_NAME
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'myapp'
  AND INDEX_NAME IS NOT NULL
  AND COUNT_READ = 0
  AND INDEX_NAME != 'PRIMARY';

7.7 索引失效场景

以下是导致索引失效(无法使用索引)的常见情况:

场景示例说明
对索引列使用函数WHERE YEAR(created_at) = 2026改为范围查询
对索引列做运算WHERE id + 1 = 10改为 WHERE id = 9
隐式类型转换WHERE phone = 13800000000(phone 是 VARCHAR)加引号
LIKE 左模糊WHERE name LIKE '%san'前缀匹配才能用索引
OR 条件WHERE a = 1 OR b = 2需要两个列都有索引
NOT IN / NOT EXISTSWHERE id NOT IN (...)视情况可能不走索引
IS NULL / IS NOT NULL视数据分布NULL 值比例高可能不走索引
范围查询后的列WHERE a > 1 AND b = 2(联合索引 a,b)b 无法使用索引
-- ❌ 索引失效
SELECT * FROM users WHERE YEAR(created_at) = 2026;

-- ✅ 索引有效
SELECT * FROM users WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';

-- ❌ 索引失效
SELECT * FROM users WHERE phone = 13800000000;  -- phone 是 VARCHAR

-- ✅ 索引有效
SELECT * FROM users WHERE phone = '13800000000';

-- ❌ 索引失效
SELECT * FROM users WHERE username LIKE '%san%';

-- ✅ 索引有效
SELECT * FROM users WHERE username LIKE 'zhang%';

-- ❌ 索引失效
SELECT * FROM users WHERE status != 1;  -- 不等于可能全表扫描

-- ✅ 改写为
SELECT * FROM users WHERE status IN (0, 2, 3);

7.8 索引优化技巧

7.8.1 使用索引提示(Index Hint)

-- 强制使用某个索引
SELECT * FROM orders FORCE INDEX (idx_user_id) WHERE user_id = 100;

-- 忽略某个索引
SELECT * FROM orders IGNORE INDEX (idx_user_id) WHERE user_id = 100;

-- 建议使用某个索引
SELECT * FROM orders USE INDEX (idx_user_id) WHERE user_id = 100;

7.8.2 降序索引的应用

-- MySQL 8.0+ 支持降序索引
ALTER TABLE orders ADD INDEX idx_created_desc (created_at DESC);

-- 查询最新订单(天然使用降序索引,无需 filesort)
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;

7.8.3 不可见索引测试

-- 想删除某个索引,但不确定是否有用
-- 先设为不可见,观察一段时间
ALTER TABLE orders ALTER INDEX idx_suspicious INVISIBLE;

-- 监控是否有查询受影响
-- 如果没问题,再真正删除
ALTER TABLE orders DROP INDEX idx_suspicious;

-- 如果有问题,恢复可见
ALTER TABLE orders ALTER INDEX idx_suspicious VISIBLE;

业务场景

场景 1:电商订单表索引设计

-- 订单表常用查询模式及索引设计

-- 查询 1:用户的所有订单
-- SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at);

-- 查询 2:按状态查询待处理订单
-- SELECT * FROM orders WHERE status = ? AND created_at > ?
ALTER TABLE orders ADD INDEX idx_status_created (status, created_at);

-- 查询 3:订单号查询
-- SELECT * FROM orders WHERE order_no = ?
ALTER TABLE orders ADD UNIQUE INDEX uk_order_no (order_no);

-- 查询 4:查询用户的待支付订单(覆盖索引)
-- SELECT id, order_no, total_amount FROM orders WHERE user_id = ? AND status = 0
ALTER TABLE orders ADD INDEX idx_user_status_amount (user_id, status, total_amount);

场景 2:索引空间评估

-- 评估索引占用空间
SELECT 
    DATABASE_NAME,
    TABLE_NAME,
    INDEX_NAME,
    STAT_VALUE * @@innodb_page_size / 1024 / 1024 AS index_size_mb
FROM mysql.innodb_index_stats
WHERE stat_name = 'size'
  AND DATABASE_NAME = 'myapp'
ORDER BY STAT_VALUE DESC;

扩展阅读