DATEADD erzeugt keine SARGable-Erwartung für eine Indexsuche

7

Ich habe eine [UserActivity]Basistabelle, die ein ActivityTypeIdPer UserIdund das, ActivityDatean dem die Aktivität stattgefunden hat , erfasst .

Ich schreibe eine Abfrage / gespeicherte Prozedur, die die Eingabe der @UserId, @ForTypeIdsowie der @DurationIntervalund @DurationIncrementdie dynamische Rückgabe von Ergebnissen basierend auf N Anzahl von Sekunden / Minuten / Stunden / Tagen / Monaten / Jahren ermöglicht. Da das datepartArgument innerhalb DATEADD/DATEDIFFkeine Parameter zulässt, musste ich auf einige Tricks zurückgreifen, um die gewünschten Ergebnisse innerhalb der WHEREKlausel zu erzielen.

Anfangs schrieb ich die Abfrage mit DATEDIFF, aber unmittelbar nachdem ich den Ausführungsplan geschrieben und einen Blick darauf geworfen hatte, fiel mir ein, dass es sich nicht um eine SARGable-Funktion handelt (zusammen mit der Tatsache, dass die Genauigkeitsstufen für einige Daten in einem Schaltjahr angeboten werden könnten). Daher habe ich die Abfrage neu geschrieben, um den DATEPARTGedanken zu nutzen , dass ich anstelle eines Index-Scans eine Indexsuche durchführen und im Allgemeinen eine bessere Leistung erzielen würde.

Leider habe ich festgestellt, dass das Schreiben der Abfrage als DATEADDdie gleichen Ergebnisse liefert: Es wird ein Index-Scan durchgeführt, und das Abfrageoptimierungsprogramm nutzt den nicht gruppierten Index nicht für [ActivityDate].

Ich las Aaron Bertrands Blog-Post "Performance Surprises and Assumptions: DATEADD" und implementierte die Änderungen, die er an CONVERTdem DATEADDTeil beschrieben hatte, in die entsprechende Spaltendefinition , datetime2da seltsame Tricks damit verbunden waren datetime2. Das Problem war jedoch auch danach noch vorhanden.

Zur besseren Veranschaulichung des Szenarios finden Sie hier eine vergleichbare Tabellendefinition.

DROP TABLE IF EXISTS [dbo].[UserActivity]
IF OBJECT_ID('[dbo].[UserActivity]', 'U') IS NULL
BEGIN
    CREATE TABLE [dbo].[UserActivity] (
        [UserId] [int] NOT NULL
        ,[UserActivityId] [bigint] IDENTITY(1,1) NOT NULL
        ,[ActivityTypeId] [tinyint] NOT NULL
        ,[ActivityDate] [datetime2](0) NOT NULL CONSTRAINT [DF_UserActivity_ActivityDate] DEFAULT GETDATE()
        ,CONSTRAINT [PK_UserActivity] PRIMARY KEY CLUSTERED ([UserActivityId] ASC)
        ,INDEX [IX_UserActivity_UserId] NONCLUSTERED ([UserId] ASC)
        ,INDEX [IX_UserActivity_ActivityTypeId] NONCLUSTERED ([ActivityTypeId] ASC)
        ,INDEX [IX_UserActivity_ActivityDate] NONCLUSTERED ([ActivityDate] ASC)
    )
END;
GO

Füllen Sie die Tabelle rekursiv mit Dummy-Daten für 5 verschiedene Benutzer mit einem Zufall ActivityTypeIdzwischen 1 und 10 mit einem neuen ActivityDatealle 4 Minuten.

DECLARE @UserId int = (SELECT ISNULL((SELECT TOP (1) [UserId] + 1 FROM [dbo].[UserActivity] ORDER BY [UserId] DESC), 1))
;WITH [UserActivitySeed] AS (
    SELECT
        CONVERT(datetime2(0), '01/01/2018') AS 'ActivityDate'
    UNION ALL
    SELECT
        DATEADD(minute, 4, [ActivityDate])
    FROM
        [UserActivitySeed]
    WHERE
        [ActivityDate] < '2018-04-01')
INSERT INTO [dbo].[UserActivity] ([UserId], [ActivityTypeId], [ActivityDate])
SELECT
    @UserId
    ,ABS(CHECKSUM(NEWID()) % 9) + 1
    ,[ActivityDate]
FROM
    [UserActivitySeed] OPTION (MAXRECURSION 32767);

GO 5

ALTER INDEX ALL ON [dbo].[UserActivity] REBUILD;

Unten ist die erste Abfrage, mit der ich geschrieben habe DATEDIFF. Hinweis: Ich schließe die @UserIdund @ForTypeIdPrädikate absichtlich aus, um diese Schlüsselsuche zu vermeiden und das Rauschen in den beigefügten Plänen zu reduzieren.

