Escaneos inesperados durante la operación de eliminación usando WHERE IN

40

Tengo una consulta como la siguiente:

DELETE FROM tblFEStatsBrowsers WHERE BrowserID NOT IN (
    SELECT DISTINCT BrowserID FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID IS NOT NULL
)

tblFEStatsBrowsers tiene 553 filas.
tblFEStatsPaperHits tiene 47.974.301 filas.

tblFEStatsBrowsers:

CREATE TABLE [dbo].[tblFEStatsBrowsers](
    [BrowserID] [smallint] IDENTITY(1,1) NOT NULL,
    [Browser] [varchar](50) NOT NULL,
    [Name] [varchar](40) NOT NULL,
    [Version] [varchar](10) NOT NULL,
    CONSTRAINT [PK_tblFEStatsBrowsers] PRIMARY KEY CLUSTERED ([BrowserID] ASC)
)

tblFEStatsPaperHits:

CREATE TABLE [dbo].[tblFEStatsPaperHits](
    [PaperID] [int] NOT NULL,
    [Created] [smalldatetime] NOT NULL,
    [IP] [binary](4) NULL,
    [PlatformID] [tinyint] NULL,
    [BrowserID] [smallint] NULL,
    [ReferrerID] [int] NULL,
    [UserLanguage] [char](2) NULL
)

Hay un índice agrupado en tblFEStatsPaperHits que no incluye BrowserID. Por lo tanto, realizar la consulta interna requerirá una exploración completa de la tabla de tblFEStatsPaperHits, lo cual está totalmente bien.

Actualmente, se ejecuta una exploración completa para cada fila en tblFEStatsBrowsers, lo que significa que tengo 553 exploraciones de tabla completa de tblFEStatsPaperHits.

Reescribir solo a WHERE EXISTS no cambia el plan:

DELETE FROM tblFEStatsBrowsers WHERE NOT EXISTS (
    SELECT * FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID = tblFEStatsBrowsers.BrowserID
)

Sin embargo, como lo sugirió Adam Machanic, agregar una opción HASH JOIN resulta en un plan de ejecución óptimo (solo un escaneo de tblFEStatsPaperHits):

DELETE FROM tblFEStatsBrowsers WHERE NOT EXISTS (
    SELECT * FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID = tblFEStatsBrowsers.BrowserID
) OPTION (HASH JOIN)

Ahora, esto no es tanto una cuestión de cómo solucionar esto: puedo usar la OPCIÓN (HASH JOIN) o crear una tabla temporal manualmente. Me pregunto por qué el optimizador de consultas usaría el plan que usa actualmente.

Dado que el QO no tiene ninguna estadística en la columna BrowserID, supongo que está asumiendo lo peor: 50 millones de valores distintos, lo que requiere una mesa de trabajo bastante grande en memoria / tempdb. Como tal, la forma más segura es realizar exploraciones para cada fila en tblFEStatsBrowsers. No existe una relación de clave externa entre las columnas BrowserID en las dos tablas, por lo que el QO no puede deducir ninguna información de tblFEStatsBrowsers.

¿Es esta, tan simple como parece, la razón?

Actualización 1
Para dar un par de estadísticas: OPCIÓN (HASH JOIN):
208.711 lecturas lógicas (12 escaneos)

OPCIÓN (LOOP JOIN, HASH GROUP):
11.008.698 lecturas lógicas (~ escaneo por ID de navegador (339))

Sin opciones:
11.008.775 lecturas lógicas (~ escaneo por ID de navegador (339))

Actualización 2
Excelentes respuestas, todos ustedes, ¡gracias! Difícil elegir solo uno. Aunque Martin fue el primero y Remus proporciona una solución excelente, tengo que dárselo al Kiwi para que se vuelva mental en los detalles :)

Mark S. Rasmussen
fuente
55
¿Puede escribir las estadísticas según Copiar estadísticas de un servidor a otro para que podamos replicar?
Mark Storey-Smith
2
@ MarkStorey-Smith Sure - pastebin.com/9HHRPFgK Suponiendo que ejecute el script en una base de datos vacía, esto me permite reproducir las consultas problemáticas cuando se incluye mostrar el plan de ejecución. Ambas consultas se incluyen al final del guión.
Mark S. Rasmussen

Respuestas:

61

"Me pregunto por qué el optimizador de consultas usaría el plan que usa actualmente".

Para decirlo de otra manera, la pregunta es por qué el siguiente plan parece más barato para el optimizador, en comparación con las alternativas (de las cuales hay muchas ).

Plan original

El lado interno de la unión está ejecutando esencialmente una consulta del siguiente formulario para cada valor correlacionado de BrowserID:

DECLARE @BrowserID smallint;

SELECT 
    tfsph.BrowserID 
FROM dbo.tblFEStatsPaperHits AS tfsph 
WHERE 
    tfsph.BrowserID = @BrowserID 
OPTION (MAXDOP 1);

