SARGable WHERE-Klausel für zwei Datumsspalten

24

Ich habe für mich eine interessante Frage zu SARGability. In diesem Fall wird ein Prädikat für die Differenz zwischen zwei Datumsspalten verwendet. Hier ist das Setup:

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

Was ich ziemlich oft sehe, ist ungefähr so:

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

... was definitiv nicht SARGable ist. Es führt zu einem Index-Scan, liest alle 1000 Zeilen, nicht gut. Geschätzte Reihen stinken. Sie würden das nie in Produktion setzen.

Nein, Sir, es hat mir nicht gefallen.

Es wäre schön, wenn wir CTEs verwirklichen könnten, denn das würde uns dabei helfen, das technisch besser zu machen. Aber nein, wir bekommen den gleichen Ausführungsplan wie oben oben.

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

Und da wir keine Konstanten verwenden, ändert dieser Code natürlich nichts und ist nicht einmal halb SARGable. Kein Spaß. Gleicher Ausführungsplan.

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

Wenn Sie sich glücklich schätzen und alle ANSI SET-Optionen in Ihren Verbindungszeichenfolgen befolgen, können Sie eine berechnete Spalte hinzufügen und danach suchen ...

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

Dadurch erhalten Sie eine Indexsuche mit drei Abfragen. Der seltsame Mann ist, wo wir DateCol1 48 Tage hinzufügen. Die Abfrage mit DATEDIFFin der WHEREKlausel, die CTEund die abschließende Abfrage mit einem Prädikat in der berechneten Spalte geben Ihnen einen viel besseren Plan mit viel besseren Schätzungen und all dem.

Ich könnte damit leben.

Was mich zu der Frage bringt: Gibt es in einer einzelnen Abfrage eine SARGable-Methode, um diese Suche durchzuführen?

Keine temporären Tabellen, keine Tabellenvariablen, keine Änderung der Tabellenstruktur und keine Ansichten.

Selbstverknüpfungen, CTEs, Unterabfragen oder mehrere Durchgänge über die Daten sind in Ordnung. Kann mit jeder Version von SQL Server arbeiten.

Das Vermeiden der berechneten Spalte ist eine künstliche Einschränkung, da ich mehr an einer Abfragelösung als an irgendetwas anderem interessiert bin.

Erik Darling
quelle

Antworten:

16

Füge dies einfach schnell hinzu, damit es als Antwort existiert (obwohl ich weiß, dass es nicht die Antwort ist, die du willst).

Eine indizierte berechnete Spalte ist normalerweise die richtige Lösung für diese Art von Problem.

Es:

  • macht das Prädikat zu einem indizierbaren Ausdruck
  • Ermöglicht die Erstellung automatischer Statistiken zur besseren Schätzung der Kardinalität
  • nicht brauchen keinen Platz in der Basistabelle zu nehmen

Um in diesem letzten Punkt klar zu sein, muss die berechnete Spalte in diesem Fall nicht beibehalten werden :

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

Nun die Abfrage:

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;

... gibt den folgenden trivialen Plan an:

Ausführungsplan

Wie Martin Smith sagte, können Sie bei Verbindungen mit den falschen Set-Optionen eine reguläre Spalte erstellen und den berechneten Wert mithilfe von Triggern verwalten.

All dies ist nur dann wirklich wichtig (abgesehen von der Code-Herausforderung), wenn es ein echtes Problem zu lösen gibt, wie Aaron in seiner Antwort sagt .

Es macht Spaß, darüber nachzudenken, aber ich weiß nicht, wie ich das erreichen kann, was Sie angesichts der Einschränkungen in der Frage vernünftigerweise wollen. Es scheint, als würde jede optimale Lösung eine neue Datenstruktur irgendeiner Art erfordern. Das nächste, was wir haben, ist die 'Funktionsindex'-Näherung, die von einem Index für eine nicht persistierte berechnete Spalte wie oben bereitgestellt wird.

Paul White sagt GoFundMonica
quelle
12

Ich riskiere Spott von einigen der größten Namen in der SQL Server-Community und werde meinen Hals rausstrecken und sagen: Nein.

Damit Ihre Abfrage SARG-fähig ist, müssen Sie im Grunde eine Abfrage erstellen, die eine Startzeile in einem Bereich aufeinanderfolgender Zeilen in einem Index genau bestimmen kann . Mit dem Index ix_dateswerden die Zeilen nicht nach dem Datumsunterschied zwischen DateCol1und sortiert DateCol2, sodass Ihre Zielzeilen auf eine beliebige Stelle im Index verteilt werden können.

Selbstverknüpfungen, mehrere Durchläufe usw. haben alle gemeinsam, dass sie mindestens einen Index-Scan enthalten, obwohl ein (verschachtelter Schleifen-) Join durchaus einen Index-Suchlauf verwenden kann. Aber ich kann nicht sehen, wie es möglich wäre, den Scan zu eliminieren.

