Consulta 100x mais lenta no SQL Server 2014, a linha Spool de contagem de linhas estima o culpado?

13

Eu tenho uma consulta que é executada em 800 milissegundos no SQL Server 2012 e leva cerca de 170 segundos no SQL Server 2014 . Eu acho que reduzi isso a uma estimativa de cardinalidade ruim para o Row Count Spooloperador. Eu li um pouco sobre operadores de spool (por exemplo, aqui e aqui ), mas ainda estou tendo problemas para entender algumas coisas:

  • Por que essa consulta precisa de um Row Count Spooloperador? Eu não acho que seja necessário para correção, então que otimização específica ela está tentando fornecer?
  • Por que o SQL Server estima que a associação ao Row Count Spooloperador remove todas as linhas?
  • Isso é um bug no SQL Server 2014? Nesse caso, vou arquivar no Connect. Mas eu gostaria de ter um entendimento mais profundo primeiro.

Nota: Posso reescrever a consulta como um LEFT JOINou adicionar índices às tabelas para obter um desempenho aceitável no SQL Server 2012 e no SQL Server 2014. Portanto, esta pergunta é mais sobre como entender essa consulta específica e planejar detalhadamente e menos sobre como formular a consulta de maneira diferente.


A consulta lenta

Consulte este Pastebin para obter um script de teste completo. Aqui está a consulta de teste específica que estou procurando:

-- Prune any existing customers from the set of potential new customers
-- This query is much slower than expected in SQL Server 2014 
SELECT *
FROM #potentialNewCustomers -- 10K rows
WHERE cust_nbr NOT IN (
    SELECT cust_nbr
    FROM #existingCustomers -- 1MM rows
)


SQL Server 2014: O plano de consulta estimado

SQL Server acredita que o Left Anti Semi Joinà Row Count Spoolvai filtrar as linhas 10.000 até 1 linha. Por esse motivo, ele seleciona a LOOP JOINpara a junção subsequente #existingCustomers.

insira a descrição da imagem aqui


SQL Server 2014: O plano de consulta real

Como esperado (por todos, exceto pelo SQL Server!), O Row Count Spoolnão removeu nenhuma linha. Então, estamos repetindo 10.000 vezes quando o SQL Server espera repetir apenas uma vez.

insira a descrição da imagem aqui


SQL Server 2012: O plano de consulta estimado

Ao usar o SQL Server 2012 (ou OPTION (QUERYTRACEON 9481)no SQL Server 2014), Row Count Spoolisso não reduz o número estimado de linhas e uma associação de hash é escolhida, resultando em um plano muito melhor.

insira a descrição da imagem aqui

A reescrita LEFT JOIN

Para referência, aqui está uma maneira de reescrever a consulta para obter um bom desempenho em todos os SQL Server 2012, 2014 e 2016. No entanto, ainda estou interessado no comportamento específico da consulta acima e se ela é um erro no novo estimador de cardinalidade do SQL Server 2014.

