Jak uniknąć używania zmiennych w klauzuli WHERE

16

Biorąc pod uwagę (uproszczoną) procedurę składowaną, taką jak ta:

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

Jeśli Saletabela jest duża, wykonanie SELECTmoże zająć dużo czasu, prawdopodobnie dlatego, że optymalizator nie może zoptymalizować ze względu na zmienną lokalną. Testowaliśmy uruchomienie SELECTczęści ze zmiennymi, a następnie daty zakodowane na stałe, a czas wykonania skrócił się z ~ 9 minut do ~ 1 sekundy.

Mamy wiele procedur przechowywanych, które wykonują zapytania w oparciu o „ustalone” zakresy dat (tydzień, miesiąc, 8 tygodni itp.), Więc parametr wejściowy to po prostu @endDate, a @startDate jest obliczany wewnątrz procedury.

Pytanie brzmi: jaka jest najlepsza praktyka unikania zmiennych w klauzuli WHERE, aby nie naruszyć optymalizatora?

Możliwości, które wymyśliliśmy, pokazano poniżej. Czy którakolwiek z tych najlepszych praktyk lub istnieje inny sposób?

Użyj procedury otoki, aby przekształcić zmienne w parametry.

Parametry nie wpływają na optymalizator w taki sam sposób jak zmienne lokalne.

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

Użyj sparametryzowanego dynamicznego 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

Użyj „zakodowanego” dynamicznego 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

Użyj DATEADD()funkcji bezpośrednio.

Nie podoba mi się to, ponieważ wywoływanie funkcji w GDZIE wpływa również na wydajność.

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

Użyj opcjonalnego parametru.

Nie jestem pewien, czy przypisanie do parametrów miałoby taki sam problem jak przypisanie do zmiennych, więc może to nie być opcja. Naprawdę nie podoba mi się to rozwiązanie, ale zawiera je dla kompletności.

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

-- Aktualizacja --

Dziękuję za sugestie i komentarze. Po ich przeczytaniu przeprowadziłem testy czasowe z różnymi podejściami. Dodaję wyniki tutaj jako odniesienie.

Uruchomienie 1 jest bez planu. Uruchomienie 2 następuje bezpośrednio po Uruchomieniu 1 z dokładnie tymi samymi parametrami, więc będzie korzystał z planu z uruchomienia 1.

Czasy NoProc dotyczą ręcznego uruchamiania zapytań SELECT w SSMS poza procedurą składowaną.

TestProc1-7 to zapytania z pierwotnego pytania.

TestProcA-B opiera się na sugestii Mikaela Erikssona . Kolumna w bazie danych to DATA, więc próbowałem przekazać parametr jako DATETIME i uruchomić z niejawnym rzutowaniem (testProcA) i jawnym rzutowaniem (testProcB).

TestProcC-D oparte są na sugestii Kennetha Fishera . Używamy już tabeli wyszukiwania dat do innych rzeczy, ale nie mamy takiej z określoną kolumną dla każdego zakresu okresów. Odmiana, której próbowałem, nadal używa MIĘDZY, ale robi to na mniejszej tabeli odnośników i łączy się z większą tabelą. Zamierzam dalej badać, czy możemy używać określonych tabel odnośników, chociaż nasze okresy są ustalone, istnieje kilka różnych.

    Łączna liczba wierszy w tabeli sprzedaży: 136 424 366

                       Uruchom 1 (ms) Uruchom 2 (ms)
    Procedura Upłynął procesor Upłynął procesor Komentarz
    Stałe NoProc 6567 62199 2870 719 Ręczne zapytanie ze stałymi
    Zmienne NoProc 9314 62424 3993 998 Ręczne zapytanie ze zmiennymi
    testProc1 6801 62919 2871 736 Zakodowany na stałe zakres
    testProc2 8955 63190 3915 979 Parametr i zmienny zakres
    testProc3 8985 63152 3932 987 Procedura owijania z zakresem parametrów
    testProc4 9142 63939 3931 977 Sparametryzowany dynamiczny SQL
    testProc5 7269 62933 2933 728 Dynamicznie zakodowany SQL
    testProc6 9266 63421 3915 984 Użyj DATEADD w dniu DATE
    testProc7 2044 13950 1092 1087 Parametr manekina
    testProcA 12120 61493 5491 1875 Użyj DATEADD w DATETIME bez CAST
    testProcB 8612 61949 3932 978 Użyj DATEADD w DATETIME z CAST
    testProcC 8861 61651 3917 993 Użyj tabeli odnośników, najpierw sprzedaż
    testProcD 8625 61740 3994 1031 Użyj tabeli wyszukiwania, Wyprzedaż ostatnia

