书签查找是SQL Server等关系型数据库中常见的一个性能瓶颈,当查询使用非聚集索引进行数据检索,但索引中并没有包含查询所需的所有列时,数据库引擎就需要通过非聚集索引中的行定位器去对应的数据页中获取缺失的列数据,这个额外的查找过程就是书签查找。

书签查找的产生原因
要理解书签查找,首先需要明确非聚集索引的结构。非聚集索引的叶子节点存储的是索引键值和对应的行定位器,行定位器指向数据行的实际存储位置。当执行的查询语句满足以下两个条件时,就会出现书签查找:
- 查询使用了非聚集索引来查找数据行
- 查询需要返回的列没有完全包含在当前使用的非聚集索引中
比如我们有一张用户表user_info,在age字段上创建了非聚集索引,现在执行如下查询:
-- 查询年龄大于20岁的用户姓名和手机号 SELECT user_name, phone FROM user_info WHERE age > 20;
由于非聚集索引只包含age字段和行定位器,没有user_name和phone字段,数据库引擎先通过age索引找到所有符合条件的行定位器,再逐个通过行定位器去数据页中获取user_name和phone,这个过程就是书签查找,当符合条件的行数很多时,会产生大量的随机IO,严重影响查询性能。
解决书签查找的常用方法
创建覆盖索引
覆盖索引是指索引包含了查询所需要的所有列,这样数据库引擎不需要进行书签查找,直接从索引中就能获取到所有需要的数据。我们可以在创建索引时指定INCLUDE子句来添加非索引键列,这些列只会存储在索引的叶子节点中,不会作为索引的排序依据。
针对上面的查询,我们可以创建如下覆盖索引:
-- 创建覆盖索引,包含查询需要的user_name和phone列 CREATE NONCLUSTERED INDEX idx_user_age_cover ON user_info(age) INCLUDE (user_name, phone);
创建这个索引之后,执行相同的查询,数据库引擎可以直接从idx_user_age_cover索引中获取到age、user_name、phone所有需要的列,不会再产生书签查找,查询性能会得到明显提升。
调整查询语句
如果暂时无法创建新的索引,也可以尝试调整查询语句,减少需要返回的列,只返回索引中已经包含的列,避免触发书签查找。比如上面的查询如果只需要age字段,就可以修改为:
-- 只返回索引包含的列,避免书签查找 SELECT age FROM user_info WHERE age > 20;
另外也可以考虑是否可以通过调整查询条件,让查询能够使用聚集索引,因为聚集索引的叶子节点就是数据页本身,使用聚集索引查找时不需要额外的书签查找过程,但这种方式需要根据实际的业务场景和索引设计来判断是否可行。
优化索引设计
在数据库设计阶段,就需要合理规划索引结构,对于高频查询的语句,提前分析其需要的列,尽量让索引能够覆盖查询的所有需求。同时要避免创建过多的冗余索引,过多的索引会增加数据写入时的维护成本,也会影响查询优化器的选择效率。
如果一张表上有多个非聚集索引,需要定期分析索引的使用情况,对于很少被使用且会引发大量书签查找的索引,可以考虑删除或者调整,避免不必要的性能损耗。
验证优化效果
优化完成后,可以通过SQL Server的执行计划来查看是否还存在书签查找操作。打开执行计划后,如果看到Key Lookup或者RID Lookup操作符,就说明还存在书签查找,需要继续调整索引或者查询语句。如果这两个操作符消失,说明书签查找已经被消除,优化生效。
同时可以通过SET STATISTICS IO ON来查看查询的IO开销,优化前的逻辑读取次数会明显多于优化后的次数,也可以直观反映优化的效果。
-- 开启IO统计 SET STATISTICS IO ON; -- 执行查询 SELECT user_name, phone FROM user_info WHERE age > 20; -- 关闭IO统计 SET STATISTICS IO OFF;