sql >> Base de Datos >  >> RDS >> Database

Encontrar valores distintos rápidamente

En 2014, escribí un artículo llamado Performance Tuning the Whole Query Plan. Examinó formas de encontrar una cantidad relativamente pequeña de valores distintos de un conjunto de datos moderadamente grande y concluyó que una solución recursiva podría ser óptima. Esta publicación de seguimiento revisa la pregunta para SQL Server 2019, usando una mayor cantidad de filas.

Entorno de prueba

Usaré la base de datos Stack Overflow 2013 de 50 GB, pero serviría cualquier tabla grande con una cantidad baja de valores distintos.

Buscaré valores distintos en BountyAmount columna de dbo.Votes tabla, presentada en orden ascendente de cantidad de recompensa. La tabla Votos tiene algo menos de 53 millones de filas (52 928 720 para ser exactos). Solo hay 19 montos de recompensa diferentes, incluido NULL .

La base de datos Stack Overflow 2013 viene sin índices no agrupados para minimizar el tiempo de descarga. Hay un índice de clave principal agrupado en el Id columna de dbo.Votes mesa. Viene configurado para compatibilidad con SQL Server 2008 (nivel 100), pero comenzaremos con una configuración más moderna de SQL Server 2017 (nivel 140):

ALTER DATABASE StackOverflow2013
SET COMPATIBILITY_LEVEL = 140;

Las pruebas se realizaron en mi computadora portátil con SQL Server 2019 CU 2. Esta máquina tiene cuatro CPU i7 (con hiperprocesamiento a 8) con una velocidad base de 2,4 GHz. Tiene 32 GB de RAM, con 24 GB disponibles para la instancia de SQL Server 2019. El umbral de costo para el paralelismo se establece en 50.

Cada resultado de la prueba representa el mejor de diez ejecuciones, con todos los datos requeridos y las páginas de índice en la memoria.

1. Índice agrupado de almacén de filas

Para establecer una línea de base, la primera ejecución es una consulta en serie sin indexación nueva (y recuerde que esto es con la base de datos establecida en el nivel de compatibilidad 140):

SELECT DISTINCT 
    V.BountyAmount 
FROM dbo.Votes AS V 
ORDER BY
    V.BountyAmount
OPTION (MAXDOP 1);

Esto escanea el índice agrupado y usa un agregado de hash en modo fila para encontrar los distintos valores de BountyAmount :

Plan de índice agrupado en serie

Esto toma 10,500ms para completar, utilizando la misma cantidad de tiempo de CPU. Recuerde que este es el mejor tiempo de diez ejecuciones con todos los datos en la memoria. Estadísticas de muestra creadas automáticamente en el BountyAmount se crearon en la primera ejecución.

Aproximadamente la mitad del tiempo transcurrido se dedica a la exploración del índice agrupado y aproximadamente la otra mitad al agregado de coincidencia de hash. Sort solo tiene 19 filas para procesar, por lo que consume solo 1 ms aproximadamente. Todos los operadores de este plan utilizan la ejecución en modo fila.

Eliminando el MAXDOP 1 sugerencia da un plan paralelo:

Plan de índice agrupado en paralelo

Este es el plan que elige el optimizador sin ninguna pista en mi configuración. Devuelve resultados en 4200ms utilizando un total de 32 800 ms de CPU (en DOP 8).

2. Índice no agrupado

Explorando toda la tabla para encontrar solo el BountyAmount parece ineficiente, por lo que es natural intentar agregar un índice no agrupado solo en la columna que necesita esta consulta:

CREATE NONCLUSTERED INDEX b 
ON dbo.Votes (BountyAmount);

Este índice tarda un tiempo en crearse (1m 40s). El MAXDOP 1 la consulta ahora usa Stream Aggregate porque el optimizador puede usar el índice no agrupado para presentar filas en BountyAmount orden:

Plan de modo de fila no agrupado en serie

Tiene una duración de 9300 ms (consumiendo la misma cantidad de tiempo de CPU). Una mejora útil en los 10 500 ms originales, pero difícilmente impactante.

Eliminando el MAXDOP 1 sugerencia da un plan paralelo con agregación local (por subproceso):

Plan de modo de fila paralelo no agrupado

