在 SQL Server 中,我应该在以下情况下强制执行 LOOP JOIN 吗?

Joh*_*nyM 15 sql-server

通常,出于所有标准原因,我建议不要使用连接提示。然而,最近我发现了一种模式,我几乎总能找到一个强制循环连接来表现更好。事实上,我开始使用和推荐它,以至于我想获得第二意见以确保我没有遗漏任何东西。下面是一个有代表性的场景(生成示例的非常具体的代码在最后):

--Case 1: NO HINT
SELECT S.*
INTO #Results
FROM #Driver AS D
JOIN SampleTable AS S ON S.ID = D.ID

--Case 2: LOOP JOIN HINT
SELECT S.*
INTO #Results
FROM #Driver AS D
INNER LOOP JOIN SampleTable AS S ON S.ID = D.ID
Run Code Online (Sandbox Code Playgroud)

SampleTable 有 100 万行,它的 PK 是 ID。
临时表 #Driver 只有一列、ID、没有索引和 50K 行。

我始终发现以下内容:

案例 1: SampleTable Hash Join
上的NO HINT索引扫描更高的持续时间(平均 333ms) 更高的 CPU(平均 331ms) 更低的逻辑读取(4714)



案例 2:LOOP JOIN HINT
索引在 SampleTable
循环中搜索 加入
较低的持续时间(平均 204 毫秒,减少 39%)
较低的 CPU(平均 206,减少 38%)
更高的逻辑读取(160015,增加 34 倍)

起初,第二种情况的高得多的读取让我有点害怕,因为降低读取通常被认为是一种不错的性能衡量标准。但我越是考虑实际发生的事情,我就不会关心它。这是我的想法:

SampleTable 包含在 4714 页上,占用大约 36MB。案例 1 将它们全部扫描,这就是我们获得 4714 次读取的原因。此外,它必须执行 100 万次散列,这是 CPU 密集型的,最终按比例增加时间。正是所有这些散列似乎在情况 1 中增加了时间。

现在考虑情况 2。它没有进行任何散列,而是进行 50000 次单独的搜索,这就是驱动读取的原因。但是相比之下,读取的成本有多高?有人可能会说,如果这些是物理读取,那可能会非常昂贵。但请记住 1) 只有给定页面的第一次读取可能是物理的,并且 2) 即便如此,情况 1 也会有相同或更严重的问题,因为它保证会命中每一页。

因此,考虑到两种情况都必须至少访问每个页面一次这一事实,似乎是一个问题,哪个更快,100 万个哈希值还是大约 155000 次读取内存?我的测试似乎是后者,但 SQL Server 始终选择前者。

所以回到我的问题:当测试显示这些类型的结果时,我应该继续强制使用这个 LOOP JOIN 提示,还是我在分析中遗漏了什么?我对反对 SQL Server 的优化器犹豫不决,但感觉它比在这些情况下更早地切换到使用散列连接。

更新 2014-04-28

我做了一些更多的测试,发现我得到的结果(在带有 2 个 CPU 的虚拟机上)我无法在其他环境中复制(我在 2 个不同的物理机上尝试了 8 个和 12 个 CPU)。优化器在后一种情况下做得更好,以至于没有这种明显的问题。我想从中吸取的教训似乎很明显,即环境可以显着影响优化器的工作方式。

执行计划

执行计划案例1 方案一 执行计划案例2 在此处输入图片说明

生成示例案例的代码

------------------------------------------------------------
-- 1. Create SampleTable with 1,000,000 rows
------------------------------------------------------------    

CREATE TABLE SampleTable
    (  
       ID         INT NOT NULL PRIMARY KEY CLUSTERED
     , Number1    INT NOT NULL
     , Number2    INT NOT NULL
     , Number3    INT NOT NULL
     , Number4    INT NOT NULL
     , Number5    INT NOT NULL
    )

--Add 1 million rows
;WITH  
    Cte0 AS (SELECT 1 AS C UNION ALL SELECT 1), --2 rows  
    Cte1 AS (SELECT 1 AS C FROM Cte0 AS A, Cte0 AS B),--4 rows  
    Cte2 AS (SELECT 1 AS C FROM Cte1 AS A ,Cte1 AS B),--16 rows 
    Cte3 AS (SELECT 1 AS C FROM Cte2 AS A ,Cte2 AS B),--256 rows 
    Cte4 AS (SELECT 1 AS C FROM Cte3 AS A ,Cte3 AS B),--65536 rows 
    Cte5 AS (SELECT 1 AS C FROM Cte4 AS A ,Cte2 AS B),--1048576 rows 
    FinalCte AS (SELECT  ROW_NUMBER() OVER (ORDER BY C) AS Number FROM   Cte5)
INSERT INTO SampleTable
SELECT Number, Number, Number, Number, Number, Number
FROM  FinalCte
WHERE Number <= 1000000

------------------------------------------------------------
-- Create 2 SPs that join from #Driver to SampleTable.
------------------------------------------------------------    
GO
IF OBJECT_ID('JoinTest_NoHint') IS NOT NULL DROP PROCEDURE JoinTest_NoHint
GO
CREATE PROC JoinTest_NoHint
AS
    SELECT S.*
    INTO #Results
    FROM #Driver AS D
    JOIN SampleTable AS S ON S.ID = D.ID
