0 Comments

数据库索引优化:从 B+Tree 到查询优化

在几乎每一个后端项目的性能优化清单里,数据库查询优化都占据着举足轻重的位置。而谈到数据库查询,就绕不开一个核心概念——索引。很多开发者知道”加索引能变快”,却说不清索引为什么快、什么时候会失效、以及如何通过执行计划定位问题。本文从 B+Tree 的数据结构讲起,一路走到真实场景中的查询优化实践,帮你建立一套完整的索引认知体系。

一、为什么需要索引

设想一张存了 100 万行的用户表,如果我们执行这样一条查询:

SELECT * FROM users WHERE email = 'alice@example.com';

没有索引时,MySQL 只能做全表扫描(Full Table Scan),逐行比对 email 字段,最坏情况需要读取 100 万行数据。而如果我们在 email 列上建立了索引,数据库就能借助索引结构快速定位到目标行,通常只需要几次磁盘 I/O。

这个性能差异是数量级的:全表扫描是 O(n) 的线性复杂度,而基于 B+Tree 的索引查找是 O(log n) 的对数复杂度。当 n 很大时,log n 与 n 之间的鸿沟就是索引存在的全部意义。

二、B+Tree 数据结构解析

绝大多数关系型数据库(MySQL、PostgreSQL、Oracle)默认使用 B+Tree 作为索引结构。理解它,是理解索引一切行为的钥匙。

2.1 从二叉树到 B-Tree

普通的二叉搜索树在数据有序插入时会退化成链表,因此数据库引入了平衡多路搜索树。B-Tree 与 B+Tree 的核心区别在于:

  • B-Tree:数据(value)存储在所有节点中,包括内部节点与叶子节点。
  • B+Tree:只有叶子节点存储实际数据,内部节点仅存放用于导航的键值(key)。

B+Tree 相对 B-Tree 的优势非常明确:

  1. 更高的扇出(fanout):内部节点不存数据,单个节点能容纳更多键,树更矮,查找需要的磁盘 I/O 更少。
  2. 叶子节点形成有序链表:所有叶子节点通过指针相连,天然支持范围查询和顺序扫描。
  3. 查询性能稳定:任何查找都必须走到叶子节点,路径长度固定。

下图是一个简化的 B+Tree 结构:

                 [ 30 | 60 ]
               /      |      \
        [10|20]    [40|50]    [70|80|90]
        /  |  \     /  |  \     /  |  |  \
       ...叶子节点(存储真正数据 + 双向链表连接)...

2.2 页(Page)与磁盘 I/O

数据库以为单位读写磁盘,MySQL InnoDB 引擎默认页大小为 16KB。B+Tree 的一个节点对应一个页。由于单页能容纳几百上千个键,即使数据量达到千万级别,树的高度通常也仅为 3~4 层。这意味着一次索引查询只需要 3~4 次磁盘 I/O——这正是 B+Tree 高效的根本原因。

-- 查看 InnoDB 页大小(默认 16384 字节 = 16KB)
SHOW VARIABLES LIKE 'innodb_page_size';

三、聚簇索引与二级索引

InnoDB 的索引体系可以分为两类,理解它们的区别至关重要。

3.1 聚簇索引(Clustered Index)

InnoDB 表的数据即索引,聚簇索引的叶子节点直接存储整行数据。一个表只有一个聚簇索引,通常是主键;若未定义主键,InnoDB 会挑选第一个非空唯一索引,实在没有则隐式生成一个 6 字节的 row id。

这也解释了为什么主键应当尽量短且自增:主键越小,二级索引越小;自增则能避免插入时的页分裂。

3.2 二级索引(Secondary Index)

非主键索引的叶子节点存储的只是主键值,而非整行数据。当查询命中了二级索引但还需要索引外的其他列时,数据库必须拿着主键值再回聚簇索引查一次完整行,这个过程叫回表(Bookmark Lookup)。

回表是索引优化中最值得关注的成本之一,也是覆盖索引存在的理由。

四、覆盖索引(Covering Index)

如果查询所需的所有列都包含在索引中,数据库就无需回表,直接从索引即可返回结果。我们用 EXPLAIN 中的 Using index 来识别这种情况。

-- 建立联合索引
CREATE INDEX idx_users_email_name ON users(email, name);

-- 覆盖索引:查询列都在索引中,Extra 显示 Using index
EXPLAIN SELECT email, name FROM users WHERE email = 'alice@example.com';

