Cláusula SARGable WHERE para dos columnas de fecha

24

Tengo lo que es, para mí, una pregunta interesante sobre SARGability. En este caso, se trata de usar un predicado en la diferencia entre dos columnas de fecha. Aquí está la configuración:

USE [tempdb]
SET NOCOUNT ON  

IF OBJECT_ID('tempdb..#sargme') IS NOT NULL
BEGIN
DROP TABLE #sargme
END

SELECT TOP 1000
IDENTITY (BIGINT, 1,1) AS ID,
CAST(DATEADD(DAY, [m].[severity] * -1, GETDATE()) AS DATE) AS [DateCol1],
CAST(DATEADD(DAY, [m].[severity], GETDATE()) AS DATE) AS [DateCol2]
INTO #sargme
FROM sys.[messages] AS [m]

ALTER TABLE [#sargme] ADD CONSTRAINT [pk_whatever] PRIMARY KEY CLUSTERED ([ID])
CREATE NONCLUSTERED INDEX [ix_dates] ON [#sargme] ([DateCol1], [DateCol2])

Lo que veré con bastante frecuencia es algo como esto:

/*definitely not sargable*/
SELECT
    * ,
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2])
FROM
    [#sargme] AS [s]
WHERE
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2]) >= 48;

... que definitivamente no es SARGable. Da como resultado una exploración de índice, lee las 1000 filas, no es bueno. Las filas estimadas apestan. Nunca pondrías esto en producción.

No señor, no me gustó.

Sería bueno si pudiéramos materializar CTE, porque eso nos ayudaría a hacer esto, bueno, más SARGable, técnicamente hablando. Pero no, tenemos el mismo plan de ejecución que arriba.

