Query 100x más lento en SQL Server 2014, Row Count Spool row estiman al culpable?


13

Tengo una consulta que se ejecuta en 800 milisegundos en SQL Server 2012 y tarda unos 170 segundos en SQL Server 2014 . Creo que he reducido esto a una estimación de cardinalidad pobre para el Row Count Spooloperador. He leído un poco sobre los operadores de spool (por ejemplo, aquí y aquí ), pero todavía tengo problemas para entender algunas cosas:

  • ¿Por qué esta consulta necesita un Row Count Spooloperador? No creo que sea necesario para la corrección, entonces, ¿qué optimización específica está tratando de proporcionar?
  • ¿Por qué SQL Server estima que la unión al Row Count Spooloperador elimina todas las filas?
  • ¿Es esto un error en SQL Server 2014? Si es así, presentaré en Connect. Pero me gustaría una comprensión más profunda primero.

Nota: Puedo reescribir la consulta como LEFT JOINo agregar índices a las tablas para lograr un rendimiento aceptable tanto en SQL Server 2012 como en SQL Server 2014. Por lo tanto, esta pregunta trata más sobre cómo entender esta consulta específica y planificar en profundidad y menos sobre cómo formular la consulta de manera diferente.


La consulta lenta

Vea este Pastebin para un script de prueba completo. Aquí está la consulta de prueba específica que estoy viendo:

-- 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: el plan de consulta estimado

SQL Server considera que el Left Anti Semi Joinal Row Count Spoolfiltrará los 10.000 filas por debajo de 1 fila. Por este motivo, selecciona a LOOP JOINpara la unión posterior a #existingCustomers.

ingrese la descripción de la imagen aquí


SQL Server 2014: el plan de consulta real

Como era de esperar (¡todos menos SQL Server!), Row Count SpoolNo eliminó ninguna fila. Por lo tanto, estamos realizando un ciclo de 10,000 veces cuando SQL Server esperaba que se repitiera solo una vez.

ingrese la descripción de la imagen aquí


SQL Server 2012: el plan de consulta estimado

Cuando se usa SQL Server 2012 (o OPTION (QUERYTRACEON 9481)en SQL Server 2014), Row Count Spoolno reduce el número estimado de filas y se elige una combinación hash, lo que resulta en un plan mucho mejor.

ingrese la descripción de la imagen aquí

La REUNIÓN DE LA IZQUIERDA

Como referencia, aquí hay una forma en la que puedo volver a escribir la consulta para lograr un buen rendimiento en todos los SQL Server 2012, 2014 y 2016. Sin embargo, todavía estoy interesado en el comportamiento específico de la consulta anterior y si es un error en el nuevo estimador de cardinalidad de 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

ingrese la descripción de la imagen aquí

Respuestas:


10

¿Por qué esta consulta necesita un operador de carrete de recuento de filas? ... ¿qué optimización específica está tratando de proporcionar?

La cust_nbrcolumna en #existingCustomerses anulable. Si en realidad contiene nulos, la respuesta correcta aquí es devolver cero filas ( NOT IN (NULL,...) siempre arrojará un conjunto de resultados vacío).

Entonces la consulta se puede considerar 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) 

Con el carrete de recuento de filas allí para evitar tener que evaluar el

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

Mas de una vez.

Esto parece ser un caso en el que una pequeña diferencia en los supuestos puede marcar una diferencia catastrófica en el rendimiento.

Después de actualizar una sola fila como a continuación ...

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

... la consulta se completó en menos de un segundo. Los recuentos de filas en versiones reales y estimadas del plan ahora son casi acertados.

SET STATISTICS TIME ON;
SET STATISTICS IO ON;

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

ingrese la descripción de la imagen aquí

Se emiten cero filas como se describe anteriormente.

Los histogramas de estadísticas y los umbrales de actualización automática en SQL Server no son lo suficientemente granulares como para detectar este tipo de cambio de una sola fila. Podría decirse que si la columna es anulable, podría ser razonable trabajar sobre la base de que contiene al menos uno, NULLincluso si el histograma de estadísticas no indica actualmente que haya alguno.


9

¿Por qué esta consulta necesita un operador de carrete de recuento de filas? No creo que sea necesario para la corrección, entonces, ¿qué optimización específica está tratando de proporcionar?

