Cách tránh sử dụng biến trong mệnh đề WHERE


16

Đưa ra một thủ tục được lưu trữ (đơn giản hóa) như thế này:

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

Nếu Salebảng lớn, SELECTcó thể mất nhiều thời gian để thực thi, rõ ràng vì trình tối ưu hóa không thể tối ưu hóa do biến cục bộ. Chúng tôi đã thử chạy SELECTphần đó với các biến sau đó ngày mã hóa cứng và thời gian thực hiện đã chuyển từ ~ 9 phút xuống ~ 1 giây.

Chúng tôi có nhiều quy trình được lưu trữ truy vấn dựa trên phạm vi ngày "cố định" (tuần, tháng, 8 tuần, v.v.) vì vậy tham số đầu vào chỉ là @endDate và @startDate được tính trong quy trình.

Câu hỏi là, cách tốt nhất để tránh các biến trong mệnh đề WHERE là gì để không ảnh hưởng đến trình tối ưu hóa?

Các khả năng chúng tôi đã đưa ra được hiển thị dưới đây. Có bất kỳ thực hành tốt nhất trong số này, hoặc có một cách khác?

Sử dụng thủ tục trình bao bọc để biến các biến thành tham số.

Các tham số không ảnh hưởng đến trình tối ưu hóa giống như cách các biến cục bộ thực hiện.

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

Sử dụng SQL động tham số hóa.

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

Sử dụng SQL động "mã hóa cứng".

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

Sử dụng DATEADD()chức năng trực tiếp.

Tôi không quan tâm đến điều này vì các chức năng gọi trong WHERE cũng ảnh hưởng đến hiệu suất.

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

Sử dụng một tham số tùy chọn.

Tôi không chắc việc gán cho tham số có gặp vấn đề tương tự như gán cho biến không, vì vậy đây có thể không phải là một tùy chọn. Tôi không thực sự thích giải pháp này nhưng bao gồm nó cho đầy đủ.

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

- Cập nhật -

Cảm ơn những lời đề nghị và ý kiến. Sau khi đọc chúng, tôi đã chạy một số bài kiểm tra thời gian với các phương pháp khác nhau. Tôi đang thêm các kết quả ở đây như một tài liệu tham khảo.

Chạy 1 là không có kế hoạch. Run 2 ngay sau Run 1 với các tham số chính xác giống nhau nên nó sẽ sử dụng gói từ run 1.

Thời gian NoProc là để chạy các truy vấn CHỌN thủ công trong SSMS bên ngoài một quy trình được lưu trữ.

TestProc1-7 là các truy vấn từ câu hỏi ban đầu.

TestProcA-B dựa trên đề xuất của Mikael Eriksson . Cột trong cơ sở dữ liệu là DATE vì vậy tôi đã thử chuyển tham số dưới dạng DATETIME và chạy với truyền ẩn (testProcA) và truyền rõ ràng (testProcB).

TestProcC-D dựa trên đề xuất của Kenneth Fisher . Chúng tôi đã sử dụng bảng tra cứu ngày cho những thứ khác, nhưng chúng tôi không có bảng với một cột cụ thể cho từng phạm vi thời gian. Biến thể tôi đã thử vẫn sử dụng GIỮA nhưng thực hiện trên bảng tra cứu nhỏ hơn và tham gia vào bảng lớn hơn. Tôi sẽ điều tra thêm về việc liệu chúng tôi có thể sử dụng các bảng tra cứu cụ thể hay không, mặc dù các khoảng thời gian của chúng tôi đã được cố định có khá nhiều bảng khác nhau.

    Tổng số hàng trong bảng Bán hàng: 136.424.366

                       Chạy 1 (ms) Chạy 2 (ms)
    Quy trình CPU đã qua CPU Nhận xét đã qua
    Các hằng số NoProc 6567 62199 2870 719 Truy vấn thủ công với các hằng số
    Biến NoProc 9314 62424 3993 998 Truy vấn thủ công với các biến
    testProc1 6801 62919 2871 736 Phạm vi mã hóa cứng
    testProc2 8955 63190 3915 979 Tham số và phạm vi biến
    testProc3 8985 63152 3932 987 Quy trình bao bọc với phạm vi tham số
    testProc4 9142 63939 3931 977 SQL động được tham số hóa
    testProc5 7269 62933 2933 728 SQL động được mã hóa cứng
    testProc6 9266 63421 3915 984 Sử dụng DATEADD vào NGÀY
    testProc7 2044 13950 1092 1087 Tham số giả
    testProcA 12120 61493 5491 1875 Sử dụng DATEADD trên DATETIME mà không cần CAST
    testProcB 8612 61949 3932 979 Sử dụng DATEADD trên DATETIME với CAST
    testProcC 8861 61651 3917 993 Sử dụng bảng tra cứu, Bán trước
    testProcD 8625 61740 3994 1031 Sử dụng bảng tra cứu, Bán lần cuối

Đây là mã kiểm tra.

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

Câu trả lời:


9