/*would be nice if it were sargable*/
WITH    [x] AS ( SELECT
                * ,
                DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2]) AS [ddif]
               FROM
                [#sargme] AS [s])
     SELECT
        *
     FROM
        [x]
     WHERE
        [x].[ddif] >= 48;

Y, por supuesto, como no estamos usando constantes, este código no cambia nada y ni siquiera es medio SARGable. No es divertido. Mismo plan de ejecución.

/*not even half sargable*/
SELECT
    * ,
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2])
FROM
    [#sargme] AS [s]
WHERE
    [s].[DateCol2] >= DATEADD(DAY, 48, [s].[DateCol1])

Si se siente afortunado y obedece todas las opciones de ANSI SET en sus cadenas de conexión, puede agregar una columna calculada y buscarla ...

ALTER TABLE [#sargme] ADD [ddiff] AS 
DATEDIFF(DAY, DateCol1, DateCol2) PERSISTED

CREATE NONCLUSTERED INDEX [ix_dates2] ON [#sargme] ([ddiff], [DateCol1], [DateCol2])

SELECT [s].[ID] ,
       [s].[DateCol1] ,
       [s].[DateCol2]
FROM [#sargme] AS [s]
WHERE [ddiff] >= 48

Esto le dará una búsqueda de índice con tres consultas. El hombre extraño es donde agregamos 48 días a DateCol1. La consulta con DATEDIFFen la WHEREcláusula, la CTEy la consulta final con un predicado en la columna calculada le dan un plan mucho más agradable con estimaciones mucho más agradables, y todo eso.

Podría vivir con esto.

Lo que me lleva a la pregunta: en una sola consulta, ¿hay una manera SARGable de realizar esta búsqueda?

Sin tablas temporales, sin variables de tabla, sin alterar la estructura de la tabla y sin vistas.

Estoy bien con autouniones, CTE, subconsultas o pases múltiples sobre los datos. Puede funcionar con cualquier versión de SQL Server.

Evitar la columna calculada es una limitación artificial porque estoy más interesado en una solución de consulta que cualquier otra cosa.

Erik Darling
fuente

Respuestas:

16

Simplemente agregue esto rápidamente para que exista como respuesta (aunque sé que no es la respuesta que desea).

Una columna calculada indexada suele ser la solución adecuada para este tipo de problema.

Eso:

  • convierte el predicado en una expresión indexable
  • permite crear estadísticas automáticas para una mejor estimación de la cardinalidad
  • no necesita ocupar espacio en la tabla base

Para ser claro en ese último punto, no se requiere que la columna calculada sea ​​persistente en este caso:

-- Note: not PERSISTED, metadata change only
ALTER TABLE #sargme
ADD DayDiff AS DATEDIFF(DAY, DateCol1, DateCol2);

-- Index the expression
CREATE NONCLUSTERED INDEX index_name
ON #sargme (DayDiff)
INCLUDE (DateCol1, DateCol2);

Ahora la consulta:

SELECT
    S.ID,
    S.DateCol1,
    S.DateCol2,
    DATEDIFF(DAY, S.DateCol1, S.DateCol2)
FROM
    #sargme AS S
WHERE
    DATEDIFF(DAY, S.DateCol1, S.DateCol2) >= 48;

... da el siguiente plan trivial :

Plan de ejecución

Como dijo Martin Smith, si tiene conexiones que usan las opciones de configuración incorrectas, podría crear una columna regular y mantener el valor calculado usando disparadores.

Todo esto solo realmente importa (aparte del desafío del código) si hay un problema real que resolver, por supuesto, como Aaron dice en su respuesta .

Es divertido pensar en esto, pero no conozco ninguna forma de lograr lo que desea razonablemente dadas las limitaciones de la pregunta. Parece que cualquier solución óptima requeriría una nueva estructura de datos de algún tipo; lo más cercano que tenemos es la aproximación del 'índice de función' proporcionada por un índice en una columna calculada no persistente como se indicó anteriormente.

Paul White dice GoFundMonica
fuente
12

Arriesgando el ridículo de algunos de los nombres más importantes en la comunidad de SQL Server, voy a sacar el cuello y decir, no.

Para que su consulta sea SARGable, básicamente debe construir una consulta que pueda identificar una fila inicial en un rango de filas consecutivas en un índice. Con el índice ix_dates, las filas no están ordenadas por la diferencia de fecha entre DateCol1y DateCol2, por lo que las filas de destino podrían distribuirse en cualquier parte del índice.

Las autouniones, los pases múltiples, etc. tienen en común que incluyen al menos un Escaneo de índice, aunque una unión (bucle anidado) puede usar una Búsqueda de índice. Pero no puedo ver cómo sería posible eliminar el escaneo.

En cuanto a obtener estimaciones de filas más precisas, no hay estadísticas sobre la diferencia de fechas.

La siguiente construcción CTE recursiva bastante fea técnicamente elimina el escaneo de toda la tabla, aunque introduce una unión de bucle anidado y un número (potencialmente muy grande) de búsquedas de índice.

DECLARE @from date, @count int;
SELECT TOP 1 @from=DateCol1 FROM #sargme ORDER BY DateCol1;
SELECT TOP 1 @count=DATEDIFF(day, @from, DateCol1) FROM #sargme WHERE DateCol1<=DATEADD(day, -48, {d '9999-12-31'}) ORDER BY DateCol1 DESC;

WITH cte AS (
    SELECT 0 AS i UNION ALL
    SELECT i+1 FROM cte WHERE i<@count)

SELECT b.*
FROM cte AS a
INNER JOIN #sargme AS b ON
    b.DateCol1=DATEADD(day, a.i, @from) AND
    b.DateCol2>=DATEADD(day, 48+a.i, @from)
OPTION (MAXRECURSION 0);

Crea un Spool de índice que contiene cada uno DateCol1en la tabla, luego realiza una Búsqueda de índice (escaneo de rango) para cada uno de ellos DateCol1y DateCol2que tienen al menos 48 días de antelación.

Más E / S, tiempo de ejecución un poco más largo, la estimación de filas todavía está muy lejos y cero posibilidades de paralelización debido a la recursividad: supongo que esta consulta podría ser útil si tiene una gran cantidad de valores dentro de relativamente pocos valores distintos consecutivos DateCol1(manteniendo el número de búsquedas bajas).

Plan de consulta CTE recursivo loco

Daniel Hutmacher
fuente
9

Probé un montón de variaciones extravagantes, pero no encontré ninguna versión mejor que una de las suyas. El principal problema es que su índice se ve así en términos de cómo se ordenan juntos date1 y date2. La primera columna estará en una bonita línea de estantería, mientras que la brecha entre ellos será muy irregular. Desea que esto se parezca más a un embudo de lo que realmente se verá:

Date1    Date2
-----    -------
*             *
*             *
*              *
 *       * 
 *        *
 *         *
  *      *
  *           *

Realmente no se me ocurre ninguna forma de hacer que se pueda buscar un cierto delta (o rango de deltas) entre los dos puntos. Y me refiero a una búsqueda única que se ejecuta una vez + un escaneo de rango, no una búsqueda que se ejecuta para cada fila. Eso implicará un escaneo y / o una especie en algún momento, y estas son cosas que obviamente debes evitar. Es una lástima que no pueda usar expresiones como DATEADD/ DATEDIFFen índices filtrados, o realizar posibles modificaciones de esquema que permitan una clasificación en el producto de la diferencia de fecha (como calcular el delta en el momento de inserción / actualización). Como es, este parece ser uno de esos casos en los que un escaneo es en realidad el método de recuperación óptimo.

Dijiste que esta consulta no era divertida, pero si miras más de cerca, esta es de lejos la mejor (y sería aún mejor si dejaras de lado la salida escalar de cálculo):

SELECT
    * ,
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2])
FROM
    [#sargme] AS [s]
WHERE
    [s].[DateCol2] >= DATEADD(DAY, 48, [s].[DateCol1])

La razón es que evita la DATEDIFFposible reducción de algunas CPU en comparación con un cálculo contra solo la columna de clave no líder en el índice, y también evita algunas conversiones implícitas desagradables datetimeoffset(7)(no me pregunte por qué están ahí, pero están). Aquí está la DATEDIFFversión:

<Predicate>
<ScalarOperator ScalarString = "fechaiff (día, CONVERT_IMPLICIT (datetimeoffset (7), [splunge]. [Dbo]. [Sargme]. [DateCol1] como [s]. [DateCol1], 0), CONVERT_IMPLICIT (datetimeoffset ( 7), [splunge]. [Dbo]. [Sargme]. [DateCol2] como [s]. [DateCol2], 0))> = (48) ">

Y aquí está el que no tiene DATEDIFF:

<Predicate>
<ScalarOperator ScalarString = "[splunge]. [Dbo]. [Sargme]. [DateCol2] como [s]. [DateCol2]> = dateadd (día, (48), [splunge]. [Dbo]. [ sargme]. [DateCol1] como [s]. [DateCol1]) ">

También encontré resultados ligeramente mejores en términos de duración cuando cambié el índice para incluir solo DateCol2(y cuando ambos índices estaban presentes, SQL Server siempre elegía el que tenía una clave y uno incluía la columna frente a la clave múltiple). Para esta consulta, dado que tenemos que escanear todas las filas para encontrar el rango de todos modos, no hay ningún beneficio en tener la segunda columna de fecha como parte de la clave y ordenada de alguna manera. Y aunque sé que no podemos obtener una búsqueda aquí, hay algo intrínsecamente bueno en no obstaculizar la capacidad de obtener uno al forzar cálculos contra la columna de la tecla principal y solo realizarlos contra columnas secundarias o incluidas.

Si fuera yo, y me di por vencido en encontrar la solución sargable, sé cuál elegiría: la que hace que SQL Server haga la menor cantidad de trabajo (incluso si el delta es casi inexistente). O mejor aún, relajaría mis restricciones sobre el cambio de esquema y similares.

¿Y cuánto importa todo eso? No lo sé. Hice la tabla de 10 millones de filas y todas las variaciones de consulta anteriores aún se completaron en menos de un segundo. Y esto está en una VM en una computadora portátil (concedido, con SSD).

Aaron Bertrand
fuente
3

Todas las formas en las que he pensado para hacer que la cláusula WHERE sea sargible son complejas y tienen ganas de trabajar hacia el índice busca como un objetivo final en lugar de un medio. Entonces, no, no creo que sea (pragmáticamente) posible.

No estaba seguro si "no alterar la estructura de la tabla" no significaba índices adicionales. Aquí hay una solución que evita por completo los escaneos de índice, pero da como resultado MUCHAS búsquedas de índice separadas, es decir, una para cada posible fecha DateCol1 en el rango Mín / Máx de valores de fecha en la tabla. (A diferencia de Daniel, que da como resultado una búsqueda por cada fecha distinta que realmente aparece en la tabla). Teóricamente es un candidato para el paralelismo b / c, evita la recursividad. Pero, sinceramente, es difícil ver una distribución de datos donde esto sea más rápido que simplemente escanear y hacer DATEDIFF. (¿Quizás un DOP realmente alto?) Y ... el código es feo. Supongo que este esfuerzo cuenta como un "ejercicio mental".

--Add this index to avoid the scan when determining the @MaxDate value
--CREATE NONCLUSTERED INDEX [ix_dates2] ON [#sargme] ([DateCol2]);
DECLARE @MinDate DATE, @MaxDate DATE;
SELECT @MinDate=DateCol1 FROM (SELECT TOP 1 DateCol1 FROM #sargme ORDER BY DateCol1 ASC) ss;
SELECT @MaxDate=DateCol2 FROM (SELECT TOP 1 DateCol2 FROM #sargme ORDER BY DateCol2 DESC) ss;

--Used 44 just to get a few more rows to test my logic
DECLARE @DateDiffSearchValue INT = 44, 
    @MinMaxDifference INT = DATEDIFF(DAY, @MinDate, @MaxDate);

--basic data profile in the table
SELECT [MinDate] = @MinDate, 
        [MaxDate] = @MaxDate, 
        [MinMaxDifference] = @MinMaxDifference, 
        [LastDate1SearchValue] = DATEADD(DAY, 0-@DateDiffSearchValue, @MaxDate);

;WITH rn_base AS (
SELECT [col1] = 0
        UNION ALL SELECT 0
        UNION ALL SELECT 0
        UNION ALL SELECT 0
),
rn_1 AS (
    SELECT t0.col1 FROM rn_base t0
        CROSS JOIN rn_base t1
        CROSS JOIN rn_base t2
        CROSS JOIN rn_base t3
),
rn_2 AS (
    SELECT rn = ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
    FROM rn_1 t0
        CROSS JOIN rn_1 t1
),
candidate_searches AS (
    SELECT 
        [Date1_EqualitySearch] = DATEADD(DAY, t.rn-1, @MinDate),
        [Date2_RangeSearch] = DATEADD(DAY, t.rn-1+@DateDiffSearchValue, @MinDate)
    FROM rn_2 t
    WHERE DATEADD(DAY, t.rn-1, @MinDate) <= DATEADD(DAY, 0-@DateDiffSearchValue, @MaxDate)
    /* Of course, ignore row-number values that would result in a
       Date1_EqualitySearch value that is < @DateDiffSearchValue days before @MaxDate */
)
--select * from candidate_searches

SELECT c.*, xapp.*, dd_rows = DATEDIFF(DAY, xapp.DateCol1, xapp.DateCol2)
FROM candidate_searches c
    cross apply (
        SELECT t.*
        FROM #sargme t
        WHERE t.DateCol1 = c.date1_equalitysearch
        AND t.DateCol2 >= c.date2_rangesearch
    ) xapp
ORDER BY xapp.ID asc --xapp.DateCol1, xapp.DateCol2 
Aaron Morelli
fuente
3

Respuesta de Community Wiki originalmente agregada por el autor de la pregunta como una edición de la pregunta

Después de dejar que esto repose un poco, y algunas personas realmente inteligentes intervienen, mi pensamiento inicial sobre esto parece correcto: no hay una forma sensata y SARGable de escribir esta consulta sin agregar una columna, ya sea computada o mantenida a través de algún otro mecanismo, a saber disparadores

Intenté algunas otras cosas, y tengo algunas otras observaciones que pueden o no ser interesantes para cualquiera que lea.

Primero, vuelva a ejecutar la configuración utilizando una tabla normal en lugar de una tabla temporal

  • Aunque conozco su reputación, quería probar estadísticas de varias columnas. Eran inútiles.
  • Quería ver qué estadísticas se usaron

Aquí está la nueva configuración:

USE [tempdb]
SET NOCOUNT ON  

DBCC FREEPROCCACHE

IF OBJECT_ID('tempdb..sargme') IS NOT NULL
BEGIN
DROP TABLE sargme
END

SELECT TOP 1000
IDENTITY (BIGINT, 1,1) AS ID,
CAST(DATEADD(DAY, [m].[severity] * -1, GETDATE()) AS DATE) AS [DateCol1],
CAST(DATEADD(DAY, [m].[severity], GETDATE()) AS DATE) AS [DateCol2]
INTO sargme
FROM sys.[messages] AS [m]

ALTER TABLE [sargme] ADD CONSTRAINT [pk_whatever] PRIMARY KEY CLUSTERED ([ID])
CREATE NONCLUSTERED INDEX [ix_dates] ON [sargme] ([DateCol1], [DateCol2])

CREATE STATISTICS [s_sargme] ON [sargme] ([DateCol1], [DateCol2])

Luego, ejecutando la primera consulta, usa el índice ix_dates y escanea, como antes. No hay cambio aquí. Esto parece redundante, pero quédate conmigo.

SELECT
    * ,
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2])
FROM
    [sargme] AS [s]
WHERE
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2]) >= 48

Ejecute la consulta CTE nuevamente, sigue siendo el mismo ...

WITH    [x] AS ( SELECT
                * ,
                DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2]) AS [ddif]
               FROM
                [sargme] AS [s])
     SELECT
        *
     FROM
        [x]
     WHERE
        [x].[ddif] >= 48;

