En SQL Server, ¿debería forzar un LOOP JOIN en el siguiente caso?


15

Por lo general, recomiendo no usar sugerencias de combinación por todas las razones estándar. Recientemente, sin embargo, he encontrado un patrón en el que casi siempre encuentro una unión de bucle forzado para un mejor rendimiento. De hecho, estoy empezando a usarlo y recomendarlo tanto que quería obtener una segunda opinión para asegurarme de que no me falta algo. Aquí hay un escenario representativo (el código muy específico para generar un ejemplo está al 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 tiene 1 millón de filas y su PK es ID.
La tabla temporal #Driver tiene solo una columna, ID, sin índices y 50,000 filas.

Lo que siempre encuentro es lo siguiente:

Caso 1: SIN SUGERENCIA
Escaneo de índice en SampleTable
Hash Join
Mayor duración (promedio 333 ms)
CPU más alta (promedio 331 ms)
Lecturas lógicas más bajas (4714)

Caso 2: SUGERENCIA SUGERENCIA
Indice Buscar en la tabla Tabla
Loop Join
Duración más baja (promedio 204ms, 39% menos)
CPU más baja (promedio 206, 38% menos)
Lecturas lógicas mucho más altas (160015, 34X más)

Al principio, las lecturas mucho más altas del segundo caso me asustaron un poco porque reducir las lecturas a menudo se considera una medida decente del rendimiento. Pero cuanto más pienso en lo que realmente está sucediendo, no me preocupa. Aquí está mi pensamiento:

SampleTable está contenido en 4714 páginas, ocupando aproximadamente 36MB. El caso 1 los escanea a todos, razón por la cual obtenemos 4714 lecturas. Además, debe realizar 1 millón de hashes, que son intensivos en CPU, y que finalmente aumentan el tiempo proporcionalmente. Es todo este hashing lo que parece aumentar el tiempo en el caso 1.

Ahora considere el caso 2. No está haciendo ningún hashing, sino que está haciendo 50000 búsquedas separadas, que es lo que está impulsando las lecturas. Pero, ¿qué tan caras son las lecturas comparativamente? Se podría decir que si se trata de lecturas físicas, podría ser bastante costoso. Pero tenga en cuenta 1) solo la primera lectura de una página determinada podría ser física, y 2) aun así, el caso 1 tendría el mismo problema o un problema peor, ya que se garantiza que llegará a cada página.

Entonces, teniendo en cuenta el hecho de que ambos casos tienen que acceder a cada página al menos una vez, parece ser una cuestión de cuál es más rápido, ¿1 millón de hashes o aproximadamente 155000 lecturas contra la memoria? Mis pruebas parecen decir lo último, pero SQL Server elige consistentemente lo primero.

Pregunta

Volviendo a mi pregunta: ¿debo seguir forzando esta sugerencia de LOOP JOIN cuando las pruebas muestran este tipo de resultados, o me falta algo en mi análisis? Dudo en ir en contra del optimizador de SQL Server, pero parece que cambia a usar un hash join mucho antes de lo que debería en casos como estos.

Actualizar 28-04-2014

Hice algunas pruebas más y descubrí que los resultados que estaba obteniendo arriba (en una VM con 2 CPU) no podía replicar en otros entornos (probé en 2 máquinas físicas diferentes con 8 y 12 CPU). El optimizador funcionó mucho mejor en los últimos casos hasta el punto en que no hubo un problema tan pronunciado. Supongo que la lección aprendida, que parece obvia en retrospectiva, es que el entorno puede afectar significativamente qué tan bien funciona el optimizador.

Planes de ejecucion

Plan de ejecución Caso 1 Plan 1 Plan de ejecución Caso 2 ingrese la descripción de la imagen aquí

Código para generar caso de muestra

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

*/

Respuestas:


13

SampleTable está contenido en 4714 páginas, ocupando aproximadamente 36MB. El caso 1 los escanea a todos, razón por la cual obtenemos 4714 lecturas. Además, debe realizar 1 millón de hashes, que son intensivos en CPU, y que finalmente aumentan el tiempo proporcionalmente. Es todo este hashing lo que parece aumentar el tiempo en el caso 1.

Hay un costo inicial para una unión hash (construir la tabla hash, que también es una operación de bloqueo), pero la unión hash finalmente tiene el costo teórico por fila más bajo de los tres tipos de unión física admitidos por SQL Server, ambos en términos de IO y CPU. Hash join realmente se destaca con una entrada de construcción relativamente pequeña y una entrada de sonda grande. Dicho esto, ningún tipo de combinación física es "mejor" en todos los escenarios.

Ahora considere el caso 2. No está haciendo ningún hashing, sino que está haciendo 50000 búsquedas separadas, que es lo que está impulsando las lecturas. Pero, ¿qué tan caras son las lecturas comparativamente? Se podría decir que si se trata de lecturas físicas, podría ser bastante costoso. Pero tenga en cuenta 1) solo la primera lectura de una página determinada podría ser física, y 2) aun así, el caso 1 tendría el mismo problema o un problema peor, ya que se garantiza que llegará a cada página.

