web-dev-qa-db-ja.com

DATEADDはSARGableなインデックスシークの期待値を生成しない

ActivityTypeIdごとのUserIdおよびアクティビティが発生したActivityDateをキャプチャする基本的な[UserActivity]テーブルがあります。

@UserId@ForTypeId、および@DurationInterval@DurationIncrementの入力を可能にするクエリ/ストアドプロシージャを作成して、[〜#〜] n [〜#〜]秒/分/時間/日/月/年の数。 DATEADD/DATEDIFF内のdatepart引数はパラメーターを許可しないため、WHERE句内で目的の結果を得るには、少し工夫する必要がありました。

最初はDATEDIFFを使用してクエリを記述しましたが、実行プランを記述して調べた直後に、それがSARGable関数ではないことを思い出しました(精度レベルによって、一部の日付が下がる可能性があるという事実とともに)うるう年)。そのため、DATEPARTを使用するようにクエリを書き直し、インデックススキャンではなくインデックスシークをヒットして、通常はパフォーマンスを向上させると考えました。

残念ながら、クエリをDATEADDとして記述しても同じ結果が得られることがわかりました。インデックススキャンが実行されており、クエリオプティマイザーが[ActivityDate]に対して非クラスター化インデックスを利用していません。

Aaron Bertrandのブログ投稿 "Performance Surprises and Assumptions:DATEADD" を読み、CONVERTDATEADDの部分に対応するdatetime2列定義は、datetime2に関係する奇妙なトリックのためです。しかし、そうした後でも問題はまだ存在していました。

シナリオをわかりやすく説明するために、比較可能な表の定義を次に示します。

DROP TABLE IF EXISTS [dbo].[UserActivity]
IF OBJECT_ID('[dbo].[UserActivity]', 'U') IS NULL
BEGIN
    CREATE TABLE [dbo].[UserActivity] (
        [UserId] [int] NOT NULL
        ,[UserActivityId] [bigint] IDENTITY(1,1) NOT NULL
        ,[ActivityTypeId] [tinyint] NOT NULL
        ,[ActivityDate] [datetime2](0) NOT NULL CONSTRAINT [DF_UserActivity_ActivityDate] DEFAULT GETDATE()
        ,CONSTRAINT [PK_UserActivity] PRIMARY KEY CLUSTERED ([UserActivityId] ASC)
        ,INDEX [IX_UserActivity_UserId] NONCLUSTERED ([UserId] ASC)
        ,INDEX [IX_UserActivity_ActivityTypeId] NONCLUSTERED ([ActivityTypeId] ASC)
        ,INDEX [IX_UserActivity_ActivityDate] NONCLUSTERED ([ActivityDate] ASC)
    )
END;
GO

4分ごとに新しいActivityTypeIdを使用して、1〜10のランダムなActivityDateで5人の異なるユーザーのダミーデータを再帰的にテーブルに入力します。

DECLARE @UserId int = (SELECT ISNULL((SELECT TOP (1) [UserId] + 1 FROM [dbo].[UserActivity] ORDER BY [UserId] DESC), 1))
;WITH [UserActivitySeed] AS (
    SELECT
        CONVERT(datetime2(0), '01/01/2018') AS 'ActivityDate'
    UNION ALL
    SELECT
        DATEADD(minute, 4, [ActivityDate])
    FROM
        [UserActivitySeed]
    WHERE
        [ActivityDate] < '2018-04-01')
INSERT INTO [dbo].[UserActivity] ([UserId], [ActivityTypeId], [ActivityDate])
SELECT
    @UserId
    ,ABS(CHECKSUM(NEWID()) % 9) + 1
    ,[ActivityDate]
FROM
    [UserActivitySeed] OPTION (MAXRECURSION 32767);

GO 5

ALTER INDEX ALL ON [dbo].[UserActivity] REBUILD;

以下は、DATEDIFFで記述した最初のクエリです。 @UserIdおよび@ForTypeId述語を意図的に除外しているため、これらのキールックアップを回避し、添付されているプラ​​ン内のノイズを削減しています。

