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

El problema con las funciones y vistas de ventana

Introducción

Desde su introducción en SQL Server 2005, las funciones de ventana como ROW_NUMBER y RANK han demostrado ser extremadamente útiles para resolver una amplia variedad de problemas comunes de T-SQL. En un intento por generalizar tales soluciones, los diseñadores de bases de datos a menudo buscan incorporarlas en las vistas para promover la encapsulación y reutilización del código. Desafortunadamente, una limitación en el optimizador de consultas de SQL Server a menudo significa que las vistas que contienen funciones de ventana no funcionan tan bien como se esperaba. Esta publicación funciona a través de un ejemplo ilustrativo del problema, detalla los motivos y proporciona una serie de soluciones alternativas.

Este problema también puede ocurrir en tablas derivadas, expresiones de tablas comunes y funciones en línea, pero lo veo más a menudo con vistas porque están escritas intencionalmente para que sean más genéricas.

Funciones de ventana

Las funciones de ventana se distinguen por la presencia de un OVER() cláusula y vienen en tres variedades:

  • Funciones de la ventana de clasificación
    • ROW_NUMBER
    • RANK
    • DENSE_RANK
    • NTILE
  • Funciones de ventana agregadas
    • MIN , MAX , AVG , SUM
    • COUNT , COUNT_BIG
    • CHECKSUM_AGG
    • STDEV , STDEV , VAR , VARP
  • Funciones de la ventana analítica
    • LAG , LEAD
    • FIRST_VALUE , LAST_VALUE
    • PERCENT_RANK , PERCENTILE_CONT , PERCENTILE_DISC , CUME_DIST

Las funciones de clasificación y ventana agregada se introdujeron en SQL Server 2005 y se ampliaron considerablemente en SQL Server 2012. Las funciones de ventana analítica son nuevas para SQL Server 2012.

Todas las funciones de ventana enumeradas anteriormente son susceptibles a la limitación del optimizador detallada en este artículo.

Ejemplo

Usando la base de datos de muestra AdventureWorks, la tarea en cuestión es escribir una consulta que devuelva todas las transacciones del producto #878 que ocurrieron en la fecha disponible más reciente. Hay todo tipo de formas de expresar este requisito en T-SQL, pero elegiremos escribir una consulta que use una función de ventana. El primer paso es encontrar registros de transacciones para el producto #878 y clasificarlos en orden de fecha descendente:

SELECT
    th.TransactionID,
    th.ReferenceOrderID,
    th.TransactionDate,
    th.Quantity,
    rnk = RANK() OVER (
        ORDER BY th.TransactionDate DESC)
FROM Production.TransactionHistory AS th
WHERE
    th.ProductID = 878
ORDER BY
    rnk;

Los resultados de la consulta son los esperados, con seis transacciones ocurriendo en la fecha más reciente disponible. El plan de ejecución contiene un triángulo de advertencia que nos alerta sobre un índice faltante:

Como es habitual con las sugerencias de índices faltantes, debemos recordar que la recomendación no es el resultado de un análisis exhaustivo de la consulta; es más una indicación de que debemos pensar un poco sobre cómo esta consulta accede a los datos que necesita.

El índice sugerido ciertamente sería más eficiente que escanear la tabla por completo, ya que permitiría que un índice buscara el producto en particular que nos interesa. El índice también cubriría todas las columnas necesarias, pero no evitaría la ordenación (por TransactionDate descendente). El índice ideal para esta consulta permitiría una búsqueda en ProductID , devolver los registros seleccionados al revés TransactionDate orden, y cubra las otras columnas devueltas:

CREATE NONCLUSTERED INDEX ix
ON Production.TransactionHistory
    (ProductID, TransactionDate DESC)
INCLUDE 
    (ReferenceOrderID, Quantity);

Con ese índice en su lugar, el plan de ejecución es mucho más eficiente. El escaneo de índice agrupado ha sido reemplazado por una búsqueda de rango y ya no es necesaria una ordenación explícita:

El paso final para esta consulta es limitar los resultados a solo aquellas filas que ocupan el primer lugar. No podemos filtrar directamente en el WHERE cláusula de nuestra consulta porque las funciones de ventana solo pueden aparecer en SELECT y ORDER BY cláusulas.

Podemos solucionar esta restricción utilizando una tabla derivada, una expresión de tabla común, una función o una vista. En esta ocasión, usaremos una expresión de tabla común (también conocida como vista en línea):

