Cómo evitar el uso de variables en la cláusula WHERE

16

Dado un procedimiento almacenado (simplificado) como este:

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

Si la Saletabla es grande, SELECTpuede tardar mucho tiempo en ejecutarse, aparentemente porque el optimizador no puede optimizar debido a la variable local. Probamos ejecutar la SELECTpieza con variables, luego con fechas codificadas y el tiempo de ejecución fue de ~ 9 minutos a ~ 1 segundo.

Tenemos numerosos procedimientos almacenados que consultan en base a rangos de fechas "fijas" (semana, mes, 8 semanas, etc.) por lo que el parámetro de entrada es solo @endDate y @startDate se calcula dentro del procedimiento.

La pregunta es, ¿cuál es la mejor práctica para evitar variables en una cláusula WHERE para no comprometer el optimizador?

Las posibilidades que se nos ocurrieron se muestran a continuación. ¿Alguna de estas mejores prácticas, o hay otra manera?

Use un procedimiento de envoltura para convertir las variables en parámetros.

Los parámetros no afectan al optimizador de la misma manera que las variables locales.

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
   DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
   EXECUTE DateRangeProc @startDate, @endDate
END

CREATE PROCEDURE DateRangeProc(@startDate DATE, @endDate DATE)
AS
BEGIN
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

Utilice SQL dinámico parametrizado.

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
  DECLARE @sql NVARCHAR(4000) = N'
    SELECT
      -- Stuff
    FROM Sale
    WHERE SaleDate BETWEEN @startDate AND @endDate
  '
  DECLARE @param NVARCHAR(4000) = N'@startDate DATE, @endDate DATE'
  EXECUTE sp_executesql @sql, @param, @startDate = @startDate, @endDate = @endDate
END

Utilice SQL dinámico "codificado".

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
  DECLARE @sql NVARCHAR(4000) = N'
    SELECT
      -- Stuff
    FROM Sale
    WHERE SaleDate BETWEEN @startDate AND @endDate
  '
  SET @sql = REPLACE(@sql, '@startDate', CONVERT(NCHAR(10), @startDate, 126))
  SET @sql = REPLACE(@sql, '@endDate', CONVERT(NCHAR(10), @endDate, 126))
  EXECUTE sp_executesql @sql
END

Usa la DATEADD()función directamente.

No estoy interesado en esto porque llamar a funciones en WHERE también afecta el rendimiento.

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN DATEADD(DAY, -6, @endDate) AND @endDate
END

Utiliza un parámetro opcional.

No estoy seguro de si asignar a parámetros tendría el mismo problema que asignar a variables, por lo que esta podría no ser una opción. Realmente no me gusta esta solución, pero la incluyo para completarla.

CREATE PROCEDURE WeeklyProc(@endDate DATE, @startDate DATE = NULL)
AS
BEGIN
  SET @startDate = DATEADD(DAY, -6, @endDate)
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

- Actualización -

Gracias por sugerencias y comentarios. Después de leerlos, realicé algunas pruebas de tiempo con los diferentes enfoques. Estoy agregando los resultados aquí como referencia.

La ejecución 1 es sin un plan. La ejecución 2 es inmediatamente posterior a la ejecución 1 con exactamente los mismos parámetros, por lo que utilizará el plan de la ejecución 1.

Los tiempos de NoProc son para ejecutar las consultas SELECT manualmente en SSMS fuera de un procedimiento almacenado.

TestProc1-7 son las consultas de la pregunta original.

TestProcA-B se basan en la sugerencia de Mikael Eriksson . La columna en la base de datos es una FECHA, así que intenté pasar el parámetro como DATETIME y ejecutarlo con conversión implícita (testProcA) y conversión explícita (testProcB).

TestProcC-D se basan en la sugerencia de Kenneth Fisher . Ya utilizamos una tabla de búsqueda de fechas para otras cosas, pero no tenemos una con una columna específica para cada rango de período. La variación que probé todavía usa ENTRE pero lo hace en la tabla de búsqueda más pequeña y se une a la tabla más grande. Voy a investigar más a fondo si podemos usar tablas de búsqueda específicas, aunque nuestros períodos son fijos, hay bastantes diferentes.

    Total de filas en la tabla de Venta: 136,424,366

                       Ejecutar 1 (ms) Ejecutar 2 (ms)
    Procedimiento CPU transcurrido CPU transcurrido Comentario
    NoProc constantes 6567 62199 2870 719 Consulta manual con constantes
    NoProc variables 9314 62424 3993 998 Consulta manual con variables
    testProc1 6801 62919 2871 736 Rango codificado
    testProc2 8955 63190 3915979 Parámetro y rango variable
    testProc3 8985 63152 3932 987 Procedimiento de envoltura con rango de parámetros
    testProc4 9142 63939 3931 977 SQL dinámico parametrizado
    testProc5 7269 62933 2933728 SQL dinámico codificado
    testProc6 9266 63421 3915 984 Use DATEADD el DATE
    testProc7 2044 13950 1092 1087 Parámetro ficticio
    testProcA 12120 61493 5491 1875 Use DATEADD en DATETIME sin CAST
    testProcB 8612 61949 3932 978 Use DATEADD en DATETIME con CAST
    testProcC 8861 61651 3917 993 Use la tabla de búsqueda, primero la venta
    testProcD 8625 61740 3994 1031 Usar tabla de búsqueda, última venta

