Clausola WHERE SARGable per due colonne di date


24

Ho una domanda interessante per me sulla SARGability. In questo caso, si tratta di utilizzare un predicato sulla differenza tra due colonne di date. Ecco la configurazione:

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

Quello che vedrò abbastanza frequentemente è qualcosa del genere:

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

... che sicuramente non è SARGable. Ne risulta una scansione dell'indice, legge tutte le 1000 righe, non va bene. Righe stimate puzzano. Non l'avresti mai messo in produzione.

No signore, non mi è piaciuto.

Sarebbe bello se potessimo materializzare i CTE, perché ciò ci aiuterebbe a rendere questo, beh, più SARGable, tecnicamente parlando. Ma no, otteniamo lo stesso piano di esecuzione in alto.

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

E ovviamente, poiché non stiamo usando le costanti, questo codice non cambia nulla e non è nemmeno metà SARGable. Non è divertente. Stesso piano di esecuzione.

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

Se ti senti fortunato e stai obbedendo a tutte le opzioni ANSI SET nelle tue stringhe di connessione, potresti aggiungere una colonna calcolata e cercarla ...

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

Questo ti farà cercare un indice con tre query. Il dispari è dove aggiungiamo 48 giorni a DateCol1. La query con DATEDIFFnella WHEREclausola, the CTEe la query finale con un predicato sulla colonna calcolata offrono tutti un piano molto più bello con stime molto più belle e tutto il resto.

Potrei vivere con questo.

Il che mi porta alla domanda: in una singola query, esiste un modo SARGable per eseguire questa ricerca?

Nessuna tabella temporanea, nessuna variabile di tabella, nessuna modifica della struttura della tabella e nessuna vista.

Sto bene con self-join, CTE, subquery o passaggi multipli sui dati. Può funzionare con qualsiasi versione di SQL Server.

Evitare la colonna calcolata è una limitazione artificiale perché sono più interessato a una soluzione di query che a qualsiasi altra cosa.

Risposte:


16

Basta aggiungere questo rapidamente in modo che esista come una risposta (anche se so che non è la risposta che desideri).

Una colonna calcolata indicizzata è in genere la soluzione giusta per questo tipo di problema.

It:

  • rende il predicato un'espressione indicizzabile
  • consente di creare statistiche automatiche per una migliore stima della cardinalità
  • non è necessario occupare spazio nella tabella di base

Per essere chiari su quest'ultimo punto, la colonna calcolata non deve essere mantenuta in questo 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);

Ora la query:

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;

... dà il seguente piano banale :

Progetto esecutivo

Come ha detto Martin Smith, se si dispone di connessioni che utilizzano le opzioni di set errate, è possibile creare una colonna regolare e mantenere il valore calcolato utilizzando i trigger.

Tutto ciò conta davvero (a parte la sfida del codice) se c'è un vero problema da risolvere, ovviamente, come afferma Aaron nella sua risposta .

È divertente pensarci, ma non so come ottenere ciò che desideri ragionevolmente, visti i vincoli della domanda. Sembra che qualsiasi soluzione ottimale richiederebbe una nuova struttura di dati di qualche tipo; il più vicino abbiamo l'approssimazione dell '"indice di funzione" fornito da un indice su una colonna calcolata non persistente come sopra.


12

Rischiando il ridicolo di alcuni dei più grandi nomi della comunità di SQL Server, sto andando fuori di testa e dire, no.

Affinché la tua query sia SARGable, dovresti fondamentalmente costruire una query in grado di individuare una riga iniziale in un intervallo di righe consecutive in un indice. Con l'indice ix_dates, le righe non sono ordinate in base alla differenza di data tra DateCol1eDateCol2 , quindi le righe di destinazione potrebbero essere distribuite ovunque nell'indice.

Auto-join, passaggi multipli, ecc. Hanno tutti in comune il fatto di includere almeno una scansione dell'indice, sebbene un join (loop nidificato) possa utilizzare una ricerca indice. Ma non riesco a vedere come sarebbe possibile eliminare la scansione.

Per quanto riguarda ottenere stime di riga più accurate, non ci sono statistiche sulla differenza di data.

Il seguente costrutto CTE ricorsivo abbastanza brutto tecnicamente elimina la scansione dell'intera tabella, sebbene introduca un Nested Loop Join e un numero (potenzialmente molto grande) di Index Seek.

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 uno spool indice che contiene tutti DateCol1nella tabella, quindi esegue una ricerca indice (scan intervallo) per ciascuno di essi DateCol1e DateCol2che sono almeno 48 giorni avanti.

Più IO, tempo di esecuzione leggermente più lungo, stima delle righe è ancora lontana e zero possibilità di parallelizzazione a causa della ricorsione: suppongo che questa query potrebbe essere utile se si dispone di un numero molto elevato di valori in relativamente pochi distinti, consecutivi DateCol1(mantenendo basso il numero di ricerche).

Piano di query CTE ricorsivo pazzo


9

Ho provato un sacco di stravaganti varianti, ma non ho trovato nessuna versione migliore di una delle tue. Il problema principale è che il tuo indice appare così in termini di come data1 e data2 sono ordinate insieme. La prima colonna sarà in una bella linea accantonata mentre il divario tra loro sarà molto frastagliato. Volete che questo assomigli più a un imbuto che al modo in cui realmente:

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

