Come evitare l'uso di variabili nella clausola WHERE


16

Data una procedura memorizzata (semplificata) come questa:

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

Se la Saletabella è grande, l' SELECTesecuzione può richiedere molto tempo, apparentemente perché l'ottimizzatore non può ottimizzare a causa della variabile locale. Abbiamo testato l'esecuzione della SELECTparte con variabili, quindi date fisse e il tempo di esecuzione è passato da ~ 9 minuti a ~ 1 secondo.

Disponiamo di numerose procedure memorizzate che eseguono query in base a intervalli di date "fissi" (settimana, mese, 8 settimane ecc.), Pertanto il parametro di input è solo @endDate e @startDate viene calcolato all'interno della procedura.

La domanda è: qual è la migliore pratica per evitare variabili in una clausola WHERE in modo da non compromettere l'ottimizzatore?

Le possibilità che abbiamo trovato sono mostrate di seguito. C'è una di queste migliori pratiche o esiste un altro modo?

Utilizzare una procedura wrapper per trasformare le variabili in parametri.

I parametri non influiscono sull'ottimizzatore allo stesso modo delle variabili locali.

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
   DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
   EXECUTE DateRangeProc @startDate, @endDate
END

CREATE PROCEDURE DateRangeProc(@startDate DATE, @endDate DATE)
AS
BEGIN
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

Usa SQL dinamico con parametri.

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
  DECLARE @sql NVARCHAR(4000) = N'
    SELECT
      -- Stuff
    FROM Sale
    WHERE SaleDate BETWEEN @startDate AND @endDate
  '
  DECLARE @param NVARCHAR(4000) = N'@startDate DATE, @endDate DATE'
  EXECUTE sp_executesql @sql, @param, @startDate = @startDate, @endDate = @endDate
END

Utilizzare SQL dinamico "codificato".

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
  DECLARE @sql NVARCHAR(4000) = N'
    SELECT
      -- Stuff
    FROM Sale
    WHERE SaleDate BETWEEN @startDate AND @endDate
  '
  SET @sql = REPLACE(@sql, '@startDate', CONVERT(NCHAR(10), @startDate, 126))
  SET @sql = REPLACE(@sql, '@endDate', CONVERT(NCHAR(10), @endDate, 126))
  EXECUTE sp_executesql @sql
END

Utilizzare la DATEADD()funzione direttamente.

Non mi interessa perché le funzioni di chiamata in WHERE influiscono anche sulle prestazioni.

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN DATEADD(DAY, -6, @endDate) AND @endDate
END

Usa un parametro opzionale.

Non sono sicuro che l'assegnazione ai parametri avrebbe lo stesso problema dell'assegnazione alle variabili, quindi questa potrebbe non essere un'opzione. Questa soluzione non mi piace molto, ma la includo per completezza.

CREATE PROCEDURE WeeklyProc(@endDate DATE, @startDate DATE = NULL)
AS
BEGIN
  SET @startDate = DATEADD(DAY, -6, @endDate)
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

-- Aggiornare --

Grazie per suggerimenti e commenti. Dopo averli letti ho eseguito alcuni test di temporizzazione con i vari approcci. Sto aggiungendo i risultati qui come riferimento.

L'esecuzione 1 è senza piano. L'esecuzione 2 è immediatamente dopo l'esecuzione 1 con esattamente gli stessi parametri, quindi utilizzerà il piano dall'esecuzione 1.

I tempi NoProc sono per l'esecuzione manuale delle query SELECT in SSMS al di fuori di una procedura memorizzata.

TestProc1-7 sono le query dalla domanda originale.

TestProcA-B si basano sul suggerimento di Mikael Eriksson . La colonna nel database è un DATE, quindi ho provato a passare il parametro come DATETIME e in esecuzione con il cast implicito (testProcA) e il cast esplicito (testProcB).