Aquí está el código de prueba.

------ SETUP ------

IF OBJECT_ID(N'testDimDate', N'U') IS NOT NULL DROP TABLE testDimDate
IF OBJECT_ID(N'testProc1', N'P') IS NOT NULL DROP PROCEDURE testProc1
IF OBJECT_ID(N'testProc2', N'P') IS NOT NULL DROP PROCEDURE testProc2
IF OBJECT_ID(N'testProc3', N'P') IS NOT NULL DROP PROCEDURE testProc3
IF OBJECT_ID(N'testProc3a', N'P') IS NOT NULL DROP PROCEDURE testProc3a
IF OBJECT_ID(N'testProc4', N'P') IS NOT NULL DROP PROCEDURE testProc4
IF OBJECT_ID(N'testProc5', N'P') IS NOT NULL DROP PROCEDURE testProc5
IF OBJECT_ID(N'testProc6', N'P') IS NOT NULL DROP PROCEDURE testProc6
IF OBJECT_ID(N'testProc7', N'P') IS NOT NULL DROP PROCEDURE testProc7
IF OBJECT_ID(N'testProcA', N'P') IS NOT NULL DROP PROCEDURE testProcA
IF OBJECT_ID(N'testProcB', N'P') IS NOT NULL DROP PROCEDURE testProcB
IF OBJECT_ID(N'testProcC', N'P') IS NOT NULL DROP PROCEDURE testProcC
IF OBJECT_ID(N'testProcD', N'P') IS NOT NULL DROP PROCEDURE testProcD
GO

CREATE TABLE testDimDate
(
   DateKey DATE NOT NULL,
   CONSTRAINT PK_DimDate_DateKey UNIQUE NONCLUSTERED (DateKey ASC)
)
GO

DECLARE @dateTimeStart DATETIME = '2000-01-01'
DECLARE @dateTimeEnd DATETIME = '2100-01-01'
;WITH CTE AS
(
   --Anchor member defined
   SELECT @dateTimeStart FullDate
   UNION ALL
   --Recursive member defined referencing CTE
   SELECT FullDate + 1 FROM CTE WHERE FullDate + 1 <= @dateTimeEnd
)
SELECT
   CAST(FullDate AS DATE) AS DateKey
INTO #DimDate
FROM CTE
OPTION (MAXRECURSION 0)

INSERT INTO testDimDate (DateKey)
SELECT DateKey FROM #DimDate ORDER BY DateKey ASC

DROP TABLE #DimDate
GO

-- Hard coded date range.
CREATE PROCEDURE testProc1 AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN '2012-12-09' AND '2012-12-10'
END
GO

