4. 索引策略与复杂查询
复杂查询的性能问题,很多时候不是“缺索引”,而是“索引与查询不匹配”。一个查询可能同时包含等值过滤、范围过滤、排序、聚合、连接和返回列,如果索引只覆盖了其中一部分,SQL Server 仍要回表、排序或扫描,代价就会迅速放大。本节围绕覆盖索引、包含列、过滤索引、索引视图和索引碎片,讨论如何让索引结构与复杂查询真正对齐。
覆盖索引与包含列:让查询在索引内闭环
覆盖索引的核心判断标准是:查询执行计划中是否还出现 Key Lookup 或 RID Lookup。如果索引的键列已经能完成过滤、连接和排序,但 SELECT 列表里还有索引未包含的列,优化器就必须回表取这些列。此时把非键列加入 INCLUDE,就能让查询在索引页内闭环完成。
键列与包含列的分工不同:键列参与 B 树排序,可用于 WHERE、JOIN、ORDER BY 和唯一性约束;INCLUDE 列只存在于叶级,不参与排序,但能随索引一起被读取。因此,把过滤和排序用的列放键列,把仅返回用的列放 INCLUDE,是更合理的做法。
-- 示例:订单查询按客户+日期过滤,返回金额与状态
CREATE NONCLUSTERED INDEX IX_Orders_Customer_OrderDate
ON Orders (CustomerID, OrderDate)
INCLUDE (TotalAmount, OrderStatus);
对于 WHERE CustomerID=@cid AND OrderDate>=@start ORDER BY OrderDate 这类查询,该索引既能定位客户,又能按日期范围查找,还天然满足排序,且返回列已在叶级,无需回表。需要注意的是,INCLUDE 列过多会显著增大索引页,增加维护和存储成本,因此应优先包含高频查询真正需要的列,而不是把整张表都塞进去。
过滤索引:只索引真正关心的数据
当查询总是带某个固定条件时,过滤索引可以只对满足条件的行建立索引,从而减小索引体积、降低维护成本。例如订单表中绝大多数查询只关心未完成订单:
CREATE NONCLUSTERED INDEX IX_Orders_Pending
ON Orders (CustomerID, OrderDate)
INCLUDE (TotalAmount)
WHERE OrderStatus = 'Pending';
过滤索引要生效,查询谓词必须与索引过滤条件匹配,且参数化查询可能因参数嗅探导致无法匹配。因此,过滤索引适合状态值分布极不均匀、且查询模式稳定的场景。若查询条件经常变化,过滤索引反而可能被忽略。
索引视图:把复杂聚合提前物化
对于多表连接后聚合的复杂查询,普通索引无法直接覆盖聚合结果。索引视图可以把聚合结果持久化,并在其上建立聚集索引,使查询直接读取已计算好的结果。创建索引视图需要开启相关选项,且视图定义必须满足确定性、SCHEMA_BINDING 等要求。
CREATE VIEW dbo.v_OrderSummary
WITH SCHEMABINDING
AS
SELECT CustomerID, COUNT_BIG(*) AS OrderCount, SUM(TotalAmount) AS TotalAmount
FROM dbo.Orders
GROUP BY CustomerID;
CREATE UNIQUE CLUSTERED INDEX IX_v_OrderSummary_Customer
ON dbo.v_OrderSummary (CustomerID);
索引视图的代价是写入放大:每次订单变更都要同步维护视图索引。因此它更适合读多写少、聚合逻辑稳定的报表类查询。若基表写入频繁,索引视图可能拖慢 OLTP 操作,需要权衡。
索引碎片:影响复杂查询的隐形因素
索引碎片分为内部碎片和外部碎片。内部碎片指页内空闲空间过多,浪费缓冲池;外部碎片指页顺序与键顺序不一致,导致范围扫描和预读效率下降。复杂查询常伴随大范围扫描和排序,碎片会放大 I/O 成本。
判断碎片程度可查询 sys.dm_db_index_physical_stats:
SELECT index_id, avg_fragmentation_in_percent, page_count
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('Orders'), NULL, NULL, 'LIMITED');
一般经验:碎片率低于 10% 可不处理;10%~30% 可考虑 REORGANIZE;高于 30% 且页数较多时考虑 REBUILD。但这不是绝对规则,还要结合页数、查询模式和维护窗口。对于小表,碎片影响有限,不必频繁重建。
索引与复杂查询匹配度分析
匹配度分析的目标是回答:现有索引能否让查询的过滤、连接、排序、聚合和返回列都在索引内完成。可以按以下维度逐项核对:
- 过滤列:是否位于键列前导位置,能否支持等值和范围条件。
- 排序列:是否与索引键顺序一致,避免额外 Sort。
- 连接列:是否可作为键列或包含列,减少 Hash Join 代价。
- 返回列:是否已包含在 INCLUDE 中,避免 Key Lookup。
- 聚合:是否可通过索引视图或覆盖索引减少聚合计算。
下面这张图把“查询特征—索引结构—执行计划表现”的匹配关系集中呈现,便于对照排查。
实际排查时,可以先从执行计划中找出代价最高的算子:如果是 Key Lookup,优先考虑 INCLUDE;如果是 Sort,检查键列顺序;如果是 Scan,检查过滤条件是否与索引前导列匹配;如果是 Hash Match 聚合,评估索引视图是否合适。碎片问题则通过维护计划定期处理,但不要把它当作万能药——碎片整理解决的是 I/O 效率,解决不了索引结构本身不匹配的问题。
最后要强调,索引不是越多越好。每个索引都会增加写入成本、占用存储并影响优化器选择。对复杂查询做匹配度分析,本质上是让索引数量、结构和查询负载之间达到平衡:让高频、高代价的查询走覆盖路径,让低频查询接受合理回表,让写入密集的表保持精简索引。只有把查询特征、索引结构和执行计划三者对齐,复杂查询的性能治理才真正落地。