Eu tenho uma consulta como a seguinte:
DELETE FROM tblFEStatsBrowsers WHERE BrowserID NOT IN (
SELECT DISTINCT BrowserID FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID IS NOT NULL
)
tblFEStatsBrowsers possui 553 linhas.
tblFEStatsPaperHits possui 47.974.301 linhas.
tblFEStatsBrowsers:
CREATE TABLE [dbo].[tblFEStatsBrowsers](
[BrowserID] [smallint] IDENTITY(1,1) NOT NULL,
[Browser] [varchar](50) NOT NULL,
[Name] [varchar](40) NOT NULL,
[Version] [varchar](10) NOT NULL,
CONSTRAINT [PK_tblFEStatsBrowsers] PRIMARY KEY CLUSTERED ([BrowserID] ASC)
)
tblFEStatsPaperHits:
CREATE TABLE [dbo].[tblFEStatsPaperHits](
[PaperID] [int] NOT NULL,
[Created] [smalldatetime] NOT NULL,
[IP] [binary](4) NULL,
[PlatformID] [tinyint] NULL,
[BrowserID] [smallint] NULL,
[ReferrerID] [int] NULL,
[UserLanguage] [char](2) NULL
)
Há um índice em cluster no tblFEStatsPaperHits que não inclui o BrowserID. A execução da consulta interna exigirá, portanto, uma verificação completa da tabela de tblFEStatsPaperHits - o que é totalmente aceitável.
Atualmente, uma varredura completa é executada para cada linha em tblFEStatsBrowsers, o que significa que eu tenho 553 varreduras de tabela completa de tblFEStatsPaperHits.
Reescrever apenas para WHERE EXISTS não altera o plano:
DELETE FROM tblFEStatsBrowsers WHERE NOT EXISTS (
SELECT * FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID = tblFEStatsBrowsers.BrowserID
)
No entanto, conforme sugerido por Adam Machanic, adicionar uma opção HASH JOIN resulta no plano de execução ideal (apenas uma única varredura de tblFEStatsPaperHits):
DELETE FROM tblFEStatsBrowsers WHERE NOT EXISTS (
SELECT * FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID = tblFEStatsBrowsers.BrowserID
) OPTION (HASH JOIN)
Agora, isso não é tanto uma questão de como corrigir isso - eu posso usar a OPTION (HASH JOIN) ou criar uma tabela temporária manualmente. Pergunto-me mais por que o otimizador de consultas jamais usaria o plano atualmente.
Como o QO não possui estatísticas na coluna BrowserID, suponho que esteja assumindo o pior - 50 milhões de valores distintos, exigindo, assim, uma grande mesa de trabalho na memória / tempdb. Dessa forma, a maneira mais segura é realizar verificações para cada linha no tblFEStatsBrowsers. Não há relação de chave estrangeira entre as colunas BrowserID nas duas tabelas, portanto, o QO não pode deduzir nenhuma informação de tblFEStatsBrowsers.
É este, por mais simples que pareça, o motivo?
Atualização 1
Para fornecer algumas estatísticas: OPTION (HASH JOIN):
208.711 leituras lógicas (12 varreduras)
OPÇÃO (LOOP JOIN, HASH GROUP):
11.008.698 leituras lógicas (~ varredura por BrowserID (339))
Nenhuma opção:
11.008.775 leituras lógicas (~ varredura por BrowserID (339))
Atualização 2
Excelentes respostas, todos vocês - obrigado! Difícil escolher apenas um. Embora Martin tenha sido o primeiro e Remus forneça uma excelente solução, eu tenho que entregá-lo ao Kiwi por se interessar nos detalhes :)
fonte
Respostas:
Em outras palavras, a questão é por que o plano a seguir parece mais barato para o otimizador, comparado com as alternativas (das quais existem muitas ).
O lado interno da junção está essencialmente executando uma consulta do seguinte formulário para cada valor correlacionado de
BrowserID
:Observe que o número estimado de linhas é 185.220 (não 289.013 ), pois a comparação de igualdade exclui implicitamente
NULL
(a menos queANSI_NULLS
sejaOFF
). O custo estimado do plano acima é de 206,8 unidades.Agora vamos adicionar uma
TOP (1)
cláusula:O custo estimado agora é de 0,00452 unidades. A adição do operador físico Top define uma meta de linha de 1 linha no operador Top. A questão passa a ser como derivar uma 'meta de linha' para a Varredura de Índice em Cluster; ou seja, quantas linhas a varredura deve esperar processar antes de uma linha corresponder ao
BrowserID
predicado?As informações estatísticas disponíveis mostram 166
BrowserID
valores distintos (1 / [Densidade total] = 1 / 0,006024096 = 166). O custo assume que os valores distintos são distribuídos uniformemente pelas linhas físicas, portanto, a meta de linha na Varredura de Índice em Cluster é definida como 166.302 (contabilizando a alteração na cardinalidade da tabela desde que as estatísticas amostradas foram reunidas).O custo estimado da varredura das 166 linhas esperadas não é muito grande (mesmo executado 339 vezes, uma vez para cada alteração de
BrowserID
) - a Varredura de Índice em Cluster mostra um custo estimado de 1,3219 unidades, mostrando o efeito de escala da meta da linha. Os custos não dimensionados do operador para E / S e CPU são mostrados como 153.931 e 52.8698, respectivamente:Na prática, é muito improvável que as primeiras 166 linhas varridas do índice (na ordem em que sejam retornadas) contenham um de cada um dos
BrowserID
valores possíveis . No entanto, oDELETE
plano tem um custo total de 1.40921 unidades e é selecionado pelo otimizador por esse motivo. Bart Duncan mostra outro exemplo desse tipo em uma postagem recente intitulada Row Goals Gone Rogue .Também é interessante notar que o operador Top no plano de execução não está associado ao Anti Semi Join (em particular, o Martin menciona 'curto-circuito'). Podemos começar a ver de onde vem o Top desativando primeiro uma regra de exploração chamada GbAggToConstScanOrTop :
Esse plano tem um custo estimado de 364.912 e mostra que a parte superior substituiu um grupo por agregado (agrupamento pela coluna correlacionada
BrowserID
). O agregado não é devido ao redundanteDISTINCT
no texto da consulta: é uma otimização que pode ser introduzida por duas regras de exploração, LASJNtoLASJNonDist e LASJOnLclDist . Desabilitar esses dois também produz este plano:Esse plano tem um custo estimado de 40729,3 unidades.
Sem a transformação do Group By para Top, o otimizador 'naturalmente' escolhe um plano de junção de hash com
BrowserID
agregação antes da junção anti-semi:E sem a restrição MAXDOP 1, um plano paralelo:
Outra maneira de 'corrigir' a consulta original seria criar o índice ausente no
BrowserID
relatório do plano de execução. Os loops aninhados funcionam melhor quando o lado interno é indexado. Estimar a cardinalidade para semi-junções é um desafio na melhor das hipóteses. Não ter uma indexação adequada (a tabela grande nem sequer possui uma chave exclusiva!) Não ajudará em nada.Escrevi mais sobre isso em Row Goals, Parte 4: O Anti Join Anti Pattern .
fonte
Quando executo seu script para criar um banco de dados apenas de estatísticas e a consulta na pergunta, recebo o plano a seguir.
As cardinalidades da tabela mostradas no plano são
tblFEStatsPaperHits
:48063400
tblFEStatsBrowsers
:339
Portanto, estima que será necessário executar a verificação
tblFEStatsPaperHits
339 vezes. Cada varredura tem o predicado correlacionadotblFEStatsBrowsers.BrowserID=tblFEStatsPaperHits.BrowserID AND tblFEStatsPaperHits.BrowserID IS NOT NULL
que é empurrado para baixo no operador de varredura.O plano não significa que haverá 339 varreduras completas. Como está sob um operador de anti-junção, assim que a primeira linha correspondente em cada verificação é encontrada, pode causar um curto-circuito no restante. O custo estimado da subárvore para esse nó é
1.32603
e todo o plano é custado em1.41337
.Para o Hash Join, ele fornece o plano abaixo
O plano geral é custado em
418.415
(cerca de 300 vezes mais caro que o plano de loops aninhados) com a única varredura completa de índice em cluster,tblFEStatsPaperHits
custada206.8
apenas. Compare isso com a1.32603
estimativa de 339 verificações parciais fornecidas anteriormente (Custo estimado médio da verificação parcial =0.003911592
).Portanto, isso indica que está custando cada verificação parcial como 53.000 vezes mais barata que uma verificação completa. Se os custos fossem escalados linearmente com a contagem de linhas, isso significaria que ele supõe que, em média, seria necessário processar 900 linhas em cada iteração antes de encontrar uma linha correspondente e pode causar um curto-circuito.
Eu não acho que os custos escalem dessa maneira linear no entanto. Eu acho que eles também incorporam algum elemento de custo fixo de inicialização. Tentando vários valores de
TOP
na consulta a seguir147
fornece o custo estimado da subárvore mais próximo de0.003911592
em0.0039113
. De qualquer maneira, fica claro que ele está baseando o custo na suposição de que cada varredura precisará processar apenas uma pequena proporção da tabela, na ordem de centenas de linhas, em vez de milhões.Não sei exatamente em que matemática baseia essa suposição e, na verdade, não corresponde às estimativas de contagem de linhas no restante do plano (as 236 linhas estimadas que saem da junção de loops aninhados implicariam que havia 236 casos em que nenhuma linha correspondente foi encontrada e uma verificação completa foi necessária). Suponho que este seja apenas um caso em que as suposições de modelagem feitas caem um pouco e deixem o plano de loops aninhados significativamente abaixo do custo.
fonte
No meu livro, mesmo uma verificação de 50 milhões de linhas é inaceitável ... Meu truque usual é materializar os valores distintos e delegar o mecanismo, mantendo-o atualizado:
Isso fornece um índice materializado uma linha por BrowserID, eliminando a necessidade de digitalizar 50 milhões de linhas. O mecanismo o manterá para você e o QO o usará 'no estado em que se encontra' na declaração que você postou (sem nenhuma dica ou reescrita de consulta).
A desvantagem é, obviamente, a contenção. Qualquer operação de inserção ou exclusão
tblFEStatsPaperHits
(e eu acho que é uma tabela de registro com inserções pesadas) terá que serializar o acesso a um determinado BrowserID. Existem várias maneiras de tornar isso viável (atualizações atrasadas, 2 etapas de registro etc.) se você estiver disposto a comprar.fonte