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時間は、SSMSでストアドプロシージャの外部でSELECTクエリを手動で実行するためのものです。

TestProc1-7は、元の質問からのクエリです。

TestProcA-Bは、Mikael Erikssonの提案に基づいています。データベースの列はDATEであるため、パラメーターをDATETIMEとして渡し、暗黙的なキャスト(testProcA)と明示的なキャスト(testProcB)で実行しようとしました。

TestProcC-Dは、Kenneth Fisherの提案に基づいています。すでに他の目的で日付ルックアップテーブルを使用していますが、各期間範囲に特定の列を持つテーブルはありません。私が試したバリエーションでは、まだBETWEENを使用していますが、より小さいルックアップテーブルで実行し、大きなテーブルに結合します。特定のルックアップテーブルを使用できるかどうかについて、さらに調査しますが、期間は固定されていますが、かなり多くの異なるテーブルがあります。

    Saleテーブルの合計行:136,424,366

                       実行1(ミリ秒)実行2(ミリ秒)
    手順CPU経過CPU経過コメント
    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 DATEにDATEADDを使用
    testProc7 2044 13950 1092 1087ダミーパラメーター
    testProcA 12120 61493 5491 1875 CASTなしでDATETIMEにDATEADDを使用
    testProcB 8612 61949 3932 978 CASTでDATETIMEにDATEADDを使用
    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。シークから最も利益を得ることができるスキャン操作を提供できる多くの行を持つ大きなテーブルがある場合。

ラッパープロシージャソリューションDateRangeProcは、コンパイル時にSQL Serverが値を参照することを確認するため、@endDateとの両方に既知の値を使用できます@startDate

両方の動的クエリは同じことを導きます。値はコンパイル時に既知です。

デフォルトのヌル値を持つものは少し特別です。コンパイル時にSQL Serverに認識される値は@endDate、およびの既知の値nullです@startDatenull間にa を使用すると0行になりますが、そのような場合はSQL Serverは常に1を推測します。この場合、それは良いことかもしれませんが、スキャンが最良の選択であった長い間隔でストアドプロシージャを呼び出すと、大量のシークを行うことになります。

「DATEADD()関数を直接使用する」をこの回答の最後に残しました。これは私が使用するものであり、奇妙なものもあるからです。

まず、SQL Serverは、where句で使用されている関数を複数回呼び出しません。DATEADDはランタイム定数と見なされます

そしてDATEADD、クエリがコンパイルされるときに評価されるので、返される行の数を適切に推定できると思います。しかし、この場合はそうではありません。
SQL Serverは、何をするかに関係なくパラメーターの値に基づいて推定DATEADDします(SQL Server 2012でテスト済み)。この場合、推定はに登録されている行の数になります@endDate。なぜそれが私にはわからないのですが、それはデータ型の使用に関係していますDATEDATETIMEストアドプロシージャ内のにシフトすると、テーブルと推定値が正確になります。つまりDATEADD、コンパイル時でDATETIMEはないために考慮されますDATE

したがって、このかなり長い回答を要約するには、ラッパープロシージャソリューションをお勧めします。SQL Serverは、動的SQLを使用する手間をかけずに、クエリのコンパイル時に提供された値を常に使用できます。

PS:

コメントでは、2つの提案がありました。

OPTION (OPTIMIZE FOR UNKNOWN)返さOPTION (RECOMPILE)れる行の9%の推定値が得られ、クエリは毎回再コンパイルされるため、SQL Serverにパラメーター値が表示されます。


3

わかりました、私はあなたのための2つの可能な解決策を持っています。

最初に、これによりパラメーター化を増やすことができるかどうか疑問に思っています。私はそれをテストする機会がありませんでしたが、うまくいくかもしれません。

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

明らかにこれは単なる例であり、間違いなくより適切に記述できますが、このタイプのテーブルでは多くの運がありました。特に静的テーブルであり、狂ったようにインデックスを作成できるためです。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.