Clause WHERE SARGable pour deux colonnes de date

24

J'ai une question intéressante pour moi sur la SARGabilité. Dans ce cas, il s'agit d'utiliser un prédicat sur la différence entre deux colonnes de date. Voici la configuration:

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])

Ce que je vais voir assez souvent, c'est quelque chose comme ceci:

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

... ce qui n'est certainement pas SARGable. Il en résulte un balayage d'index, lit les 1000 lignes, pas bon. Les lignes estimées puent. Vous ne mettriez jamais cela en production.

Non monsieur, je n'aimais pas ça.

Ce serait bien si nous pouvions matérialiser les CTE, car cela nous aiderait à rendre cela, enfin, plus SARGable-er, techniquement parlant. Mais non, nous obtenons le même plan d'exécution qu'en haut.

/*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;

Et bien sûr, puisque nous n'utilisons pas de constantes, ce code ne change rien, et n'est même pas à moitié SARGable. Pas drôle. Même plan d'exécution.

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

Si vous vous sentez chanceux et que vous obéissez à toutes les options ANSI SET dans vos chaînes de connexion, vous pouvez ajouter une colonne calculée et la rechercher ...

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

Cela vous donnera une recherche d'index avec trois requêtes. L'homme étrange est l'endroit où nous ajoutons 48 jours à DateCol1. La requête avec DATEDIFFdans la WHEREclause, la CTEet la requête finale avec un prédicat sur la colonne calculée vous donnent tous un plan beaucoup plus agréable avec des estimations beaucoup plus belles, et tout cela.

Je pourrais vivre avec ça.

Ce qui m'amène à la question: dans une seule requête, existe-t-il un moyen SARGable d'effectuer cette recherche?

Aucune table temporaire, aucune variable de table, aucune modification de la structure de la table et aucune vue.

Je vais bien avec les auto-jointures, les CTE, les sous-requêtes ou les passages multiples sur les données. Peut fonctionner avec n'importe quelle version de SQL Server.

Éviter la colonne calculée est une limitation artificielle car je suis plus intéressé par une solution de requête qu'autre chose.

Erik Darling
la source

Réponses:

16

Il suffit d'ajouter cela rapidement pour qu'il existe comme réponse (même si je sais que ce n'est pas la réponse que vous voulez).

Une colonne calculée indexée est généralement la bonne solution pour ce type de problème.

Il:

  • fait du prédicat une expression indexable
  • permet de créer des statistiques automatiques pour une meilleure estimation de la cardinalité
  • n'a pas besoin de prendre de place dans la table de base

Pour être clair sur ce dernier point, il n'est pas nécessaire que la colonne calculée soit persistante dans ce cas:

-- 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);

Maintenant la requête:

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;

... donne le plan trivial suivant :

Plan d'exécution

Comme l'a dit Martin Smith, si vous avez des connexions utilisant les mauvaises options définies, vous pouvez créer une colonne régulière et conserver la valeur calculée à l'aide de déclencheurs.

Tout cela n'a vraiment d'importance (côté défi du code) que s'il y a un vrai problème à résoudre, bien sûr, comme le dit Aaron dans sa réponse .

C'est amusant à penser, mais je ne sais pas comment atteindre ce que vous voulez raisonnablement compte tenu des contraintes de la question. Il semble que toute solution optimale nécessiterait une nouvelle structure de données d'un certain type; la plus proche est l'approximation de «l'indice de fonction» fournie par un indice sur une colonne calculée non persistante comme ci-dessus.

Paul White dit GoFundMonica
la source
12

Risquant le ridicule de certains des plus grands noms de la communauté SQL Server, je vais sortir le cou et dire non.

Pour que votre requête soit SARGable, vous devez essentiellement construire une requête qui peut identifier une ligne de départ dans une plage de lignes consécutives dans un index. Avec l'index ix_dates, les lignes ne sont pas triées par la différence de date entre DateCol1et DateCol2, donc vos lignes cibles peuvent être réparties n'importe où dans l'index.

Les auto-jointures, les passes multiples, etc. ont toutes en commun d'inclure au moins un balayage d'index, bien qu'une jointure (boucle imbriquée) puisse bien utiliser une recherche d'index. Mais je ne vois pas comment il serait possible d'éliminer le scan.

Quant à obtenir des estimations de ligne plus précises, il n'y a pas de statistiques sur la différence de date.

La construction CTE récursive assez moche suivante élimine techniquement l'analyse de la table entière, bien qu'elle introduise une jointure de boucle imbriquée et un nombre (potentiellement très important) de recherches d'index.

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);

Il crée un spouleur d'index contenant tous DateCol1les éléments du tableau, puis effectue une recherche d'index (balayage de plage) pour chacun d'entre eux DateCol1et DateCol2qui sont au moins 48 jours plus tard.

Plus d'E / S, temps d'exécution légèrement plus long, l'estimation des lignes est encore loin et aucune chance de parallélisation en raison de la récursivité: je suppose que cette requête pourrait être utile si vous avez un très grand nombre de valeurs dans relativement peu de segments distincts, consécutifs DateCol1(en gardant le nombre de recherches bas).

Plan de requête CTE récursif fou

Daniel Hutmacher
la source
9

J'ai essayé un tas de variations farfelues, mais je n'ai trouvé aucune version meilleure que la vôtre. Le principal problème est que votre index ressemble à ceci en termes de tri des dates1 et date2. La première colonne va être dans une belle ligne rayonnée tandis que l'écart entre eux va être très irrégulier. Vous voulez que cela ressemble plus à un entonnoir qu'à la façon dont il le sera vraiment:

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

Il n'y a pas vraiment de moyen de penser à ce que l'on puisse rechercher pour un certain delta (ou plage de deltas) entre les deux points. Et je veux dire une seule recherche qui est exécutée une fois + un balayage de plage, pas une recherche qui est exécutée pour chaque ligne. Cela impliquera un scan et / ou un tri à un moment donné, et ce sont des choses que vous voulez éviter évidemment. Il est dommage que vous ne puissiez pas utiliser d'expressions telles que DATEADD/ DATEDIFFdans des index filtrés, ou effectuer des modifications de schéma possibles qui permettraient un tri sur le produit de la différence de date (comme calculer le delta au moment de l'insertion / mise à jour). En l'état, cela semble être l'un de ces cas où une analyse est en fait la méthode de récupération optimale.

Vous avez dit que cette requête n'était pas amusante, mais si vous regardez de plus près, c'est de loin la meilleure (et ce serait encore mieux si vous omettiez la sortie scalaire de calcul):

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

La raison en est qu'en évitant DATEDIFFpotentiellement le rasage de certains CPU par rapport à un calcul par rapport uniquement à la colonne clé non principale de l'index, et en évitant également certaines conversions implicites désagréables datetimeoffset(7)(ne me demandez pas pourquoi elles sont là, mais elles le sont). Voici la DATEDIFFversion:

<Predicate>
<ScalarOperator ScalarString = "dateiff (jour, CONVERT_IMPLICIT (datetimeoffset (7), [splunge]. [Dbo]. [Sargme]. [DateCol1] as [s]. [DateCol1], 0), CONVERT_IMPLICIT (datetimeoffset ( 7), [splunge]. [Dbo]. [Sargme]. [DateCol2] as [s]. [DateCol2], 0))> = (48) ">

Et voici celui sans DATEDIFF:

<Predicate>
<ScalarOperator ScalarString = "[splunge]. [Dbo]. [Sargme]. [DateCol2] as [s]. [DateCol2]> = dateadd (jour, (48), [splunge]. [Dbo]. [Dbo]. [ sargme]. [DateCol1] as [s]. [DateCol1]) ">

J'ai également trouvé des résultats légèrement meilleurs en termes de durée lorsque j'ai modifié l'index pour inclure uniquement DateCol2(et lorsque les deux index étaient présents, SQL Server a toujours choisi celui avec une clé et une colonne d'inclusion par rapport à plusieurs clés). Pour cette requête, étant donné que nous devons de toute façon analyser toutes les lignes pour trouver la plage, il n'y a aucun avantage à avoir la deuxième colonne de date dans le cadre de la clé et triée de quelque manière que ce soit. Et bien que je sache que nous ne pouvons pas obtenir de recherche ici, il y a quelque chose de fondamentalement bon à ne pas entraver la possibilité d'en obtenir un en forçant les calculs par rapport à la colonne clé principale et en les effectuant uniquement par rapport aux colonnes secondaires ou incluses.

Si c'était moi, et j'ai renoncé à trouver la solution sargable, je sais laquelle je choisirais - celle qui fait que SQL Server fait le moins de travail (même si le delta est presque inexistant). Ou mieux encore, je voudrais assouplir mes restrictions sur le changement de schéma et autres.

Et combien tout cela compte? Je ne sais pas. J'ai créé le tableau 10 millions de lignes et toutes les variations de requête ci-dessus sont toujours terminées en moins d'une seconde. Et c'est sur une VM sur un ordinateur portable (accordé, avec SSD).

Aaron Bertrand
la source
3

Toutes les façons dont j'ai pensé à rendre cette clause WHERE sarg-able sont complexes et donnent l'impression de travailler vers l'index cherche comme un objectif final plutôt que comme un moyen. Donc, non, je ne pense pas que ce soit (pragmatique) possible.

Je ne savais pas si "pas de modification de la structure de la table" signifiait pas d'index supplémentaires. Voici une solution qui évite complètement les analyses d'index, mais entraîne de nombreuses recherches d'index distinctes, c'est-à-dire une pour chaque date DateCol1 possible dans la plage Min / Max des valeurs de date du tableau. (Contrairement à Daniel qui entraîne une recherche pour chaque date distincte qui apparaît réellement dans le tableau). Il est théoriquement candidat au parallélisme b / c il évite la récursivité. Mais honnêtement, il est difficile de voir une distribution de données où cette chose est plus rapide que de simplement numériser et faire DATEDIFF. (Peut-être un DOP vraiment élevé?) Et ... le code est moche. Je suppose que cet effort compte comme un "exercice 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
la source
3

Réponse au wiki communautaire ajoutée à l'origine par l'auteur de la question en tant que modification de la question

Après avoir laissé cela reposer un peu et que certaines personnes vraiment intelligentes interviennent, ma pensée initiale à ce sujet semble correcte: il n'y a aucun moyen sain et SARGable d'écrire cette requête sans ajouter de colonne, calculée ou maintenue via un autre mécanisme, à savoir déclencheurs.

J'ai essayé quelques autres choses, et j'ai d'autres observations qui peuvent ou non être intéressantes pour quiconque lira.

Tout d'abord, relancez l'installation en utilisant une table standard plutôt qu'une table temporaire

  • Même si je connais leur réputation, je voulais essayer les statistiques multi-colonnes. Ils étaient inutiles.
  • Je voulais voir quelles statistiques étaient utilisées

Voici la nouvelle configuration:

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])

Ensuite, en exécutant la première requête, il utilise l'index ix_dates et analyse, comme auparavant. Aucun changement ici. Cela semble redondant, mais restez avec moi.

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

Exécutez à nouveau la requête CTE, toujours la même ...

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

Bien! Exécutez à nouveau la requête pas même demi-sargable:

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

Ajoutez maintenant la colonne calculée et réexécutez les trois, ainsi que la requête qui atteint la colonne calculée:

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 vous êtes resté avec moi ici, merci. Ceci est la partie d'observation intéressante du poste.

Exécuter une requête avec un indicateur de trace non documentée par Fabiano Amorim pour voir quelles statistiques chaque requête utilisée est plutôt cool. Voir qu'aucun plan ne touchait un objet de statistiques jusqu'à ce que la colonne calculée soit créée et indexée semblait étrange.

Qu'est-ce que le caillot de sang

Heck, même la requête qui a frappé la colonne calculée UNIQUEMENT n'a pas touché un objet de statistiques jusqu'à ce que je l'exécute plusieurs fois et que le paramétrage soit simple. Ainsi, même s'ils ont tous initialement analysé l'indice ix_dates, ils ont utilisé des estimations de cardinalité codées en dur (30% du tableau) plutôt que tout objet statistique à leur disposition.

Un autre point qui a soulevé un sourcil ici est que lorsque j'ai ajouté uniquement l'index non cluster, les plans de requête ont tous analysé le HEAP, plutôt que d'utiliser l'index non cluster sur les deux colonnes de date.

Merci à tous ceux qui ont répondu. Vous ętes tous merveilleux.

Paul White dit GoFundMonica
la source