このクエリのPasteThePlan でわかるように、DATEDIFFがSARG可能でない場合、インデックススキャンは期待どおりに実行されます。

DECLARE @UserId int = 1
DECLARE @ForTypeId int = 3
DECLARE @DurationInterval varchar(6) = 'hour'
DECLARE @DurationIncrement int = 1

SELECT
    COUNT(UA.[UserActivityId]) AS 'ActivityTypeCount'
FROM
    [dbo].[UserActivity] UA
WHERE
    -- Exclude the @UserId and @ForTypeId predicates.
    -- UA.[UserId] = @UserId
    -- AND UA.[ActivityTypeId] = @ForTypeId
    -- AND 
    CASE
        WHEN @DurationInterval IN ('year', 'yy', 'yyyy') THEN DATEDIFF(SECOND, UA.[ActivityDate], GETDATE()) / 3600.0 / 24.0 / 365.25
        WHEN @DurationInterval IN ('month', 'mm', 'm') THEN DATEDIFF(SECOND, UA.[ActivityDate], GETDATE()) / 3600.0 / 24.0 / 365.25 * 12
        WHEN @DurationInterval IN ('day', 'dd', 'd') THEN DATEDIFF(SECOND, UA.[ActivityDate], GETDATE()) / 3600.0 / 24.0
        WHEN @DurationInterval IN ('hour', 'hh') THEN DATEDIFF(SECOND, UA.[ActivityDate], GETDATE()) / 3600.0
        WHEN @DurationInterval IN ('minute', 'mi', 'n') THEN DATEDIFF(SECOND, UA.[ActivityDate], GETDATE()) / 60.0
        WHEN @DurationInterval IN ('second', 'ss', 's') THEN DATEDIFF(SECOND, UA.[ActivityDate], GETDATE())
    END < @DurationIncrement

以下はDATEADDクエリです。 PasteThePlan here。 残念ながら、インデックスシークは行われていません。これは私の側では間違った仮定かもしれませんが、なぜそれがまったく起こらないのかについて私は困惑しています。

DECLARE @UserId int = 1
DECLARE @ForTypeId int = 3
DECLARE @DurationInterval varchar(6) = 'hour'
DECLARE @DurationIncrement int = 1

SELECT
    COUNT(UA.[UserActivityId]) AS 'ActivityTypeCount'
FROM
    [dbo].[UserActivity] UA
WHERE
    -- Exclude the @UserId and @ForTypeId predicates.
    -- UA.[UserId] = @UserId
    -- AND UA.[ActivityTypeId] = @ForTypeId
    -- AND 
    (
        (@DurationInterval IN ('year', 'yy', 'yyyy') AND UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(YEAR, -@DurationIncrement, GETDATE())))
        OR
        (@DurationInterval IN ('month', 'mm', 'm') AND UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(MONTH, -@DurationIncrement, GETDATE())))
        OR
        (@DurationInterval IN ('day', 'dd', 'd') AND UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(DAY, -@DurationIncrement, GETDATE())))
        OR
        (@DurationInterval IN ('hour', 'hh') AND UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(HOUR, -@DurationIncrement, GETDATE())))
        OR
        (@DurationInterval IN ('minute', 'mi', 'n') AND UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(MINUTE, -@DurationIncrement, GETDATE())))
        OR
        (@DurationInterval IN ('second', 'ss', 's') AND UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(SECOND, -@DurationIncrement, GETDATE())))
        )

これの原因は何ですか?私がORを使用した結果として見られる動作は、インデックスを使用する可能性さえも否定していますか?私はここで骨の折れるほど明白な何かを見落としていますか?

UPDATE:上記の2番目の質問により、OR操作の前にクエリを実行するようになりました。クエリがインデックスシークを実行したため、これらの比較中にSQL Serverが気に入らない何かが発生しています。 PasteThePlanはここにあります。

DECLARE @DurationIncrement int = 1

SELECT
    COUNT(UA.[UserActivityId]) AS 'ActivityTypeCount'
