No SQL Server, devo forçar um LOOP JOIN no seguinte caso?

15

Normalmente, eu recomendo não usar dicas de junção por todos os motivos padrão. Recentemente, no entanto, encontrei um padrão em que quase sempre encontro uma junção de loop forçado para ter um desempenho melhor. Na verdade, estou começando a usá-lo e recomendá-lo tanto que desejei obter uma segunda opinião para garantir que não estou perdendo algo. Aqui está um cenário representativo (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 possui 1 milhão de linhas e seu PK é ID.
A tabela temporária #Driver possui apenas uma coluna, ID, sem índices e 50 mil linhas.

O que eu sempre acho é o seguinte:

Caso 1: NO HINT
Verificação de índice na
junção de hash de SampleTable
Maior duração (média de 333ms)
Maior CPU (média de 331ms)
Leituras lógicas mais baixas (4714)

Caso 2: LOOP JOIN HINT
Procura de índice na
junção do loop SampleTable
Duração mais baixa (média 204ms, 39% menos)
CPU mais baixa (média 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 é frequentemente considerado uma medida decente de desempenho. Mas quanto mais penso no que realmente está 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 examina todos e é por isso que obtemos 4714 leituras. Além disso, ele deve executar 1 milhão de hashes, que consomem muita CPU e, finalmente, aumentam o tempo proporcionalmente. É todo esse hash que parece aumentar o tempo no caso 1.

Agora considere o caso 2. Ele não está fazendo nenhum hash, mas está fazendo 50000 buscas separadas, que é o que está impulsionando as leituras. Mas quão caras são as leituras comparativamente? Pode-se dizer que, se essas são leituras físicas, pode ser bastante caro. Mas lembre-se de que 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 ele atinge todas as páginas.

Portanto, considerando o fato de que ambos os casos precisam acessar cada página pelo menos uma vez, parece ser uma questão mais rápida: 1 milhão de hashes ou cerca de 155000 leituras contra a memória? Meus testes parecem dizer o último, mas o SQL Server escolhe consistentemente o primeiro.

Questão

Então, voltando à minha pergunta: devo continuar forçando essa dica de LOOP JOIN ao testar esses tipos de resultados ou estou perdendo alguma coisa em minha análise? Eu hesito em ir contra o otimizador do SQL Server, mas parece que ele muda para o uso de uma junção de hash muito mais cedo do que deveria em casos como esses.

Atualização 2014-04-28

Fiz mais alguns testes e descobri que os resultados que eu estava obtendo acima (em uma VM com 2 CPUs) não conseguiam replicar 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 um problema tão pronunciado. Acho que a lição aprendida, que parece óbvia em retrospecto, é que o ambiente pode afetar significativamente o desempenho do otimizador.

Planos de Execução

Caso do plano de Plano 1 execução 1 Caso do plano de execução 2 insira a descrição da imagem aqui

Código para gerar exemplo de caso

------------------------------------------------------------
-- 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

*/
JohnnyM
fonte

Respostas:

13

SampleTable está contido em 4714 páginas, ocupando cerca de 36 MB. O caso 1 examina todos e é por isso que obtemos 4714 leituras. Além disso, ele deve executar 1 milhão de hashes, que consomem muita CPU e, finalmente, aumentam o tempo proporcionalmente. É todo esse hash que parece aumentar o tempo no caso 1.

Há um custo de inicialização para uma junção de hash (criar a tabela de hash, que também é uma operação de bloqueio), mas, em última análise, a junção de hash tem o menor custo teórico por linha dos três tipos de junção física suportados pelo SQL Server, ambos em termos de IO e CPU. A junção de hash realmente se destaca com uma entrada de compilação relativamente pequena e uma entrada de sonda grande. Dito isto, nenhum tipo de junção física é 'melhor' em todos os cenários.

Agora considere o caso 2. Ele não está fazendo nenhum hash, mas está fazendo 50000 buscas separadas, que é o que está impulsionando as leituras. Mas quão caras são as leituras comparativamente? Pode-se dizer que, se essas são leituras físicas, pode ser bastante caro. Mas lembre-se de que 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 ele atinge todas as páginas.

Cada busca requer a navegação de uma árvore b até a raiz, o que é computacionalmente caro comparado com uma única sonda de hash. Além disso, o padrão geral de E / S para o lado interno de uma junção de loops aninhados é aleatório, comparado com o padrão de acesso seqüencial da entrada de varredura no lado da sonda para uma junção de hash. Dependendo do subsistema IO físico subjacente, as leituras sequenciais podem ser mais rápidas que as leituras aleatórias. Além disso, o mecanismo de leitura antecipada do SQL Server funciona melhor com E / S sequenciais, emitindo leituras maiores.

Portanto, considerando o fato de que ambos os casos precisam acessar cada página pelo menos uma vez, parece ser uma questão mais rápida: 1 milhão de hashes ou cerca de 155000 leituras contra a memória? Meus testes parecem dizer o último, mas o SQL Server escolhe consistentemente o primeiro.

O otimizador de consulta do SQL Server faz várias suposições. Uma é que o primeiro acesso a uma página feita por uma consulta resultará em um IO físico (a 'suposição de cache frio'). A chance de uma leitura posterior vir de uma página já lida na memória pela mesma consulta é modelada, mas isso não passa de um palpite.

A razão pela qual o modelo do otimizador funciona dessa maneira é que geralmente é melhor otimizar para o pior caso (é necessário E / S físico). Muitas deficiências podem ser encobertas pelo paralelismo e pela execução de coisas na memória. Os planos de consulta que o otimizador produziria se assumisse que todos os dados estavam na memória poderiam ter um desempenho muito ruim se essa suposição fosse inválida.

O plano produzido usando a suposição de cache frio pode não ter um desempenho tão bom quanto se um cache quente fosse assumido, mas seu pior desempenho geralmente será superior.

Devo continuar forçando essa dica de LOOP JOIN ao testar esses tipos de resultados, ou estou perdendo alguma coisa em minha análise? Eu hesito em ir contra o otimizador do SQL Server, mas parece que ele muda para o uso de uma junção de hash muito mais cedo do que deveria em casos como esses.

Você deve ter muito cuidado ao fazer isso por dois motivos. Primeiro, junte-se sugestões também silenciosamente forçar o físico se juntar a fim de coincidir com a ordem escrita da consulta (como se você também tinha especificado OPTION (FORCE ORDER). Isso limita severamente as alternativas disponíveis para o otimizador e pode não ser sempre o que você quer. OPTION (LOOP JOIN)Forças loops aninhados ingressa na consulta, mas não impõe a ordem de ingresso por escrito.

Segundo, você está assumindo que o tamanho do conjunto de dados permanecerá pequeno e a maioria das leituras lógicas virá do cache. Se essas suposições se tornarem inválidas (talvez com o tempo), o desempenho será prejudicado. O otimizador de consulta interno é bastante bom para reagir a mudanças nas circunstâncias; remover essa liberdade é algo que você deve pensar muito.

No geral, a menos que haja uma razão convincente para forçar junções de loops, eu evitaria isso. Os planos padrão geralmente são bastante próximos do ideal e tendem a ser mais resilientes diante das circunstâncias em mudança.

Paul White restabelece Monica
fonte
Obrigado Paul. Excelente análise detalhada. Com base em alguns testes adicionais que fiz, acho que o que está acontecendo é que os palpites informados pelo otimizador são consistentemente desconsiderados para esse exemplo específico quando o tamanho da tabela temporária está entre 5K e 100K. Dado o fato de que nossos requisitos garantem que a tabela temporária seja <50K, parece seguro para mim. Estou curioso, você ainda evitaria qualquer tipo de sugestão de junção sabendo disso?
precisa saber é o seguinte
1
As dicas do @JohnnyM existem por uma razão. Não há problema em usá-los onde você tiver boas razões para fazê-lo. Dito isto, raramente uso dicas de junção por causa do implícito FORCE ORDER. Na ocasião ímpar em que eu uso uma dica de junção, costumo adicionar OPTION (FORCE ORDER)um comentário para explicar o porquê.
Paul White Restabelecer Monica
0

50.000 linhas unidas em uma tabela de milhões de linhas parecem muito para qualquer tabela sem um índice.

É difícil dizer exatamente o que fazer nesse caso, pois é tão isolado do problema que você está realmente tentando resolver. Eu certamente espero que não seja um padrão geral no seu código em que você esteja se juntando a muitas tabelas temporárias não indexadas com quantidades significativas de linhas.

Tomando o exemplo apenas pelo que diz, por que não colocar um índice no #Driver? O D.ID é verdadeiramente único? Nesse caso, é semanticamente equivalente a uma instrução EXISTS, que permitirá ao SQL Server saber que você não deseja continuar pesquisando S por valores duplicados de D:

SELECT S.*
INTO #Results
FROM SampleTable S
WHERE EXISTS (SELECT * #Driver D WHERE S.ID = D.ID);

Em resumo, para esse padrão, eu não usaria uma dica de LOOP. Eu simplesmente não usaria esse padrão. Eu faria um dos seguintes, em ordem de prioridade, se possível:

  • Use um CTE em vez de uma tabela temporária para #Driver, se possível
  • Use um índice não clusterizado exclusivo no #Driver no ID, se ele for único (supondo que esta seja a única vez em que você usa o #Driver e que não deseja dados da própria tabela - se você realmente precisa de dados dessa tabela, pode muito bem torná-lo um índice em cluster)
Dave Markle
fonte