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

Limitaciones del optimizador con índices filtrados

Uno de los casos de uso del índice filtrado mencionado en Books Online se refiere a una columna que contiene principalmente NULLs valores. La idea es crear un índice filtrado que excluya los NULLs , lo que da como resultado un índice no agrupado más pequeño que requiere menos mantenimiento que el índice equivalente sin filtrar. Otro uso popular de los índices filtrados es filtrar NULLs de un UNIQUE index, brindando el comportamiento que los usuarios de otros motores de base de datos podrían esperar de un UNIQUE predeterminado índice o restricción:la unicidad se aplica solo para el no NULLs valores.

Desafortunadamente, el optimizador de consultas tiene limitaciones en lo que respecta a los índices filtrados. Esta publicación analiza un par de ejemplos menos conocidos.

Tablas de muestra

Usaremos dos tablas (A y B) que tienen la misma estructura:una clave principal agrupada sustituta, una mayormente NULLs columna que es única (sin tener en cuenta NULLs ) y una columna de relleno que representa las otras columnas que podrían estar en una tabla real.

La columna de interés es mayormente NULLs uno, que he declarado como SPARSE . La opción escasa no es obligatoria, solo la incluyo porque no tengo muchas posibilidades de usarla. En cualquier caso, SPARSE probablemente tenga sentido en muchos escenarios donde se espera que los datos de la columna sean en su mayoría NULLs . Si lo desea, puede eliminar el atributo disperso de los ejemplos.

CREATE TABLE dbo.TableA
(
    pk      integer IDENTITY PRIMARY KEY,
    data    bigint SPARSE NULL,
    padding binary(250) NOT NULL DEFAULT 0x
);
 
CREATE TABLE dbo.TableB
(
    pk      integer IDENTITY PRIMARY KEY,
    data    bigint SPARSE NULL,
    padding binary(250) NOT NULL DEFAULT 0x
);

Cada tabla contiene los números del 1 al 2000 en la columna de datos con 40 000 filas adicionales donde la columna de datos es NULLs :

-- Numbers 1 - 2,000
INSERT
    dbo.TableA WITH (TABLOCKX)
    (data)
SELECT TOP (2000)
    ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
FROM sys.columns AS c
CROSS JOIN sys.columns AS c2
ORDER BY
    ROW_NUMBER() OVER (ORDER BY (SELECT NULL));
 
-- NULLs
INSERT TOP (40000)
    dbo.TableA WITH (TABLOCKX)
    (data)
SELECT
    CONVERT(bigint, NULL)
FROM sys.columns AS c
CROSS JOIN sys.columns AS c2;
 
-- Copy into TableB
INSERT dbo.TableB WITH (TABLOCKX)
    (data)
SELECT
    ta.data
FROM dbo.TableA AS ta;

Ambas tablas obtienen un UNIQUE índice filtrado para los 2000 no NULLs valores de datos:

CREATE UNIQUE NONCLUSTERED INDEX uqA
ON dbo.TableA (data) 
WHERE data IS NOT NULL;
 
CREATE UNIQUE NONCLUSTERED INDEX uqB
ON dbo.TableB (data) 
WHERE data IS NOT NULL;

La salida de DBCC SHOW_STATISTICS resume la situación:

DBCC SHOW_STATISTICS (TableA, uqA) WITH STAT_HEADER;
DBCC SHOW_STATISTICS (TableB, uqB) WITH STAT_HEADER;

Consulta de muestra

La consulta a continuación realiza una unión simple de las dos tablas:imagine que las tablas están en algún tipo de relación padre-hijo y muchas de las claves externas son NULL. Algo por el estilo de todos modos.

SELECT
    ta.data,
    tb.data
FROM dbo.TableA AS ta
JOIN dbo.TableB AS tb
    ON ta.data = tb.data;

Plan de ejecución predeterminado

Con SQL Server en su configuración predeterminada, el optimizador elige un plan de ejecución con una combinación de bucles anidados paralelos:

Este plan tiene un costo estimado de 7.7768 unidades magic Optimizer™.

Sin embargo, hay algunas cosas extrañas en este plan. Index Seek usa nuestro índice filtrado en la tabla B, pero la consulta está impulsada por un escaneo de índice agrupado de la tabla A. El predicado de unión es una prueba de igualdad en las columnas de datos, que rechazará NULLs (independientemente del ANSI_NULLS entorno). Podríamos haber esperado que el optimizador realizara algún razonamiento avanzado basado en esa observación, pero no. Este plan lee cada fila de la tabla A (incluidos los 40 000 NULLs ), realiza una búsqueda en el índice filtrado en la tabla B para cada uno, basándose en el hecho de que NULLs no coincidirá con NULLs en esa busqueda. Esta es una tremenda pérdida de esfuerzo.