TestProcC-D si basano sul suggerimento di Kenneth Fisher . Usiamo già una tabella di ricerca della data per altre cose, ma non ne abbiamo una con una colonna specifica per ogni intervallo di periodi. La variazione che ho provato utilizza ancora TRA ma lo fa sulla tabella di ricerca più piccola e si unisce alla tabella più grande. Analizzerò ulteriormente se è possibile utilizzare specifiche tabelle di ricerca, anche se i nostri periodi sono fissi, ce ne sono diversi.

    Righe totali nella tabella di vendita: 136.424.366

                       Run 1 (ms) Run 2 (ms)
    Procedura CPU elaborata CPU elaborata Commento
    Costanti NoProc 6567 62199 2870 719 Interrogazione manuale con costanti
    Variabili NoProc 9314 62424 3993 998 Interrogazione manuale con variabili
    testProc1 6801 62919 2871 736 Range codificato
    testProc2 8955 63190 3915 979 Parametro e intervallo variabile
    testProc3 8985 63152 3932 987 Procedura wrapper con intervallo di parametri
    testProc4 9142 63939 3931 977 SQL dinamico con parametri
    testProc5 7269 62933 2933 728 SQL dinamico codificato
    testProc6 9266 63421 3915 984 Utilizzare DATEADD alla data DATE
    testProc7 2044 13950 1092 1087 Parametro fittizio
    testProcA 12120 61493 5491 1875 Utilizzare DATEADD il DATETIME senza CAST
    testProcB 8612 61949 3932 978 Utilizzare DATEADD su DATETIME con CAST
    testProcC 8861 61651 3917 993 Usa tabella di ricerca, prima vendita
    testProcD 8625 61740 3994 1031 Utilizza la tabella di ricerca, Ultima vendita

Ecco il codice di prova.

------ SETUP ------

IF OBJECT_ID(N'testDimDate', N'U') IS NOT NULL DROP TABLE testDimDate
IF OBJECT_ID(N'testProc1', N'P') IS NOT NULL DROP PROCEDURE testProc1
IF OBJECT_ID(N'testProc2', N'P') IS NOT NULL DROP PROCEDURE testProc2
IF OBJECT_ID(N'testProc3', N'P') IS NOT NULL DROP PROCEDURE testProc3
IF OBJECT_ID(N'testProc3a', N'P') IS NOT NULL DROP PROCEDURE testProc3a
IF OBJECT_ID(N'testProc4', N'P') IS NOT NULL DROP PROCEDURE testProc4
IF OBJECT_ID(N'testProc5', N'P') IS NOT NULL DROP PROCEDURE testProc5
IF OBJECT_ID(N'testProc6', N'P') IS NOT NULL DROP PROCEDURE testProc6
IF OBJECT_ID(N'testProc7', N'P') IS NOT NULL DROP PROCEDURE testProc7
IF OBJECT_ID(N'testProcA', N'P') IS NOT NULL DROP PROCEDURE testProcA
IF OBJECT_ID(N'testProcB', N'P') IS NOT NULL DROP PROCEDURE testProcB
IF OBJECT_ID(N'testProcC', N'P') IS NOT NULL DROP PROCEDURE testProcC
IF OBJECT_ID(N'testProcD', N'P') IS NOT NULL DROP PROCEDURE testProcD
GO

CREATE TABLE testDimDate
(
   DateKey DATE NOT NULL,
   CONSTRAINT PK_DimDate_DateKey UNIQUE NONCLUSTERED (DateKey ASC)
)
GO

DECLARE @dateTimeStart DATETIME = '2000-01-01'
DECLARE @dateTimeEnd DATETIME = '2100-01-01'
;WITH CTE AS
(
   --Anchor member defined
   SELECT @dateTimeStart FullDate
   UNION ALL
   --Recursive member defined referencing CTE
   SELECT FullDate + 1 FROM CTE WHERE FullDate + 1 <= @dateTimeEnd
)
SELECT
   CAST(FullDate AS DATE) AS DateKey
INTO #DimDate
FROM CTE
OPTION (MAXRECURSION 0)

INSERT INTO testDimDate (DateKey)
SELECT DateKey FROM #DimDate ORDER BY DateKey ASC

DROP TABLE #DimDate
GO

-- Hard coded date range.
CREATE PROCEDURE testProc1 AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN '2012-12-09' AND '2012-12-10'
END
GO