Escaneo de golpes de papel

Tenga en cuenta que el número estimado de filas es 185,220 (no 289,013 ) ya que la comparación de igualdad excluye implícitamente NULL(a menos que ANSI_NULLSsea OFF). El costo estimado del plan anterior es de 206.8 unidades.

Ahora agreguemos una TOP (1)cláusula:

DECLARE @BrowserID smallint;

SELECT TOP (1)
    tfsph.BrowserID 
FROM dbo.tblFEStatsPaperHits AS tfsph 
WHERE 
    tfsph.BrowserID = @BrowserID 
OPTION (MAXDOP 1);

Con TOP (1)

El costo estimado es ahora de 0.00452 unidades. La adición del operador físico superior establece un objetivo de fila de 1 fila en el operador superior. La pregunta se convierte entonces en cómo derivar un 'objetivo de fila' para el Análisis de índice agrupado; es decir, ¿cuántas filas debe procesar el análisis antes de que una fila coincida con el BrowserIDpredicado?

La información estadística disponible muestra 166BrowserID valores distintos (1 / [Toda la densidad] = 1 / 0.006024096 = 166). El cálculo de costos supone que los valores distintos se distribuyen de manera uniforme sobre las filas físicas, por lo que el objetivo de la fila en el Análisis de índice agrupado se establece en 166.302 (lo que representa el cambio en la cardinalidad de la tabla desde que se recopilaron las estadísticas muestreadas).

El costo estimado de escanear las 166 filas esperadas no es muy grande (incluso ejecutado 339 veces, una vez por cada cambio de BrowserID): la exploración de índice agrupado muestra un costo estimado de 1.3219 unidades, mostrando el efecto de escala del objetivo de la fila. Los costos del operador sin escala para E / S y CPU se muestran como 153.931 y 52.8698 respectivamente:

Costo estimado de la meta de la fila

En la práctica, es muy poco probable que las primeras 166 filas escaneadas del índice (en el orden en que se devuelvan) contendrán uno de cada uno de los BrowserIDvalores posibles . Sin embargo, el DELETEplan tiene un costo total de 1.40921 unidades, y el optimizador lo selecciona por ese motivo. Bart Duncan muestra otro ejemplo de este tipo en una publicación reciente titulada Row Goals Gone Rogue .

También es interesante observar que el operador Top en el plan de ejecución no está asociado con Anti Semi Join (en particular, el Martin de 'cortocircuito' menciona). Podemos comenzar a ver de dónde viene la parte superior deshabilitando primero una regla de exploración llamada GbAggToConstScanOrTop :

DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, LOOP JOIN, RECOMPILE);
GO
DBCC RULEON ('GbAggToConstScanOrTop');

GbAggToConstScanOrTop deshabilitado

Ese plan tiene un costo estimado de 364.912 y muestra que la parte superior reemplazó un grupo por agregado (agrupación por la columna correlacionada BrowserID). El agregado no se debe a la redundancia DISTINCTen el texto de la consulta: es una optimización que puede ser introducida por dos reglas de exploración, LASJNtoLASJNonDist y LASJOnLclDist . Deshabilitar esos dos también produce este plan:

DBCC RULEOFF ('LASJNtoLASJNonDist');
DBCC RULEOFF ('LASJOnLclDist');
DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, LOOP JOIN, RECOMPILE);
GO
DBCC RULEON ('LASJNtoLASJNonDist');
DBCC RULEON ('LASJOnLclDist');
DBCC RULEON ('GbAggToConstScanOrTop');

Plan de carrete

Ese plan tiene un costo estimado de 40729.3 unidades.

Sin la transformación de Agrupar por arriba, el optimizador 'naturalmente' elige un plan de unión hash con BrowserIDagregación antes de la anti semi unión:

DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, RECOMPILE);
GO
DBCC RULEON ('GbAggToConstScanOrTop');

No hay un plan DOP 1 superior

Y sin la restricción MAXDOP 1, un plan paralelo:

Sin plan paralelo superior

Otra forma de "arreglar" la consulta original sería crear el índice BrowserIDque falta en el que informa el plan de ejecución. Los bucles anidados funcionan mejor cuando se indexa el lado interno. Estimar la cardinalidad para semiuniones es un desafío en el mejor de los casos. No tener una indexación adecuada (¡la tabla grande ni siquiera tiene una clave única!) No ayudará en absoluto.

Escribí más sobre esto en Row Goals, Part 4: The Anti Join Anti Pattern .

Paul White dice GoFundMonica
fuente
3
Me inclino ante ti, me acabas de presentar varios conceptos nuevos que nunca antes había encontrado. Justo cuando sientes que sabes algo, alguien por ahí te decepcionará, en el buen sentido :) Agregar el índice definitivamente ayudaría. Sin embargo, además de esta operación única, la columna BrowserID nunca accede / agrega al campo, por lo que prefiero guardar esos bytes ya que la tabla es bastante grande (esta es solo una de muchas bases de datos idénticas). No hay una clave única sobre la mesa, ya que no existe una singularidad natural. Todas las selecciones son por PaperID y opcionalmente un punto.
Mark S. Rasmussen
22