WITH RankedTransactions AS
(
    SELECT
        th.TransactionID,
        th.ReferenceOrderID,
        th.TransactionDate,
        th.Quantity,
        rnk = RANK() OVER (
            ORDER BY th.TransactionDate DESC)
    FROM Production.TransactionHistory AS th
    WHERE
        th.ProductID = 878
)
SELECT
    TransactionID,
    ReferenceOrderID,
    TransactionDate,
    Quantity
FROM RankedTransactions
WHERE
    rnk = 1;

El plan de ejecución es el mismo que antes, con un filtro adicional para devolver solo las filas clasificadas como n.º 1:

La consulta devuelve las seis filas igualmente clasificadas que esperamos:

Generalizar la consulta

Resulta que nuestra consulta es muy útil, por lo que se toma la decisión de generalizarla y almacenar la definición en una vista. Para que esto funcione para cualquier producto, debemos hacer dos cosas:devolver el ProductID desde la vista, y divida la función de clasificación por producto:

CREATE VIEW dbo.MostRecentTransactionsPerProduct
WITH SCHEMABINDING
AS
SELECT
    sq1.ProductID,
    sq1.TransactionID,
    sq1.ReferenceOrderID,
    sq1.TransactionDate,
    sq1.Quantity
FROM 
(
    SELECT
        th.ProductID,
        th.TransactionID,
        th.ReferenceOrderID,
        th.TransactionDate,
        th.Quantity,
        rnk = RANK() OVER (
            PARTITION BY th.ProductID
            ORDER BY th.TransactionDate DESC)
    FROM Production.TransactionHistory AS th
) AS sq1
WHERE
    sq1.rnk = 1;

Seleccionar todas las filas de la vista da como resultado el siguiente plan de ejecución y resultados correctos:

Ahora podemos encontrar las transacciones más recientes del producto 878 con una consulta mucho más sencilla en la vista:

SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsPerProduct AS mrt 
WHERE
    mrt.ProductID = 878;

Nuestra expectativa es que el plan de ejecución para esta nueva consulta sea exactamente el mismo que antes de crear la vista. El optimizador de consultas debería poder insertar el filtro especificado en WHERE cláusula hacia abajo en la vista, lo que resulta en una búsqueda de índice.

Sin embargo, debemos detenernos y pensar un poco en este punto. El optimizador de consultas solo puede producir planes de ejecución que garanticen los mismos resultados que la especificación de consulta lógica:¿es seguro enviar nuestro WHERE? cláusula en la vista?PARTITION BY cláusula de la función de ventana en la vista. El razonamiento es que la eliminación de grupos completos (particiones) de la función de ventana no afectará la clasificación de las filas devueltas por la consulta. La pregunta es, ¿el optimizador de consultas de SQL Server sabe esto? La respuesta depende de la versión de SQL Server que estemos ejecutando.

Plan de ejecución de SQL Server 2005

Una mirada a las propiedades de filtro en este plan muestra que aplica dos predicados:

El ProductID = 878 el predicado no se ha empujado hacia abajo en la vista, lo que da como resultado un plan que escanea nuestro índice, clasificando cada fila de la tabla antes de filtrar el producto n.° 878 y las filas clasificadas como n.° 1.

El optimizador de consultas de SQL Server 2005 no puede insertar predicados adecuados más allá de una función de ventana en un ámbito de consulta inferior (vista, expresión de tabla común, función en línea o tabla derivada). Esta limitación se aplica a todas las compilaciones de SQL Server 2005.

Plan de ejecución de SQL Server 2008+

Este es el plan de ejecución para la misma consulta en SQL Server 2008 o posterior:

El ProductID El predicado se ha empujado con éxito más allá de los operadores de clasificación, reemplazando el escaneo de índice con la búsqueda de índice eficiente.

El optimizador de consultas de 2008 incluye una nueva regla de simplificación SelOnSeqPrj (seleccione en el proyecto de secuencia) que puede empujar predicados seguros de alcance externo más allá de las funciones de la ventana. Para producir el plan menos eficiente para esta consulta en SQL Server 2008 o posterior, tenemos que deshabilitar temporalmente esta característica del optimizador de consultas:

SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsPerProduct AS mrt 
WHERE
    mrt.ProductID = 878
OPTION (QUERYRULEOFF SelOnSeqPrj);

Desafortunadamente, el SelOnSeqPrj regla de simplificación solo funciona cuando el predicado realiza una comparación con una constante . Por ese motivo, la siguiente consulta genera un plan subóptimo en SQL Server 2008 y versiones posteriores:

DECLARE @ProductID INT = 878;
 
SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsPerProduct AS mrt 
WHERE
    mrt.ProductID = @ProductID;

