Después de escribir un blog sobre cómo los índices filtrados podrían ser más potentes y, más recientemente, sobre cómo pueden volverse inútiles mediante la parametrización forzada, estoy revisando el tema de la parametrización/índices filtrados. Recientemente surgió una solución aparentemente demasiado simple en el trabajo y tenía que compartirla.
Tomemos el siguiente ejemplo, donde tenemos una base de datos de ventas que contiene una tabla de pedidos. A veces solo queremos una lista (o un recuento) de los pedidos que aún no se han enviado, que, con el tiempo (¡con suerte!) representan un porcentaje cada vez más pequeño de la tabla general:
CREATE DATABASE Sales; GO USE Sales; GO -- simplified, obviously: CREATE TABLE dbo.Orders ( OrderID int IDENTITY(1,1) PRIMARY KEY, OrderDate datetime NOT NULL, filler char(500) NOT NULL DEFAULT '', IsShipped bit NOT NULL DEFAULT 0 ); GO -- let's put some data in there; 7,000 shipped orders, and 50 unshipped: INSERT dbo.Orders(OrderDate, IsShipped) -- random dates over two years SELECT TOP (7000) DATEADD(DAY, ABS(object_id % 730), '20171101'), 1 FROM sys.all_columns UNION ALL -- random dates from this month SELECT TOP (50) DATEADD(DAY, ABS(object_id % 30), '20191201'), 0 FROM sys.all_columns;
En este escenario, podría tener sentido crear un índice filtrado como este (que agiliza cualquier consulta que intente llegar a esos pedidos no enviados):
CREATE INDEX ix_OrdersNotShipped ON dbo.Orders(IsShipped, OrderDate) WHERE IsShipped = 0;
Podemos ejecutar una consulta rápida como esta para ver cómo usa el índice filtrado:
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0;
El plan de ejecución es bastante simple, pero hay una advertencia sobre UnmatchedIndexes:
El nombre de la advertencia es un poco engañoso:el optimizador finalmente pudo usar el índice, pero sugiere que sería "mejor" sin parámetros (que no usamos explícitamente), aunque la declaración parece estar parametrizada:
Si realmente lo desea, puede eliminar la advertencia, sin ninguna diferencia en el rendimiento real (solo sería cosmético). Una forma es agregar un predicado de impacto cero, como AND (1 > 0)
:
SELECT wadd = OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 AND (1 > 0);
Otro (probablemente más común) es agregar OPTION (RECOMPILE)
:
SELECT wrecomp = OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 OPTION (RECOMPILE);
Ambas opciones generan el mismo plan (una búsqueda sin advertencias):
Hasta aquí todo bien; nuestro índice filtrado se está utilizando (como se esperaba). Estos no son los únicos trucos, por supuesto; consulte los comentarios a continuación para conocer otros que los lectores ya han enviado.
Entonces, la complicación
Debido a que la base de datos está sujeta a una gran cantidad de consultas ad hoc, alguien activa la parametrización forzada, intentando reducir la compilación y evitar que los planes de uso único y bajo contaminen el caché del plan:
ALTER DATABASE Sales SET PARAMETERIZATION FORCED;
Ahora nuestra consulta original no puede usar el índice filtrado; se ve obligado a escanear el índice agrupado:
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0;
Vuelve la advertencia sobre índices no coincidentes y recibimos nuevas advertencias sobre E/S residual. Tenga en cuenta que la declaración está parametrizada, pero se ve un poco diferente:
Esto es por diseño, ya que todo el propósito de la parametrización forzada es parametrizar consultas como esta. Pero anula el propósito de nuestro índice filtrado, ya que está destinado a admitir un solo valor en el predicado, no un parámetro que pueda cambiar.
Tonterías
Nuestra consulta "trucada" que usa el predicado adicional tampoco puede usar el índice filtrado y termina con un plan de arranque un poco más complicado:
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 AND (1 > 0);
OPCIÓN (RECOMPILAR)
La reacción típica en este caso, al igual que con la eliminación de la advertencia anterior, es agregar OPTION (RECOMPILE)
a la declaración. Esto funciona y permite elegir el índice filtrado para una búsqueda eficiente...
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 OPTION (RECOMPILE);
…pero agregando OPTION (RECOMPILE)
y tomar este golpe de compilación adicional contra cada ejecución de la consulta no siempre será aceptable en entornos de gran volumen (especialmente si ya están vinculados a la CPU).
Consejos
Alguien sugirió insinuar explícitamente el índice filtrado para evitar los costos de recompilar. En general, esto es bastante frágil, porque depende de que el índice sobreviva al código; Tiendo a usar esto como último recurso. En este caso no es válido de todos modos. Cuando las reglas de parametrización impiden que el optimizador elija el índice filtrado automáticamente, también le impiden elegirlo manualmente. Mismo problema con un FORCESEEK
genérico pista:
SELECT OrderID, OrderDate FROM dbo.Orders WITH (INDEX (ix_OrdersNotShipped)) WHERE IsShipped = 0; SELECT OrderID, OrderDate FROM dbo.Orders WITH (FORCESEEK) WHERE IsShipped = 0;
Ambos arrojan este error:
Mensaje 8622, nivel 16, estado 1El procesador de consultas no pudo producir un plan de consulta debido a las sugerencias definidas en esta consulta. Vuelva a enviar la consulta sin especificar ninguna sugerencia y sin usar SET FORCEPLAN.
Y esto tiene sentido, porque no hay forma de saber que el valor desconocido para el IsShipped
el parámetro coincidirá con el índice filtrado (o admitirá una operación de búsqueda en cualquier índice).
SQL dinámico?
Le sugerí que podría usar SQL dinámico, para al menos solo pagar ese golpe de recompilación cuando sabe que quiere presionar el índice más pequeño:
DECLARE @IsShipped bit = 0; DECLARE @sql nvarchar(max) = N'SELECT dynsql = OrderID, OrderDate FROM dbo.Orders' + CASE WHEN @IsShipped IS NOT NULL THEN N' WHERE IsShipped = @IsShipped' ELSE N'' END + CASE WHEN @IsShipped = 0 THEN N' OPTION (RECOMPILE)' ELSE N'' END; EXEC sys.sp_executesql @sql, N'@IsShipped bit', @IsShipped;
Esto conduce al mismo plan eficiente que el anterior. Si cambió la variable a @IsShipped = 1
, obtendrá el escaneo de índice agrupado más costoso que debería esperar:
Pero a nadie le gusta usar SQL dinámico en un caso extremo como este:hace que el código sea más difícil de leer y mantener, e incluso si este código no estuviera en la aplicación, aún sería una lógica adicional que tendría que agregarse allí, lo que lo hace menos deseable. .
Algo más sencillo
Hablamos brevemente sobre la implementación de una guía de planes, que ciertamente no es más simple, pero luego un colega sugirió que podría engañar al optimizador "ocultando" la declaración parametrizada dentro de un procedimiento almacenado, una vista o una función con valores de tabla en línea. Era tan simple que no creía que funcionara.
Pero luego lo probé:
CREATE PROCEDURE dbo.GetUnshippedOrders AS BEGIN SET NOCOUNT ON; SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0; END GO CREATE VIEW dbo.vUnshippedOrders AS SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0; GO CREATE FUNCTION dbo.fnUnshippedOrders() RETURNS TABLE AS RETURN (SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0); GO
Estas tres consultas realizan la búsqueda eficiente contra el índice filtrado:
EXEC dbo.GetUnshippedOrders; GO SELECT OrderID, OrderDate FROM dbo.vUnshippedOrders; GO SELECT OrderID, OrderDate FROM dbo.fnUnshippedOrders();
Conclusión
Me sorprendió que esto fuera tan efectivo. Por supuesto, esto requiere que cambies la aplicación; si no puede cambiar el código de la aplicación para llamar a un procedimiento almacenado o hacer referencia a la vista o función (o incluso agregar OPTION (RECOMPILE)
), tendrás que seguir buscando otras opciones. Pero si puede cambiar el código de la aplicación, rellenar el predicado en otro módulo puede ser el camino a seguir.