Eu tenho uma [UserActivity]
tabela básica que captura um ActivityTypeId
por UserId
e o ActivityDate
em que a atividade ocorreu.
Estou escrevendo uma consulta/procedimento armazenado que permite a entrada do @UserId
, @ForTypeId
, bem como o @DurationInterval
e @DurationIncrement
para retornar resultados dinamicamente com base no número N de segundos/minutos/horas/dias/meses/anos. Dado que o datepart
argumento dentro DATEADD/DATEDIFF
não permite parâmetros, eu tive que reverter um pouco de truque para obter os resultados desejados dentro da WHERE
cláusula.
Inicialmente escrevi a consulta usando DATEDIFF
, mas imediatamente após escrever e dar uma olhada no plano de execução, lembrei que não é uma função SARGable (junto com o fato de que os níveis de precisão podem oferecer para algumas datas caindo em um ano bissexto). Então, eu reescrevi a consulta para utilizar DATEPART
o pensamento de que eu atingiria uma busca de índice em vez de uma varredura de índice e geralmente teria um desempenho melhor.
Infelizmente, descobri que escrever a consulta como DATEADD
oferece os mesmos resultados: uma verificação de índice está ocorrendo e o otimizador de consulta não está aproveitando o índice não clusterizado em relação ao [ActivityDate]
.
Eu li a postagem no blog de Aaron Bertrand, "Performance Surprises and Assumptions: DATEADD" e implementei as mudanças que ele descreveu para CONVERT
a DATEADD
parte na datetime2
definição de coluna equivalente devido a truques estranhos envolvidos com datetime2
. No entanto, o problema ainda estava presente mesmo depois de fazê-lo.
Para ilustrar melhor o cenário, aqui está uma definição de tabela comparável.
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
Preencha a tabela com dados fictícios recursivamente para 5 usuários diferentes com um aleatório ActivityTypeId
entre 1 e 10 com um novo a ActivityDate
cada 4 minutos.
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;
Abaixo está a primeira consulta que escrevi com DATEDIFF
. Observe que estou excluindo os predicados @UserId
e @ForTypeId
intencionalmente para evitar essas pesquisas de chave e reduzir o ruído nos planos anexados.
Como você encontrará em PasteThePlan para esta consulta , ele está realizando uma verificação de índice conforme o esperado, uma vez que DATEDIFF
não é SARGable.
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
Abaixo está a DATEADD
consulta. PasteThePlan aqui. Infelizmente, uma busca de índice não está ocorrendo. Esta pode ser uma suposição incorreta da minha parte, mas estou perplexo por que isso não está ocorrendo.
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())))
)
Qual é a causa disso? O comportamento que estou vendo é resultado do meu uso de OR
negação de qualquer potencial para que ele chegue a usar o índice? Estou ignorando algo meticulosamente óbvio aqui?
ATUALIZAÇÃO: Minha segunda pergunta acima me levou a realizar uma consulta anterior às OR
operações. A consulta executou a busca de índice, então algo está ocorrendo durante essas comparações que o SQL Server não gosta. PasteThePlan aqui.
DECLARE @DurationIncrement int = 1
SELECT
COUNT(UA.[UserActivityId]) AS 'ActivityTypeCount'
FROM
[dbo].[UserActivity] UA
WHERE
UA.[ActivityDate] > CONVERT(datetime2(0), DATEADD(HOUR, -@DurationIncrement, GETDATE()))
ATUALIZAÇÃO: Solução compartilhada aqui.
A
OR
condição é avaliada em tempo de compilação, em vez de em tempo de execução, o que significa que suaWHERE
condição não gera uma busca.E apenas para limpar o código, refatorei o seu
CONVERT
para tornar o código um pouco mais legível.Eu tentaria mudar a
WHERE
cláusula para:Não tenho acesso a um ambiente onde posso verificar isso, mas por favor me avise se funcionar.
Na compilação, o SQL Server não sabe o valor de
@DurationInterval
e, portanto, compila o plano mais adequado para recuperar os dados para qualquer cenário possível.Você pode provar isso adicionando uma
WITH (FORCESEEK)
opção à consulta, que mostra que, para fazer uma Busca de Índice para uma determinada consulta, haverá uma busca individual para cadaOR
condição.https://www.brentozar.com/pastetheplan/?id=HkE3lkuqf
A varredura é determinada como uma maneira mais otimizada de recuperar os dados do que 6 buscas.
@Daniel Hutmacher fornece uma solução ideal que executa uma busca de índice único em arquivos
IX_UserActivity_ActivityDate
. Como alternativa, você pode adicionar umOPTION(RECOMPILE)
, embora isso force a recompilação toda vez que a consulta for executada, potencialmente causando mais danos do que benefícios.Uma consulta de "pia de cozinha" como essa (várias cláusulas de filtragem distintas, uma ou mais das quais é usada dependendo do valor de uma entrada) nunca será sargável, mesmo que todas as suas cláusulas individuais sejam.
As duas opções rápidas são dividi-los em procedimentos individuais e chamar cada um conforme necessário por um procedimento mestre ou usar SQL ad-hoc.
Para um artigo detalhado descrevendo várias opções para esse tipo de consulta/procedimento, consulte http://www.sommarskog.se/dyn-search.html
Para referência futura, esta é a solução que encontrei com base na resposta proposta de Daniel Hutmatcher.