在SQL Server中,存储过程执行缓慢常常源于执行计划中的键查找(Key Lookup)操作。当查询的WHERE条件命中了某个非聚集索引,但SELECT列表或连接条件中还引用了该索引未包含的其他列时,数据库引擎不得不根据索引指针回到基表获取数据,这一过程就是回表。如果数据量大且循环次数多,逻辑读会急剧上升。通过为非聚集索引添加INCLUDE非键列,可以把这些额外需要的列直接存放在索引的叶子节点中,使索引能够覆盖整个查询,从而消除键查找,将随机IO转化为顺序的索引扫描。

一、理解非键列INCLUDE的基本原理
在SQL Server里,创建非聚集索引时,我们习惯把用于过滤和排序的列放在索引键(Key Columns)中,这些列会参与B树结构的排序与查找。而INCLUDE子句允许我们将不参与查找、只用于输出的列作为非键列附加在叶子页。它们不被排序,也不影响树的结构,仅仅随叶子节点一起存储。这样,当查询只需要索引键列和INCLUDE列时,引擎无需访问基表即可返回结果。
从存储角度看,INCLUDE列增加了索引页的大小,但避免了回表的开销。对于写操作,插入和更新这些非键列同样会带来维护成本,因为索引页也要随之变动。不过,由于非键列不参与树形导航,其更新代价通常低于将其作为索引键。理解这一点,是我们在存储过程调优时做权衡的基础。
二、通过实例看存储过程中的索引扫描问题
假设有一个订单表Orders,结构简化如下:OrderId为主键,CustomerId、Status、CreateTime为常用查询列,另外还有Amount、Remark等宽列。某存储过程根据客户和状态统计订单金额:
CREATE PROCEDURE GetCustomerOrderSummary
@CustomerId INT,
@Status TINYINT
AS
BEGIN
SELECT OrderId, CreateTime, Amount
FROM Orders
WHERE CustomerId = @CustomerId AND Status = @Status;
END
如果仅在CustomerId和Status上建立普通非聚集索引,由于Amount不在索引中,执行计划会对每个匹配行执行键查找来获取Amount,造成大量逻辑读。使用SET STATISTICS IO ON观察,可能看到几百次甚至上千次的逻辑读。
此时,我们将索引改写为包含非键列的形式:
CREATE NONCLUSTERED INDEX IX_Orders_Customer_Status ON Orders (CustomerId, Status) INCLUDE (OrderId, CreateTime, Amount);
再次执行存储过程,优化器可以直接在索引叶子节点拿到全部所需列,执行计划变为索引扫描或范围查找且无键查找。逻辑读下降到原来的十分之一左右,存储过程响应时间显著缩短。
三、INCLUDE覆盖查询的适用边界与误区
并不是所有列都适合放进INCLUDE。首先,INCLUDE列无法用于谓词过滤的索引查找,比如对上述索引若按Amount做范围查询,INCLUDE中的Amount不起加速作用,因为非键列无序。其次,若INCLUDE的列过长(如大文本、大二进制),会使索引页膨胀,降低缓存命中率,反而拖累性能。另外,高频更新的列放入INCLUDE会增加写负载。
常见的误区是认为INCLUDE可以替代联合索引。实际上,如果查询经常按INCLUDE列排序或过滤,就应该将其作为键列而非非键列。INCLUDE的真正价值在于:查询的过滤路径已由少数键列满足,仅输出列需要扩展。在存储过程调优中,应先通过执行计划确认瓶颈是键查找,再针对性地用INCLUDE消除回表,而不是无脑拓宽索引。
四、结合存储过程做系统性优化建议
在日常对存储过程做索引优化时,建议先用DMV查询高逻辑读的对象,例如sys.dm_exec_query_stats配合sys.dm_exec_sql_text定位慢过程,再用SET SHOWPLAN_XML分析缺失索引建议。注意,缺失索引建议常常推荐建联合索引,我们要手动判断哪些列只需INCLUDE。对于报表类、只读类的存储过程,可以适当多用INCLUDE提升读性能;对于交易类高频写表,则需严格控制INCLUDE列数量与宽度。
最后,在测试环境验证时,不仅要看执行时间,也要对比写入吞吐。可以用压力工具模拟并发,观察索引维护是否成为瓶颈。只有读收益明显大于写代价,这样的INCLUDE覆盖索引才算真正优化了存储过程的索引扫描。
SQL_Server存储过程索引覆盖修改时间:2026-08-09 18:00:31