¡Bien! Vuelva a ejecutar la consulta ni siquiera medio sargable:

SELECT
    * ,
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2])
FROM
    [sargme] AS [s]
WHERE
    [s].[DateCol2] >= DATEADD(DAY, 48, [s].[DateCol1])

Ahora agregue la columna calculada y vuelva a ejecutar las tres, junto con la consulta que llega a la columna calculada:

ALTER TABLE [sargme] ADD [ddiff] AS 
DATEDIFF(DAY, DateCol1, DateCol2) PERSISTED

CREATE NONCLUSTERED INDEX [ix_dates2] ON [sargme] ([ddiff], [DateCol1], [DateCol2])

SELECT [s].[ID] ,
       [s].[DateCol1] ,
       [s].[DateCol2]
FROM [sargme] AS [s]
WHERE [ddiff] >= 48

Si te quedas conmigo aquí, gracias. Esta es la parte de observación interesante de la publicación.

Ejecutar una consulta con un indicador de seguimiento no documentado por Fabiano Amorim para ver qué estadísticas utiliza cada consulta es bastante genial. Al ver que ningún plan tocaba un objeto de estadísticas hasta que la columna calculada fue creada e indexada, parecía extraña.

Lo que el coágulo de sangre

Diablos, incluso la consulta que golpeó la columna calculada SOLAMENTE no tocó un objeto de estadísticas hasta que lo ejecuté varias veces y obtuve una parametrización simple. Entonces, aunque todos exploraron inicialmente el índice ix_dates, usaron estimaciones de cardinalidad codificadas (30% de la tabla) en lugar de cualquier objeto de estadística disponible para ellos.

Otro punto que levantó una ceja aquí es que cuando agregué solo el índice no agrupado, los planes de consulta analizaron el HEAP, en lugar de usar el índice no agrupado en ambas columnas de fecha.

Gracias a todos los que respondieron. Todos ustedes son maravillosos

Paul White dice GoFundMonica
fuente