-- Parameter and variable date range.
CREATE PROCEDURE testProc2(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
END
GO

-- Parameter date range.
CREATE PROCEDURE testProc3a(@startDate DATE, @endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
END
GO

-- Wrapper procedure.
CREATE PROCEDURE testProc3(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   EXEC testProc3a @startDate, @endDate
END
GO

-- Parameterized dynamic SQL.
CREATE PROCEDURE testProc4(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   DECLARE @sql NVARCHAR(4000) = N'SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate'
   DECLARE @param NVARCHAR(4000) = N'@startDate DATE, @endDate DATE'
   EXEC sp_executesql @sql, @param, @startDate = @startDate, @endDate = @endDate
END
GO

-- Hard coded dynamic SQL.
CREATE PROCEDURE testProc5(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   DECLARE @sql NVARCHAR(4000) = N'SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN ''@startDate'' AND ''@endDate'''
   SET @sql = REPLACE(@sql, '@startDate', CONVERT(NCHAR(10), @startDate, 126))
   SET @sql = REPLACE(@sql, '@endDate', CONVERT(NCHAR(10), @endDate, 126))
   EXEC sp_executesql @sql
END
GO

-- Explicitly use DATEADD on a DATE.
CREATE PROCEDURE testProc6(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN DATEADD(DAY, -1, @endDate) AND @endDate
END
GO

-- Dummy parameter.
CREATE PROCEDURE testProc7(@endDate DATE, @startDate DATE = NULL) AS
BEGIN
   SET NOCOUNT ON
   SET @startDate = DATEADD(DAY, -1, @endDate)
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
END
GO

-- Explicitly use DATEADD on a DATETIME with implicit CAST for comparison with SaleDate.
-- Based on the answer from Mikael Eriksson.
CREATE PROCEDURE testProcA(@endDateTime DATETIME) AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN DATEADD(DAY, -1, @endDateTime) AND @endDateTime
END
GO

-- Explicitly use DATEADD on a DATETIME but CAST to DATE for comparison with SaleDate.
-- Based on the answer from Mikael Eriksson.
CREATE PROCEDURE testProcB(@endDateTime DATETIME) AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN CAST(DATEADD(DAY, -1, @endDateTime) AS DATE) AND CAST(@endDateTime AS DATE)
END
GO

-- Use a date lookup table, Sale first.
-- Based on the answer from Kenneth Fisher.
CREATE PROCEDURE testProcC(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   SELECT SUM(Value) FROM Sale J INNER JOIN testDimDate D ON D.DateKey = J.SaleDate WHERE D.DateKey BETWEEN @startDate AND @endDate
END
GO

-- Use a date lookup table, Sale last.
-- Based on the answer from Kenneth Fisher.
CREATE PROCEDURE testProcD(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   SELECT SUM(Value) FROM testDimDate D INNER JOIN Sale J ON J.SaleDate = D.DateKey WHERE D.DateKey BETWEEN @startDate AND @endDate
END
GO

------ TEST ------

SET STATISTICS TIME OFF

DECLARE @endDate DATE = '2012-12-10'
DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)

DBCC FREEPROCCACHE WITH NO_INFOMSGS
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS

RAISERROR('Run 1: NoProc with constants', 0, 0) WITH NOWAIT
SET STATISTICS TIME ON
SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN '2012-12-09' AND '2012-12-10'
SET STATISTICS TIME OFF

RAISERROR('Run 2: NoProc with constants', 0, 0) WITH NOWAIT
SET STATISTICS TIME ON
SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN '2012-12-09' AND '2012-12-10'
SET STATISTICS TIME OFF

DBCC FREEPROCCACHE WITH NO_INFOMSGS
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS

RAISERROR('Run 1: NoProc with variables', 0, 0) WITH NOWAIT
SET STATISTICS TIME ON
SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
SET STATISTICS TIME OFF

RAISERROR('Run 2: NoProc with variables', 0, 0) WITH NOWAIT
SET STATISTICS TIME ON
SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
SET STATISTICS TIME OFF

DECLARE @sql NVARCHAR(4000)

DECLARE _cursor CURSOR LOCAL FAST_FORWARD FOR
   SELECT
      procedures.name,
      procedures.object_id
   FROM sys.procedures
   WHERE procedures.name LIKE 'testProc_'
   ORDER BY procedures.name ASC

OPEN _cursor

DECLARE @name SYSNAME
DECLARE @object_id INT

FETCH NEXT FROM _cursor INTO @name, @object_id
WHILE @@FETCH_STATUS = 0
BEGIN
   SET @sql = CASE (SELECT COUNT(*) FROM sys.parameters WHERE object_id = @object_id)
      WHEN 0 THEN @name
      WHEN 1 THEN @name + ' ''@endDate'''
      WHEN 2 THEN @name + ' ''@startDate'', ''@endDate'''
   END

   SET @sql = REPLACE(@sql, '@name', @name)
   SET @sql = REPLACE(@sql, '@startDate', CONVERT(NVARCHAR(10), @startDate, 126))
   SET @sql = REPLACE(@sql, '@endDate', CONVERT(NVARCHAR(10), @endDate, 126))

   DBCC FREEPROCCACHE WITH NO_INFOMSGS
   DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS

   RAISERROR('Run 1: %s', 0, 0, @sql) WITH NOWAIT
   SET STATISTICS TIME ON
   EXEC sp_executesql @sql
   SET STATISTICS TIME OFF

   RAISERROR('Run 2: %s', 0, 0, @sql) WITH NOWAIT
   SET STATISTICS TIME ON
   EXEC sp_executesql @sql
   SET STATISTICS TIME OFF

   FETCH NEXT FROM _cursor INTO @name, @object_id
END

CLOSE _cursor
DEALLOCATE _cursor

Risposte:


9

Lo sniffer dei parametri è il tuo amico quasi sempre e dovresti scrivere le tue domande in modo che possano essere utilizzate. Lo sniffing dei parametri consente di creare il piano per l'utente utilizzando i valori dei parametri disponibili durante la compilazione della query. Il lato oscuro dello sniffing dei parametri è quando i valori utilizzati durante la compilazione della query non sono ottimali per le query a venire.

La query in una procedura memorizzata viene compilata quando viene eseguita la procedura memorizzata, non quando viene eseguita la query, quindi i valori che SQL Server deve affrontare qui ...

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

è un valore noto per @endDatee un valore sconosciuto per @startDate. Ciò lascerà SQL Server a indovinare il 30% delle righe restituite per il filtro in @startDatecombinazione con qualunque cosa le statistiche gli diano @endDate. Se hai una grande tabella con molte righe che potrebbero darti un'operazione di scansione in cui trarrai maggior beneficio da una ricerca.

La soluzione della procedura wrapper garantisce che SQL Server visualizzi i valori quando DateRangeProcviene compilato in modo da poter utilizzare valori noti per entrambi @endDatee @startDate.

Entrambe le tue query dinamiche portano alla stessa cosa, i valori sono noti al momento della compilazione.

Quello con un valore null predefinito è un po 'speciale. I valori noti a SQL Server in fase di compilazione sono noti per @endDatee nullper @startDate. L'uso di a nullin Between ti darà 0 righe ma in questi casi SQL Server indovina sempre 1. In questo caso potrebbe essere una buona cosa, ma se si chiama la procedura memorizzata con un ampio intervallo di date in cui una scansione sarebbe stata la scelta migliore, potrebbe finire per fare un sacco di ricerche.

Ho lasciato "Usa la funzione DATEADD () direttamente" alla fine di questa risposta perché è quella che vorrei usare e c'è anche qualcosa di strano in essa.

Innanzitutto, SQL Server non chiama la funzione più volte quando viene utilizzata nella clausola where. DATEADD è considerato costante di runtime .

E penso che DATEADDvenga valutato quando viene compilata la query in modo da ottenere una buona stima del numero di righe restituite. Ma non è così in questo caso.
Stime di SQL Server basate sul valore nel parametro indipendentemente da ciò che fai DATEADD(testato su SQL Server 2012), quindi nel tuo caso la stima sarà il numero di righe su cui è registrato @endDate. Perché non lo so, ma ha a che fare con l'uso del tipo di dati DATE. Passare a DATETIMEnella procedura memorizzata, la tabella e il preventivo saranno precisi, il che significa che DATEADDè considerato al momento della compilazione per DATETIMEnon per DATE.

Quindi, per riassumere questa risposta piuttosto lunga, consiglierei la soluzione della procedura wrapper. Consentirà sempre a SQL Server di utilizzare i valori forniti durante la compilazione della query senza il fastidio di utilizzare SQL dinamico.

PS:

Nei commenti hai ricevuto due suggerimenti.

OPTION (OPTIMIZE FOR UNKNOWN)fornirà una stima del 9% delle righe restituite e OPTION (RECOMPILE)consentirà a SQL Server di visualizzare i valori dei parametri poiché la query viene ricompilata ogni volta.


3

Ok, ho due possibili soluzioni per te.

Innanzitutto mi chiedo se questo consentirà una maggiore parametrizzazione. Non ho avuto la possibilità di provarlo, ma potrebbe funzionare.

CREATE PROCEDURE WeeklyProc(@endDate DATE, @startDate DATE)
AS
BEGIN
  IF @startDate IS NULL
    SET @startDate = DATEADD(DAY, -6, @endDate)
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

L'altra opzione sfrutta il fatto che si stanno utilizzando intervalli di tempo fissi. Innanzitutto crea una tabella DateLookup. Qualcosa come questo

CurrentDate    8WeekStartDate    8WeekEndDate    etc

Compilalo per ogni data tra oggi e il prossimo secolo. Questo è solo ~ 36500 righe quindi una tabella abbastanza piccola. Quindi modificare la query in questo modo

IF @Range = '8WeekRange' 
    SELECT
      -- Stuff
    FROM Sale
    JOIN DateLookup
        ON SaleDate BETWEEN [8WeekStartDate] AND [8WeekEndDate]
    WHERE DateLookup.CurrentDate = GetDate()

Ovviamente questo è solo un esempio e potrebbe sicuramente essere scritto meglio, ma ho avuto molta fortuna con questo tipo di tabella. Soprattutto perché è una tabella statica e può essere indicizzata come un matto.

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.