Oto kod testowy.

------ 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
źródło

Odpowiedzi:

9

Wąchanie parametrów jest twoim przyjacielem prawie przez cały czas i powinieneś pisać swoje zapytania, aby można było z niego korzystać. Wykrywanie parametrów pomaga w budowaniu planu za pomocą wartości parametrów dostępnych podczas kompilacji zapytania. Ciemna strona wykrywania parametrów polega na tym, że wartości użyte podczas kompilacji zapytania nie są optymalne dla nadchodzących zapytań.

Zapytanie w procedurze składowanej jest kompilowane podczas wykonywania procedury przechowywanej, a nie podczas wykonywania zapytania, więc wartości, z którymi SQL Server ma do czynienia tutaj ...

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

jest znaną wartością dla @endDatei nieznaną wartością dla @startDate. W ten sposób SQL Server zgadnie 30% wierszy zwróconych dla filtru w @startDatepołączeniu z tym, co mówią statystyki @endDate. Jeśli masz duży stół z dużą ilością wierszy, który może dać ci operację skanowania, w której najbardziej skorzystasz z wyszukiwania.

Rozwiązanie procedury opakowania zapewnia, że ​​SQL Server widzi wartości podczas DateRangeProckompilacji, dzięki czemu może używać znanych wartości zarówno dla, jak @endDatei @startDate.

Oba zapytania dynamiczne prowadzą do tego samego, wartości są znane w czasie kompilacji.

Ten z domyślną wartością zerową jest nieco wyjątkowy. Wartości znane SQL Serverowi w czasie kompilacji to znana wartość dla @endDatei nulldla @startDate. Użycie znaku pośredniego nullda 0 wierszy, ale SQL Server zawsze zgaduje w 1 w takich przypadkach. Może to być dobre w tym przypadku, ale jeśli wywołasz procedurę przechowywaną z dużym interwałem dat, w którym skanowanie byłoby najlepszym wyborem, może to zakończyć wiele prób.

Pozostawiłem „Użyj funkcji DATEADD () bezpośrednio” na końcu tej odpowiedzi, ponieważ jest to ta, której użyłbym i jest w tym również coś dziwnego.

Po pierwsze, SQL Server nie wywołuje funkcji wiele razy, gdy jest używana w klauzuli where. DATEADD uważa się za stałą czasu działania .

I wydaje mi się, że DATEADDjest to oceniane podczas kompilacji zapytania, aby uzyskać dobre oszacowanie liczby zwróconych wierszy. Ale w tym przypadku tak nie jest.
Szacunki programu SQL Server oparte na wartości w parametrze niezależnie od tego, co robisz DATEADD(testowane w SQL Server 2012), więc w twoim przypadku oszacowaniem będzie liczba zarejestrowanych wierszy @endDate. Dlaczego tak się dzieje, że nie wiem, ale ma to związek z wykorzystaniem typu danych DATE. Przejdź do DATETIMEprocedury składowanej, tabeli i oszacowania będą dokładne, co oznacza, że DATEADDjest brane pod uwagę w czasie kompilacji dla DATETIMEnie DATE.

Podsumowując tę ​​dość długą odpowiedź, poleciłbym rozwiązanie procedury owijania. Zawsze pozwoli programowi SQL Server na korzystanie z wartości podanych podczas kompilowania zapytania bez konieczności używania dynamicznego SQL.

PS:

W komentarzach masz dwie sugestie.

OPTION (OPTIMIZE FOR UNKNOWN)da oszacowanie 9% zwróconych wierszy i OPTION (RECOMPILE)sprawi, że SQL Server zobaczy wartości parametrów, ponieważ zapytanie jest rekompilowane za każdym razem.

Mikael Eriksson
źródło
3

Ok, mam dla ciebie dwa możliwe rozwiązania.

Najpierw zastanawiam się, czy to pozwoli na zwiększoną parametryzację. Nie miałem okazji go przetestować, ale może się udać.

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

Druga opcja wykorzystuje fakt, że używasz stałych ram czasowych. Najpierw utwórz tabelę DateLookup. Coś takiego

CurrentDate    8WeekStartDate    8WeekEndDate    etc

Wypełnij go dla każdej daty od teraz do następnego wieku. To tylko ~ 36500 wierszy, więc dość mały stół. Następnie zmień swoje zapytanie w ten sposób

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

Oczywiście jest to tylko przykład i na pewno można go napisać lepiej, ale miałem dużo szczęścia z tego typu stołami. Zwłaszcza, że ​​jest to statyczny stół i może być indeksowany jak szalony.

Kenneth Fisher
źródło