Esto se ejecuta en 3400 ms usando 25,800ms de tiempo de CPU. Es posible que podamos mejorar la compresión de filas o páginas en el nuevo índice, pero quiero pasar a opciones más interesantes.

3. Modo por lotes en almacenamiento en fila (BMOR)

Ahora configure la base de datos en el modo de compatibilidad de SQL Server 2019 usando:

ALTER DATABASE StackOverflow2013
SET COMPATIBILITY_LEVEL = 150;

Esto le da al optimizador la libertad de elegir el modo por lotes en el almacenamiento de filas si lo considera conveniente. Esto puede proporcionar algunos de los beneficios de la ejecución en modo por lotes sin necesidad de un índice de almacenamiento de columnas. Para obtener detalles técnicos profundos y opciones no documentadas, consulte el excelente artículo de Dmitry Pilugin sobre el tema.

Desafortunadamente, el optimizador aún elige la ejecución en modo de fila completa utilizando agregados de flujo para las consultas de prueba en serie y en paralelo. Para obtener un modo por lotes en el plan de ejecución de la tienda de filas, podemos agregar una sugerencia para fomentar la agregación mediante la coincidencia de hash (que puede ejecutarse en modo por lotes):

SELECT DISTINCT
    V.BountyAmount
FROM dbo.Votes AS V
ORDER BY
    V.BountyAmount
OPTION (HASH GROUP, MAXDOP 1);

Esto nos da un plan con todos los operadores ejecutándose en modo por lotes:

Modo por lotes en serie en el plan de almacenamiento en fila

Los resultados se devuelven en 2600 ms (toda la CPU como de costumbre). Esto ya es más rápido que el paralelo plan de modo de fila (3400 ms transcurridos) mientras se usa mucho menos CPU (2600 ms frente a 25 800 ms).

Eliminando el MAXDOP 1 pista (pero manteniendo HASH GROUP ) proporciona un modo de lote paralelo en el plan de almacenamiento de filas:

Modo de lote paralelo en plan de almacenamiento en fila

Esto se ejecuta en solo 725 ms utilizando 5700 ms de CPU.

4. Modo por lotes en el almacén de columnas

El modo de lote paralelo en el resultado del almacenamiento en fila es una mejora impresionante. Podemos hacerlo aún mejor al proporcionar una representación de almacén de columnas de los datos. Para mantener todo lo demás igual, agregaré un no agrupado índice de almacenamiento de columnas solo en la columna que necesitamos:

CREATE NONCLUSTERED COLUMNSTORE INDEX nb 
ON dbo.Votes (BountyAmount);

Esto se completa a partir del índice no agrupado del árbol b existente y se crea en solo 15 segundos.

SELECT DISTINCT
    V.BountyAmount
FROM dbo.Votes AS V
ORDER BY V.BountyAmount
OPTION (MAXDOP 1);

El optimizador elige un plan de modo por lotes completo que incluye un análisis de índice de almacén de columnas:

Plan de almacenamiento de columnas en serie

Esto se ejecuta en 115 ms utilizando la misma cantidad de tiempo de CPU. El optimizador elige este plan sin ninguna sugerencia sobre la configuración de mi sistema porque el costo estimado del plan está por debajo del umbral de costo para el paralelismo .

Para obtener un plan paralelo, podemos reducir el umbral de costo o usar una sugerencia no documentada:

SELECT DISTINCT
    V.BountyAmount
FROM dbo.Votes AS V
ORDER BY
    V.BountyAmount
OPTION (USE HINT ('ENABLE_PARALLEL_PLAN_PREFERENCE'));

En cualquier caso, el plan paralelo es:

Plan de almacenamiento de columnas paralelas

El tiempo transcurrido de la consulta ahora se ha reducido a 30 ms , mientras consume 210ms de CPU.

5. Modo por lotes en almacenamiento en columna con empuje hacia abajo

El mejor tiempo de ejecución actual de 30 ms es impresionante, especialmente si se compara con los 10 500 ms originales. Sin embargo, es un poco vergonzoso que tengamos que pasar casi 53 millones de filas (en 58 868 lotes) del escaneo al agregado de coincidencia de hash.

