Как избежать использования переменных в предложении 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 основаны на предложении Микаэля Эрикссона . Столбец в базе данных является DATE, поэтому я попытался передать параметр как DATETIME и запустить с неявным приведением (testProcA) и явным приведением (testProcB).

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

    Всего строк в таблице Sale: 136,424,366

                       Прогон 1 (мс) Прогон 2 (мс)
    Процедура ЦП Истекший ЦП Истекший Комментарий
    Константы 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
WileCau
источник

Ответы:

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 не вызывает функцию несколько раз, когда она используется в предложении where. 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

Другой вариант использует тот факт, что вы используете фиксированные временные рамки. Сначала создайте таблицу DateLookup. Что-то вроде этого

CurrentDate    8WeekStartDate    8WeekEndDate    etc

Заполните это для каждой даты между настоящим и следующим столетием. Это всего ~ 36500 строк, так что таблица довольно маленькая. Затем измените ваш запрос следующим образом

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

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

Кеннет Фишер
источник