Normalmente, eu não recomendo o uso de dicas de junção por todos os motivos padrão. Recentemente, no entanto, descobri um padrão em que quase sempre encontro uma junção de loop forçada para ter um desempenho melhor. Na verdade, estou começando a usar e recomendo tanto que queria uma segunda opinião para ter certeza de que não estou perdendo nada. Aqui está um cenário representativo (o código muito específico para gerar um exemplo está no final):
--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
SampleTable tem 1 milhão de linhas e seu PK é ID.
A tabela temporária #Driver tem apenas uma coluna, ID, nenhum índice e 50 mil linhas.
O que eu sempre encontro é o seguinte:
Caso 1: NO HINT
Index Scan na SampleTable
Hash Join
Maior duração (média de 333 ms)
Maior CPU (média de 331 ms)
Baixas leituras lógicas (4714)
Caso 2: LOOP JOIN HINT
Index Seek on SampleTable
Loop Join
Menor duração (média de 204ms, 39% menos)
CPU mais baixa (média de 206, 38% menos)
Leituras lógicas muito mais altas (160015, 34X mais)
A princípio, as leituras muito mais altas do segundo caso me assustaram um pouco porque diminuir as leituras costuma ser considerado uma medida decente de desempenho. Mas quanto mais penso sobre o que está realmente acontecendo, isso não me preocupa. Aqui está o meu pensamento:
SampleTable está contido em 4714 páginas, ocupando cerca de 36 MB. O caso 1 verifica todos eles, e é por isso que obtemos 4714 leituras. Além disso, ele deve executar 1 milhão de hashes, que consomem muita CPU e, em última análise, aumentam o tempo proporcionalmente. É todo esse hashing que parece aumentar o tempo no caso 1.
Agora considere o caso 2. Ele não está fazendo nenhum hash, mas em vez disso está fazendo 50.000 buscas separadas, que é o que está aumentando as leituras. Mas quão caras são as leituras comparativamente? Pode-se dizer que, se essas forem leituras físicas, pode ser muito caro. Mas lembre-se de 1) apenas a primeira leitura de uma determinada página pode ser física e 2) mesmo assim, o caso 1 teria o mesmo ou pior problema, pois é garantido que atingirá todas as páginas.
Levando em conta o fato de que ambos os casos precisam acessar cada página pelo menos uma vez, parece ser uma questão de qual é mais rápido, 1 milhão de hashes ou cerca de 155.000 leituras na memória? Meus testes parecem dizer o último, mas o SQL Server escolhe consistentemente o primeiro.
Pergunta
Então, de volta à minha pergunta: devo continuar forçando essa dica LOOP JOIN quando o teste mostra esses tipos de resultados ou estou perdendo alguma coisa na minha análise? Estou hesitante em ir contra o otimizador do SQL Server, mas parece que ele muda para usar uma junção de hash muito antes do que deveria em casos como esses.
Atualização 2014-04-28
Fiz mais alguns testes e descobri que os resultados que estava obtendo acima (em uma VM com 2 CPUs) não podiam ser replicados em outros ambientes (tentei em 2 máquinas físicas diferentes com 8 e 12 CPUs). O otimizador se saiu muito melhor nos últimos casos, a ponto de não haver esse problema pronunciado. Acho que a lição aprendida, que parece óbvia em retrospecto, é que o ambiente pode afetar significativamente o funcionamento do otimizador.
Planos de Execução
Plano de Execução Caso 1
Plano de Execução Caso 2
Código para gerar caso de exemplo
------------------------------------------------------------
-- 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
*/