Um genauere Zeilenschätzungen zu erhalten, gibt es keine Statistiken zum Datumsunterschied.

Das folgende, ziemlich hässliche rekursive CTE-Konstrukt eliminiert technisch das Scannen der gesamten Tabelle, obwohl es einen Nested Loop Join und eine (möglicherweise sehr große) Anzahl von Index-Suchvorgängen einführt.

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

Es wird ein Index-Spool erstellt, der alle DateCol1in der Tabelle enthaltenen Elemente enthält , und anschließend wird für jedes dieser Elemente DateCol1und ein Index-Suchlauf (Bereichsscan) durchgeführtDateCol2 dass mindestens 48 Tage nach vorn.

Weitere E / A-Vorgänge, etwas längere Ausführungszeit, die Zeilenschätzung ist noch weit entfernt und die Wahrscheinlichkeit einer Parallelisierung ist aufgrund der Rekursion gleich Null: Ich vermute, diese Abfrage ist möglicherweise hilfreich, wenn Sie eine sehr große Anzahl von Werten innerhalb relativ weniger eindeutiger, aufeinanderfolgender Werte haben DateCol1(Halten Sie die Anzahl der Suchvorgänge niedrig).

Verrückter rekursiver CTE-Abfrageplan

Daniel Hutmacher
quelle
9

Ich habe ein paar verrückte Variationen ausprobiert, fand aber keine bessere Version als eine von Ihnen. Das Hauptproblem ist, dass Ihr Index in Bezug auf die Sortierung von Datum1 und Datum2 so aussieht. Die erste Spalte befindet sich in einer schönen Reihe, während die Lücke zwischen ihnen sehr ausgefranst sein wird. Sie möchten, dass dies eher wie ein Trichter aussieht, als wie es wirklich der Fall ist:

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

Ich kann mir nicht wirklich vorstellen, dies für ein bestimmtes Delta (oder einen Bereich von Deltas) zwischen den beiden Punkten suchbar zu machen. Und ich meine einen einzelnen Suchlauf, der einmal ausgeführt wird + einen Entfernungsscan, nicht einen Suchlauf, der für jede Zeile ausgeführt wird. Das wird irgendwann einen Scan und / oder eine Sortierung beinhalten, und dies sind Dinge, die Sie offensichtlich vermeiden möchten. Es ist schade, dass Sie keine Ausdrücke wie DATEADD/ DATEDIFFin gefilterten Indizes verwenden oder mögliche Schemaänderungen vornehmen können, die eine Sortierung des Produkts mit dem Datumsunterschied ermöglichen (wie das Berechnen des Deltas zur Einfüge- / Aktualisierungszeit). Dies scheint einer der Fälle zu sein, in denen ein Scan tatsächlich die optimale Abrufmethode ist.

Sie sagten, dass diese Abfrage keinen Spaß machte, aber wenn Sie genauer hinschauen, ist dies bei weitem die beste (und wäre sogar noch besser, wenn Sie die skalare Rechenausgabe weglassen):

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

Der Grund ist, dass das Vermeiden des DATEDIFFpotenziellen Einsparens von CPU im Vergleich zu einer Berechnung nur anhand der nicht führenden Schlüsselspalte im Index und auch das Vermeiden einiger unangenehmer impliziter Konvertierungen in datetimeoffset(7)(fragen Sie mich nicht, warum diese vorhanden sind, aber sie sind). Hier ist die DATEDIFFVersion:

<Predicate>
<ScalarOperator ScalarString = "Datum (Tag, CONVERT_IMPLICIT (DatumZeitoffset (7), [Sprung]. [DBO]. [Sprache]. [DateCol1] als [s]. [DateCol1], 0), CONVERT_IMPLICIT (DatumZeitoffset ( 7), [splunge]. [Dbo]. [Sargme]. [DateCol2] als [s]. [DateCol2], 0))> = (48) ">

Und hier ist der ohne DATEDIFF:

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

Auch ich fand etwas bessere Ergebnisse in Bezug auf die Dauer, als ich den Index änderte, um nur einzuschließen DateCol2 (und als beide Indizes vorhanden waren, wählte SQL Server immer den mit einem Schlüssel und einer Einschlussspalte gegen Mehrschlüssel). Da bei dieser Abfrage ohnehin alle Zeilen durchsucht werden müssen, um den Bereich zu ermitteln, ist es nicht vorteilhaft, die zweite Datumsspalte als Teil des Schlüssels zu haben und in irgendeiner Weise zu sortieren. Und obwohl ich weiß, dass wir hier keine Suche bekommen können, ist es von Natur aus ein gutes Gefühl, nicht zu suchen , die Fähigkeit, eine zu bekommen, behindern, indem Berechnungen für die führende Schlüsselspalte erzwungen und nur für sekundäre oder eingeschlossene Spalten durchgeführt werden.