-- Re-writing with LEFT JOIN yields much better performance in 2012/2014/2016
SELECT n.*
FROM #potentialNewCustomers n
LEFT JOIN (SELECT 1 AS test, cust_nbr FROM #existingCustomers) c
    ON c.cust_nbr = n.cust_nbr
WHERE c.test IS NULL

insira a descrição da imagem aqui

Geoff Patterson
fonte

Respostas:

10

Por que essa consulta precisa de um operador Spool de contagem de linhas? ... que otimização específica ela está tentando fornecer?

A cust_nbrcoluna in #existingCustomersé anulável. Se ele realmente contém nulos, a resposta correta aqui é retornar zero linhas ( NOT IN (NULL,...) sempre produzirá um conjunto de resultados vazio).

Portanto, a consulta pode ser considerada como

SELECT p.*
FROM   #potentialNewCustomers p
WHERE  NOT EXISTS (SELECT *
                   FROM   #existingCustomers e1
                   WHERE  p.cust_nbr = e1.cust_nbr)
       AND NOT EXISTS (SELECT *
                       FROM   #existingCustomers e2
                       WHERE  e2.cust_nbr IS NULL) 

Com o carretel de número de linhas lá para evitar a necessidade de avaliar o

EXISTS (SELECT *
        FROM   #existingCustomers e2
        WHERE  e2.cust_nbr IS NULL) 

Mais de uma vez.

Apenas parece ser um caso em que uma pequena diferença nas suposições pode fazer uma diferença bastante catastrófica no desempenho.

Depois de atualizar uma única linha, como abaixo ...

UPDATE #existingCustomers
SET    cust_nbr = NULL
WHERE  cust_nbr = 1;

... a consulta foi concluída em menos de um segundo. A contagem de linhas nas versões real e estimada do plano agora está quase no local.

SET STATISTICS TIME ON;
SET STATISTICS IO ON;

SELECT *
FROM   #potentialNewCustomers
WHERE  cust_nbr NOT IN (SELECT cust_nbr
                        FROM   #existingCustomers 
                       ) 

insira a descrição da imagem aqui

Zero linhas são exibidas como descrito acima.

Os histogramas de estatísticas e os limites de atualização automática no SQL Server não são granulares o suficiente para detectar esse tipo de alteração de linha única. Provavelmente, se a coluna for anulável, pode ser razoável trabalhar com base em que ela contém pelo menos um, NULLmesmo que o histograma estatístico não indique atualmente que existe algum.

Martin Smith
fonte
9

Por que essa consulta precisa de um operador Spool de contagem de linhas? Eu não acho que seja necessário para correção, então que otimização específica ela está tentando fornecer?

Veja a resposta completa de Martin para esta pergunta. O ponto principal é que, se uma única linha dentro do campo NOT INé NULL, a lógica booleana funciona de tal forma que "a resposta correta é retornar zero linhas". O Row Count Spooloperador está otimizando essa lógica (necessária).

Por que o SQL Server estima que a associação ao operador Spool de Contagem de Linhas remove todas as linhas?

A Microsoft fornece um excelente white paper sobre o SQL 2014 Cardinality Estimator . Neste documento, encontrei as seguintes informações:

O novo CE pressupõe que os valores consultados existem no conjunto de dados, mesmo que o valor fique fora do intervalo do histograma. O novo CE neste exemplo usa uma frequência média calculada multiplicando a cardinalidade da tabela pela densidade.

Muitas vezes, essa mudança é muito boa; ele alivia muito o problema-chave crescente e geralmente gera um plano de consulta mais conservador (estimativa de linha mais alta) para valores que estão fora do intervalo com base no histograma de estatísticas.

No entanto, nesse caso específico, supor que um NULLvalor seja encontrado leva à suposição de que a associação ao Row Count Spoolfiltro filtrará todas as linhas de #potentialNewCustomers. No caso em que de fato há uma NULLlinha, esta é uma estimativa correta (como visto na resposta de Martin). No entanto, no caso de não haver uma NULLlinha, o efeito pode ser devastador porque o SQL Server produz uma estimativa pós-ingresso de 1 linha, independentemente de quantas linhas de entrada apareçam. Isso pode levar a escolhas de junção muito ruins no restante do plano de consulta.

Isso é um bug no SQL 2014? Nesse caso, vou arquivar no Connect. Mas eu gostaria de ter um entendimento mais profundo primeiro.

Acho que está na área cinzenta entre um bug e uma suposição ou limitação que afeta o desempenho do novo Cardinality Estimator do SQL Server. No entanto, essa peculiaridade pode causar regressões substanciais no desempenho em relação ao SQL 2012 no caso específico de uma NOT INcláusula anulável que não possui nenhum NULLvalor.

Portanto, arquivei um problema do Connect para que a equipe do SQL esteja ciente das possíveis implicações dessa alteração no Estimador de cardinalidade.

Atualização: Estamos no CTP3 agora para SQL16 e confirmei que o problema não ocorre lá.

Geoff Patterson
fonte
5

A resposta de Martin Smith e sua resposta automática abordaram todos os pontos principais corretamente, só quero enfatizar uma área para futuros leitores:

Portanto, essa pergunta é mais sobre como entender essa consulta específica e planejar detalhadamente e menos sobre como formular a consulta de maneira diferente.

O objetivo declarado da consulta é:

-- Prune any existing customers from the set of potential new customers

É fácil expressar esse requisito no SQL, de várias maneiras. Qual deles é escolhido é mais uma questão de estilo do que qualquer outra coisa, mas a especificação da consulta ainda deve ser escrita para retornar resultados corretos em todos os casos. Isso inclui contabilizar nulos.

Expressando totalmente o requisito lógico:

  • Retornar clientes em potencial que ainda não são clientes
  • Liste cada cliente em potencial no máximo uma vez
  • Excluir potenciais nulos e clientes existentes (o que um cliente nulo significa)

Em seguida, podemos escrever uma consulta que corresponda a esses requisitos usando a sintaxe que preferirmos. Por exemplo:

WITH DistinctPotentialNonNullCustomers AS
(
    SELECT DISTINCT 
        PNC.cust_nbr 
    FROM #potentialNewCustomers AS PNC
    WHERE 
        PNC.cust_nbr IS NOT NULL
)
SELECT
    DPNNC.cust_nbr
FROM DistinctPotentialNonNullCustomers AS DPNNC
WHERE
    DPNNC.cust_nbr NOT IN
    (
        SELECT 
            EC.cust_nbr 
        FROM #existingCustomers AS EC 
        WHERE 
            EC.cust_nbr IS NOT NULL
    );

Isso produz um plano de execução eficiente, que retorna resultados corretos:

Plano de execução

Podemos expressar NOT INcomo <> ALLou NOT = ANYsem afetar o plano ou os resultados:

WITH DistinctPotentialNonNullCustomers AS
(
    SELECT DISTINCT 
        PNC.cust_nbr 
    FROM #potentialNewCustomers AS PNC
    WHERE 
        PNC.cust_nbr IS NOT NULL
)
SELECT
    DPNNC.cust_nbr
FROM DistinctPotentialNonNullCustomers AS DPNNC
WHERE
    DPNNC.cust_nbr <> ALL
    (
        SELECT 
            EC.cust_nbr 
        FROM #existingCustomers AS EC 
        WHERE 
            EC.cust_nbr IS NOT NULL
    );
WITH DistinctPotentialNonNullCustomers AS
(
    SELECT DISTINCT 
        PNC.cust_nbr 
    FROM #potentialNewCustomers AS PNC
    WHERE 
        PNC.cust_nbr IS NOT NULL
)
SELECT
    DPNNC.cust_nbr
FROM DistinctPotentialNonNullCustomers AS DPNNC
WHERE
    NOT DPNNC.cust_nbr = ANY
    (
        SELECT 
            EC.cust_nbr 
        FROM #existingCustomers AS EC 
        WHERE 
            EC.cust_nbr IS NOT NULL
    );

Ou usando NOT EXISTS:

WITH DistinctPotentialNonNullCustomers AS
(
    SELECT DISTINCT 
        PNC.cust_nbr 
    FROM #potentialNewCustomers AS PNC
    WHERE 
        PNC.cust_nbr IS NOT NULL
)
SELECT
    DPNNC.cust_nbr
FROM DistinctPotentialNonNullCustomers AS DPNNC
WHERE 
    NOT EXISTS
    (
        SELECT * 
        FROM #existingCustomers AS EC
        WHERE
            EC.cust_nbr = DPNNC.cust_nbr
            AND EC.cust_nbr IS NOT NULL
    );

Não há nada de mágico nisso, ou algo particularmente desagradável sobre o uso IN, ANYou ALL- nós apenas precisamos escrever a consulta corretamente, para que ela sempre produza os resultados certos.

A forma mais compacta usa EXCEPT:

SELECT 
    PNC.cust_nbr 
FROM #potentialNewCustomers AS PNC
WHERE 
    PNC.cust_nbr IS NOT NULL
EXCEPT
SELECT
    EC.cust_nbr 
FROM #existingCustomers AS EC
WHERE 
    EC.cust_nbr IS NOT NULL;

Isso também produz resultados corretos, embora o plano de execução possa ser menos eficiente devido à ausência de filtragem de bitmap:

Plano de execução sem bitmap

A pergunta original é interessante porque expõe um problema que afeta o desempenho com a implementação de verificação nula necessária. O ponto desta resposta é que a escrita da consulta também evita o problema corretamente .

Paul White 9
fonte