GO
IF OBJECT_ID('JoinTest_LoopHint') IS NOT NULL DROP PROCEDURE JoinTest_LoopHint
GO
CREATE PROC JoinTest_LoopHint
AS
    SELECT S.*
    INTO #Results
    FROM #Driver AS D
    INNER LOOP JOIN SampleTable AS S ON S.ID = D.ID
GO

------------------------------------------------------------
-- Create driver table with 50K rows
------------------------------------------------------------    
GO
IF OBJECT_ID('tempdb..#Driver') IS NOT NULL DROP TABLE #Driver
SELECT ID
INTO #Driver
FROM SampleTable
WHERE ID % 20 = 0

------------------------------------------------------------
-- Run each test and run Profiler
------------------------------------------------------------    

GO
/*Reg*/  EXEC JoinTest_NoHint
GO
/*Loop*/ EXEC JoinTest_LoopHint


------------------------------------------------------------
-- Results
------------------------------------------------------------    

/*

Duration CPU   Reads    TextData
315      313   4714     /*Reg*/  EXEC JoinTest_NoHint
309      296   4713     /*Reg*/  EXEC JoinTest_NoHint
327      329   4713     /*Reg*/  EXEC JoinTest_NoHint
398      406   4715     /*Reg*/  EXEC JoinTest_NoHint
316      312   4714     /*Reg*/  EXEC JoinTest_NoHint
217      219   160017   /*Loop*/ EXEC JoinTest_LoopHint
211      219   160014   /*Loop*/ EXEC JoinTest_LoopHint
217      219   160013   /*Loop*/ EXEC JoinTest_LoopHint
190      188   160013   /*Loop*/ EXEC JoinTest_LoopHint
187      187   160015   /*Loop*/ EXEC JoinTest_LoopHint

*/
Run Code Online (Sandbox Code Playgroud)

Pau*_*ite 13

SampleTable 包含在 4714 页上,占用大约 36MB。案例 1 将它们全部扫描,这就是我们获得 4714 次读取的原因。此外,它必须执行 100 万次散列,这是 CPU 密集型的,最终按比例增加时间。正是所有这些散列似乎在情况 1 中增加了时间。

散列连接有启动成本(构建散列表,这也是一个阻塞操作),但散列连接最终在SQL Server 支持的三种物理连接类型中具有最低的理论每行成本,两者都在IO 和 CPU 方面。散列连接真正发挥了自己的作用,它具有相对较小的构建输入和较大的探测输入。也就是说,没有任何物理连接类型在所有情况下都“更好”。

现在考虑情况 2。它没有进行任何散列,而是进行 50000 次单独的搜索,这就是驱动读取的原因。但是相对而言,读取成本有多高?有人可能会说,如果这些是物理读取,那可能会非常昂贵。但请记住 1) 只有给定页面的第一次读取可能是物理的,并且 2) 即便如此,情况 1 也会有相同或更严重的问题,因为它保证会命中每一页。

每次查找都需要将 b 树导航到根,与单个哈希探测相比,这在计算上是昂贵的。此外,与散列连接的探针端扫描输入的顺序访问模式相比,嵌套循环连接内侧的一般 IO 模式是随机的。根据底层物理 IO 子系统,顺序读取可能比随机读取快。此外,SQL Server 预读机制与顺序 IO 配合得更好,发出更大的读取。

因此,考虑到两种情况都必须至少访问每个页面一次这一事实,似乎是一个问题,哪个更快,100 万个哈希值还是大约 155000 次读取内存?我的测试似乎是后者,但 SQL Server 始终选择前者。

SQL Server 查询优化器做出了许多假设。一个是查询对页面的第一次访问将导致物理 IO(“冷缓存假设”)。稍后读取来自已被同一查询读入内存的页面的可能性被建模,但这只是一个有根据的猜测。

优化器模型以这种方式工作的原因是通常最好针对最坏情况进行优化(需要物理 IO)。许多缺点可以通过并行性和在内存中运行的东西来掩盖。如果优化器假设所有数据都在内存中,那么如果该假设被证明是无效的,那么它所生成的查询计划可能会执行得很差。

使用冷缓存假设生成的计划可能不如假设使用热缓存时执行得那么好,但其最坏情况下的性能通常会更好。

当测试显示这些类型的结果时,我应该继续强制使用这个 LOOP JOIN 提示,还是我在分析中遗漏了什么?我对反对 SQL Server 的优化器犹豫不决,但感觉它比在这些情况下更早地切换到使用散列连接。

出于两个原因,您应该非常小心地执行此操作。首先,连接提示还悄悄地强制物理连接顺序匹配查询的写入顺序(就像您还指定了OPTION (FORCE ORDER)。这严重限制了优化器可用的替代方案,并且可能并不总是您想要的。OPTION (LOOP JOIN)强制嵌套循环连接查询,但不强制执行写入的连接顺序。

其次,您假设数据集大小将保持较小,并且大部分逻辑读取将来自缓存。如果这些假设变得无效(可能随着时间的推移),性能就会下降。内置的查询优化器非常擅长对不断变化的环境做出反应;取消这种自由是你应该认真考虑的事情。

总的来说,除非有令人信服的理由强制循环连接,否则我会避免它。默认计划通常非常接近最佳,并且在面对不断变化的情况时往往更具弹性。