Non riesco davvero a pensare a come renderlo ricercabile per un certo delta (o intervallo di delta) tra i due punti. E intendo una singola ricerca eseguita una volta + una scansione di intervallo, non una ricerca eseguita per ogni riga. Ciò comporterà una scansione e / o una specie ad un certo punto, e queste sono cose che vuoi evitare ovviamente. Peccato che non puoi usare espressioni come DATEADD/DATEDIFF negli indici filtrati o eseguire eventuali modifiche allo schema che consentirebbero un ordinamento sul prodotto della data diff (come calcolare il delta al momento dell'inserimento / aggiornamento). Così com'è, questo sembra essere uno di quei casi in cui una scansione è in realtà il metodo di recupero ottimale.

Hai detto che questa query non è stata divertente, ma se guardi più da vicino, questa è di gran lunga la migliore (e sarebbe ancora migliore se tralasciassi l'output scalare di calcolo):

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

Il motivo è che evitare la DATEDIFFpotenziale rasatura della CPU rispetto a un calcolo rispetto solo alla colonna chiave non principale nell'indice e anche evitare alcune brutte conversioni implicite a datetimeoffset(7)(non chiedermi perché ci sono, ma lo sono). Ecco la DATEDIFFversione:

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

Ed ecco quello senza DATEDIFF:

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

Inoltre ho trovato risultati leggermente migliori in termini di durata quando ho cambiato l'indice per includere solo DateCol2(e quando erano presenti entrambi gli indici, SQL Server ha sempre scelto quello con una chiave e uno includeva colonna vs multi-chiave). Per questa query, poiché dobbiamo scansionare tutte le righe per trovare comunque l'intervallo, non vi è alcun vantaggio di avere la seconda colonna della data come parte della chiave e ordinarla in alcun modo. E mentre so che non possiamo cercare qui, c'è qualcosa di intrinsecamente positivo nel non ostacolare la possibilità di ottenerne uno forzando i calcoli sulla colonna chiave principale e eseguendoli solo su colonne secondarie o incluse.

Se fossi in me, e rinunciassi a trovare la soluzione più ampia, so quale sceglierei: quello che fa fare a SQL Server il minor numero di lavori (anche se il delta è quasi inesistente). O meglio, allenterei le mie restrizioni sul cambio di schema e simili.

E quanto conta tutto ciò? Non lo so. Ho creato la tabella 10 milioni di righe e tutte le varianti di query sopra riportate sono state completate in meno di un secondo. E questo è su una VM su un laptop (garantito, con SSD).


3

Tutti i modi in cui ho pensato di rendere quella clausola WHERE in grado di essere condivisi sono complessi e sembra che lavorare per l'indice cerchi come obiettivo finale piuttosto che come mezzo. Quindi no, non credo sia (pragmaticamente) possibile.

Non ero sicuro che "non modificare la struttura della tabella" non significava indici aggiuntivi. Ecco una soluzione che evita completamente le scansioni dell'indice, ma risulta in MOLTE ricerche di indici separate, ovvero una per ogni possibile data DateCol1 nell'intervallo Min / Max dei valori di data nella tabella. (A differenza di Daniel, che si traduce in una ricerca per ogni data distinta che appare effettivamente nella tabella). In teoria è un candidato per il parallelismo b / c che evita la ricorsione. Ma onestamente, è difficile vedere una distribuzione di dati in cui questa cosa è più veloce della semplice scansione e esecuzione di DATEDIFF. (Forse un DOP davvero alto?) E ... il codice è brutto. Immagino che questo sforzo valga come un "esercizio mentale".

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

3

Risposta Wiki della community aggiunta originariamente dall'autore della domanda come modifica alla domanda

Dopo averlo lasciato riposare per un po ', e alcune persone davvero intelligenti hanno risposto, il mio pensiero iniziale su questo sembra corretto: non esiste un modo sano e SARGable di scrivere questa query senza aggiungere una colonna, né calcolata, né mantenuta tramite qualche altro meccanismo, vale a dire trigger.

Ho provato alcune altre cose e ho alcune altre osservazioni che potrebbero essere o meno interessanti per chiunque legga.

Innanzitutto, rieseguire l'installazione utilizzando una tabella normale anziché una tabella temporanea

  • Anche se conosco la loro reputazione, volevo provare le statistiche multi-colonna. Erano inutili.
  • Volevo vedere quali statistiche venivano utilizzate

Ecco la nuova configurazione:

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

Quindi, eseguendo la prima query, utilizza l'indice ix_dates e scansiona, proprio come prima. Nessun cambiamento qui. Sembra ridondante, ma resta con me.

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

Esegui nuovamente la query CTE, sempre lo stesso ...

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

Tutto apposto! Esegui di nuovo la query non pari a metà neanche:

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

Ora aggiungi la colonna calcolata e riesegui tutti e tre, insieme alla query che colpisce la colonna calcolata:

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

Se sei rimasto con me qui, grazie. Questa è l'interessante porzione di osservazione del post.

Eseguire una query con un flag di traccia non documentato di Fabiano Amorim per vedere quali statistiche ogni query utilizzata è piuttosto interessante. Vedere che nessun piano toccava un oggetto statistico fino a quando la colonna calcolata non veniva creata e indicizzata sembrava strano.

Che coagulo di sangue

Diamine, anche la query che ha colpito SOLO la colonna calcolata non ha toccato un oggetto statistico fino a quando non l'ho eseguito alcune volte e ha ottenuto una semplice parametrizzazione. Pertanto, anche se inizialmente hanno scansionato l'indice ix_dates, hanno utilizzato stime di cardinalità codificate (30% della tabella) anziché qualsiasi oggetto statistico disponibile.

Un altro punto che ha sollevato un sopracciglio qui è che quando ho aggiunto solo l'indice non cluster, la query pianifica tutti gli scanner di HEAP, anziché utilizzare l'indice non cluster su entrambe le colonne della data.

Grazie a tutti coloro che hanno risposto. Sei tutto meraviglioso.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.