Thông số đánh hơi là bạn của bạn hầu như mọi lúc và bạn nên viết các truy vấn của mình để có thể sử dụng nó. Việc đánh hơi tham số giúp xây dựng kế hoạch cho bạn bằng cách sử dụng các giá trị tham số có sẵn khi truy vấn được biên dịch. Mặt tối của việc đánh hơi tham số là khi các giá trị được sử dụng khi biên dịch truy vấn không tối ưu cho các truy vấn tới.

Truy vấn trong một thủ tục được lưu trữ được biên dịch khi thủ tục được lưu trữ được thực thi, chứ không phải khi truy vấn được thực thi để các giá trị mà SQL Server phải xử lý ở đây ...

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

là một giá trị được biết đến @endDatevà một giá trị không xác định cho @startDate. Điều đó sẽ khiến SQL Server đoán được 30% số hàng được trả về cho bộ lọc @startDatekết hợp với bất kỳ số liệu thống kê nào cho biết @endDate. Nếu bạn có một bảng lớn với nhiều hàng có thể cung cấp cho bạn thao tác quét, nơi bạn sẽ được hưởng lợi nhiều nhất từ ​​tìm kiếm.

Giải pháp thủ tục trình bao bọc của bạn đảm bảo rằng SQL Server nhìn thấy các giá trị khi DateRangeProcđược biên dịch để nó có thể sử dụng các giá trị đã biết cho cả hai @endDate@startDate.

Cả hai truy vấn động của bạn đều dẫn đến cùng một thứ, các giá trị được biết tại thời gian biên dịch.

Cái có giá trị null mặc định là một chút đặc biệt. Các giá trị được biết đến với SQL Server tại thời gian biên dịch là một giá trị đã biết cho @endDatenullcho @startDate. Sử dụng một nullở giữa sẽ cung cấp cho bạn 0 hàng nhưng SQL Server luôn đoán là 1 trong các trường hợp đó. Đó có thể là một điều tốt trong trường hợp này nhưng nếu bạn gọi thủ tục được lưu trữ với khoảng thời gian ngày lớn trong đó quét sẽ là lựa chọn tốt nhất thì cuối cùng có thể thực hiện một loạt các tìm kiếm.

Tôi đã để lại "Sử dụng hàm DATEADD () trực tiếp" cho đến cuối câu trả lời này vì đây là câu tôi sẽ sử dụng và cũng có một cái gì đó kỳ lạ với nó.

Trước hết, SQL Server không gọi hàm nhiều lần khi nó được sử dụng trong mệnh đề where. DATEADD được coi là hằng số thời gian chạy .

Và tôi nghĩ rằng nó DATEADDđược đánh giá khi truy vấn được biên dịch để bạn có thể ước tính tốt về số lượng hàng được trả về. Nhưng nó không phải là như vậy trong trường hợp này.
Các ước tính của SQL Server dựa trên giá trị trong tham số bất kể bạn làm gì với DATEADD(được thử nghiệm trên SQL Server 2012), do đó, trong trường hợp của bạn, ước tính sẽ là số lượng hàng được đăng ký @endDate. Tại sao tôi không biết nhưng nó phải làm với việc sử dụng kiểu dữ liệu DATE. Chuyển sang DATETIMEtrong thủ tục được lưu trữ và bảng và ước tính sẽ chính xác, có nghĩa DATEADDlà được xem xét tại thời điểm biên dịch DATETIMEkhông dành cho DATE.

Vì vậy, để tóm tắt câu trả lời khá dài này, tôi muốn giới thiệu giải pháp thủ tục trình bao bọc. Nó sẽ luôn cho phép SQL Server sử dụng các giá trị được cung cấp khi biên dịch truy vấn mà không gặp rắc rối khi sử dụng SQL động.

Tái bút

Trong các ý kiến ​​bạn có hai gợi ý.

OPTION (OPTIMIZE FOR UNKNOWN)sẽ cung cấp cho bạn ước tính 9% số hàng được trả về và OPTION (RECOMPILE)sẽ khiến SQL Server thấy các giá trị tham số do truy vấn được biên dịch lại mỗi lần.


3

Ok, tôi có hai giải pháp có thể cho bạn.

Đầu tiên tôi tự hỏi nếu điều này sẽ cho phép tăng tham số hóa. Tôi chưa có cơ hội thử nghiệm nhưng nó có thể hoạt động.

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

Tùy chọn khác tận dụng thực tế là bạn đang sử dụng các khung thời gian cố định. Đầu tiên tạo bảng DateLookup. Một cái gì đó như thế này

CurrentDate    8WeekStartDate    8WeekEndDate    etc

Điền vào nó cho mỗi ngày giữa bây giờ và thế kỷ tiếp theo. Đây chỉ là ~ 36500 hàng nên một bảng khá nhỏ. Sau đó thay đổi truy vấn của bạn như thế này

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

Rõ ràng đây chỉ là một ví dụ và chắc chắn có thể được viết tốt hơn nhưng tôi đã gặp rất nhiều may mắn với loại bàn này. Đặc biệt vì nó là một bảng tĩnh và có thể được lập chỉ mục như điên.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.