So vermeiden Sie die Verwendung von Variablen in der WHERE-Klausel

16

Bei einer (vereinfachten) gespeicherten Prozedur wie dieser:

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

Wenn die SaleTabelle groß SELECTist, kann die Ausführung sehr lange dauern, anscheinend, weil der Optimierer aufgrund der lokalen Variablen nicht optimieren kann. Wir haben das getestetSELECT Teil mit Variablen und dann mit fest codierten Daten und die Ausführungszeit ging von ~ 9 Minuten auf ~ 1 Sekunde.

Wir haben zahlreiche gespeicherte Prozeduren, die basierend auf "festen" Datumsbereichen (Woche, Monat, 8 Wochen usw.) abfragen, sodass der Eingabeparameter nur @endDate ist und @startDate innerhalb der Prozedur berechnet wird.

Die Frage ist, wie man Variablen in einer WHERE-Klausel am besten vermeidet, um den Optimierer nicht zu gefährden.

Die Möglichkeiten, die wir uns ausgedacht haben, sind unten aufgeführt. Gibt es eine dieser bewährten Methoden oder gibt es einen anderen Weg?

Verwenden Sie eine Wrapper-Prozedur, um die Variablen in Parameter umzuwandeln.

Parameter wirken sich nicht wie lokale Variablen auf den Optimierer aus.

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

Verwenden Sie parametrisiertes dynamisches SQL.

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

Verwenden Sie "fest codiertes" dynamisches SQL.

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

Nutzen Sie die DATEADD()Funktion direkt.

Daran bin ich nicht interessiert, weil das Aufrufen von Funktionen in WHERE auch die Leistung beeinflusst.

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

Verwenden Sie einen optionalen Parameter.

Ich bin nicht sicher, ob die Zuweisung zu Parametern das gleiche Problem wie die Zuweisung zu Variablen haben würde, daher ist dies möglicherweise keine Option. Ich mag diese Lösung nicht wirklich, aber der Vollständigkeit halber.

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

- Update -

Danke für Anregungen und Kommentare. Nachdem ich sie gelesen hatte, führte ich einige Timing-Tests mit den verschiedenen Ansätzen durch. Ich füge die Ergebnisse hier als Referenz hinzu.

Lauf 1 ist ohne Plan. Lauf 2 folgt unmittelbar auf Lauf 1 mit genau den gleichen Parametern, sodass der Plan aus Lauf 1 verwendet wird.

Die NoProc-Zeiten dienen zum manuellen Ausführen der SELECT-Abfragen in SSMS außerhalb einer gespeicherten Prozedur.

TestProc1-7 sind die Abfragen aus der ursprünglichen Frage.

TestProcA-B basieren auf dem Vorschlag von Mikael Eriksson . Die Spalte in der Datenbank ist ein DATE, daher habe ich versucht, den Parameter als DATETIME zu übergeben und mit implizitem Casting (testProcA) und explizitem Casting (testProcB) auszuführen.

TestProcC-D basiert auf dem Vorschlag von Kenneth Fisher . Wir verwenden bereits eine Datums-Nachschlagetabelle für andere Dinge, haben jedoch keine mit einer bestimmten Spalte für jeden Zeitraum. Die Variation, die ich ausprobiert habe, verwendet immer noch BETWEEN, tut dies jedoch für die kleinere Nachschlagetabelle und verbindet sich mit der größeren Tabelle. Ich werde weiter untersuchen, ob wir bestimmte Nachschlagetabellen verwenden können, obwohl unsere Zeiträume festgelegt sind, gibt es einige verschiedene.

    Gesamtanzahl der Zeilen in der Verkaufstabelle: 136.424.366

                       Lauf 1 (ms) Lauf 2 (ms)
    Vorgehensweise CPU verstrichen CPU verstrichen Kommentar
    NoProc-Konstanten 6567 62199 2870 719 Manuelle Abfrage mit Konstanten
    NoProc-Variablen 9314 62424 3993 998 Manuelle Abfrage mit Variablen
    testProc1 6801 62919 2871 736 Hartcodierter Bereich
    testProc2 8955 63190 3915 979 Parameter- und Variablenbereich
    testProc3 8985 63152 3932 987 Wrapper-Prozedur mit Parameterbereich
    testProc4 9142 63939 3931 977 Parametriertes dynamisches SQL
    testProc5 7269 62933 2933 728 Fest codiertes dynamisches SQL
    testProc6 9266 63421 3915 984 Verwenden Sie DATEADD am DATE
    testProc7 2044 13950 1092 1087 Dummy-Parameter
    testProcA 12120 61493 5491 1875 Verwenden Sie DATEADD für DATETIME ohne CAST
    testProcB 8612 61949 3932 978 Verwenden Sie DATEADD für DATETIME mit CAST
    testProcC 8861 61651 3917 993 Nachschlagetabelle verwenden, Verkauf zuerst
    testProcD 8625 61740 3994 1031 Nachschlagetabelle verwenden, Verkauf zuletzt

Hier ist der Testcode.

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

Antworten:

9

