创建未被 (SELECT) 查询使用的索引会降低该查询的性能

SEa*_*986 10 performance index sql-server

我刚刚看了Pinal Dave 的这个视频

他有一个SELECT查询,它在 tempdb 中产生 ~370k 读取和 ~1200 读取查询所SELECT来自的表。

然后他创建了一个索引(我们称之为,Index1),它删除了 tempdb 假脱机,从而提高了查询的性能。到目前为止一切正常。

然而,他随后创建了一个进一步的索引(我们称之为Index2)并Index1保持原样。

然后他再次运行他的查询,尽管Index2没有被使用,但查询性能恢复到原来的状态,~370k tempdb spool 仍然存在。

他实际上似乎并没有描述导致这种情况的原因(除非我错过了)

要重现的代码如下(感谢 Martin Smith 提供 Pastebin)这假设 AdventureWorks 的 vanilla 版本,其标准索引位于 SalesOrderDetail

SET STATISTICS XML ON;
SET STATISTICS IO ON
GO

-- The query
DBCC FREEPROCCACHE;
SELECT SalesOrderID, ProductId,SalesOrderDetailID, OrderQty
FROM   Sales.SalesOrderDetail sod
WHERE  ProductID = (SELECT AVG(ProductID)
                    FROM   Sales.SalesOrderDetail sod1
                    WHERE  sod.SalesOrderID = sod1.SalesOrderID
                    GROUP  BY sod1.SalesOrderID);
/* 
(11110 rows affected)
Table 'Worktable'. Scan count 3, logical reads 368495, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table 'SalesOrderDetail'. Scan count 1, logical reads 1246, physical reads 2, page server reads 0, read-ahead reads 1284, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
*/
Run Code Online (Sandbox Code Playgroud)

是我们做任何事情之前的计划(嵌套循环和表线轴)

然后我们创建一个索引

CREATE NONCLUSTERED INDEX IX_Index1 ON Sales.SalesOrderDetail (SalesOrderID, ProductId) INCLUDE (SalesOrderDetailID, OrderQty);
Run Code Online (Sandbox Code Playgroud)

然后我们可以再次运行查询并查看改进的计划并且 tempdb 假脱机消失了:

DBCC FREEPROCCACHE;
SELECT SalesOrderID, ProductId,SalesOrderDetailID, OrderQty
FROM   Sales.SalesOrderDetail sod
WHERE  ProductID = (SELECT AVG(ProductID)
                    FROM   Sales.SalesOrderDetail sod1
                    WHERE  sod.SalesOrderID = sod1.SalesOrderID
                    GROUP  BY sod1.SalesOrderID);
/*
(11110 rows affected)
Table 'SalesOrderDetail'. Scan count 2, logical reads 608, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
*/
Run Code Online (Sandbox Code Playgroud)

计划

然后我们创建另一个(对我们的查询没有用)索引

CREATE NONCLUSTERED INDEX IX_Index2 ON Sales.SalesOrderDetail (ProductId,SalesOrderID) INCLUDE (SalesOrderDetailID, OrderQty);
Run Code Online (Sandbox Code Playgroud)

然后我们再次运行我们的查询:

-- Run the same query again
DBCC FREEPROCCACHE;
SELECT SalesOrderID, ProductId,SalesOrderDetailID, OrderQty
FROM   Sales.SalesOrderDetail sod
WHERE  ProductID = (SELECT AVG(ProductID)
                    FROM   Sales.SalesOrderDetail sod1
                    WHERE  sod.SalesOrderID = sod1.SalesOrderID
                    GROUP  BY sod1.SalesOrderID);

/*
(11110 rows affected)
Table 'Worktable'. Scan count 3, logical reads 368495, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table 'SalesOrderDetail'. Scan count 1, logical reads 304, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
*/
Run Code Online (Sandbox Code Playgroud)

读取和计划与我们添加任何索引之前的相同。

我什至可以尝试强制使用 IX_Index1:

