Bagaimana menghindari penggunaan variabel dalam klausa WHERE

16

Diberikan prosedur tersimpan (disederhanakan) seperti ini:

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

Jika Saletabelnya besar, maka SELECTperlu waktu lama untuk dijalankan, tampaknya karena pengoptimal tidak dapat mengoptimalkan karena variabel lokal. Kami menguji menjalankan SELECTbagian dengan variabel kemudian tanggal kode keras dan waktu eksekusi pergi dari ~ 9 menit menjadi ~ 1 detik.

Kami memiliki banyak prosedur tersimpan yang kueri berdasarkan rentang tanggal "tetap" (minggu, bulan, 8-minggu, dll) sehingga parameter inputnya hanya @endDate dan @startDate dihitung di dalam prosedur.

Pertanyaannya adalah, apa praktik terbaik untuk menghindari variabel dalam klausa WHERE agar tidak membahayakan pengoptimal?

Kemungkinan yang kami temukan ditunjukkan di bawah ini. Apakah ada praktik terbaik ini, atau ada cara lain?

Gunakan prosedur pembungkus untuk mengubah variabel menjadi parameter.

Parameter tidak mempengaruhi pengoptimal seperti halnya variabel lokal lakukan.

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

Gunakan SQL dinamis parameter.

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

Gunakan SQL dinamis "hard-coded".

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

Gunakan DATEADD()fungsinya secara langsung.

Saya tidak tertarik dengan ini karena fungsi panggilan di WHERE juga memengaruhi kinerja.

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

Gunakan parameter opsional.

Saya tidak yakin apakah menugaskan ke parameter akan memiliki masalah yang sama seperti menugaskan ke variabel, jadi ini mungkin bukan pilihan. Saya tidak terlalu suka solusi ini tetapi memasukkannya untuk kelengkapan.

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

- Perbarui -

Terima kasih atas saran dan komentarnya. Setelah membacanya saya menjalankan beberapa tes pengaturan waktu dengan berbagai pendekatan. Saya menambahkan hasilnya di sini sebagai referensi.

Run 1 tanpa rencana. Run 2 segera setelah Run 1 dengan parameter yang persis sama sehingga akan menggunakan paket dari run 1.

Waktu NoProc adalah untuk menjalankan query SELECT secara manual di SSMS di luar prosedur tersimpan.

TestProc1-7 adalah pertanyaan dari pertanyaan awal.

TestProcA-B didasarkan pada saran Mikael Eriksson . Kolom dalam database adalah DATE jadi saya mencoba melewati parameter sebagai DATETIME dan berjalan dengan casting implisit (testProcA) dan casting eksplisit (testProcB).

TestProcC-D didasarkan pada saran dari Kenneth Fisher . Kami sudah menggunakan tabel pencarian tanggal untuk hal-hal lain, tetapi kami tidak memilikinya dengan kolom tertentu untuk setiap rentang periode. Variasi yang saya coba masih menggunakan ANTARA tetapi melakukannya pada tabel pencarian yang lebih kecil dan bergabung ke tabel yang lebih besar. Saya akan menyelidiki lebih lanjut, apakah kita dapat menggunakan tabel pencarian khusus, meskipun periode kita diperbaiki ada beberapa yang berbeda.

    Total baris dalam tabel Penjualan: 136.424.366

                       Run 1 (ms) Run 2 (ms)
    Prosedur CPU yang Berlalu CPU Komentar yang Lewat
    Konstanta NoProc 6567 62199 2870 719 Permintaan manual dengan konstanta
    Variabel NoProc 9314 62424 3993 998 Permintaan manual dengan variabel
    testProc1 6801 62919 2871 736 Kisaran kode keras
    testProc2 8955 63190 3915 979 Parameter dan rentang variabel
    testProc3 8985 63152 3932 987 Prosedur wrapper dengan rentang parameter
    testProc4 9142 63939 3931 977 Parameter dinamis SQL
    testProc5 7269 62933 2933 728 SQL dinamis berkode keras
    testProc6 9266 63421 3915 984 Gunakan DATEADD pada DATE
    testProc7 2044 13950 1092 1087 Parameter Dummy
    testProcA 12120 61493 5491 1875 Gunakan DATEADD pada DATETIME tanpa CAST
    testProcB 8612 61949 3932 978 Gunakan DATEADD pada DATETIME dengan CAST
    testProcC 8861 61651 3917 993 Gunakan tabel pencarian, Jual dulu
    testProcD 8625 61740 3994 1031 Gunakan tabel pencarian, Dijual terakhir

Ini kode tesnya.

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

Jawaban:

9