El problema aún puede ocurrir incluso cuando el predicado usa un valor constante. SQL Server puede decidir auto-parametrizar consultas triviales (una para la cual existe un mejor plan obvio). Si la parametrización automática tiene éxito, el optimizador ve un parámetro en lugar de una constante y el SelOnSeqPrj la regla no se aplica.

Para las consultas en las que no se intenta la parametrización automática (o en las que se determina que no son seguras), la optimización aún puede fallar, si la opción de base de datos para FORCED PARAMETERIZATION Está encendido. Nuestra consulta de prueba (con el valor constante 878) no es segura para la parametrización automática, pero la configuración de parametrización forzada anula esto, lo que da como resultado un plan ineficiente:

ALTER DATABASE AdventureWorks
SET PARAMETERIZATION FORCED;
GO
SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsPerProduct AS mrt 
WHERE
    mrt.ProductID = 878;
GO
ALTER DATABASE AdventureWorks
SET PARAMETERIZATION SIMPLE;

Solución alternativa de SQL Server 2008+

Para permitir que el optimizador "vea" un valor constante para la consulta que hace referencia a una variable o parámetro local, podemos agregar una OPTION (RECOMPILE) sugerencia de consulta:

DECLARE @ProductID INT = 878;
 
SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsPerProduct AS mrt 
WHERE
    mrt.ProductID = @ProductID
OPTION (RECOMPILE);

Nota: El plan de ejecución previo a la ejecución ("estimado") todavía muestra un escaneo de índice porque el valor de la variable aún no está establecido. Cuando la consulta se ejecuta , sin embargo, el plan de ejecución muestra el plan de búsqueda de índice deseado:

El SelOnSeqPrj regla no existe en SQL Server 2005, por lo que OPTION (RECOMPILE) no puedo ayudar allí. En caso de que te lo estés preguntando, la OPTION (RECOMPILE) la solución da como resultado una búsqueda incluso si la opción de base de datos para la parametrización forzada está activada.

Todas las versiones solución #1

En algunos casos, es posible reemplazar la vista problemática, la expresión de tabla común o la tabla derivada con una función con valores de tabla en línea parametrizada:

CREATE FUNCTION dbo.MostRecentTransactionsForProduct
(
    @ProductID integer
)  
RETURNS TABLE
WITH SCHEMABINDING AS
RETURN
    SELECT
        sq1.ProductID,
        sq1.TransactionID,
        sq1.ReferenceOrderID,
        sq1.TransactionDate,
        sq1.Quantity
    FROM 
    (
        SELECT
            th.ProductID,
            th.TransactionID,
            th.ReferenceOrderID,
            th.TransactionDate,
            th.Quantity,
            rnk = RANK() OVER (
                PARTITION BY th.ProductID
                ORDER BY th.TransactionDate DESC)
        FROM Production.TransactionHistory AS th
        WHERE
            th.ProductID = @ProductID
    ) AS sq1
    WHERE
        sq1.rnk = 1;

Esta función coloca explícitamente el ProductID predicado en el mismo ámbito que la función de ventana, evitando la limitación del optimizador. Escrita para usar la función en línea, nuestra consulta de ejemplo se convierte en:

SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsForProduct(878) AS mrt;

Esto produce el plan de búsqueda de índice deseado en todas las versiones de SQL Server que admiten funciones de ventana. Esta solución produce una búsqueda incluso cuando el predicado hace referencia a un parámetro o variable local:OPTION (RECOMPILE) no es necesario. PARTITION BY y dejar de devolver el ProductID columna. Dejé la definición igual que la vista que reemplazó para ilustrar más claramente la causa de las diferencias en el plan de ejecución.

Solución alternativa n.° 2 para todas las versiones

La segunda solución solo se aplica a las funciones de ventana de clasificación que se filtran para devolver filas numeradas o clasificadas como n.° 1 (usando ROW_NUMBER , RANK , o DENSE_RANK ). Sin embargo, este es un uso muy común, por lo que vale la pena mencionarlo.

Un beneficio adicional es que esta solución alternativa puede generar planes que son aún más eficientes. que los planes de búsqueda de índices vistos anteriormente. Como recordatorio, el mejor plan anterior se veía así:

Ese plan de ejecución se clasifica en 1918 filas a pesar de que finalmente devuelve solo 6 . Podemos mejorar este plan de ejecución usando la función de ventana en un ORDER BY cláusula en lugar de clasificar las filas y luego filtrar por el rango n.º 1:

SELECT TOP (1) WITH TIES
    th.TransactionID,
    th.ReferenceOrderID,
    th.TransactionDate,
    th.Quantity
FROM Production.TransactionHistory AS th
WHERE
    th.ProductID = 878
ORDER BY
    RANK() OVER (
        ORDER BY th.TransactionDate DESC);

Esa consulta ilustra muy bien el uso de una función de ventana en ORDER BY cláusula, pero podemos hacerlo aún mejor, eliminando la función de ventana por completo:

SELECT TOP (1) WITH TIES
    th.TransactionID,
    th.ReferenceOrderID,
    th.TransactionDate,
    th.Quantity
FROM Production.TransactionHistory AS th
WHERE
    th.ProductID = 878
ORDER BY
    th.TransactionDate DESC;

Este plan lee solo 7 filas de la tabla para devolver el mismo conjunto de resultados de 6 filas. ¿Por qué 7 filas? El operador Top se está ejecutando en WITH TIES modo:

Continúa solicitando una fila a la vez desde su subárbol hasta que cambia TransactionDate. La séptima fila es necesaria para la parte superior para asegurarse de que no califiquen más filas de valor empatado.

Podemos extender la lógica de la consulta anterior para reemplazar la definición de vista problemática:

ALTER VIEW dbo.MostRecentTransactionsPerProduct
WITH SCHEMABINDING
AS
SELECT
    p.ProductID,
    Ranked1.TransactionID,
    Ranked1.ReferenceOrderID,
    Ranked1.TransactionDate,
    Ranked1.Quantity
FROM
    -- List of product IDs
    (SELECT ProductID FROM Production.Product) AS p
CROSS APPLY
(
    -- Returns rank #1 results for each product ID
    SELECT TOP (1) WITH TIES
        th.TransactionID,
        th.ReferenceOrderID,
        th.TransactionDate,
        th.Quantity
    FROM Production.TransactionHistory AS th
    WHERE
        th.ProductID = p.ProductID
    ORDER BY
        th.TransactionDate DESC
) AS Ranked1;

La vista ahora usa un CROSS APPLY para combinar los resultados de nuestro ORDER BY optimizado Consulta por cada producto. Nuestra consulta de prueba no ha cambiado:

DECLARE @ProductID integer;
SET @ProductID = 878;
 
SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsPerProduct AS mrt 
WHERE
    mrt.ProductID = @ProductID;

Tanto los planes previos como los posteriores a la ejecución muestran una búsqueda de índice sin necesidad de una OPTION (RECOMPILE) sugerencia de consulta. El siguiente es un plan posterior a la ejecución ("real"):

Si la vista hubiera usado ROW_NUMBER en lugar de RANK , la vista de reemplazo simplemente habría omitido WITH TIES cláusula en el TOP (1) . La nueva vista también podría escribirse como una función con valores de tabla en línea parametrizada, por supuesto.

Se podría argumentar que el plan de búsqueda de índice original con rnk = 1 el predicado también podría optimizarse para probar solo 7 filas. Después de todo, el optimizador debe saber que el operador Sequence Project produce las clasificaciones en estricto orden ascendente, por lo que la ejecución podría finalizar tan pronto como se vea una fila con una clasificación mayor que uno. Sin embargo, el optimizador no contiene esta lógica hoy.

Reflexiones finales

La gente a menudo se siente decepcionada por el rendimiento de las vistas que incorporan funciones de ventana. El motivo a menudo se remonta a la limitación del optimizador descrita en esta publicación (o quizás porque el diseñador de la vista no se dio cuenta de que los predicados aplicados a la vista deben aparecer en la PARTITION BY cláusula para ser presionada con seguridad).

Quiero enfatizar que esta limitación no solo se aplica a las vistas, y tampoco se limita a ROW_NUMBER , RANK y DENSE_RANK . Debe tener en cuenta esta limitación cuando utilice cualquier función con un OVER cláusula en una vista, expresión de tabla común, tabla derivada o función con valores de tabla en línea.

Los usuarios de SQL Server 2005 que se encuentran con este problema se enfrentan a la opción de reescribir la vista como una función con valores de tabla en línea parametrizada, o usar el APPLY técnica (cuando corresponda).

Los usuarios de SQL Server 2008 tienen la opción adicional de usar una OPTION (RECOMPILE) sugerencia de consulta si el problema se puede resolver permitiendo que el optimizador vea una referencia constante en lugar de una variable o parámetro. Sin embargo, recuerde verificar los planes posteriores a la ejecución cuando use esta sugerencia:el plan previo a la ejecución generalmente no puede mostrar el plan óptimo.