Vea la respuesta completa de Martin para esta pregunta. El punto clave es que si una sola fila dentro de NOT INes NULL, la lógica booleana funciona de tal manera que "la respuesta correcta es devolver cero filas". El Row Count Spooloperador está optimizando esta lógica (necesaria).

¿Por qué SQL Server estima que la unión al operador de Recuento de filas elimina todas las filas?

Microsoft proporciona un excelente documento técnico sobre el estimador de cardinalidad SQL 2014 . En este documento, encontré la siguiente información:

El nuevo CE supone que los valores consultados existen en el conjunto de datos, incluso si el valor cae fuera del rango del histograma. La nueva CE en este ejemplo usa una frecuencia promedio que se calcula multiplicando la cardinalidad de la tabla por la densidad.

A menudo, tal cambio es muy bueno; alivia en gran medida el problema clave ascendente y, por lo general, produce un plan de consulta más conservador (estimación de fila más alta) para valores que están fuera de rango según el histograma de estadísticas.

Sin embargo, en este caso específico, suponer que se encontrará un NULLvalor conduce a la suposición de que unirse a la Row Count Spoolfiltrará todas las filas #potentialNewCustomers. En el caso de que de hecho haya una NULLfila, esta es una estimación correcta (como se ve en la respuesta de Martin). Sin embargo, en el caso de que no haya una NULLfila, el efecto puede ser devastador porque SQL Server produce una estimación posterior a la unión de 1 fila, independientemente de cuántas filas de entrada aparezcan. Esto puede conducir a elecciones de unión muy pobres en el resto del plan de consulta.

¿Es esto un error en SQL 2014? Si es así, presentaré en Connect. Pero me gustaría una comprensión más profunda primero.

Creo que está en el área gris entre un error y una suposición o limitación que afecta el rendimiento del nuevo Estimador de cardinalidad de SQL Server. Sin embargo, esta peculiaridad puede causar regresiones sustanciales en el rendimiento en relación con SQL 2012 en el caso específico de una NOT INcláusula anulable que no tiene ningún NULLvalor.

Por lo tanto, he presentado un problema de conexión para que el equipo de SQL esté al tanto de las posibles implicaciones de este cambio en el Estimador de cardinalidad.

Actualización: ahora estamos en CTP3 para SQL16, y he confirmado que el problema no ocurre allí.


5

La respuesta de Martin Smith y su auto-respuesta han abordado todos los puntos principales correctamente, solo quiero enfatizar un área para futuros lectores:

Entonces, esta pregunta se trata más de comprender esta consulta y plan específicos en profundidad y menos sobre cómo formular la consulta de manera diferente.

El propósito declarado de la consulta es:

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

Este requisito es fácil de expresar en SQL, de varias maneras. Cuál es el elegido es tanto una cuestión de estilo como cualquier otra cosa, pero la especificación de la consulta aún debe escribirse para devolver resultados correctos en todos los casos. Esto incluye la contabilidad de nulos.

Expresando completamente el requisito lógico:

  • Devolver clientes potenciales que aún no son clientes
  • Enumere cada cliente potencial como máximo una vez
  • Excluir clientes nulos potenciales y existentes (lo que sea que signifique un cliente nulo)

Luego podemos escribir una consulta que coincida con esos requisitos utilizando la sintaxis que prefieramos. Por ejemplo:

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

Esto produce un plan de ejecución eficiente, que devuelve resultados correctos:

Plan de ejecución

Podemos expresar NOT INcomo <> ALLo NOT = ANYsin afectar el plan o los 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
    );

O 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
    );

No hay nada mágico en esto, ni nada particularmente desagradable sobre el uso IN, ANYo ALL, simplemente necesitamos escribir la consulta correctamente, para que siempre produzca los resultados correctos.

La forma más compacta utiliza 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;

Esto también produce resultados correctos, aunque el plan de ejecución puede ser menos eficiente debido a la ausencia de filtrado de mapa de bits:

Plan de ejecución sin mapa de bits

La pregunta original es interesante porque expone un problema que afecta el rendimiento con la implementación de verificación nula necesaria. El punto de esta respuesta es que escribir la consulta correctamente también evita el problema.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.