Tenho a seguinte tabela:
CREATE TABLE [dbo].[MP_Notification_Audit](
[id] [bigint] IDENTITY(1,1) NOT NULL,
[type] [int] NOT NULL,
[source_user_id] [bigint] NOT NULL,
[target_user_id] [bigint] NOT NULL,
[discussion_id] [bigint] NULL,
[discussion_comment_id] [bigint] NULL,
[discussion_media_id] [bigint] NULL,
[patient_id] [bigint] NULL,
[task_id] [bigint] NULL,
[date_created] [datetimeoffset](7) NOT NULL,
[clicked] [bit] NULL,
[date_clicked] [datetimeoffset](7) NULL,
[title] [nvarchar](max) NULL,
[body] [nvarchar](max) NULL,
CONSTRAINT [PK_MP_Notification_Audit] PRIMARY KEY CLUSTERED
(
[id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO
ALTER TABLE [dbo].[MP_Notification_Audit] ADD CONSTRAINT [DF_MP_Notification_Audit_date_created] DEFAULT (sysdatetimeoffset()) FOR [date_created]
GO
CREATE NONCLUSTERED INDEX [IX_MP_Notification_Audit_TargetUserDateCreated] ON [dbo].[MP_Notification_Audit]
(
[target_user_id] ASC,
[date_created] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
GO
Existem mais de 10.000 linhas na tabela com um [target_user_id]
de 100017
.
Quando executo a seguinte consulta:
SELECT
[target_user_id], [patient_id]
FROM
[dbo].[MP_Notification_Audit]
WHERE
[target_user_id] = 100017
ORDER BY
[date_created] ASC
OFFSET 9200 ROWS
FETCH NEXT 10 ROWS ONLY
... Recebo o seguinte plano de execução real:
Por que o SQL Server precisou fazer 9210 em vez de 10 pesquisas de chave em cluster? O índice [IX_MP_Notification_Audit_TargetUserDateCreated]
deveria permitir que ele descobrisse os 10 RIDs necessários para recuperar para obter [patient_id]
e fazer apenas 10 pesquisas de chave em cluster, certo?
Eu também descobri um comportamento mais estranho - parece que o SQL Server 'puni' você por não selecionar colunas não indexáveis. Se em vez disso OFFSET
, 10.000 linhas, recebo o seguinte plano de execução:
SELECT
[target_user_id], [patient_id]
FROM
[dbo].[MP_Notification_Audit]
WHERE
[target_user_id] = 100017
ORDER BY
[date_created] ASC
OFFSET 10000 ROWS
FETCH NEXT 10 ROWS ONLY
... com a recomendação de criar um índice que inclua [patient_id]
, e uma varredura de índice clusterizado ineficiente para toda a tabela. O tempo gasto foi de 0,126s, no entanto, aparentemente, isso poderia ter sido muito melhor porque quando adiciono a coluna não indexável [title]
à consulta, recebo isso:
SELECT
[target_user_id], [patient_id], [title]
FROM
[dbo].[MP_Notification_Audit]
WHERE
[target_user_id] = 100017
ORDER BY
[date_created] ASC
OFFSET 10000 ROWS
FETCH NEXT 10 ROWS ONLY
... e o índice não clusterizado ainda é usado, o tempo gasto é de apenas 0,032s. O SQL Server basicamente diz "você poderia ter criado um índice para fazer isso com mais eficiência, então nem vamos usar o índice que você tem e vamos fazer a pesquisa de forma ineficiente para puni-lo", ou estou me falta alguma coisa?
É por causa da ordem das operações em que as cláusulas de sua consulta são executadas. A
WHERE
cláusula ocorre antes de suas cláusulasOFFSET
eFETCH
. É por isso que não apenas existem cerca de 10.000 (9.000 em seu primeiro exemplo) pesquisas de chave , mas há tantas buscas de índice . Esta é a filtragem que ocorre como resultado do seu predicadotarget_user_id
na suaWHERE
cláusula. Os Planos de Execução são lidos da direita para a esquerda por ordem dos eventos, portanto, se você seguir o plano a partir do índice busca e seguir seu caminho à esquerda, verá o operador Top que é o que representa suas cláusulasOFFSET
/FETCH
e, portanto, vem após suaWHERE
cláusula.Simplificando, o SQL Server primeiro precisa localizar as linhas com base nos filtros que você aplica por meio de seus predicados (nas cláusulas
WHERE
,HAVING
e ).JOIN
Então, quando encontrar as linhas corretas, poderá aplicar suas cláusulasOFFSET
/ .FETCH
Se não obtivesse todas as linhas com base em seus filtros primeiro, não saberia quais linhas precisariaOFFSET
/FETCH
.O SQL Server Engine poderia ter sido programado um pouco melhor logicamente nesse caso? Possivelmente. Eles poderiam ter projetado o mecanismo de uma maneira que percebesse que uma operação Top iria eventualmente ocorrer nos dados pesquisados pelo índice e aplicá-los antes que as pesquisas de chave ocorressem para pelo menos minimizar a quantidade de pesquisas de chave , mas eu imagino que seria apenas ajudar o desempenho em certos casos e tornar as coisas programaticamente muito mais complicadas que podem não valer a pena. Por exemplo, as pesquisas de chave estão acontecendo em paralelo à operação de busca de índice (portanto, elas estão igualmente à direita nas etapas do plano). Se o topooperação ocorresse antes das pesquisas de chave para reduzir os dados, as pesquisas de chave de linhas restantes teriam que acontecer em série após a operação Top , consequentemente em série também após a operação de busca de índice , o que seria pior em termos de desempenho em alguns casos.
Em relação à sua segunda pergunta sobre o que você experimentou com o plano de execução mudando de buscas de índice para varreduras de índice clusterizado de volta para buscas de índice novamente, isso é conhecido como Ponto de Virada . Basicamente, o Query Optimizer analisa sua consulta e calcula rapidamente o custo de cada operação entre uma série de planos de execução diferentes que ele pode usar para buscar seus dados, em parte com base nas estatísticas em cache de seus dados que o SQL Server mantém. A soma total desses custos permite que o Query Optimizer escolha o plano de execução com o menor custo (idealmente o plano de execução mais rápido).
Essas estatísticas armazenadas em cache são baseadas em quantas linhas existem para cada valor em cada coluna do total de linhas na tabela e são usadas para fazer estimativas de cardinalidade em cada operação que precisa ocorrer. Em resumo, a estimativa de cardinalidade é o número de linhas que o SQL Server acredita que uma determinada operação retornará. Por exemplo, sua
WHERE
cláusula em seu primeiro exemplo retorna aproximadamente 9.000 linhas e o SQL Server Engine provavelmente estimou a cardinalidade próxima a esse número, o que resultou no custo de uma operação de busca de índice suficiente para que uma busca de índice fosse escolhida.Quando você disse explicitamente ao SQL Server para retornar 10.000 linhas, ele provavelmente acionou o Tipping Point , o que o fez pensar que a quantidade de linhas que ele precisa retornar será mais eficiente com uma operação de verificação de índice , porque a cardinalidade das linhas é alta o suficiente que o SQL Engine acha que uma varredura provavelmente encontrará linhas mais contíguas com eficiência, em vez de fazer 10.000 buscas de índice . (Para encurtar a história, o Optimizer não é perfeito, especialmente quando dança tão perto do ponto de inflexão , e uma busca de índice provavelmente ainda é mais eficiente aqui.)
Por que quando você adicionou outra coluna não indexada à sua
SELECT
lista faz com que o Otimizador de Consultas escolha um plano que não esteja acima do ponto de inflexão , eu não poderia te dizer com certeza (especialmente sem o plano de execução real na minha frente) porque é bastante complicado o que está acontecendo nos bastidores, mas, de modo geral, as estimativas feitas, com base nas estatísticas em cache de seus dados naquele momento, resultaram no cálculo de custos que não estavam mais acima do ponto de inflexão e resultaram na escolha um plano de execução que aproveita as buscas de índice .Concordo que o otimizador poderia ser mais inteligente sobre onde a pesquisa de chave deve ocorrer no plano com
OFFSET
eFETCH
.Como solução alternativa, você pode usar um CTE como abaixo.
A pesquisa de chave de loop aninhada é feita após o
TOP
operador no plano .