-- Run the query an force the index
DBCC FREEPROCCACHE;
SELECT SalesOrderID, ProductId,SalesOrderDetailID, OrderQty
FROM   Sales.SalesOrderDetail sod WITH (INDEX = IX_Index1)
WHERE  ProductID = (SELECT AVG(ProductID)
                    FROM   Sales.SalesOrderDetail sod1 WITH (INDEX = IX_Index1)
                    WHERE  sod.SalesOrderID = sod1.SalesOrderID
                    GROUP  BY sod1.SalesOrderID);
/*
(11110 rows affected)
Table 'Worktable'. Scan count 3, logical reads 368495, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table '
Run Code Online (Sandbox Code Playgroud)

我仍然得到

Mar*_*ith 12

当前状态

在您的“好”情况下,计划如下所示。上分支按SalesOrderID(因此也是按)排序,SalesOrderID,ProductId因为分组确保每个只有一行SalesOrderID。较低的分支按SalesOrderID,ProductId顺序读取索引并将它们合并连接在一起。

在此处输入图片说明

您的“坏”案例使用行模式窗口聚合的典型执行计划(带有 公共子表达式 spool)。它并不像链接的视频所显示的那么糟糕。对于这些工作表,读取是按行读取而不是按页读取报告的(因此,将读取乘以 8 KB 来计算假脱机中的数据肯定是无效的),但是根据 SQL Server 的成本模型,“坏”情况的成本更高,所以为什么它会选择它吗?

合并连接复习

X,Y上的合并连接要求合并连接的两个输入都以兼容的方式排序。至少两个输入必须至少按相同的初始列排序(即都被排序X或都被排序Y)。为了获得最大的效率(即理想地避免“多对多”合并连接的开销),它们通常应该以相同的方式对equi 连接谓词中涉及的所有键进行排序- 即要么ORDER BY X,YORDER BY Y,X(每个键的ASC,DESC方向不是重要但必须在两个输入中相同)

它如何在ORDER BY X,Y和之间选择ORDER BY Y,X

这似乎是合并连接和复合连接谓词的一般限制。在没有任何外部原因选择指定的排序(例如显式ORDER BY)的情况下,它只会决定一个有点任意的列顺序。

对于表源,显然它会查找它遇到的第一个索引,该索引适合以任一顺序提供所需的列,并采用其中的键列顺序作为合并连接所需的顺序。

用于索引匹配的顺序似乎与 的顺序相反indexid。这通常与索引创建顺序相关,但并非总是如此(因为聚集索引是保留的indexid1或者可以在 a 之后填充 id 中的空白DROP INDEX

它不会对后续索引是否更适合(例如,与查询中已使用的索引提示更窄或更兼容)进行任何分析。

我不是 100% 确定这是负责的代码,但下面的调用堆栈表明排序合并连接的实现规则正在查找表元数据以找出一些“自然排序”

在此处输入图片说明

添加索引使计划变得更糟的另一个示例 - 专注于合并连接

CREATE TABLE #Test(X INT, Y INT, Filler CHAR(8000),INDEX IX1 UNIQUE /*Index id = 2*/ (X,Y));

INSERT INTO #Test (X,Y) SELECT TOP 10000 ROW_NUMBER() OVER (ORDER BY 1/0), ROW_NUMBER() OVER (ORDER BY 1/0) FROM sys.all_objects o1, sys.all_objects o2;

SET STATISTICS IO ON;

--#1 logical reads 60 uses IX1 - merge join chosen organically
SELECT t1.X, t1.Y
FROM #Test t1 
INNER JOIN #Test t2 ON t1.X = t2.X and t1.Y = t2.Y

SET STATISTICS IO OFF;
CREATE UNIQUE CLUSTERED INDEX ix2 ON #Test(Y,X) 
SET STATISTICS IO ON;

--#2 logical reads 50, Still using IX1 and merge join. The clustered index just created has an id of 1 so lower than 2. 
--IX1 no longer contains a RID so reads a bit lower than previously
SELECT t1.X, t1.Y
FROM #Test t1 
INNER JOIN #Test t2 ON t1.X = t2.X and t1.Y = t2.Y

SET STATISTICS IO OFF;
CREATE UNIQUE INDEX ix3 ON #Test(Y,X) INCLUDE (Filler);
SET STATISTICS IO ON;