Parameterschnüffeln ist fast immer dein Freund und du solltest deine Abfragen schreiben, damit sie verwendet werden können. Das Parameter-Sniffing hilft beim Erstellen des Plans anhand der Parameterwerte, die beim Kompilieren der Abfrage verfügbar sind. Die dunkle Seite des Parameter-Sniffings ist, wenn die beim Kompilieren der Abfrage verwendeten Werte für die kommenden Abfragen nicht optimal sind.

Die Abfrage in einer gespeicherten Prozedur wird kompiliert, wenn die gespeicherte Prozedur ausgeführt wird, nicht, wenn die Abfrage ausgeführt wird, damit die Werte, mit denen SQL Server hier umgehen muss ...

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

ist ein bekannter Wert für @endDateund ein unbekannter Wert für @startDate. Dadurch kann SQL Server 30% der für den Filter zurückgegebenen Zeilen in @startDateKombination mit den statistischen Daten abschätzen @endDate. Wenn Sie einen großen Tisch mit vielen Zeilen haben, kann dies zu einem Scanvorgang führen, bei dem Sie am meisten von einer Suche profitieren.

Ihre Wrapperprozedurlösung stellt sicher, dass SQL Server die Werte beim DateRangeProcKompilieren erkennt, sodass bekannte Werte für @endDateund verwendet werden können @startDate.

Ihre beiden dynamischen Abfragen führen zu demselben Ergebnis, die Werte sind zur Kompilierungszeit bekannt.

Die mit einem Standardwert von Null ist etwas Besonderes. Die Werte, die SQL Server zur Kompilierungszeit bekannt sind, sind ein bekannter Wert für @endDateund nullfür @startDate. Wenn Sie ein nullZwischenzeichen verwenden, erhalten Sie 0 Zeilen, in diesen Fällen wird von SQL Server jedoch immer 1 angenommen. Dies ist in diesem Fall möglicherweise eine gute Sache, aber wenn Sie die gespeicherte Prozedur mit einem großen Datumsintervall aufrufen, in dem ein Scan die beste Wahl gewesen wäre, werden möglicherweise mehrere Suchvorgänge ausgeführt.

Ich habe "Benutze die DATEADD () - Funktion direkt" bis zum Ende dieser Antwort verlassen, da dies die ist, die ich verwenden würde, und es ist auch etwas Merkwürdiges dabei.

Zunächst ruft SQL Server die Funktion nicht mehrmals auf, wenn sie in der where-Klausel verwendet wird. DATEADD wird als Laufzeitkonstante betrachtet .

Und ich würde denken, dass dies DATEADDausgewertet wird, wenn die Abfrage kompiliert wird, sodass Sie eine gute Schätzung der Anzahl der zurückgegebenen Zeilen erhalten. Dies ist jedoch in diesem Fall nicht der Fall.
SQL Server-Schätzungen basieren auf dem Wert im Parameter, unabhängig davon, wie Sie vorgehen DATEADD(getestet in SQL Server 2012). In Ihrem Fall entspricht die Schätzung der Anzahl der registrierten Zeilen @endDate. Warum das so ist, weiß ich nicht, aber es hat mit der Verwendung des Datentyps zu tun DATE. Verschieben Sie DATETIMEin die gespeicherte Prozedur und die Tabelle und die Schätzung werden korrekt sein, was bedeutet, dass DATEADDzum Zeitpunkt der Kompilierung für DATETIMEnicht für berücksichtigt wird DATE.

Um diese ziemlich lange Antwort zusammenzufassen, würde ich die Wrapper-Prozedur-Lösung empfehlen. Es ermöglicht SQL Server immer, die Werte zu verwenden, die beim Kompilieren der Abfrage angegeben wurden, ohne dass die mühsame Verwendung von dynamischem SQL erforderlich ist.

PS:

In Kommentaren hast du zwei Vorschläge.

OPTION (OPTIMIZE FOR UNKNOWN)gibt Ihnen eine Schätzung von 9% der zurückgegebenen Zeilen und lässt OPTION (RECOMPILE)SQL Server die Parameterwerte sehen, da die Abfrage jedes Mal neu kompiliert wird.

Mikael Eriksson
quelle
3

Ok, ich habe zwei mögliche Lösungen für Sie.

Zunächst frage ich mich, ob dies eine erhöhte Parametrisierung ermöglichen wird. Ich hatte keine Gelegenheit, es auszuprobieren, aber es könnte funktionieren.

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

Die andere Option nutzt die Tatsache, dass Sie feste Zeitrahmen verwenden. Erstellen Sie zunächst eine DateLookup-Tabelle. Etwas wie das

CurrentDate    8WeekStartDate    8WeekEndDate    etc

Füllen Sie es für jedes Datum zwischen jetzt und dem nächsten Jahrhundert aus. Das sind nur ~ 36500 Zeilen, also eine ziemlich kleine Tabelle. Dann ändern Sie Ihre Abfrage wie folgt

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

Offensichtlich ist dies nur ein Beispiel und könnte sicherlich besser geschrieben werden, aber ich hatte viel Glück mit dieser Art von Tabelle. Zumal es sich um eine statische Tabelle handelt und wie verrückt indiziert werden kann.

Kenneth Fisher
quelle