Parameter sniffing adalah teman Anda hampir sepanjang waktu dan Anda harus menulis pertanyaan Anda sehingga dapat digunakan. Parameter sniffing membantu membangun paket untuk Anda menggunakan nilai parameter yang tersedia saat kueri dikompilasi. Sisi gelap dari sniffing parameter adalah ketika nilai yang digunakan saat mengkompilasi kueri tidak optimal untuk kueri yang akan datang.

Kueri dalam prosedur tersimpan dikompilasi ketika prosedur tersimpan dijalankan, bukan ketika kueri dieksekusi sehingga nilai yang harus ditangani oleh SQL Server di sini ...

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

adalah nilai @endDateyang diketahui dan nilai yang tidak diketahui untuk @startDate. Itu akan meninggalkan SQL Server untuk menebak 30% dari baris yang dikembalikan untuk filter yang @startDatedikombinasikan dengan statistik apa pun yang diperintahkan @endDate. Jika Anda memiliki meja besar dengan banyak baris yang bisa memberi Anda operasi pemindaian di mana Anda akan mendapat manfaat paling banyak dari pencarian.

Solusi prosedur pembungkus Anda memastikan bahwa SQL Server melihat nilai saat DateRangeProcdikompilasi sehingga dapat menggunakan nilai yang diketahui untuk keduanya @endDatedan @startDate.

Kedua kueri dinamis Anda mengarah ke hal yang sama, nilainya diketahui pada waktu kompilasi.

Yang dengan nilai null default agak istimewa. Nilai yang diketahui SQL Server pada waktu kompilasi adalah nilai yang diketahui untuk @endDatedan nulluntuk @startDate. Menggunakan nulldi antara akan memberi Anda 0 baris tetapi SQL Server selalu menebak 1 dalam kasus tersebut. Itu mungkin hal yang baik dalam kasus ini tetapi jika Anda memanggil prosedur tersimpan dengan interval tanggal yang besar di mana pemindaian akan menjadi pilihan terbaik mungkin berakhir dengan melakukan banyak pencarian.

Saya meninggalkan "Gunakan fungsi DATEADD () langsung" ke akhir jawaban ini karena itu adalah yang akan saya gunakan dan ada sesuatu yang aneh dengan itu juga.

Pertama, SQL Server tidak memanggil fungsi beberapa kali ketika digunakan di mana klausa. DATEADD dianggap konstan runtime .

Dan saya akan berpikir itu DATEADDdievaluasi ketika kueri dikompilasi sehingga Anda akan mendapatkan perkiraan yang baik tentang jumlah baris yang dikembalikan. Tetapi tidak demikian halnya dalam kasus ini.
Perkiraan SQL Server berdasarkan nilai dalam parameter terlepas dari apa yang Anda lakukan DATEADD(diuji pada SQL Server 2012) sehingga dalam kasus Anda perkiraan akan menjadi jumlah baris yang terdaftar @endDate. Mengapa itu yang saya tidak tahu tetapi ada hubungannya dengan penggunaan tipe data DATE. Bergeser ke DATETIMEdalam prosedur tersimpan dan tabel dan perkiraan akan akurat, artinya DATEADDdipertimbangkan pada waktu kompilasi DATETIMEbukan untuk DATE.

Jadi untuk meringkas jawaban yang agak panjang ini saya akan merekomendasikan solusi prosedur pembungkus. Itu akan selalu memungkinkan SQL Server untuk menggunakan nilai-nilai yang diberikan saat mengkompilasi permintaan tanpa kesulitan menggunakan SQL dinamis.

PS:

Dalam komentar Anda mendapat dua saran.

OPTION (OPTIMIZE FOR UNKNOWN)akan memberi Anda perkiraan 9% dari baris yang dikembalikan dan OPTION (RECOMPILE)akan membuat SQL Server melihat nilai parameter karena kueri dikompilasi ulang setiap waktu.

Mikael Eriksson
sumber
3

Ok, saya punya dua solusi untuk Anda.

Pertama saya bertanya-tanya apakah ini akan memungkinkan peningkatan parameterisasi. Saya belum memiliki kesempatan untuk mengujinya tetapi mungkin berhasil.

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

Opsi lain memanfaatkan fakta bahwa Anda menggunakan kerangka waktu yang tetap. Pertama-tama buat tabel DateLookup. Sesuatu seperti ini

CurrentDate    8WeekStartDate    8WeekEndDate    etc

Isi untuk setiap tanggal antara sekarang dan abad berikutnya. Ini hanya ~ 36500 baris sehingga meja yang cukup kecil. Kemudian ubah kueri Anda seperti ini

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

Jelas ini hanya sebuah contoh dan tentu saja dapat ditulis lebih baik tetapi saya sudah banyak beruntung dengan tabel jenis ini. Terutama karena ini adalah tabel statis dan dapat diindeks seperti orang gila.

Kenneth Fisher
sumber