--#3 logical reads 20,068 - No longer chooses MERGE join of its own accord and if forced uses more expensive index
SELECT t1.X, t1.Y
FROM #Test t1 
INNER MERGE JOIN #Test t2 ON t1.X = t2.X and t1.Y = t2.Y;

--#4 Back to 50 reads. The merge join is happy to use the order required by the ORDER BY
SELECT t1.X, t1.Y
FROM #Test t1 
INNER MERGE JOIN #Test t2 ON t1.X = t2.X and t1.Y = t2.Y
ORDER BY t1.X, t1.Y;

--#5 50 reads but now uses hash join 
SELECT t1.X, t1.Y
FROM #Test t1 
INNER JOIN #Test t2 ON t1.X = t2.X and t1.Y+0 = t2.Y+0;

--#6 50 reads, Forcing the merge join and looking at properties shows it is now seen as "many to many"
SELECT t1.X, t1.Y
FROM #Test t1 
INNER MERGE JOIN #Test t2 ON t1.X = t2.X and t1.Y+0 = t2.Y+0;

SET STATISTICS IO OFF;

--What if there is no useful index?
DROP INDEX ix3 ON #Test
DROP INDEX ix1 ON #Test
DROP INDEX ix2 ON #Test
CREATE CLUSTERED INDEX ix4 ON #Test(X)

--#7 Sorts are by X, Y 
SELECT t1.X, t1.Y
FROM #Test t1 
INNER MERGE JOIN #Test t2 ON t1.X = t2.X and t1.Y = t2.Y;

--#8 Sorts are by Y, X 
SELECT t1.X, t1.Y
FROM #Test t1 
INNER MERGE JOIN #Test t2 ON  t1.Y = t2.Y AND t1.X = t2.X;


DROP TABLE #Test
Run Code Online (Sandbox Code Playgroud)
  • #1 在带有列的表上有一个覆盖非聚集索引(X,Y)- 合并连接是自然选择的(没有提示)并使用索引顺序扫描。
  • #2 添加一个键为有序的聚集索引(Y,X)。合并连接仍然自然选择并使用最初创建的索引。聚集索引的 id 为 1 - 低于预先存在的索引。
  • #3 添加一个带有有序键的覆盖非聚集索引(Y,X)。这需要比 IX1 多得多的读取来扫描,因为它包含一个非常宽的列 ( Filler) -尽管如此,SQL Server 现在只考虑(Y,X)有序合并连接并且由于额外的读取而不会选择没有提示的合并连接......
  • #4 ...除非有其他原因使用该(X,Y)订单。添加一个ORDER BY t1.X, t1.Y恢复到原始计划。
  • #5 更改谓词以t1.Y+0 = t2.Y+0阻止匹配尝试(Y,X)但没有提示它选择哈希连接
  • #6 提示MERGE连接显示它现在被视为“多对多” - 这就是 SQL Server 不会自动选择它的原因。但是当暗示它能够使用更窄的(X,Y)索引时。
  • #7 在删除所有有用的索引并创建一个带有单键列的聚集索引后,X似乎合并连接现在需要X,Y顺序(而不是使用索引)X来执行带有残差谓词的多对多合并连接)
  • #8 反转案例 #7 中谓词的顺序现在也会导致合并连接(of Y,X)的排序顺序不同

为什么索引提示不起作用?

在您使用索引提示的问题的最后一个示例中,SQL Server 确实考虑了合并连接,但是由于合并连接已经决定它将需要列顺序,ProductId,SalesOrderID然后使用提示索引和合并连接的计划将需要一个扫描暗示的索引,然后进行排序以使其成为连接所需的顺序。所以这个想法以成本为由被驳回(因为备忘录路径<EnforceSort>PhyOp_Sort比最终选择的计划更昂贵)。

相关答案:为什么更改声明的连接列顺序会引入排序?

附录

保罗怀特在答案中添加了这个有见地的评论