Sería bueno si SQL Server pudiera empujar la agregación hacia abajo en el escaneo y simplemente devolver valores distintos del almacén de columnas directamente. Podrías pensar que necesitamos expresar el DISTINCT como GROUP BY para obtener el empuje agregado agrupado, pero eso es lógicamente redundante y no es la historia completa en cualquier caso.

Con la implementación actual de SQL Server, en realidad necesitamos calcular un agregado para activar el pushdown agregado. Más que eso, necesitamos usar el resultado agregado de alguna manera, o el optimizador simplemente lo eliminará como innecesario.

Una forma de escribir la consulta para lograr un pushdown agregado es agregar un requisito de pedido secundario lógicamente redundante:

SELECT 
    V.BountyAmount
FROM dbo.Votes AS V 
GROUP BY
    V.BountyAmount
ORDER BY 
    V.BountyAmount,
    COUNT_BIG(*)    -- New!
OPTION (MAXDOP 1);

El plan de serie es ahora:

Plan de almacenamiento de columnas en serie con inserción agregada

¡Observe que no se pasan filas del Escaneo al Agregado! Debajo de las sábanas, agregados parciales de BountyAmount Los valores y sus recuentos de filas asociados se pasan a Hash Match Aggregate, que suma los agregados parciales para formar el agregado final (global) requerido. Esto es muy eficiente, como lo confirma el tiempo transcurrido de 13ms (todo lo cual es tiempo de CPU). Como recordatorio, el plan de serie anterior tardó 115 ms.

Para completar el conjunto, podemos obtener una versión paralela de la misma forma que antes:

Plan de almacenamiento de columnas paralelas con empuje agregado hacia abajo

Esto funciona durante 7 ms usando 40ms de CPU en total.

Es una pena que necesitemos calcular y usar un agregado que no necesitamos solo para empujar hacia abajo. Quizás esto se mejore en el futuro para que DISTINCT y GROUP BY sin agregados se pueden empujar hacia abajo para escanear.

6. Expresión de tabla común recursiva en modo fila

Al principio, prometí revisar la solución CTE recursiva utilizada para encontrar una pequeña cantidad de duplicados en un gran conjunto de datos. Implementar el requisito actual usando esa técnica es bastante sencillo, aunque el código es necesariamente más largo que cualquier cosa que hayamos visto hasta este punto:

WITH R AS
(
    -- Anchor
    SELECT
        V.BountyAmount
    FROM dbo.Votes AS V
    ORDER BY 
        V.BountyAmount ASC
        OFFSET 0 ROWS
        FETCH FIRST 1 ROW ONLY
 
    UNION ALL
 
    -- Recursive
    SELECT
        Q1.BountyAmount
    FROM 
    (
        SELECT 
            V.BountyAmount,
            rn = ROW_NUMBER() OVER (
                ORDER BY V.BountyAmount ASC)
        FROM R
        JOIN dbo.Votes AS V
            ON V.BountyAmount > ISNULL(R.BountyAmount, -1)
    ) AS Q1
    WHERE
        Q1.rn = 1
)
SELECT
    R.BountyAmount
FROM R
ORDER BY
    R.BountyAmount ASC
OPTION (MAXRECURSION 0);

La idea tiene sus raíces en el llamado escaneo de salto de índice:encontramos el valor más bajo de interés al comienzo del índice de árbol b ordenado ascendente, luego buscamos encontrar el siguiente valor en el orden del índice, y así sucesivamente. La estructura de un índice de árbol b hace que encontrar el siguiente valor más alto sea muy eficiente:no hay necesidad de escanear los duplicados.

El único truco real aquí es convencer al optimizador para que nos permita usar TOP en la parte 'recursiva' del CTE para devolver una copia de cada valor distinto. Consulte mi artículo anterior si necesita un repaso de los detalles.

El plan de ejecución (explicado en general por Craig Freedman aquí) es:

CTE recursivo serial

La consulta devuelve resultados correctos en 1 ms utilizando 1 ms de CPU, según Sentry One Plan Explorer.

7. T-SQL iterativo

Se puede expresar una lógica similar usando un WHILE círculo. El código puede ser más fácil de leer y comprender que el CTE recursivo. También evita tener que usar trucos para sortear las muchas restricciones en la parte recursiva de un CTE. El rendimiento es competitivo en torno a los 15 ms. Este código se proporciona por interés y no está incluido en la tabla de resumen de rendimiento.

