数据库索引为什么能加速查询?从 B+ 树到联合索引的底层原理
在开发过程中,我们经常会遇到这样的场景:一张表刚开始只有几千条数据,查询几乎瞬间完成;随着数据增长到几十万甚至上千万条,同一条 SQL 却越来越慢。
这时候,很多人的第一反应是:
给查询字段加个索引。
加完索引以后,查询速度可能从几秒缩短到几十毫秒。但索引为什么有这么明显的效果?为什么有时候明明创建了索引,数据库却不使用?联合索引中的字段顺序又为什么如此重要?
要真正回答这些问题,就需要理解数据库索引背后的数据结构和查询过程。
一、没有索引时,数据库如何查找数据
假设我们有一张用户表:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(50),
phone VARCHAR(20),
age INT,
city VARCHAR(50)
);
现在执行下面的查询:
SELECT * FROM users WHERE phone = '13800000000';
如果 phone 字段没有索引,数据库通常只能从表中的第一条记录开始,逐行检查手机号是否匹配。
这个过程叫作全表扫描。
如果表中有 10 万条数据,最坏情况下需要检查 10 万条记录;如果有 1000 万条数据,最坏情况下就需要检查 1000 万条记录。
它的时间复杂度可以近似理解为:
O(n)
数据越多,需要扫描的记录就越多,查询速度自然会下降。
索引的作用,就是帮助数据库快速缩小搜索范围,避免逐行检查整张表。
二、索引可以理解为一本书的目录
一本几百页的技术书中,如果没有目录,你想找到“数据库事务”这一节,就只能一页一页地翻。
有了目录以后,你可以先在目录中找到章节名称,然后直接翻到对应页码。
数据库索引的作用与目录类似:
索引值 → 数据所在的位置
例如,为手机号创建索引:
CREATE INDEX idx_users_phone ON users(phone);
数据库会额外维护一份有序的数据结构,其中大致保存着:
手机号 → 对应数据记录的位置
查询手机号时,数据库不再扫描整张表,而是先在索引中查找手机号,再根据索引记录的位置读取完整数据。
不过,这只是便于理解的简化模型。实际数据库中的索引通常不是简单的数组,而是采用 B+ 树等数据结构。
三、为什么数据库索引常用 B+ 树
二叉搜索树、红黑树、哈希表都能提高查找速度,但关系型数据库普遍偏爱 B+ 树。
原因与数据库的存储方式有关。
数据库中的大量数据通常存放在磁盘上。相比内存访问,磁盘读取的成本非常高。因此,索引设计的关键不仅是减少比较次数,更重要的是减少磁盘读取次数。
1. 二叉树的问题
普通二叉搜索树中的每个节点最多只有两个子节点。
当数据量很大时,树的高度也会增加。查询一条数据,需要从根节点一层层向下查找。如果树高为 20,就可能需要访问多个不同的数据页。
如果这些数据页不在内存中,就会触发多次磁盘读取。
此外,如果数据按照递增顺序插入,普通二叉搜索树还可能退化成链表:
1
\
2
\
3
\
4
此时查询效率又会接近全表扫描。
2. B+ 树的优势
B+ 树是一种多叉平衡树。一个节点中可以存放多个索引值,并拥有多个子节点。
这意味着,即使保存上千万条数据,B+ 树的高度通常也不会太高。
一次查询大致可以理解为:
根节点 → 中间节点 → 叶子节点
只需要少量的数据页访问,就可以定位目标记录。
B+ 树还有一个重要特点:真正的数据或数据位置通常存放在叶子节点中,并且叶子节点之间按照顺序连接。
这使它同时适合两类查询:
SELECT * FROM users WHERE id = 100;
以及:
SELECT * FROM users
WHERE id BETWEEN 100 AND 200;
等值查询可以沿着树快速定位;范围查询找到起点后,可以沿叶子节点顺序读取后续数据。
这正是 B+ 树比普通哈希索引更适合关系型数据库的重要原因之一。
四、什么是聚簇索引
在常见的存储引擎中,主键索引通常属于聚簇索引。
聚簇索引的叶子节点保存的是完整数据记录,而不仅仅是数据所在的位置。
假设用户表的主键是 id,主键索引可以简单理解为:
id → 完整的用户数据
执行下面的查询时:
SELECT * FROM users WHERE id = 100;
数据库通过主键索引找到对应的叶子节点后,就能直接获得整条记录。
由于一张表中的数据只能按照一种方式组织,因此一张表通常只能拥有一个聚簇索引。
如果表中没有合适的主键,数据库可能需要选择其他唯一字段,甚至生成隐藏字段来组织数据。因此,在建表时设计一个稳定、简短的主键非常重要。
五、什么是二级索引和回表
除了主键索引之外,我们为其他字段创建的索引通常称为二级索引,也叫辅助索引。
例如:
CREATE INDEX idx_users_phone ON users(phone);
二级索引的叶子节点通常不会保存完整的用户记录,而是保存索引字段和对应的主键值:
phone → id
现在执行:
SELECT * FROM users WHERE phone = '13800000000';
数据库可能需要完成两个步骤:
- 在手机号索引中找到对应的主键值。
- 使用主键值到聚簇索引中查找完整记录。
第二个步骤就叫作回表。
回表不是错误,也不意味着索引没有作用,但它会增加额外的查询成本。如果一次查询需要回表几十万次,性能可能会明显下降。
六、覆盖索引为什么更快
如果查询需要的所有字段都能直接从二级索引中获得,数据库就不需要再次访问聚簇索引。
这种情况称为覆盖索引。
例如,建立联合索引:
CREATE INDEX idx_users_phone_name ON users(phone, name);
执行查询:
SELECT id, phone, name
FROM users
WHERE phone = '13800000000';
由于二级索引中已经包含 phone、name 和主键 id,数据库可以直接返回结果,不必回表。
因此,覆盖索引通常能够减少随机读取和数据页访问,提高查询效率。
不过,不能为了覆盖所有查询而无限增加索引字段。索引越宽,占用的存储空间越大,维护成本也越高。设计覆盖索引时,需要重点优化那些访问频繁、性能敏感的查询。
七、联合索引与最左匹配原则
假设系统中经常按照城市、年龄和姓名查询用户,于是创建一个联合索引:
CREATE INDEX idx_users_city_age_name
ON users(city, age, name);
这个索引不是三个完全独立的索引,而是按照以下顺序排序:
先按 city 排序
city 相同时,再按 age 排序
city 和 age 都相同时,再按 name 排序
因此,下面的查询通常能够较好地使用该索引:
SELECT * FROM users
WHERE city = '北京';
SELECT * FROM users
WHERE city = '北京'
AND age = 25;
SELECT * FROM users
WHERE city = '北京'
AND age = 25
AND name = '小明';
但是下面的查询可能无法充分利用这个联合索引:
SELECT * FROM users
WHERE age = 25;
原因是索引首先按照 city 排序。脱离 city 以后,所有记录的 age 在整棵索引中并不是全局连续排列的。
这就是联合索引的最左匹配原则。
可以把联合索引 (city, age, name) 理解成一本按照“城市、年龄、姓名”排序的通讯录。如果不知道城市,只知道年龄,就很难直接定位到某个连续范围。
八、范围查询可能中断后续索引利用
考虑下面的查询:
SELECT * FROM users
WHERE city = '北京'
AND age > 20
AND name = '小明';
联合索引仍然是:
(city, age, name)
数据库可以先根据 city 找到北京的数据,再利用 age 找到大于 20 的范围。
但是进入年龄范围后,不同年龄下面的姓名是分别排序的,name 对整个年龄范围不再保持全局有序。因此,name 往往不能继续用于缩小索引扫描范围。
需要注意的是,不能简单地说“范围条件后面的字段完全不使用”。部分数据库版本可以通过索引条件下推等机制,在索引层过滤后续字段,减少回表数量。
更准确的说法是:
范围条件之后的索引字段,通常难以继续用于确定精确的扫描边界,但仍可能参与过滤。
九、哪些写法容易导致索引失效
创建索引并不代表每条查询都会自动使用它。下面几种情况尤其值得注意。
1. 对索引字段进行函数计算
SELECT * FROM users
WHERE YEAR(created_at) = 2026;
数据库需要先对每条记录的 created_at 执行函数,然后才能判断结果是否等于 2026。这可能导致普通索引无法直接定位数据。
可以改写为范围查询:
SELECT * FROM users
WHERE created_at >= '2026-01-01 00:00:00'
AND created_at < '2027-01-01 00:00:00';
这样更容易利用 created_at 上的索引。
2. 隐式类型转换
假设手机号字段是字符串:
phone VARCHAR(20)
查询时却写成:
SELECT * FROM users
WHERE phone = 13800000000;
数据库可能需要在比较过程中进行类型转换,从而影响索引使用。
更稳妥的写法是:
SELECT * FROM users
WHERE phone = '13800000000';
查询参数的类型应尽量与字段类型保持一致。
3. 使用前置模糊匹配
SELECT * FROM users
WHERE name LIKE '%明';
普通 B+ 树索引依赖从左到右的有序性。由于查询条件无法确定字符串的开头,数据库通常难以从索引中的某个位置开始查找。
下面这种写法更容易使用索引:
SELECT * FROM users
WHERE name LIKE '小%';
因为数据库知道字符串以“小”开头,可以在索引中确定一个连续范围。
4. 查询了表中的大量数据
假设某个字段只有“启用”和“禁用”两个值,而且表中 90% 的记录都是“启用”。
执行:
SELECT * FROM users
WHERE status = '启用';
即使 status 有索引,数据库也可能选择全表扫描。
因为通过索引找到大量主键后,再频繁回表读取完整数据,成本可能比顺序扫描整张表还高。
数据库优化器选择的是它估算成本更低的执行方式,而不是机械地“有索引就用索引”。
十、索引不是越多越好
索引可以加速读取,但也会带来明显成本。
1. 占用存储空间
每个索引都需要单独保存一份有序数据结构。表越大、索引字段越多,索引占用的空间也越大。
2. 降低写入性能
插入一条记录时,数据库不仅要写入表数据,还要更新相关索引。
执行更新操作时,如果修改了索引字段,数据库可能需要删除旧索引项并插入新索引项。
索引越多,写入时需要维护的数据结构就越多。
3. 增加优化器选择成本
当一张表存在大量相似索引时,优化器需要在多个执行方案之间进行成本估算。同时,重复索引和冗余索引也会增加维护难度。
例如已经存在联合索引:
(city, age)
那么单独的:
(city)
在很多场景中可能就是冗余索引,因为联合索引本身已经可以支持按 city 查询。
不过,是否能够删除还要结合索引大小、查询覆盖情况和真实执行计划判断,不能只凭字段前缀直接下结论。
十一、联合索引的字段顺序应该如何设计
设计联合索引时,可以综合考虑以下几个因素。
1. 查询条件是否稳定出现
经常同时出现在查询条件中的字段,才适合组合到一个联合索引中。
不要根据偶尔出现的一条 SQL 建立复杂索引。
2. 等值条件通常放在范围条件前面
例如常见查询是:
SELECT * FROM orders
WHERE user_id = 100
AND status = 'PAID'
AND created_at >= '2026-01-01';
可以考虑建立:
CREATE INDEX idx_orders_user_status_time
ON orders(user_id, status, created_at);
这样可以先通过两个等值条件缩小范围,再对创建时间进行范围扫描。
3. 同时考虑排序和分页
如果系统经常执行:
SELECT id, created_at
FROM orders
WHERE user_id = 100
ORDER BY created_at DESC
LIMIT 20;
索引 (user_id, created_at) 不仅可以帮助过滤用户数据,还可能减少额外排序,并快速找到最近的 20 条记录。
4. 选择性不是唯一标准
选择性高的字段能够更快缩小数据范围,但联合索引字段顺序不能只按照选择性机械排列。
还需要考虑:
- 实际查询的条件组合;
- 是否存在范围查询;
- 是否需要排序;
- 是否可以形成覆盖索引;
- 哪些查询最频繁;
- 哪些查询对响应时间最敏感。
索引设计本质上是在读取性能、写入性能和存储成本之间做权衡。
十二、不要凭感觉判断索引是否有效
分析慢查询时,不要只看 SQL 表面,也不要因为看到索引名称就认定查询已经被优化。
应该查看数据库提供的执行计划,例如:
EXPLAIN
SELECT *
FROM users
WHERE city = '北京'
AND age = 25;
执行计划通常可以帮助我们判断:
- 数据库选择了哪个索引;
- 预计扫描多少行;
- 使用了哪些索引字段;
- 是否需要回表;
- 是否进行了额外排序;
- 是否创建了临时结果;
- 查询最终采用的是索引扫描还是全表扫描。
但执行计划中的行数通常是估算值。遇到复杂问题时,还应结合实际执行耗时、扫描行数、返回行数和数据库监控数据进行判断。
优化的目标不是让执行计划中“出现索引”,而是减少无效扫描、随机读取和不必要的数据处理。
十三、一个常见的分页性能问题
很多系统使用下面的方式进行深分页:
SELECT *
FROM orders
ORDER BY id
LIMIT 1000000, 20;
虽然只返回 20 条数据,但数据库可能仍然需要先扫描或定位前面的 100 万条记录,然后将它们丢弃。
页码越靠后,查询成本越高。
如果业务允许,可以记录上一页最后一条数据的主键,改成:
SELECT *
FROM orders
WHERE id > 1000000
ORDER BY id
LIMIT 20;
这种方式通常称为游标分页或基于位置的分页。
数据库可以直接从索引中某个位置继续向后读取,避免跳过大量无用数据。
当然,这种方式更适合“上一页、下一页”或滚动加载,不适合必须随机跳转到任意页码的场景。
十四、索引优化的正确思路
索引优化并不是看到慢查询就不停地创建索引,而应该按照一个完整过程进行:
- 找到真正耗时或调用频繁的 SQL。
- 查看执行计划和实际扫描数据量。
- 分析查询条件、排序、分页与返回字段。
- 根据主要访问模式设计单列索引或联合索引。
- 判断是否需要通过覆盖索引减少回表。
- 测试索引对写入性能和存储空间的影响。
- 上线后继续观察真实数据,而不是只依赖测试环境结果。
- 定期清理重复、长期未使用或收益过低的索引。
最重要的是围绕真实业务查询设计索引,而不是试图为每个字段都建立索引。
总结
数据库索引之所以能够提高查询速度,是因为它使用有序的数据结构缩小搜索范围,减少需要读取和比较的数据量。
理解索引时,需要掌握几个关键概念:
- 全表扫描的成本会随着数据量增加而增长;
- B+ 树通过较低的树高减少磁盘访问次数;
- 主键索引的叶子节点通常保存完整记录;
- 二级索引通常保存索引字段和主键值;
- 通过二级索引再次查找完整数据的过程叫作回表;
- 覆盖索引可以避免回表;
- 联合索引需要遵循从左到右的排序规则;
- 函数计算、隐式转换和前置模糊匹配可能影响索引使用;
- 命中大量数据时,全表扫描有时反而更快;
- 索引会占用空间,并降低插入、更新和删除的性能;
- 判断索引是否有效,必须结合执行计划和真实运行数据。
真正优秀的索引设计,不是让一张表拥有尽可能多的索引,而是用尽可能少的索引,高效支持尽可能重要的查询。
- 点赞
- 收藏
- 关注作者
评论(0)