在问题查询中获得没有排序的合并连接的另一种方法是更改​​为“...WHERE ProductID + 0 = (SELECT AVG(ProductID)... 查找最佳排序是 NP 难的”,因此数据库引擎依赖于启发式。

+0变通方法的情况下,合并连接谓词现在已启用,(SalesOrderID, Expr1004) = (SalesOrderID, Expr1002)因此这足以防止它尝试将索引与前导列匹配ProductId。这也依赖于GROUP BY保证每个 的上部输入中只有一行的语义SalesOrderID。否则额外的不透明度可能会导致 SQL Server 得出合并连接将是多对多的结论,因此请尝试其他连接类型(如现在添加到我上面的示例代码中的示例)


Ran*_*gen 6

尝试通过添加提示来获得性能更好的执行计划结构

实现限制重映射

当添加索引时,在辅助查询上使用查询规则ImplRestrRemap,因为估计查询成本比合并联接便宜。这更多是问题的结果。为什么它的子树成本比在您的特定情况下使用合并联接更低,如下所示。

您可以通过添加跟踪标志检查输出树来找到此规则的使用:

OPTION(
RECOMPILE, 
QUERYTRACEON 3604,
QUERYTRACEON 8607)
Run Code Online (Sandbox Code Playgroud)

OPTION(QUERYRULEOFF ImplRestrRemap)您可以通过添加到查询来禁用该规则。

SELECT SalesOrderID, ProductId,SalesOrderDetailID, OrderQty
FROM   Sales.SalesOrderDetail sod
WHERE  ProductID = (SELECT AVG(ProductID)
                    FROM   Sales.SalesOrderDetail sod1
                    WHERE  sod.SalesOrderID = sod1.SalesOrderID
                    GROUP  BY sod1.SalesOrderID)
OPTION 
(
    QUERYRULEOFF ImplRestrRemap
);
Run Code Online (Sandbox Code Playgroud)

这确实提供了散列连接而不是合并连接,并且估计的子树成本比假脱机计划更高。另一个区别是使用两个创建的非聚集索引,而不是访问一个索引两次。

在此输入图像描述

合并连接

当尝试将合并联接添加回计划以及禁用规则时,您将看到查询计划中添加了排序:

OPTION 
(
    QUERYRULEOFF ImplRestrRemap, 
    MERGE JOIN
);
Run Code Online (Sandbox Code Playgroud)

在此输入图像描述

估计的子树成本高于散列连接,该散列连接的估计子树成本又高于假脱机计划。在没有提示的情况下解释计划的选择。

它仍然使用两个创建的 NC 索引。

索引提示

如果我们想强制两个表访问使用相同的索引来尝试获取“样本”计划,您可以添加索引提示。

SELECT SalesOrderID, ProductId,SalesOrderDetailID, OrderQty
FROM   Sales.SalesOrderDetail sod WITH(INDEX(IX_SalesOrderID_ProductId))
WHERE  ProductID = (SELECT AVG(ProductID)
                    FROM   Sales.SalesOrderDetail  sod1 WITH(INDEX(IX_SalesOrderID_ProductId))
                    WHERE  sod.SalesOrderID = sod1.SalesOrderID
                    GROUP  BY sod1.SalesOrderID)
OPTION 
(
    QUERYRULEOFF ImplRestrRemap, 
    MERGE JOIN
);
Run Code Online (Sandbox Code Playgroud)

这添加了另一个排序运算符:

在此输入图像描述

在此输入图像描述

差异

加盟方向

因为合并连接的内侧和外侧连接列被交换

无排序合并连接执行计划

<InnerSideJoinColumns>
  <ColumnReference Database="[AdventureWorks2017]" Schema="[Sales]" Table="[SalesOrderDetail]" Alias="[sod]" Column="SalesOrderID" />
  <ColumnReference Database="[AdventureWorks2017]" Schema="[Sales]" Table="[SalesOrderDetail]" Alias="[sod]" Column="ProductID" />
</InnerSideJoinColumns>
<OuterSideJoinColumns>
  <ColumnReference Database="[AdventureWorks2017]" Schema="[Sales]" Table="[SalesOrderDetail]" Alias="[sod1]" Column="SalesOrderID" />
  <ColumnReference Column="Expr1002" />
</OuterSideJoinColumns>
Run Code Online (Sandbox Code Playgroud)

双排序合并连接执行计划

<InnerSideJoinColumns>
  <ColumnReference Database="[AdventureWorks2017]" Schema="[Sales]" Table="[SalesOrderDetail]" Alias="[sod]" Column="ProductID" />
  <ColumnReference Database="[AdventureWorks2017]" Schema="[Sales]" Table="[SalesOrderDetail]" Alias="[sod]" Column="SalesOrderID" />
</InnerSideJoinColumns>
<OuterSideJoinColumns>
  <ColumnReference Column="Expr1002" />
  <ColumnReference Database="[AdventureWorks2017]" Schema="[Sales]" Table="[SalesOrderDetail]" Alias="[sod1]" Column="SalesOrderID" />
</OuterSideJoinColumns>
Run Code Online (Sandbox Code Playgroud)

从某种意义上说,性能更好的查询计划可以通过索引键路径

--> SalesOrderId - SalesOrderId --> ProductId - ProductId (Agg)

而排序查询计划只能遵循

--> ProductId (Agg) - ProductId --> SalesOrderId - SalesOrderId

正如预期的那样,由于添加了排序,使用双重排序合并连接计划时索引扫描不会排序

 <IndexScan Ordered="false" ForcedIndex="true" ForceSeek="false" ForceScan="false" NoExpandHint="false" Storage="RowStore">
Run Code Online (Sandbox Code Playgroud)

尝试强制执行更好的查询计划

当尝试强制执行正确的计划时,例如,在按不提供问题的顺序创建索引后运行查询,然后捕获计划 xml:

drop index if exists IX_ProductId_SalesOrderID on Sales.SalesOrderDetail;
drop index if exists IX_SalesOrderID_ProductId on Sales.SalesOrderDetail;

CREATE NONCLUSTERED INDEX IX_ProductId_SalesOrderID ON Sales.SalesOrderDetail (ProductId,SalesOrderID) INCLUDE (SalesOrderDetailID, OrderQty);
CREATE NONCLUSTERED INDEX IX_SalesOrderID_ProductId ON Sales.SalesOrderDetail (SalesOrderID, ProductId) INCLUDE (SalesOrderDetailID, OrderQty);
Run Code Online (Sandbox Code Playgroud)

然后使用该计划 XML 并以不同的顺序重新创建索引:

drop index if exists IX_ProductId_SalesOrderID on Sales.SalesOrderDetail;
drop index if exists IX_SalesOrderID_ProductId on Sales.SalesOrderDetail;
CREATE NONCLUSTERED INDEX IX_SalesOrderID_ProductId ON Sales.SalesOrderDetail (SalesOrderID, ProductId) INCLUDE (SalesOrderDetailID, OrderQty);
CREATE NONCLUSTERED INDEX IX_ProductId_SalesOrderID ON Sales.SalesOrderDetail (ProductId,SalesOrderID) INCLUDE (SalesOrderDetailID, OrderQty);
Run Code Online (Sandbox Code Playgroud)

对于相同的查询和相同的索引, HINTUSE PLAN报告错误:

消息 8698,级别 16,状态 0,第 1 行 查询处理器无法生成查询计划,因为 USE PLAN 提示包含无法验证为查询合法的计划。删除或替换使用计划提示。为了获得计划强制成功的最佳可能性,请验证 USE PLAN 提示中提供的计划是否是 SQL Server 针对同一查询自动生成的计划。

表明在合并连接中使用相同的排序顺序甚至是不可能的,并且必须恢复使用排序运算符作为合并连接的最佳选项。

与此相反,USE PLAN当能够使用性能更好的合并连接计划时,将带有提示的假脱机计划应用于查询实际上是可能的。

本问答中提出的要点提供了有关为什么会发生这种情况的更多信息。

解决方案

订购依据

添加前面提到的Q/A中指定的 order by会导致选择正确的执行计划,即使索引创建顺序不同也是如此。

SELECT SalesOrderID, ProductId
FROM   Sales.SalesOrderDetail2 sod 
WHERE  ProductID = (SELECT AVG(ProductID)
                    FROM   Sales.SalesOrderDetail2  sod1  
                    WHERE  sod.SalesOrderID = sod1.SalesOrderID
                    GROUP  BY sod1.SalesOrderID
                    )
ORDER BY SalesOrderID;
Run Code Online (Sandbox Code Playgroud)

在此输入图像描述