Lo extraño es que el optimizador debe haberse dado cuenta de que la combinación rechaza NULLs para elegir el índice filtrado para la tabla B busque, pero no pensó en filtrar NULLs de la tabla A primero, o mejor aún, simplemente escanear el NULLs -índice filtrado libre en la tabla A. Quizás se pregunte si esta es una decisión basada en costos, ¿quizás las estadísticas no son muy buenas? ¿Quizás deberíamos forzar el uso del índice filtrado con una pista? Indicar el índice filtrado en la tabla A solo da como resultado el mismo plan con los roles invertidos:escanear la tabla B y buscar en la tabla A. Forzar el índice filtrado para ambas tablas produce error 8622 :el procesador de consultas no pudo producir un plan de consulta.

Agregar un predicado NOT NULL

Sospechar que la causa tiene algo que ver con el NULLs implícito -rechazo del predicado de unión, agregamos un NOT NULL explícito predicado al ON cláusula (o el WHERE cláusula si lo prefiere, se trata de lo mismo aquí):

SELECT
    ta.data,
    tb.data
FROM dbo.TableA AS ta
JOIN dbo.TableB AS tb
    ON ta.data = tb.data
    AND ta.data IS NOT NULL;

Añadimos el NOT NULL verifique la columna de la tabla A porque el plan original escaneó el índice agrupado de esa tabla en lugar de usar nuestro índice filtrado (la búsqueda en la tabla B estuvo bien; sí usó el índice filtrado). La nueva consulta es semánticamente exactamente igual a la anterior, pero el plan de ejecución es diferente:

Ahora tenemos el escaneo esperado del índice filtrado en la tabla A, que produce 2,000 no NULLs filas para impulsar las búsquedas de bucle anidado en la tabla B. Ambas tablas están utilizando nuestros índices filtrados aparentemente de manera óptima ahora:el nuevo plan cuesta solo 0.362835 unidades (por debajo de 7.7768). Sin embargo, podemos hacerlo mejor.

Agregar dos predicados NOT NULL

El redundante NOT NULL el predicado de la tabla A hizo maravillas; ¿Qué sucede si también agregamos uno para la tabla B?

SELECT
    ta.data,
    tb.data
FROM dbo.TableA AS ta
JOIN dbo.TableB AS tb
    ON ta.data = tb.data
    AND ta.data IS NOT NULL 
    AND tb.data IS NOT NULL;

Esta consulta sigue siendo lógicamente la misma que los dos esfuerzos anteriores, pero el plan de ejecución es diferente nuevamente:

Este plan crea una tabla hash para las 2000 filas de la tabla A, luego busca coincidencias usando las 2000 filas de la tabla B. El número estimado de filas devueltas es mucho mejor que el plan anterior (¿observó la estimación de 7619 allí?) y el costo de ejecución estimado ha vuelto a caer, de 0,362835 a 0,0772056 .

Podría intentar forzar una unión hash utilizando una pista en el original o NOT NULL único consultas, pero no obtendrá el plan de bajo costo que se muestra arriba. El optimizador simplemente no tiene la capacidad de razonar completamente sobre el NULLs -rechazar el comportamiento de la combinación tal como se aplica a nuestros índices filtrados sin ambos predicados redundantes.

Puedes sorprenderte por esto, incluso si es solo la idea de que un predicado redundante no fue suficiente (seguramente si ta.data es NOT NULL y ta.data = tb.data , se deduce que tb.data es también NOT NULL , ¿verdad?)

Todavía no es perfecto

Es un poco sorprendente ver un hash unirse allí. Si está familiarizado con las principales diferencias entre los tres operadores de combinación física, probablemente sepa que la combinación hash es una de las principales candidatas donde:

  1. La entrada preordenada no está disponible
  2. La entrada de compilación hash es más pequeña que la entrada de la sonda
  3. La entrada de la sonda es bastante grande

Ninguna de estas cosas es cierta aquí. Nuestra expectativa sería que el mejor plan para esta consulta y conjunto de datos sería una combinación de combinación, explotando la entrada ordenada disponible de nuestros dos índices filtrados. Podemos intentar insinuar una combinación de fusión, conservando los dos ON adicionales predicados de la cláusula:

SELECT 
    ta.data,
    tb.data
FROM dbo.TableA AS ta
JOIN dbo.TableB AS tb
    ON ta.data = tb.data
    AND ta.data IS NOT NULL 
    AND tb.data IS NOT NULL
OPTION (MERGE JOIN);

La forma del plano es como esperábamos:

Un escaneo ordenado de ambos índices filtrados, excelentes estimaciones de cardinalidad, fantástico. Solo un pequeño problema:este plan de ejecución es mucho peor; el coste estimado ha saltado de 0,0772056 a 0,741527 . El motivo del salto en el costo estimado se revela al verificar las propiedades del operador de combinación de fusión:

Esta es una combinación costosa de muchos a muchos, donde el motor de ejecución debe realizar un seguimiento de los duplicados de la entrada externa en una mesa de trabajo y rebobinar según sea necesario. ¿Duplicados? ¡Estamos escaneando un índice único! Resulta que el optimizador no sabe que un índice único filtrado produce valores únicos (conectar artículo aquí). De hecho, se trata de una unión uno a uno, pero el optimizador cuesta como si fuera muchos a muchos, lo que explica por qué prefiere el plan de unión hash.

Una estrategia alternativa

Parece que seguimos enfrentándonos a las limitaciones del optimizador cuando usamos índices filtrados aquí (a pesar de que es un caso de uso destacado en Books Online). ¿Qué sucede si intentamos usar vistas en su lugar?

Uso de vistas

Las siguientes dos vistas solo filtran las tablas base para mostrar las filas donde la columna de datos es NOT NULL :

CREATE VIEW dbo.VA
WITH SCHEMABINDING AS
SELECT
    pk,
    data,
    padding
FROM dbo.TableA
WHERE data IS NOT NULL;
GO
CREATE VIEW dbo.VB
WITH SCHEMABINDING AS
SELECT
    pk,
    data,
    padding
FROM dbo.TableB
WHERE data IS NOT NULL;

Reescribir la consulta original para usar las vistas es trivial:

SELECT 
    v.data,
    v2.data
FROM dbo.VA AS v
JOIN dbo.VB AS v2
    ON v.data = v2.data;

Recuerde que esta consulta produjo originalmente un plan de bucles anidados paralelos con un costo de 7,7768 unidades. Con las referencias de vista, obtenemos este plan de ejecución:

Este es exactamente el mismo plan de combinación hash que tuvimos que agregar NOT NULL redundante predicados para obtener con los índices filtrados (el costo es 0.0772056 unidades como antes). Esto se esperaba, porque todo lo que hemos hecho esencialmente aquí es empujar el extra NOT NULL predicados de la consulta a una vista.

Indización de las vistas

También podemos intentar materializar las vistas creando un índice agrupado único en la columna pk:

CREATE UNIQUE CLUSTERED INDEX cuq ON dbo.VA (pk);
CREATE UNIQUE CLUSTERED INDEX cuq ON dbo.VB (pk);

Ahora podemos agregar índices no agrupados únicos en la columna de datos filtrados en la vista indexada:

CREATE UNIQUE NONCLUSTERED INDEX ix ON dbo.VA (data);
CREATE UNIQUE NONCLUSTERED INDEX ix ON dbo.VB (data);

Observe que el filtrado se realiza en la vista, estos índices no agrupados no se filtran.

El plan perfecto

Ahora estamos listos para ejecutar nuestra consulta en la vista, usando NOEXPAND sugerencia de tabla:

SELECT 
    v.data,
    v2.data
FROM dbo.VA AS v WITH (NOEXPAND)
JOIN dbo.VB AS v2 WITH (NOEXPAND)
    ON v.data = v2.data;

El plan de ejecución es:

El optimizador puede ver el sin filtrar los índices de vista no agrupados son únicos, por lo que no se necesita una combinación de combinación de muchos a muchos. Este plan de ejecución final tiene un coste estimado de 0,0310929 unidades, incluso más bajo que el plan hash join (0.0772056 unidades). Esto valida nuestra expectativa de que una combinación de combinación debe tener el costo estimado más bajo para esta consulta y conjunto de datos de muestra.

El NOEXPAND se necesitan sugerencias incluso en Enterprise Edition para garantizar que el optimizador utilice la garantía de unicidad proporcionada por los índices de vista.

Resumen

Esta publicación destaca dos importantes limitaciones del optimizador con índices filtrados:

  • Los predicados de combinación redundantes pueden ser necesarios para hacer coincidir los índices filtrados
  • Los índices únicos filtrados no proporcionan información de unicidad al optimizador

En algunos casos, puede ser práctico simplemente agregar los predicados redundantes a cada consulta. La alternativa es encapsular los predicados implícitos deseados en una vista sin indexar. El plan de coincidencia de hash en esta publicación fue mucho mejor que el plan predeterminado, aunque el optimizador debería poder encontrar el plan de combinación de combinación ligeramente mejor. A veces, es posible que deba indexar la vista y usar NOEXPAND sugerencias (obligatorio de todos modos para las instancias de la Edición estándar). En otras circunstancias, ninguno de estos enfoques será adecuado. Lo siento :)