SET NOCOUNT ON;
SET STATISTICS XML OFF;
 
DECLARE @Result table
(
    BountyAmount integer NULL UNIQUE CLUSTERED
);
 
DECLARE @BountyAmount integer;
 
-- First value in index order
WITH U AS
(
    SELECT
        V.BountyAmount
    FROM dbo.Votes AS V
    ORDER BY 
        V.BountyAmount ASC
        OFFSET 0 ROWS
        FETCH NEXT 1 ROW ONLY
)
UPDATE U
SET @BountyAmount = U.BountyAmount
OUTPUT Inserted.BountyAmount
    INTO @Result (BountyAmount);
 
-- Next higher value
WHILE @@ROWCOUNT > 0
BEGIN
    WITH U AS
    (
        SELECT
            V.BountyAmount
        FROM dbo.Votes AS V
        WHERE
            V.BountyAmount > ISNULL(@BountyAmount, -1)
        ORDER BY 
            V.BountyAmount ASC
            OFFSET 0 ROWS
            FETCH NEXT 1 ROW ONLY
    )
    UPDATE U
    SET @BountyAmount = U.BountyAmount
    OUTPUT Inserted.BountyAmount 
        INTO @Result (BountyAmount);
END;
 
-- Accumulated results
SELECT
    R.BountyAmount
FROM @Result AS R 
ORDER BY
    R.BountyAmount;

Tabla de resumen de rendimiento

Tabla de resumen de rendimiento (duración/CPU en milisegundos)

La “Est. Costo” muestra la estimación de costos del optimizador para cada consulta según lo informado en el sistema de prueba.

Pensamientos finales

Encontrar una pequeña cantidad de valores distintos puede parecer un requisito bastante específico, pero lo he encontrado con bastante frecuencia a lo largo de los años, generalmente como parte del ajuste de una consulta más grande.

Los últimos ejemplos estuvieron bastante cerca en rendimiento. Muchas personas estarían contentas con cualquiera de los resultados en fracciones de segundo, dependiendo de las prioridades. Incluso el modo por lotes en serie en el resultado de almacenamiento en fila de 2600 ms se compara bien con el mejor paralelo planes de modo de fila, lo que es un buen augurio para aceleraciones significativas simplemente actualizando a SQL Server 2019 y habilitando el nivel de compatibilidad de la base de datos 150. No todos podrán pasar rápidamente al almacenamiento de almacenamiento en columna, y no siempre será la solución correcta de todos modos . El modo por lotes en el almacenamiento de filas proporciona una forma ordenada de lograr algunas de las ganancias posibles con los almacenamientos de columnas, suponiendo que pueda convencer al optimizador para que opte por usarlo.

El resultado de empuje hacia abajo agregado de la tienda de columnas paralelas de 57 millones de filas procesado en 7ms (usando 40ms de CPU) es notable, especialmente considerando el hardware. El resultado de la reducción agregada en serie de 13 ms es igualmente impresionante. Sería genial si no hubiera tenido que agregar un resultado agregado sin sentido para obtener estos planes.

Para aquellos que aún no pueden migrar a SQL Server 2019 o al almacenamiento de almacenamiento en columnas, el CTE recursivo sigue siendo una solución viable y eficiente cuando existe un índice de árbol b adecuado y se garantiza que la cantidad de valores distintos necesarios es bastante pequeña. Sería bueno si SQL Server pudiera acceder a un árbol b como este sin necesidad de escribir un CTE recursivo (o un código T-SQL de bucle iterativo equivalente usando WHILE ).

Otra posible solución para el problema es crear una vista indexada. Esto proporcionará valores distintos con gran eficiencia. La desventaja, como de costumbre, es que cada cambio en la tabla subyacente deberá actualizar el recuento de filas almacenado en la vista materializada.

Cada una de las soluciones presentadas aquí tiene su lugar, dependiendo de los requisitos. Tener una amplia gama de herramientas disponibles es, en términos generales, algo bueno cuando se ajustan las consultas. La mayoría de las veces, elegiría una de las soluciones de modo por lotes porque su rendimiento será bastante predecible sin importar cuántos duplicados estén presentes.