7AA7 博客

MySQL 索引入门:B+ 树、联合索引与最左前缀

逻辑熵 Logic UID:93
2026-10-16 01:50
24 0 1

索引是数据库性能问题里被讨论得最多、也最容易被误解的一层。很多线上慢查询的根因不是”没加索引”,而是”加了索引但没用上”,或者”用了索引但代价比想象中高”。想把索引用明白,得先理解它在磁盘和内存之间是怎么组织数据的,再回头看 SQL 语句里那些看似随意的写法如何影响优化器的决策。

一、为什么是 B+ 树

关系型数据库的索引要解决的核心矛盾是:数据量远大于内存,而每一次磁盘随机读的代价都很高。设计目标因此很明确——用尽量少的磁盘 IO 定位到目标行。

如果用二叉搜索树,树的高度会随数据量增长,每下降一层就可能是一次磁盘 IO。B+ 树的做法是把节点做大:一个节点通常对应一个磁盘页(InnoDB 中默认 16KB),节点内放几百个键值对,树的高度被压到 3 到 4 层。假设非叶节点单页能容纳上千个指针,三层结构就可以索引上亿级别的行,且根节点和非叶节点常驻内存,实际落到磁盘的往往只有最后一层的一到两次 IO。

B+ 树相比 B 树还有两个对数据库特别友好的特性:

  • 非叶节点只存键不存数据,单页能放更多键,树更矮;
  • 叶子节点用双向链表串联,范围查询只需要定位到起点,然后顺着链表扫描,不需要回溯树结构。
-- 这类范围扫描能直接利用叶子链表
SELECT * FROM orders WHERE created_at >= '2026-10-01' AND created_at < '2026-10-08';

理解了这个结构,后面很多现象就有了出处:索引字段上做函数计算会让 B+ 树的有序性失效、范围条件之后的索引列”用不上”、回表会放大 IO 次数——这些都不是玄学,而是结构决定的。

二、聚簇索引、二级索引与回表

InnoDB 的表数据本身就存储在一棵 B+ 树里,这棵树的叶子节点直接放整行数据,这就是聚簇索引。通常情况下聚簇索引就是主键;如果没有显式主键,InnoDB 会选择第一个非空唯一索引,实在没有就会生成隐藏的行 ID。

聚簇索引的直接推论:按主键查询是最快的路径,而且主键最好是有意义的、顺序写入的(比如自增 ID 或趋势递增的雪花 ID)。随机的主键(比如随机 UUID)会让插入变成随机写,页分裂频繁,长期运行后数据页的填充率下降,占用空间变大。

非主键索引叫二级索引,它的叶子节点存的是索引列的值加主键值。因此通过二级索引查非索引列时会经历两步:

  1. 在二级索引树里定位到主键值;
  2. 拿主键值回聚簇索引树再查一次完整行。

这就是回表。回表的代价取决于命中的行数和每一行的随机 IO,一条看似走了索引的语句,如果要回表几万次,性能可能还不如全表扫描。

减少回表主要有两条路:

  • 覆盖索引:查询所需的列都在索引里,直接从二级索引返回,跳过第二步。典型的例子是 SELECT id, status FROM orders WHERE user_id = ?,把 (user_id, status) 建成联合索引后,这个查询就是覆盖查询。
  • 减少命中行数:通过更精确的过滤条件让索引少扫一些叶子。

可以用 EXPLAIN 里的 Extra 列验证:出现 Using index 表示走了覆盖索引。

三、联合索引与最左前缀

联合索引 (a, b, c) 在物理上是按 a、b、c 的顺序排序的一棵 B+ 树。理解了排序方式,最左前缀原则就自然成立:

查询条件 是否用得上索引
a = 1 用得上
a = 1 AND b = 2 用得上
a = 1 AND b = 2 AND c = 3 用得上
b = 2 用不上(缺少最左列)
a = 1 AND c = 3 只用得上 a 部分
a > 1 AND b = 2 a 走范围扫描,b 无法继续精确匹配

最后两行值得展开。a = 1 AND c = 3 中 c 不能参与查找,是因为在 a 相同的记录里,数据按 b 再按 c 排序,跳过 b 直接找 c 就失去了有序性。a > 1 AND b = 2 同理:范围条件扫出的是一段连续区间,区间内的 b 是乱序的。这就是常说的”范围之后的列失效”。