-- 需要回表:查询了索引外的 age 列
EXPLAIN SELECT email, name, age FROM users WHERE email = 'alice@example.com';

覆盖索引能显著减少磁盘 I/O,是高频查询优化的利器。但也要注意:不要为了覆盖而盲目建立大而全的索引,它会增大写入成本与存储开销。

五、最左前缀原则

联合索引遵循最左前缀原则:查询条件必须从索引的最左列开始,且不能跳过中间列,才能命中索引。

假设存在索引 (a, b, c)

查询条件 是否命中索引
WHERE a = 1 ✅ 命中 a
WHERE a = 1 AND b = 2 ✅ 命中 a, b
WHERE a = 1 AND c = 3 ⚠️ 仅命中 a
WHERE b = 2 ❌ 无法命中
WHERE c = 3 ❌ 无法命中

因此联合索引的列顺序需要根据实际查询的过滤性和复用频率来精心设计。

六、索引失效的常见场景

即使建了索引,以下场景也可能导致索引失效,让数据库退回全表扫描:

6.1 对索引列使用函数或表达式

-- 失效:对列使用了函数
SELECT * FROM users WHERE YEAR(birthday) = 1990;

-- 优化:改造为范围查询
SELECT * FROM users WHERE birthday >= '1990-01-01' AND birthday < '1991-01-01';

6.2 隐式类型转换

-- 失效:phone 是字符串列,却传入数字,触发隐式转换
SELECT * FROM users WHERE phone = 13800138000;

-- 正确:保持类型一致
SELECT * FROM users WHERE phone = '13800138000';

6.3 模糊查询以通配符开头

-- 失效:% 在最前面,无法利用索引
SELECT * FROM users WHERE name LIKE '%张三';

-- 有效:前缀匹配可用索引
SELECT * FROM users WHERE name LIKE '张三%';

6.4 违反最左前缀

如前所述,跳过联合索引的最左列会导致索引失效。

七、用 EXPLAIN 分析执行计划

写出 SQL 只是第一步,真正的优化建立在看懂执行计划之上。EXPLAIN 是 MySQL 最常用的诊断工具:

EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com';

重点关注的几个字段:

  • type:访问类型,从优到劣依次为 system > const > eq_ref > ref > range > index > ALL。出现 ALL 即全表扫描,通常需要优化。
  • key:实际使用的索引,为 NULL 表示未命中任何索引。
  • rows:预估扫描行数,越少越好。
  • Extra:附加信息,Using index 表示覆盖索引,Using filesort 表示存在额外排序,Using temporary 表示使用了临时表,后两者往往是性能隐患。
-- 找出排序的额外开销
EXPLAIN SELECT * FROM users ORDER BY age DESC;
-- 若 age 无索引,Extra 会显示 Using filesort

八、实战:一步步优化一次慢查询

假设我们有一个订单表 orders,业务要查询某用户近 30 天的已完成订单:

SELECT id, order_no, amount, created_at
FROM orders
WHERE user_id = 10086
  AND status = 'completed'
  AND created_at >= '2026-07-16'
ORDER BY created_at DESC;

第一步:建立联合索引 (user_id, status, created_at)。由于 user_id 等值、status 等值、created_at 范围且参与排序,这个索引既能过滤,又能利用 created_at 的有序性避免 filesort。

第二步:让查询列进入索引实现覆盖,避免回表。因为需要返回 id(主键)、order_noamount,我们可以扩展索引:

CREATE INDEX idx_orders_user_status_time
ON orders(user_id, status, created_at, order_no, amount);

但注意主键 id 会自动追加到二级索引末尾,无需显式加入。

第三步:用 EXPLAIN 验证结果。理想情况下,type 应显示 rangeExtra 显示 Using index condition; Using where 或不含 Using filesort

EXPLAIN SELECT id, order_no, amount, created_at
FROM orders
WHERE user_id = 10086
  AND status = 'completed'
  AND created_at >= '2026-07-16'
ORDER BY created_at DESC;

九、写在最后

索引是把双刃剑:用得好是性能利器,用不好既拖慢写入又浪费存储。掌握 B+Tree 的原理、理解聚簇索引与回表的成本、熟练运用最左前缀与覆盖索引,再配合 EXPLAIN 反复验证,你就能在真实项目中游刃有余地解决问题。

一个朴素的建议是:永远用执行计划说话,而不是凭直觉加索引。优化的本质,是用数据驱动决策。


本文为「品味 IT」技术博客原创内容,欢迎交流讨论。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注