Como evitar o uso de variáveis ​​na cláusula WHERE

16

Dado um procedimento armazenado (simplificado) como este:

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 a Saletabela for grande, SELECTpode levar muito tempo para ser executada, aparentemente porque o otimizador não pode otimizar devido à variável local. Testamos a execução da SELECTpeça com variáveis ​​e datas codificadas e o tempo de execução passou de ~ 9 minutos para ~ 1 segundo.

Temos vários procedimentos armazenados que consultam com base em períodos "fixos" (semana, mês, 8 semanas etc.), portanto o parâmetro de entrada é apenas @endDate e @startDate é calculado dentro do procedimento.

A questão é: qual é a melhor prática para evitar variáveis ​​em uma cláusula WHERE para não comprometer o otimizador?

As possibilidades que surgimos são mostradas abaixo. Existe alguma dessas práticas recomendadas ou existe outra maneira?

Use um procedimento de wrapper para transformar as variáveis ​​em parâmetros.

Os parâmetros não afetam o otimizador da mesma maneira que as variáveis ​​locais.

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

Use SQL dinâmico parametrizado.

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

Use SQL dinâmico "codificado".

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

Use a DATEADD()função diretamente.

Não estou interessado nisso, porque chamar funções no WHERE também afeta o desempenho.

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

Use um parâmetro opcional.

Não tenho certeza se atribuir a parâmetros teria o mesmo problema que atribuir a variáveis, portanto, isso pode não ser uma opção. Eu realmente não gosto desta solução, mas incluí-la por completo.

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

- Atualização -

Obrigado por sugestões e comentários. Depois de lê-los, fiz alguns testes de tempo com as várias abordagens. Estou adicionando os resultados aqui como referência.

A execução 1 está sem um plano. A execução 2 é imediatamente após a execução 1 com exatamente os mesmos parâmetros, portanto, ele usará o plano da execução 1.

Os horários do NoProc são para executar as consultas SELECT manualmente no SSMS fora de um procedimento armazenado.

TestProc1-7 são as consultas da pergunta original.

O TestProcA-B é baseado na sugestão de Mikael Eriksson . A coluna no banco de dados é DATE, então tentei passar o parâmetro como DATETIME e executar com conversão implícita (testProcA) e conversão explícita (testProcB).

TestProcC-D são baseados na sugestão de Kenneth Fisher . Já usamos uma tabela de consulta de datas para outras coisas, mas não temos uma com uma coluna específica para cada período. A variação que tentei ainda usa BETWEEN, mas na tabela de pesquisa menor e se junta à tabela maior. Vou investigar mais a fundo se podemos usar tabelas de pesquisa específicas, embora nossos períodos sejam fixos, existem alguns diferentes.

    Total de linhas na tabela Venda: 136.424.366

                       Execução 1 (ms) Execução 2 (ms)
    Procedimento CPU decorrida CPU decorrida Comentário
    Constantes NoProc 6567 62199 2870 719 Consulta manual com constantes
    Variáveis ​​NoProc 9314 62424 3993 998 Consulta manual com variáveis
    testProc1 6801 62919 2871 736 Faixa codificada
    testProc2 8955 63190 3915 979 Parâmetro e faixa variável
    testProc3 8985 63152 3932 987 Procedimento do invólucro com faixa de parâmetros
    testProc4 9142 63939 3931 977 SQL dinâmico parametrizado
    testProc5 7269 62933 2933 728 SQL dinâmico codificado
    testProc6 9266 63421 3915 984 Use DATEADD em DATE
    testProc7 2044 13950 1092 1087 Parâmetro fictício
    testProcA 12120 61493 5491 1875 Use DATEADD em DATETIME sem CAST
    testProcB 8612 61949 3932 978 Use DATEADD em DATETIME com CAST
    testProcC 8861 61651 3917 993 Use a tabela de pesquisa, Venda primeiro
    testProcD 8625 61740 3994 1031 Use a tabela de pesquisa, Última venda

Aqui está o código de teste.

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

Respostas:

9

