数据库索引为什么能加速查询?从 B+ 树到联合索引的底层原理

举报
yd_232225224 发表于 2026/09/23 21:51:16 2026/09/23
【摘要】 在开发过程中,我们经常会遇到这样的场景:一张表刚开始只有几千条数据,查询几乎瞬间完成;随着数据增长到几十万甚至上千万条,同一条 SQL 却越来越慢。这时候,很多人的第一反应是:给查询字段加个索引。加完索引以后,查询速度可能从几秒缩短到几十毫秒。但索引为什么有这么明显的效果?为什么有时候明明创建了索引,数据库却不使用?联合索引中的字段顺序又为什么如此重要?要真正回答这些问题,就需要理解数据库索...

在开发过程中,我们经常会遇到这样的场景:一张表刚开始只有几千条数据,查询几乎瞬间完成;随着数据增长到几十万甚至上千万条,同一条 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';

数据库可能需要完成两个步骤:

  1. 在手机号索引中找到对应的主键值。
  2. 使用主键值到聚簇索引中查找完整记录。

第二个步骤就叫作回表。

回表不是错误,也不意味着索引没有作用,但它会增加额外的查询成本。如果一次查询需要回表几十万次,性能可能会明显下降。

六、覆盖索引为什么更快

如果查询需要的所有字段都能直接从二级索引中获得,数据库就不需要再次访问聚簇索引。

这种情况称为覆盖索引。

例如,建立联合索引:

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;

这种方式通常称为游标分页或基于位置的分页。

数据库可以直接从索引中某个位置继续向后读取,避免跳过大量无用数据。

当然,这种方式更适合“上一页、下一页”或滚动加载,不适合必须随机跳转到任意页码的场景。

十四、索引优化的正确思路

索引优化并不是看到慢查询就不停地创建索引,而应该按照一个完整过程进行:

  1. 找到真正耗时或调用频繁的 SQL。
  2. 查看执行计划和实际扫描数据量。
  3. 分析查询条件、排序、分页与返回字段。
  4. 根据主要访问模式设计单列索引或联合索引。
  5. 判断是否需要通过覆盖索引减少回表。
  6. 测试索引对写入性能和存储空间的影响。
  7. 上线后继续观察真实数据,而不是只依赖测试环境结果。
  8. 定期清理重复、长期未使用或收益过低的索引。

最重要的是围绕真实业务查询设计索引,而不是试图为每个字段都建立索引。

总结

数据库索引之所以能够提高查询速度,是因为它使用有序的数据结构缩小搜索范围,减少需要读取和比较的数据量。

理解索引时,需要掌握几个关键概念:

  • 全表扫描的成本会随着数据量增加而增长;
  • B+ 树通过较低的树高减少磁盘访问次数;
  • 主键索引的叶子节点通常保存完整记录;
  • 二级索引通常保存索引字段和主键值;
  • 通过二级索引再次查找完整数据的过程叫作回表;
  • 覆盖索引可以避免回表;
  • 联合索引需要遵循从左到右的排序规则;
  • 函数计算、隐式转换和前置模糊匹配可能影响索引使用;
  • 命中大量数据时,全表扫描有时反而更快;
  • 索引会占用空间,并降低插入、更新和删除的性能;
  • 判断索引是否有效,必须结合执行计划和真实运行数据。

真正优秀的索引设计,不是让一张表拥有尽可能多的索引,而是用尽可能少的索引,高效支持尽可能重要的查询。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。