索引扫描变慢常因回表而非未走索引;非聚集索引需通过指针回查数据页,导致高逻辑读,解决方法是用INCLUDE列构建覆盖索引,但需避免冗余、大字段及高频更新列,并排查参数嗅探、隐式转换和统计信息过期等问题。

为什么索引扫描变慢,往往不是因为没走索引,而是要回表
当你在执行计划里看到 Index Seek 或 Index Scan,但逻辑读依然很高,大概率是“找到了索引,却还得回去查数据行”——也就是回表(Key Lookup 或 RID Lookup)。SQL Server 在非聚集索引叶子节点只存了索引键 + 指针(聚集键或 RID),如果查询的列不在索引键里,引擎必须根据指针再捞一次数据页。一次查询触发几百次 Key Lookup,性能就垮了。
解决思路很直接:让索引“自带答案”,即把 SELECT 中用到的非 WHERE/JOIN/ORDER BY 列,通过 INCLUDE 加进索引,变成覆盖索引(Covering Index)。
怎么写一个真正覆盖查询的 INCLUDE 索引
别只看 WHERE 条件列,得把整个 SELECT 列表、JOIN 条件列、甚至 ORDER BY 用到的列都拉进来评估。重点看执行计划里的 “Output List” 和警告 “Key Lookup” 节点。
- 等值过滤列(如
WHERE Status = 'shipped')放索引键最前 - 范围条件列(如
AND OrderDate >= '2025-01-01')放键中间或靠后 - 所有 SELECT 出来的列(比如
OrderID,TotalAmount,CustomerName)全塞进INCLUDE子句 - 如果用了
JOIN,且被驱动表的连接列没包含在索引键中,也得考虑加进INCLUDE(尤其当它是宽字符串或大字段时)
示例:存储过程里有这句
SELECT o.OrderID, o.TotalAmount, c.CompanyName FROM Orders o JOIN Customers c ON o.CustomerID = c.CustomerID WHERE o.Status = 'shipped' AND o.OrderDate >= '2025-04-01'
对应推荐索引:
CREATE NONCLUSTERED INDEX IX_Orders_Status_OrderDate ON Orders (Status, OrderDate) INCLUDE (OrderID, TotalAmount, CustomerID);
注意:CustomerID 虽然没出现在 SELECT,但它是 JOIN 条件,必须包含;而 CompanyName 在 Customers 表上,不能靠这个索引覆盖,得另建或改写 JOIN 逻辑。
INCLUDE 列不是越多越好,这些地方容易翻车
INCLUDE 是把列值冗余存进索引页,不参与排序也不影响 B-Tree 结构,但它会增大索引体积、拖慢 INSERT/UPDATE/DELETE,并可能挤掉缓冲池里更热的数据页。
- 别 INCLUDE
varchar(max)、text、xml或超过 900 字节的列——SQL Server 不允许,建索引会直接报错Msg 1947 - 避免 INCLUDE 高频更新列(比如
LastModifiedTime)——每次 UPDATE 都要同步刷索引页,写放大明显 - 如果
INCLUDE后索引大小 > 基表数据大小的 1.5 倍,就得权衡:是加索引,还是改查询(比如去掉不用的列)或加物化视图 - 测试时对比
sys.dm_db_index_usage_stats的user_seeks和user_lookups:理想状态是后者趋近于 0
重建后没变快?先检查这三个漏网之鱼
加了 INCLUDE 索引,但存储过程响应时间没降,问题大概率不在索引本身。
- 参数嗅探还在作祟:同一存储过程,不同参数值导致缓存了两个低效计划。试试在慢语句末尾加
OPTION (RECOMPILE),而不是整个过程加WITH RECOMPILE - 隐式转换偷偷干掉索引:比如传入
@status NVARCHAR(10),但列是VARCHAR(10),执行计划里会出现CONVERT_IMPLICIT,强制全表扫描 - 统计信息过期:重建索引不会自动更新统计信息(除非显式加
STATISTICS_NORECOMPUTE = OFF),跑一遍UPDATE STATISTICS Orders WITH FULLSCAN再测
覆盖索引不是银弹——它只解决“找得到但拿不全”的问题。真卡在 JOIN 顺序、大字段序列化、或临时表膨胀上,再好的 INCLUDE 也救不了。

