Cada búsqueda requiere navegar un árbol b hasta la raíz, lo que es computacionalmente costoso en comparación con una sola sonda hash. Además, el patrón general de E / S para el lado interno de una unión de bucles anidados es aleatorio, en comparación con el patrón de acceso secuencial de la entrada de exploración del lado de la sonda a una unión hash. Dependiendo del subsistema IO físico subyacente, las lecturas secuenciales pueden ser más rápidas que las lecturas aleatorias. Además, el mecanismo de lectura anticipada de SQL Server funciona mejor con E / S secuenciales, emitiendo lecturas más grandes.

Entonces, teniendo en cuenta el hecho de que ambos casos tienen que acceder a cada página al menos una vez, parece ser una cuestión de cuál es más rápido, ¿1 millón de hashes o aproximadamente 155000 lecturas contra la memoria? Mis pruebas parecen decir lo último, pero SQL Server elige consistentemente lo primero.

El optimizador de consultas de SQL Server realiza una serie de suposiciones. Una es que el primer acceso a una página realizada por una consulta dará como resultado un IO físico (el 'supuesto de caché en frío'). Se modela la posibilidad de que una lectura posterior provenga de una página ya leída en la memoria por la misma consulta, pero esto no es más que una suposición educada.

La razón por la cual el modelo del optimizador funciona de esta manera es que generalmente es mejor optimizar para el peor de los casos (se necesita IO físico). Muchas deficiencias pueden ser cubiertas por paralelismo y ejecutar cosas en la memoria. La consulta planifica que el optimizador produciría si asumiera que todos los datos en la memoria podrían funcionar muy mal si esa suposición resultara no válida.

El plan producido utilizando el supuesto de caché en frío puede no funcionar tan bien como si se supusiera un caché en caliente, pero su rendimiento en el peor de los casos generalmente será superior.

¿Debo seguir forzando esta sugerencia de LOOP JOIN cuando las pruebas muestran este tipo de resultados, o me falta algo en mi análisis? Dudo en ir en contra del optimizador de SQL Server, pero parece que cambia a usar un hash join mucho antes de lo que debería en casos como estos.

Debe tener mucho cuidado al hacer esto por dos razones. Primero, las sugerencias de combinación también fuerzan silenciosamente el orden de combinación física para que coincida con el orden escrito de la consulta (como si también lo hubiera especificado OPTION (FORCE ORDER). Esto limita severamente las alternativas disponibles para el optimizador, y puede que no siempre sea lo que desea. OPTION (LOOP JOIN)Fuerzas bucles anidados se une a la consulta, pero no impone el orden de unión escrito.

En segundo lugar, está asumiendo que el tamaño del conjunto de datos seguirá siendo pequeño y que la mayoría de las lecturas lógicas provendrán de la memoria caché. Si estos supuestos se vuelven inválidos (quizás con el tiempo), el rendimiento se degradará. El optimizador de consultas incorporado es bastante bueno para reaccionar a las circunstancias cambiantes; eliminar esa libertad es algo en lo que deberías pensar mucho.

En general, a menos que haya una razón convincente para forzar uniones de bucles, lo evitaría. Los planes predeterminados suelen estar bastante cerca de lo óptimo, y tienden a ser más resistentes ante las circunstancias cambiantes.


Gracias Paul Excelente análisis detallado. Basado en algunas pruebas adicionales que hice, creo que lo que está sucediendo es que las conjeturas educadas del optimizador están constantemente fuera de este ejemplo en particular cuando el tamaño de la tabla temporal está entre 5K y 100K. Dado que nuestros requisitos garantizan que la tabla temporal será <50K, me parece seguro. Tengo curiosidad, ¿aún evitarías algún tipo de sugerencia de unión sabiendo esto?
JohnnyM

1
@JohnnyM Las sugerencias existen por una razón. Está bien usarlos donde tenga buenas razones para hacerlo. Dicho esto, rara vez uso sugerencias de unión debido a lo implícito FORCE ORDER. En alguna ocasión, utilizo una sugerencia de combinación, a menudo agrego OPTION (FORCE ORDER)un comentario para explicar por qué.
Paul White Restablece a Monica

0

50,000 filas unidas contra una tabla de un millón de filas parece ser mucho para cualquier tabla sin un índice.

Es difícil decirle exactamente qué hacer en este caso, ya que está tan aislado del problema que realmente está tratando de resolverlo. Ciertamente, espero que no sea un patrón general dentro de su código donde se une a muchas tablas temporales no indexadas con cantidades significativas de filas.

Tomando el ejemplo solo por lo que dice, ¿por qué no poner un índice en #Driver? ¿Es D.ID verdaderamente único? Si es así, eso es semánticamente equivalente a una declaración EXISTS, que al menos le permitirá a SQL Server saber que no desea continuar buscando valores duplicados de D en S:

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

En resumen, para este patrón, no usaría una sugerencia LOOP. Simplemente no usaría este patrón. Haría uno de los siguientes, en orden de prioridad si es factible:

  • Use un CTE en lugar de una tabla temporal para #Driver si es posible
  • Use un índice único no agrupado en #Driver en ID si es único (suponiendo que esta sea la única vez que use #Driver y que no desee ningún dato de la tabla en sí; si realmente necesita datos de esa tabla, usted bien podría hacerlo un índice agrupado)
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.