Cuando ejecuto su script para crear una base de datos solo de estadísticas y la consulta en la pregunta obtengo el siguiente plan.

Plan

Las cardinalidades de tabla que se muestran en el plan son

  • tblFEStatsPaperHits: 48063400
  • tblFEStatsBrowsers : 339

Por lo tanto, estima que necesitará realizar el escaneo tblFEStatsPaperHits339 veces. Cada exploración tiene el predicado correlacionado tblFEStatsBrowsers.BrowserID=tblFEStatsPaperHits.BrowserID AND tblFEStatsPaperHits.BrowserID IS NOT NULLque se introduce en el operador de exploración.

Sin embargo, el plan no significa que habrá 339 escaneos completos. Como se encuentra bajo un operador anti semiunión tan pronto como se encuentra la primera fila coincidente en cada exploración, puede provocar un cortocircuito en el resto. El costo estimado del subárbol para este nodo es 1.32603y todo el plan tiene un costo 1.41337.

Para el Hash Join, da el siguiente plan

Hash Join

El plan general tiene un costo de 418.415(alrededor de 300 veces más caro que el plan de bucles anidados) con el escaneo de índice agrupado completo solo en un tblFEStatsPaperHitscosto 206.8solo. Compárese esto con la 1.32603estimación de 339 exploraciones parciales dadas anteriormente (coste estimado de exploración parcial Moderado = 0.003911592).

Esto indicaría que le está costando a cada escaneo parcial 53,000 veces menos costoso que un escaneo completo. Si los costos se escalaran linealmente con el recuento de filas, eso significaría que se supone que, en promedio, solo necesitaría procesar 900 filas en cada iteración antes de encontrar una fila coincidente y hacer un cortocircuito.

Sin embargo, no creo que los costos se escalen de esa manera lineal. Creo que también incorporan algún elemento de costo fijo de inicio. Intentando varios valores de TOPen la siguiente consulta

SELECT TOP 147 BrowserID 
FROM [dbo].[tblFEStatsPaperHits] 

147da el costo estimado de subárbol más cercano a 0.003911592at 0.0039113. De cualquier manera, está claro que basa el costo en el supuesto de que cada escaneo solo tendrá que procesar una pequeña proporción de la tabla, del orden de cientos de filas en lugar de millones.

No estoy seguro exactamente en qué matemáticas basa esta suposición y realmente no cuadra con las estimaciones de recuento de filas en el resto del plan (las 236 filas estimadas que salen de la unión de bucles anidados implicarían que había 236 casos en los que no se encontró ninguna fila coincidente y se requirió una exploración completa Supongo que este es solo un caso en el que los supuestos de modelado realizados disminuyen un poco y dejan el plan de bucles anidados significativamente bajo costo.

Martin Smith
fuente
20

En mi libro, incluso un escaneo de 50 millones de filas es inaceptable ... Mi truco habitual es materializar los distintos valores y delegar el motor para mantenerlo actualizado:

create view [dbo].[vwFEStatsPaperHitsBrowserID]
with schemabinding
as
select BrowserID, COUNT_BIG(*) as big_count
from [dbo].[tblFEStatsPaperHits]
group by [BrowserID];
go

create unique clustered index [cdxVwFEStatsPaperHitsBrowserID] 
  on [vwFEStatsPaperHitsBrowserID]([BrowserID]);
go

Esto le da un índice materializado de una fila por BrowserID, eliminando la necesidad de escanear 50 millones de filas. El motor lo mantendrá por usted y el QO lo usará 'tal cual' en la declaración que publicó (sin ninguna sugerencia o reescritura de consulta).

La desventaja es, por supuesto, la contención. Cualquier operación de inserción o eliminación en tblFEStatsPaperHits(y supongo que es una tabla de registro con inserciones pesadas) tendrá que serializar el acceso a un ID de navegador determinado. Hay maneras de hacerlo viable (actualizaciones retrasadas, 2 registros por etapas, etc.) si está dispuesto a comprarlo.

Remus Rusanu
fuente
Te escucho, cualquier escaneo tan grande es definitivamente inaceptable. En este caso, es para algunas operaciones de limpieza de datos únicas, por lo que opto por no crear índices adicionales (y no puedo hacerlo temporalmente ya que interrumpiría el sistema). No tengo EE pero dado que esto es una sola vez, las pistas estarían bien. Sin embargo, mi curiosidad principal fue cómo el QO se levantó con el plan :) La tabla es una tabla de registro y hay inserciones pesadas. Sin embargo, hay una tabla de registro asincrónica separada que luego actualiza las filas en tblFEStatsPaperHits para que pueda administrarla yo mismo, si es necesario.
Mark S. Rasmussen