索引是数据库性能问题里被讨论得最多、也最容易被误解的一层。很多线上慢查询的根因不是”没加索引”,而是”加了索引但没用上”,或者”用了索引但代价比想象中高”。想把索引用明白,得先理解它在磁盘和内存之间是怎么组织数据的,再回头看 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)会让插入变成随机写,页分裂频繁,长期运行后数据页的填充率下降,占用空间变大。
非主键索引叫二级索引,它的叶子节点存的是索引列的值加主键值。因此通过二级索引查非索引列时会经历两步:
- 在二级索引树里定位到主键值;
- 拿主键值回聚簇索引树再查一次完整行。
这就是回表。回表的代价取决于命中的行数和每一行的随机 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)单独建索引意义不大,优化器往往会估算出”扫全表更快”而放弃索引。低基数字段更适合放在联合索引的后面,用来在前面的高基数列缩小范围之后做二次过滤。
比选择性更常见的是写法导致的失效,以下几种在代码评审里见到的概率很高:
- 对索引列做函数或运算。
WHERE DATE(created_at) = '2026-10-21'用不上created_at的索引,改成范围写法created_at >= '2026-10-21' AND created_at < '2026-10-22'即可。 - 隐式类型转换。如果
user_id是 BIGINT,而查询写成WHERE user_id = '1001',多数情况下还能走索引;反过来,字符串列上写数字条件(WHERE phone = 13800138000)会触发转换,索引失效。参数类型和列类型对齐,是最廉价的优化。 - LIKE 左通配。
LIKE '%关键词'无法利用有序性,只能退化为扫描。LIKE '关键词%'则可以。确实需要后缀匹配时,可以考虑反转列存储或全文检索方案。 - 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 和慢查询日志对照,把长期没被命中的索引清掉,和加索引一样重要。
Comments (0)
No comments yet — be the first.
Please Sign in to join the discussion.