Як уникнути використання змінних у пункті WHERE


16

З огляду на (спрощену) збережену процедуру, таку як ця:

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

Якщо Saleтаблиця велика, виконання SELECTможе зайняти тривалий час, очевидно, тому що оптимізатор не може оптимізувати через локальну змінну. Ми протестували запускSELECT частини зі змінними, а потім жорсткими кодованими датами, і час виконання зайняв від ~ 9 хвилин до ~ 1 секунди.

У нас є чимало збережених процедур, які запитуються на основі "фіксованих" діапазонів дат (тиждень, місяць, 8 тижнів тощо), тому вхідним параметром є просто @endDate, а @startDate обчислюється всередині процедури.

Питання полягає в тому, яка найкраща практика уникнення змінних у пункті WHERE, щоб не ставити під загрозу оптимізатор?

Можливості, які ми придумали, показані нижче. Чи є якась із цих найкращих практик, чи є інший спосіб?

Використовуйте процедуру обгортки, щоб перетворити змінні в параметри.

Параметри не впливають на оптимізатор так само, як і місцеві змінні.

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

Використовуйте параметризований динамічний 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

Використовуйте "жорсткий" динамічний 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

Використовуйте DATEADD()функцію безпосередньо.

Я не захоплююсь цим, тому що виклик функцій WHERE також впливає на продуктивність.

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

Використовуйте необов'язковий параметр.

Я не впевнений, якщо призначення параметрів матиме ту саму проблему, що і при призначенні змінних, тому це може бути не варіантом. Мені не дуже подобається це рішення, але включаю його для повноти.

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

- Оновлення -

Дякуємо за пропозиції та коментарі. Прочитавши їх, я провів декілька випробувань на терміни з різними підходами. Я додаю результати тут як орієнтир.

Виконання 1 - без плану. Виконання 2 - це відразу після запуску 1 з точно такими ж параметрами, тому він буде використовувати план з запуску 1.

Часи NoProc призначені для запуску SELECT запитів вручну в SSMS за межами збереженої процедури.

TestProc1-7 - це запити з оригінального запитання.

TestProcA-B заснований на пропозиції Мікаеля Ерікссона . Стовпець у базі даних - ДАТА, тому я спробував передати параметр як DATETIME і працювати з неявним кастингом (testProcA) та явним кастингом (testProcB).

TestProcC-D засновані на пропозиції Кеннета Фішера . Ми вже використовуємо таблицю пошуку дат для інших речей, але у нас немає таблиці з певним стовпцем для кожного періоду періоду. Варіант, який я спробував, все ще використовує BETWEEN, але робить це на меншій таблиці пошуку та приєднується до більшої таблиці. Я продовжую досліджувати, чи можемо ми використовувати конкретні таблиці пошуку, хоча наші періоди встановлені, існує досить багато різних.

    Загальна кількість рядків у таблиці продажу: 136,424,366

                       Виконати 1 (мс) Виконати 2 (мс)
    Процедура CPU Elapsed CPU Elapsed Коментар
    Константи NoProc 6567 62199 2870 719 Ручний запит із константами
    Змінні NoProc 9314 62424 3993 998 Ручний запит зі змінними
    testProc1 6801 62919 2871 736 Діапазон жорсткого коду
    testProc2 8955 63190 3915 979 Параметр і змінний діапазон
    testProc3 8985 63152 3932 987 Процедура обгортки з діапазоном параметрів
    testProc4 9142 63939 3931 977 Параметризований динамічний SQL
    testProc5 7269 62933 2933 728 Жорстко закодований динамічний SQL
    testProc6 9266 63421 3915 984 Використовуйте DATEADD DATE
    testProc7 2044 13950 1092 1087 Параметр манекена
    testProcA 12120 61493 5491 1875 Використовуйте DATEADD в DATETIME без CAST
    testProcB 8612 61949 3932 978 Використовуйте DATEADD у DATETIME з CAST
    testProcC 8861 61651 3917 993 Використовуйте таблицю пошуку, продаж у першу чергу
    testProcD 8625 61740 3994 1031 Використовувати таблицю пошуку, останній продаж