Wenn ich es wäre und aufhören würde, die sargable-Lösung zu finden, weiß ich, welche ich wählen würde - diejenige, die SQL Server am wenigsten arbeiten lässt (auch wenn das Delta fast nicht existiert). Oder noch besser, ich würde meine Einschränkungen in Bezug auf Schemaänderungen und dergleichen lockern.

Und wie wichtig ist das alles? Ich weiß es nicht. Ich habe in der Tabelle 10 Millionen Zeilen erstellt und alle oben genannten Abfragevarianten in weniger als einer Sekunde ausgeführt. Und das ist auf einer VM auf einem Laptop (selbstverständlich mit SSD).

Aaron Bertrand
quelle
3

Alle Möglichkeiten, die ich mir überlegt habe, um diese WHERE-Klausel zu verallgemeinern, sind komplex und scheinen eher ein Ziel als ein Mittel zu sein, auf Indexsuche hinzuarbeiten. Also, nein, ich denke nicht, dass es (pragmatisch) möglich ist.

Ich war mir nicht sicher, ob "keine Änderung der Tabellenstruktur" keine zusätzlichen Indizes bedeutete. Hier ist eine Lösung, die Indexprüfungen vollständig vermeidet, aber zu einer VIELEN Anzahl separater Indexsuchen führt , dh eine für jedes mögliche DateCol1-Datum im Min / Max-Bereich der Datumswerte in der Tabelle. (Im Gegensatz zu Daniel, bei dem nach jedem bestimmten Datum gesucht wird, das tatsächlich in der Tabelle angezeigt wird). Theoretisch ist es ein Kandidat für Parallelität, da es eine Rekursion vermeidet. Aber ehrlich gesagt ist es schwierig, eine Datenverteilung zu sehen, bei der dieses Ding schneller ist als nur zu scannen und DATEDIFF auszuführen. (Vielleicht ein wirklich hoher DOP?) Und ... der Code ist hässlich. Ich denke, diese Anstrengung zählt als "mentale Übung".

--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
quelle
3

Community-Wiki-Antwort, die ursprünglich vom Frageautor als Bearbeitung der Frage hinzugefügt wurde

Nachdem ich das ein bisschen stehen gelassen habe und einige wirklich kluge Leute mitmachen, scheint mein erster Gedanke richtig zu sein: Es gibt keine vernünftige und SARG-fähige Möglichkeit, diese Abfrage zu schreiben, ohne eine Spalte hinzuzufügen, die entweder berechnet oder über einen anderen Mechanismus verwaltet wird, nämlich löst aus.

Ich habe ein paar andere Dinge ausprobiert, und ich habe einige andere Beobachtungen, die für jemanden, der liest, vielleicht interessant sind oder nicht.

Führen Sie zunächst das Setup mit einer regulären Tabelle und nicht mit einer temporären Tabelle erneut aus

  • Obwohl ich ihren Ruf kenne, wollte ich mehrspaltige Statistiken ausprobieren. Sie waren nutzlos.
  • Ich wollte sehen, welche Statistiken verwendet wurden

Hier ist das neue Setup:

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

Beim Ausführen der ersten Abfrage wird dann der Index ix_dates verwendet und wie zuvor gescannt. Keine Änderung hier. Das scheint überflüssig, aber bleib bei mir.

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

Führen Sie die CTE-Abfrage erneut aus, jedoch auf dieselbe Weise ...

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

In Ordung! Führen Sie die nicht-gerade-halb-aufladbare Abfrage erneut aus:

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

Fügen Sie nun die berechnete Spalte hinzu und führen Sie alle drei zusammen mit der Abfrage, die auf die berechnete Spalte zutrifft, erneut aus:

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

Wenn du hier bei mir bleibst, danke. Dies ist der interessante Beobachtungsteil des Beitrags.

Das Ausführen einer Abfrage mit einem nicht dokumentierten Ablaufverfolgungsflag von Fabiano Amorim , um festzustellen , welche Statistik jede Abfrage verwendet, ist ziemlich cool. Zu sehen, dass kein Plan ein Statistikobjekt berührt hat, bis die berechnete Spalte erstellt und indiziert wurde, erschien merkwürdig.

Was zum Blutgerinnsel

Selbst die Abfrage, die NUR die berechnete Spalte traf, berührte kein Statistikobjekt, bis ich es einige Male ausführte und es eine einfache Parametrisierung erhielt. Obwohl alle den ix_dates-Index anfangs durchsucht haben, verwendeten sie hartcodierte Kardinalitätsschätzungen (30% der Tabelle) und keine statistischen Objekte, die ihnen zur Verfügung standen.

Ein weiterer Punkt, der hier eine Augenbraue hochgezogen hat, ist, dass die Abfrage, wenn ich nur den nicht gruppierten Index hinzufügte, alle den HEAP durchsuchte, anstatt den nicht gruppierten Index für beide Datumsspalten zu verwenden.

Vielen Dank an alle, die geantwortet haben. Ihr seid alle wundervoll.

Paul White sagt GoFundMonica
quelle