-- Parameter and variable date range.
CREATE PROCEDURE testProc2(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
END
GO

-- Parameter date range.
CREATE PROCEDURE testProc3a(@startDate DATE, @endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
END
GO

-- Wrapper procedure.
CREATE PROCEDURE testProc3(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   EXEC testProc3a @startDate, @endDate
END
GO

-- Parameterized dynamic SQL.
CREATE PROCEDURE testProc4(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   DECLARE @sql NVARCHAR(4000) = N'SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate'
   DECLARE @param NVARCHAR(4000) = N'@startDate DATE, @endDate DATE'
   EXEC sp_executesql @sql, @param, @startDate = @startDate, @endDate = @endDate
END
GO

-- Hard coded dynamic SQL.
CREATE PROCEDURE testProc5(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   DECLARE @sql NVARCHAR(4000) = N'SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN ''@startDate'' AND ''@endDate'''
   SET @sql = REPLACE(@sql, '@startDate', CONVERT(NCHAR(10), @startDate, 126))
   SET @sql = REPLACE(@sql, '@endDate', CONVERT(NCHAR(10), @endDate, 126))
   EXEC sp_executesql @sql
END
GO

-- Explicitly use DATEADD on a DATE.
CREATE PROCEDURE testProc6(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN DATEADD(DAY, -1, @endDate) AND @endDate
END
GO

-- Dummy parameter.
CREATE PROCEDURE testProc7(@endDate DATE, @startDate DATE = NULL) AS
BEGIN
   SET NOCOUNT ON
   SET @startDate = DATEADD(DAY, -1, @endDate)
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
END
GO

-- Explicitly use DATEADD on a DATETIME with implicit CAST for comparison with SaleDate.
-- Based on the answer from Mikael Eriksson.
CREATE PROCEDURE testProcA(@endDateTime DATETIME) AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN DATEADD(DAY, -1, @endDateTime) AND @endDateTime
END
GO

-- Explicitly use DATEADD on a DATETIME but CAST to DATE for comparison with SaleDate.
-- Based on the answer from Mikael Eriksson.
CREATE PROCEDURE testProcB(@endDateTime DATETIME) AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN CAST(DATEADD(DAY, -1, @endDateTime) AS DATE) AND CAST(@endDateTime AS DATE)
END
GO

-- Use a date lookup table, Sale first.
-- Based on the answer from Kenneth Fisher.
CREATE PROCEDURE testProcC(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   SELECT SUM(Value) FROM Sale J INNER JOIN testDimDate D ON D.DateKey = J.SaleDate WHERE D.DateKey BETWEEN @startDate AND @endDate
END
GO

-- Use a date lookup table, Sale last.
-- Based on the answer from Kenneth Fisher.
CREATE PROCEDURE testProcD(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   SELECT SUM(Value) FROM testDimDate D INNER JOIN Sale J ON J.SaleDate = D.DateKey WHERE D.DateKey BETWEEN @startDate AND @endDate
END
GO

------ TEST ------

SET STATISTICS TIME OFF

DECLARE @endDate DATE = '2012-12-10'
DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)

DBCC FREEPROCCACHE WITH NO_INFOMSGS
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS

RAISERROR('Run 1: NoProc with constants', 0, 0) WITH NOWAIT
SET STATISTICS TIME ON
SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN '2012-12-09' AND '2012-12-10'
SET STATISTICS TIME OFF

RAISERROR('Run 2: NoProc with constants', 0, 0) WITH NOWAIT
SET STATISTICS TIME ON
SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN '2012-12-09' AND '2012-12-10'
SET STATISTICS TIME OFF

DBCC FREEPROCCACHE WITH NO_INFOMSGS
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS

RAISERROR('Run 1: NoProc with variables', 0, 0) WITH NOWAIT
SET STATISTICS TIME ON
SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
SET STATISTICS TIME OFF

RAISERROR('Run 2: NoProc with variables', 0, 0) WITH NOWAIT
SET STATISTICS TIME ON
SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
SET STATISTICS TIME OFF

DECLARE @sql NVARCHAR(4000)

DECLARE _cursor CURSOR LOCAL FAST_FORWARD FOR
   SELECT
      procedures.name,
      procedures.object_id
   FROM sys.procedures
   WHERE procedures.name LIKE 'testProc_'
   ORDER BY procedures.name ASC

OPEN _cursor

DECLARE @name SYSNAME
DECLARE @object_id INT

FETCH NEXT FROM _cursor INTO @name, @object_id
WHILE @@FETCH_STATUS = 0
BEGIN
   SET @sql = CASE (SELECT COUNT(*) FROM sys.parameters WHERE object_id = @object_id)
      WHEN 0 THEN @name
      WHEN 1 THEN @name + ' ''@endDate'''
      WHEN 2 THEN @name + ' ''@startDate'', ''@endDate'''
   END

   SET @sql = REPLACE(@sql, '@name', @name)
   SET @sql = REPLACE(@sql, '@startDate', CONVERT(NVARCHAR(10), @startDate, 126))
   SET @sql = REPLACE(@sql, '@endDate', CONVERT(NVARCHAR(10), @endDate, 126))

   DBCC FREEPROCCACHE WITH NO_INFOMSGS
   DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS

   RAISERROR('Run 1: %s', 0, 0, @sql) WITH NOWAIT
   SET STATISTICS TIME ON
   EXEC sp_executesql @sql
   SET STATISTICS TIME OFF

   RAISERROR('Run 2: %s', 0, 0, @sql) WITH NOWAIT
   SET STATISTICS TIME ON
   EXEC sp_executesql @sql
   SET STATISTICS TIME OFF

   FETCH NEXT FROM _cursor INTO @name, @object_id
END

CLOSE _cursor
DEALLOCATE _cursor
WileCau
fuente

Respuestas:

9

El rastreo de parámetros es tu amigo casi todo el tiempo y debes escribir tus consultas para que se pueda usar. El rastreo de parámetros ayuda a construir el plan para usted utilizando los valores de parámetros disponibles cuando se compila la consulta. El lado oscuro de la detección de parámetros es cuando los valores utilizados al compilar la consulta no son óptimos para las consultas futuras.

La consulta en un procedimiento almacenado se compila cuando se ejecuta el procedimiento almacenado, no cuando se ejecuta la consulta, por lo que los valores que SQL Server tiene que tratar aquí ...

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

es un valor conocido para @endDatey un valor desconocido para @startDate. Eso dejará que SQL Server adivine el 30% de las filas devueltas para el filtro @startDatecombinado con lo que las estadísticas le indiquen @endDate. Si tiene una tabla grande con muchas filas que podrían proporcionarle una operación de escaneo donde se beneficiaría más de una búsqueda.

Su solución de procedimiento de contenedor se asegura de que SQL Server vea los valores cuando DateRangeProcse compila para que pueda usar valores conocidos para ambos @endDatey @startDate.

Ambas consultas dinámicas conducen a lo mismo, los valores se conocen en tiempo de compilación.

El que tiene un valor nulo predeterminado es un poco especial. Los valores conocidos por SQL Server en tiempo de compilación son valores conocidos por @endDatey nullpara @startDate. El uso nullde un punto intermedio le dará 0 filas, pero SQL Server siempre adivina 1 en esos casos. Eso podría ser algo bueno en este caso, pero si llama al procedimiento almacenado con un intervalo de fecha grande donde un escaneo hubiera sido la mejor opción, puede terminar haciendo un montón de búsquedas.

Dejé "Usar la función DATEADD () directamente" al final de esta respuesta porque es la que usaría y también tiene algo extraño.

En primer lugar, SQL Server no llama a la función varias veces cuando se usa en la cláusula where. DATEADD se considera constante en tiempo de ejecución .

Y creo que eso DATEADDse evalúa cuando se compila la consulta para que pueda obtener una buena estimación del número de filas devueltas. Pero no es así en este caso.
Las estimaciones de SQL Server se basan en el valor del parámetro independientemente de lo que haga con DATEADD(probado en SQL Server 2012), por lo que en su caso la estimación será el número de filas registradas @endDate. No sé por qué lo hace, pero tiene que ver con el uso del tipo de datos DATE. Cambie a DATETIMEen el procedimiento almacenado y la tabla y la estimación serán precisas, lo que significa que DATEADDse considera en el momento de la compilación DATETIMEno es para DATE.

Entonces, para resumir esta respuesta bastante larga, recomendaría la solución del procedimiento de envoltura. Siempre permitirá que SQL Server use los valores proporcionados al compilar la consulta sin la molestia de usar SQL dinámico.

PD:

En los comentarios tienes dos sugerencias.

OPTION (OPTIMIZE FOR UNKNOWN)le dará una estimación del 9% de las filas devueltas y OPTION (RECOMPILE)hará que SQL Server vea los valores de los parámetros ya que la consulta se vuelve a compilar cada vez.

Mikael Eriksson
fuente
3

Ok, tengo dos posibles soluciones para ti.

Primero me pregunto si esto permitirá una mayor parametrización. No he tenido la oportunidad de probarlo, pero podría funcionar.

CREATE PROCEDURE WeeklyProc(@endDate DATE, @startDate DATE)
AS
BEGIN
  IF @startDate IS NULL
    SET @startDate = DATEADD(DAY, -6, @endDate)
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

La otra opción aprovecha el hecho de que está utilizando marcos de tiempo fijos. Primero cree una tabla DateLookup. Algo como esto

CurrentDate    8WeekStartDate    8WeekEndDate    etc

Complete para cada fecha entre ahora y el próximo siglo. Esto es solo ~ 36500 filas, por lo que es una tabla bastante pequeña. Luego cambie su consulta así

IF @Range = '8WeekRange' 
    SELECT
      -- Stuff
    FROM Sale
    JOIN DateLookup
        ON SaleDate BETWEEN [8WeekStartDate] AND [8WeekEndDate]
    WHERE DateLookup.CurrentDate = GetDate()

Obviamente, este es solo un ejemplo y ciertamente podría escribirse mejor, pero he tenido mucha suerte con este tipo de tabla. Particularmente porque es una tabla estática y se puede indexar como loca.

Kenneth Fisher
fuente