Ось код тесту.

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

Відповіді:


9

Нюхаючи параметр - це ваш друг майже весь час, і вам слід писати запити, щоб ним можна було користуватися. Нюхання параметрів допомагає будувати план для вас, використовуючи значення параметрів, доступні при складанні запиту. Темна сторона нюху параметрів - це коли значення, використані при складанні запиту, не є оптимальними для надходження запитів.

Запит у збереженій процедурі компілюється, коли збережена процедура виконується, а не коли виконується запит, тому значення, з якими SQL Server має тут працювати ...

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

- це відоме значення для @endDateі невідоме значення для @startDate. Це дозволить SQL Server здогадуватися про 30% рядків, повернутих для фільтра, у @startDateпоєднанні з тим, про що говорить статистика @endDate. Якщо у вас є велика таблиця з великою кількістю рядків, яка могла б дати вам операцію сканування, де ви б найбільше виграли від пошуку.

Ваше рішення процедури обгортки гарантує, що SQL Server бачить значення при DateRangeProcкомпіляції, щоб він міг використовувати відомі значення як для, так @endDateі для @startDate.

Обидва ваші динамічні запити призводять до одного і того ж, значення відомі під час компіляції.

Той, який має значення за замовчуванням, є дещо особливим. Значення, відомі SQL Server під час компіляції, є відомим значенням для @endDateтаnull для @startDate. Якщо використовувати nullпроміжку між ними, ви отримаєте 0 рядків, але SQL Server завжди вгадає значення 1 у цих випадках. Це може бути хорошою справою в цьому випадку, але якщо ви зателефонуєте на збережену процедуру з великим інтервалом дат, коли сканування було б найкращим вибором, то в кінцевому підсумку ви зможете зробити купу шукань.

Я залишив "Використовувати функцію DATEADD () безпосередньо" в кінці цієї відповіді, тому що це саме те, що я б використав, і з цим є щось дивне.

По-перше, SQL Server не викликає функцію кілька разів, коли вона використовується в пункті де. DATEADD вважається постійною тривалістю виконання .

І я думаю, що DATEADDце оцінюється, коли запит складається, щоб ви отримали хорошу оцінку щодо кількості повернених рядків. Але це не так у цьому випадку.
SQL Server оцінює виходячи зі значення в параметрі незалежно від того, що ви робите DATEADD(протестовано на SQL Server 2012), тому у вашому випадку оцінка буде кількістю рядків, на які зареєстровано @endDate. Чому це так, я не знаю, але це стосується використання типу даних DATE. Перехід до DATETIMEзбереженої процедури, а таблиця та оцінка будуть точними, тобто значення, яке DATEADDрозглядається під час компіляції для DATETIMEне для DATE.

Отже, щоб узагальнити цю досить тривалу відповідь, я б порекомендував рішення для процедури обгортки. Це завжди дозволить SQL Server використовувати значення, надані під час компіляції запиту без зайвих проблем з використанням динамічного SQL.

PS:

У коментарях ви отримали дві пропозиції.

OPTION (OPTIMIZE FOR UNKNOWN)дасть оцінку 9% повернених рядків і OPTION (RECOMPILE)змусить SQL Server бачити значення параметрів, оскільки запит перекомпілюється кожного разу.


3

Гаразд, у мене є два можливих рішення для вас.

Спочатку мені цікаво, чи це дозволить збільшити параметризацію. У мене не було можливості перевірити це, але це може спрацювати.

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

Інший варіант використовує той факт, що ви використовуєте фіксовані часові рамки. Спочатку створіть таблицю DataLookup. Щось на зразок цього

CurrentDate    8WeekStartDate    8WeekEndDate    etc

Заповніть його для кожної дати між тепер і наступним століттям. Це лише ~ 36500 рядків, тому досить невелика таблиця. Потім змініть запит так

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

Очевидно, що це лише приклад, і, безумовно, можна було б написати краще, але я мав багато удачі з цією таблицею. Тим більше, що це статична таблиця і її можна індексувати як божевільну.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.