FROM
    [dbo].[UserActivity] UA
WHERE
    UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(HOUR, -@DurationIncrement, GETDATE()))

UPDATE:ここで共有されるソリューション

7
PicoDeGallo

OR条件は、実行時ではなくコンパイル時に評価されます。つまり、WHERE条件はシークを生成しません。

コードをクリーンアップするために、コードをもう少し読みやすくするためにCONVERTをリファクタリングしました。

WHERE句を次のように変更してみます。

UA.[ActivityDate]>CONVERT(datetime2(0), (CASE
    WHEN @DurationInterval IN ('year', 'yy', 'yyyy') THEN DATEADD(year, -@DurationIncrement, GETDATE())
    WHEN @DurationInterval IN ('month', 'mm', 'm')   THEN DATEADD(month, -@DurationIncrement, GETDATE())
    WHEN ...
    END))

これを確認できる環境にアクセスできませんが、動作するかどうかをお知らせください。

9

コンパイル時に、SQL Serverは@DurationIntervalの値を認識しないため、考えられるシナリオのデータを取得するのに最適な計画をコンパイルします。

クエリにWITH (FORCESEEK)オプションを追加することにより、特定のクエリに対してインデックスシークを行うために、各ORに対して個別のシークが行われることを証明できます。状態。

https://www.brentozar.com/pastetheplan/?id=HkE3lkuqf

enter image description here

スキャンは、6回のシークよりもデータを取得するためのより最適な方法であると判断されます。

@Daniel Hutmacherは、IX_UserActivity_ActivityDateで単一のインデックスシークを実行する最適なソリューションを提供します。または、OPTION(RECOMPILE)を追加することもできますが、これにより、クエリが実行されるたびに再コンパイルが強制的に行われ、潜在的に害になる可能性があります。

7
Mark Sinkinson

そのような「キッチンシンク」クエリ(入力の値に応じて1つ以上が使用される複数の個別のフィルタリング句)は、その個々の句がすべてであっても、検索可能になることはありません。

2つのクイックオプションは、それらを個別のプロシージャに分割し、マスタープロシージャの必要に応じてそれぞれを呼び出すか、アドホックSQLを使用することです。

このタイプのクエリ/手順の多くのオプションを説明する詳細な記事については、 http://www.sommarskog.se/dyn-search.html を参照してください。

6
David Spillett

将来の参考のために、これは Daniel Hutmatcherの提案された回答に基づいて私が見つけたソリューションです

DECLARE @UserId int = 1
DECLARE @ForTypeId int = 3
DECLARE @DurationInterval varchar(6) = 'hour'
DECLARE @DurationIncrement int = 1

SELECT
    COUNT(UA.[UserActivityId]) AS 'ActivityTypeCount'
FROM
    [dbo].[UserActivity] UA
WHERE
    -- Exclude the @UserId and @ForTypeId predicates.
    -- UA.[UserId] = @UserId
    -- AND UA.[ActivityTypeId] = @ForTypeId
    -- AND 
    UA.[ActivityDate] > CONVERT(datetime2(0),
    (CASE
        WHEN @DurationInterval IN ('year', 'yy', 'yyyy') THEN DATEADD(YEAR, -@DurationIncrement, GETDATE())
        WHEN @DurationInterval IN ('month', 'mm', 'm') THEN DATEADD(MONTH, -@DurationIncrement, GETDATE())
        WHEN @DurationInterval IN ('day', 'dd', 'd') THEN DATEADD(DAY, -@DurationIncrement, GETDATE())
        WHEN @DurationInterval IN ('hour', 'hh') THEN DATEADD(HOUR, -@DurationIncrement, GETDATE())
        WHEN @DurationInterval IN ('minute', 'mi', 'n') THEN DATEADD(MINUTE, -@DurationIncrement, GETDATE())
        WHEN @DurationInterval IN ('second', 'ss', 's') THEN DATEADD(SECOND, -@DurationIncrement, GETDATE())
    END))
3
PicoDeGallo