-- 表结构与索引
CREATE TABLE orders (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  status TINYINT NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  created_at DATETIME NOT NULL,
  KEY idx_user_status_created (user_id, status, created_at)
) ENGINE=InnoDB;

-- 覆盖索引 + 最左前缀,理想形态
SELECT id, created_at FROM orders
WHERE user_id = 1001 AND status = 2 ORDER BY created_at DESC LIMIT 20;

这条语句的执行路径是:用 (user_id, status) 定位到一段有序区间,created_at DESC 直接利用索引的反向扫描,不需要额外排序(Extra 里不会有 Using filesort),返回的 id 和 created_at 也都在索引里(Using index)。写业务查询时,把”过滤条件 + 排序 + 返回列”一起考虑进索引设计,比事后补索引有效得多。

四、索引选择性与失效场景

选择性指的是不重复值占总行数的比例。对基数极低的字段(比如只有两三个取值的 status)单独建索引意义不大,优化器往往会估算出”扫全表更快”而放弃索引。低基数字段更适合放在联合索引的后面,用来在前面的高基数列缩小范围之后做二次过滤。

比选择性更常见的是写法导致的失效,以下几种在代码评审里见到的概率很高:

  1. 对索引列做函数或运算。WHERE DATE(created_at) = '2026-10-21' 用不上 created_at 的索引,改成范围写法 created_at >= '2026-10-21' AND created_at < '2026-10-22' 即可。
  2. 隐式类型转换。如果 user_id 是 BIGINT,而查询写成 WHERE user_id = '1001',多数情况下还能走索引;反过来,字符串列上写数字条件(WHERE phone = 13800138000)会触发转换,索引失效。参数类型和列类型对齐,是最廉价的优化。
  3. LIKE 左通配。LIKE '%关键词' 无法利用有序性,只能退化为扫描。LIKE '关键词%' 则可以。确实需要后缀匹配时,可以考虑反转列存储或全文检索方案。
  4. OR 连接非索引列。WHERE indexed_col = 1 OR other_col = 2 中只要有一侧无索引,整体就退化为全表扫描。改写成 UNION ALL 两个子查询各自走索引,通常更优。

五、读懂 EXPLAIN 的关键列

EXPLAIN 不会给执行计划打分,它给的是优化器的估算值。看懂以下几列就足以定位大部分索引问题:

  • type:访问类型,性能大致按 system/const > eq_ref > ref > range > index > ALL 递减。业务查询出现 ALL 且 rows 很大时,几乎就是要优化的信号。
  • key:实际选择的索引。为 NULL 表示没走索引;出现的索引不是你期望的那个,说明联合索引的列顺序或条件写法有问题。
  • rows:预估扫描行数。这是估算值而非精确值,量级判断足够,不能当作精确指标。
  • Extra:Using index 是覆盖索引,Using index condition 是索引下推(把部分过滤下推到存储引擎层,减少回表次数),Using filesort 和 Using temporary 分别意味着额外排序和临时表,都是需要关注的信号。

举个反例的执行计划:

+----+-------------+-------+------+---------------+------+---------+------+--------+----------------+
| id | select_type | table | type | possible_keys | key  | key_len | rows | Extra                |
+----+-------------+-------+------+---------------+------+---------+------+----------------+
|  1 | SIMPLE      | t1    | ALL  | NULL          | NULL | NULL    | 80万 | Using where          |
+----+-------------+-------+------+---------------+------+---------+------+----------------+

type: ALL 加全表 rows 估算,配合”没走任何索引”,说明这条语句在大表上必然慢。此时的排查顺序是:条件列有没有索引、写法是否破坏了索引可用性、数据类型是否匹配——往往三步之内就能定位原因。

索引不是越多越好。每个索引都会增加写入成本和存储开销,联合索引能覆盖的场景就不必再为其中的前缀单列重复建索引。定期用 SHOW INDEX 和慢查询日志对照,把长期没被命中的索引清掉,和加索引一样重要。

484浏览量
24赞
0评论
0参与用户
11 分钟阅读时间

评论(0)

还没有评论,来抢沙发。