A detecção de parâmetros é sua amiga quase o tempo todo e você deve escrever suas consultas para que possam ser usadas. A detecção de parâmetros ajuda a criar o plano para você, usando os valores dos parâmetros disponíveis quando a consulta é compilada. O lado sombrio da detecção de parâmetros é quando os valores usados ​​ao compilar a consulta não são ideais para as consultas que virão.

A consulta em um procedimento armazenado é compilada quando o procedimento armazenado é executado, não quando a consulta é executada, portanto os valores com os quais o SQL Server precisa lidar aqui ...

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

é um valor conhecido para @endDatee um valor desconhecido para @startDate. Isso deixará o SQL Server adivinhar 30% das linhas retornadas para o filtro, @startDatecombinadas com o que as estatísticas exigirem @endDate. Se você tiver uma tabela grande com muitas linhas, poderá oferecer uma operação de varredura na qual você se beneficiaria mais com uma busca.

Sua solução procedimento de mensagens garante que SQL Server vê os valores quando DateRangeProcé compilado para que ele possa usar valores conhecidos para ambos @endDatee @startDate.

Ambas as suas consultas dinâmicas levam à mesma coisa, os valores são conhecidos em tempo de compilação.

Aquele com um valor nulo padrão é um pouco especial. Os valores conhecidos pelo SQL Server em tempo de compilação são um valor conhecido para @endDatee nullpara @startDate. O uso de um nullentre fornece 0 linhas, mas o SQL Server sempre adivinhar 1 nesses casos. Isso pode ser uma coisa boa nesse caso, mas se você chamar o procedimento armazenado com um grande intervalo de datas em que uma verificação seria a melhor opção, pode acabar fazendo várias buscas.

Deixei "Use a função DATEADD () diretamente" até o final desta resposta porque é a que eu usaria e há algo de estranho nela também.

Primeiro, o SQL Server não chama a função várias vezes quando é usada na cláusula where. DATEADD é considerado constante de tempo de execução .

E eu acho que isso DATEADDé avaliado quando a consulta é compilada, para que você obtenha uma boa estimativa do número de linhas retornadas. Mas não é assim neste caso.
Estimativas do SQL Server com base no valor do parâmetro, independentemente do que você faz DATEADD(testado no SQL Server 2012); portanto, no seu caso, a estimativa será o número de linhas registradas @endDate. Por que faz isso não sei, mas tem a ver com o uso do tipo de dados DATE. Mude para DATETIMEno procedimento armazenado e a tabela e a estimativa serão precisas, o que significa que DATEADDé considerado em tempo de compilação por DATETIMEnão DATE.

Então, para resumir essa resposta bastante longa, eu recomendaria a solução do procedimento de wrapper. Ele sempre permitirá que o SQL Server use os valores fornecidos ao compilar a consulta sem o incômodo de usar o SQL dinâmico.

PS:

Nos comentários, você recebeu duas sugestões.

OPTION (OPTIMIZE FOR UNKNOWN)fornecerá uma estimativa de 9% das linhas retornadas e OPTION (RECOMPILE)fará com que o SQL Server veja os valores dos parâmetros, pois a consulta é recompilada todas as vezes.

Mikael Eriksson
fonte
3

Ok, eu tenho duas soluções possíveis para você.

Primeiro, estou me perguntando se isso permitirá maior parametrização. Não tive a chance de testá-lo, mas pode funcionar.

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

A outra opção aproveita o fato de você estar usando prazos fixos. Primeiro, crie uma tabela DateLookup. Algo assim

CurrentDate    8WeekStartDate    8WeekEndDate    etc

Preencha-o para todas as datas entre agora e o próximo século. São apenas ~ 36500 linhas, portanto, uma tabela bastante pequena. Em seguida, altere sua consulta assim

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

Obviamente, este é apenas um exemplo e certamente poderia ser escrito melhor, mas tive muita sorte com esse tipo de tabela. Especialmente porque é uma tabela estática e pode ser indexada como uma loucura.

Kenneth Fisher
fonte