Wie Sie in PasteThePlan für diese Abfrage finden , führt es erwartungsgemäß einen DATEDIFFIndexscan durch, der nicht SARGable ist.

DECLARE @UserId int = 1
DECLARE @ForTypeId int = 3
DECLARE @DurationInterval varchar(6) = 'hour'
DECLARE @DurationIncrement int = 1

SELECT
    COUNT(UA.[UserActivityId]) AS 'ActivityTypeCount'
FROM
    [dbo].[UserActivity] UA
WHERE
    -- Exclude the @UserId and @ForTypeId predicates.
    -- UA.[UserId] = @UserId
    -- AND UA.[ActivityTypeId] = @ForTypeId
    -- AND 
    CASE
        WHEN @DurationInterval IN ('year', 'yy', 'yyyy') THEN DATEDIFF(SECOND, UA.[ActivityDate], GETDATE()) / 3600.0 / 24.0 / 365.25
        WHEN @DurationInterval IN ('month', 'mm', 'm') THEN DATEDIFF(SECOND, UA.[ActivityDate], GETDATE()) / 3600.0 / 24.0 / 365.25 * 12
        WHEN @DurationInterval IN ('day', 'dd', 'd') THEN DATEDIFF(SECOND, UA.[ActivityDate], GETDATE()) / 3600.0 / 24.0
        WHEN @DurationInterval IN ('hour', 'hh') THEN DATEDIFF(SECOND, UA.[ActivityDate], GETDATE()) / 3600.0
        WHEN @DurationInterval IN ('minute', 'mi', 'n') THEN DATEDIFF(SECOND, UA.[ActivityDate], GETDATE()) / 60.0
        WHEN @DurationInterval IN ('second', 'ss', 's') THEN DATEDIFF(SECOND, UA.[ActivityDate], GETDATE())
    END < @DurationIncrement

Unten ist die DATEADDAbfrage. PasteThePlan hier. Leider findet keine Indexsuche statt. Dies mag meinerseits eine falsche Annahme sein, aber ich bin ratlos darüber, warum sie überhaupt nicht auftritt.

DECLARE @UserId int = 1
DECLARE @ForTypeId int = 3
DECLARE @DurationInterval varchar(6) = 'hour'
DECLARE @DurationIncrement int = 1

SELECT
    COUNT(UA.[UserActivityId]) AS 'ActivityTypeCount'
FROM
    [dbo].[UserActivity] UA
WHERE
    -- Exclude the @UserId and @ForTypeId predicates.
    -- UA.[UserId] = @UserId
    -- AND UA.[ActivityTypeId] = @ForTypeId
    -- AND 
    (
        (@DurationInterval IN ('year', 'yy', 'yyyy') AND UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(YEAR, -@DurationIncrement, GETDATE())))
        OR
        (@DurationInterval IN ('month', 'mm', 'm') AND UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(MONTH, -@DurationIncrement, GETDATE())))
        OR
        (@DurationInterval IN ('day', 'dd', 'd') AND UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(DAY, -@DurationIncrement, GETDATE())))
        OR
        (@DurationInterval IN ('hour', 'hh') AND UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(HOUR, -@DurationIncrement, GETDATE())))
        OR
        (@DurationInterval IN ('minute', 'mi', 'n') AND UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(MINUTE, -@DurationIncrement, GETDATE())))
        OR
        (@DurationInterval IN ('second', 'ss', 's') AND UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(SECOND, -@DurationIncrement, GETDATE())))
        )

Was ist die Ursache dafür? Ist das Verhalten, das ich sehe, darauf zurückzuführen, dass ich das ORPotenzial negiert habe, überhaupt den Index zu verwenden? Übersehe ich hier etwas akribisch Offensichtliches?

UPDATE: Meine zweite Frage oben veranlasste mich, eine Abfrage vor den OROperationen durchzuführen. Die Abfrage hat die Indexsuche durchgeführt, sodass bei diesen Vergleichen etwas auftritt, das SQL Server nicht gefällt. PasteThePlan hier.

DECLARE @DurationIncrement int = 1

SELECT
    COUNT(UA.[UserActivityId]) AS 'ActivityTypeCount'
FROM
    [dbo].[UserActivity] UA
WHERE
    UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(HOUR, -@DurationIncrement, GETDATE()))

UPDATE: Lösung hier geteilt.

Pico de Gallo
quelle

Antworten:

9

Die ORBedingung wird zur Kompilierungszeit und nicht zur Laufzeit ausgewertet. Dies bedeutet, dass Ihre WHEREBedingung keine Suche generiert.

Und nur um den Code zu bereinigen, habe ich Ihren Code überarbeitet, CONVERTum ihn besser lesbar zu machen.

Ich würde versuchen, die WHEREKlausel zu ändern in:

UA.[ActivityDate]>CONVERT(datetime2(0), (CASE
    WHEN @DurationInterval IN ('year', 'yy', 'yyyy') THEN DATEADD(year, -@DurationIncrement, GETDATE())
    WHEN @DurationInterval IN ('month', 'mm', 'm')   THEN DATEADD(month, -@DurationIncrement, GETDATE())
    WHEN ...
    END))

Ich habe keinen Zugriff auf eine Umgebung, in der ich dies überprüfen kann, aber bitte lassen Sie mich wissen, ob es funktioniert.

Daniel Hutmacher
quelle
Das war die Lösung! Durch Vertauschen der WHEREKlausel als solche wird der nicht gruppierte Index entsprechend getroffen. Ich habe mein OP mit der richtigen Abfrage aktualisiert. Danke mein Herr.
PicoDeGallo
7

Bei der Kompilierung kennt SQL Server den Wert von nicht @DurationIntervalund kompiliert daher den Plan, der am besten zum Abrufen der Daten für ein mögliches Szenario geeignet ist.

Sie können dies beweisen, indem Sie WITH (FORCESEEK)der Abfrage eine Option hinzufügen , aus der hervorgeht, dass für jede ORBedingung eine individuelle Suche durchgeführt wird, um eine Indexsuche für die angegebene Abfrage durchzuführen .

https://www.brentozar.com/pastetheplan/?id=HkE3lkuqf

Geben Sie hier die Bildbeschreibung ein

Es wird festgestellt, dass der Scan eine optimalere Methode zum Abrufen der Daten darstellt als 6 Suchvorgänge.

@ Daniel Hutmacher bietet eine optimale Lösung, die eine einzelne Indexsuche durchführt IX_UserActivity_ActivityDate. Alternativ können Sie eine hinzufügen OPTION(RECOMPILE), obwohl dies jedes Mal, wenn die Abfrage ausgeführt wird, eine Neukompilierung erzwingen würde, was möglicherweise mehr Schaden als Nutzen verursachen könnte.

Mark Sinkinson
quelle
2
Beachten Sie, dass diese Filter Prädikate für Startausdrücke haben, sodass zur Laufzeit nur eine Suche ausgeführt wird.
Paul White 9
6

Eine solche "Küchenspüle" -Abfrage (mehrere unterschiedliche Filterklauseln, von denen eine oder mehrere abhängig vom Wert einer Eingabe verwendet werden) wird niemals sarkierbar sein, selbst wenn alle ihre einzelnen Klauseln vorhanden sind.

Die beiden schnellen Optionen bestehen darin, sie in einzelne Prozeduren zu unterteilen und jede nach Bedarf von einer Master-Prozedur aufzurufen oder Ad-hoc-SQL zu verwenden.

Einen ausführlichen Artikel, der eine Reihe von Optionen für diese Art von Abfrage / Prozedur beschreibt, finden Sie unter http://www.sommarskog.se/dyn-search.html

David Spillett
quelle
1
Ich spreche
Aaron Bertrand
Ein weiterer Beitrag von Ihnen zum Lesezeichen. Vielen Dank, @AaronBertrand
PicoDeGallo
3

Zum späteren Nachschlagen ist dies die Lösung, zu der ich auf der Grundlage der von Daniel Hutmatcher vorgeschlagenen Antwort gekommen bin.

DECLARE @UserId int = 1
DECLARE @ForTypeId int = 3
DECLARE @DurationInterval varchar(6) = 'hour'
DECLARE @DurationIncrement int = 1

SELECT
    COUNT(UA.[UserActivityId]) AS 'ActivityTypeCount'
FROM
    [dbo].[UserActivity] UA
WHERE
    -- Exclude the @UserId and @ForTypeId predicates.
    -- UA.[UserId] = @UserId
    -- AND UA.[ActivityTypeId] = @ForTypeId
    -- AND 
    UA.[ActivityDate] > CONVERT(datetime2(0),
    (CASE
        WHEN @DurationInterval IN ('year', 'yy', 'yyyy') THEN DATEADD(YEAR, -@DurationIncrement, GETDATE())
        WHEN @DurationInterval IN ('month', 'mm', 'm') THEN DATEADD(MONTH, -@DurationIncrement, GETDATE())
        WHEN @DurationInterval IN ('day', 'dd', 'd') THEN DATEADD(DAY, -@DurationIncrement, GETDATE())
        WHEN @DurationInterval IN ('hour', 'hh') THEN DATEADD(HOUR, -@DurationIncrement, GETDATE())
        WHEN @DurationInterval IN ('minute', 'mi', 'n') THEN DATEADD(MINUTE, -@DurationIncrement, GETDATE())
        WHEN @DurationInterval IN ('second', 'ss', 's') THEN DATEADD(SECOND, -@DurationIncrement, GETDATE())
    END))
Pico de Gallo
quelle