Verificações inesperadas durante a operação de exclusão usando WHERE IN

40

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

Mark S. Rasmussen
fonte
5
Você pode criar um script das estatísticas conforme Copiar estatísticas de um servidor para outro, para que possamos replicar?
Mark Storey-Smith
2
@ MarkStorey-Smith Sure - pastebin.com/9HHRPFgK Supondo que você execute o script em um banco de dados vazio, isso me permite reproduzir as consultas problemáticas ao incluir o plano de execução. Ambas as consultas estão incluídas no final do script.
Mark S. Rasmussen

Respostas:

61

"Estou me perguntando por que o otimizador de consultas jamais usaria o plano que atualmente usa."

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

Plano Original

O lado interno da junção está essencialmente executando uma consulta do seguinte formulário para cada valor correlacionado de BrowserID:

DECLARE @BrowserID smallint;

SELECT 
    tfsph.BrowserID 
FROM dbo.tblFEStatsPaperHits AS tfsph 
WHERE 
    tfsph.BrowserID = @BrowserID 
OPTION (MAXDOP 1);

Paper Hits Scan

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 que ANSI_NULLSseja OFF). O custo estimado do plano acima é de 206,8 unidades.

Agora vamos adicionar uma TOP (1)cláusula:

DECLARE @BrowserID smallint;

SELECT TOP (1)
    tfsph.BrowserID 
FROM dbo.tblFEStatsPaperHits AS tfsph 
WHERE 
    tfsph.BrowserID = @BrowserID 
OPTION (MAXDOP 1);

Com TOP (1)

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

As informações estatísticas disponíveis mostram 166BrowserID 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:

Custos estimados em escala da meta de linha

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 BrowserIDvalores possíveis . No entanto, o DELETEplano 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 :

DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, LOOP JOIN, RECOMPILE);
GO
DBCC RULEON ('GbAggToConstScanOrTop');

GbAggToConstScanOrTop desativado

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 redundante DISTINCTno 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:

DBCC RULEOFF ('LASJNtoLASJNonDist');
DBCC RULEOFF ('LASJOnLclDist');
DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, LOOP JOIN, RECOMPILE);
GO
DBCC RULEON ('LASJNtoLASJNonDist');
DBCC RULEON ('LASJOnLclDist');
DBCC RULEON ('GbAggToConstScanOrTop');

Plano de spool

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 BrowserIDagregação antes da junção anti-semi:

DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, RECOMPILE);
GO
DBCC RULEON ('GbAggToConstScanOrTop');

Nenhum plano superior do DOP 1

E sem a restrição MAXDOP 1, um plano paralelo:

Nenhum plano paralelo superior

Outra maneira de 'corrigir' a consulta original seria criar o índice ausente no BrowserIDrelató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 .

Paul White diz que a GoFundMonica
fonte
3
Eu me curvo, você acabou de me apresentar vários novos conceitos que nunca encontrei antes. Apenas quando você sente que sabe alguma coisa, alguém lá fora o decepcionará - de uma maneira positiva :) Adicionar o índice definitivamente ajudaria. No entanto, além dessa operação única, o campo nunca é acessado / agregado pela coluna BrowserID e, portanto, prefiro salvar esses bytes, pois a tabela é bastante grande (esse é apenas um dos muitos bancos de dados idênticos). Não existe uma chave única na mesa, pois não existe uma singularidade natural nela. Todas as seleções são feitas por PaperID e, opcionalmente, um período.
Mark S. Rasmussen
22

Quando executo seu script para criar um banco de dados apenas de estatísticas e a consulta na pergunta, recebo o plano a seguir.

Plano

As cardinalidades da tabela mostradas no plano são

  • tblFEStatsPaperHits: 48063400
  • tblFEStatsBrowsers : 339

Portanto, estima que será necessário executar a verificação tblFEStatsPaperHits339 vezes. Cada varredura tem o predicado correlacionado tblFEStatsBrowsers.BrowserID=tblFEStatsPaperHits.BrowserID AND tblFEStatsPaperHits.BrowserID IS NOT NULLque é 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.32603e todo o plano é custado em 1.41337.

Para o Hash Join, ele fornece o plano abaixo

Hash Join

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, tblFEStatsPaperHitscustada 206.8apenas. Compare isso com a 1.32603estimativa 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 TOPna consulta a seguir

SELECT TOP 147 BrowserID 
FROM [dbo].[tblFEStatsPaperHits] 

147fornece o custo estimado da subárvore mais próximo de 0.003911592em 0.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.

Martin Smith
fonte
20

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:

create view [dbo].[vwFEStatsPaperHitsBrowserID]
with schemabinding
as
select BrowserID, COUNT_BIG(*) as big_count
from [dbo].[tblFEStatsPaperHits]
group by [BrowserID];
go

create unique clustered index [cdxVwFEStatsPaperHitsBrowserID] 
  on [vwFEStatsPaperHitsBrowserID]([BrowserID]);
go

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.

Remus Rusanu
fonte
Ouvi dizer que qualquer varredura desse tamanho é definitivamente geralmente inaceitável. Nesse caso, é para algumas operações de limpeza de dados únicas, por isso optei por não criar índices adicionais (e não posso fazê-lo temporariamente, pois isso interromperia o sistema). Eu não tenho EE, mas como isso é único, as dicas ficariam bem. Minha principal curiosidade foi sobre como o QO chegou ao plano :) A tabela é uma tabela de registro e existem inserções pesadas. Há uma tabela de log assíncrona separada, que atualiza as linhas posteriormente em tblFEStatsPaperHits para que eu possa gerenciar sozinho, se necessário.
Mark S. Rasmussen