Unerwartete Scans während des Löschvorgangs mit WHERE IN

40

Ich habe eine Frage wie die folgende:

DELETE FROM tblFEStatsBrowsers WHERE BrowserID NOT IN (
    SELECT DISTINCT BrowserID FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID IS NOT NULL
)

tblFEStatsBrowsers hat 553 Zeilen.
tblFEStatsPaperHits hat 47.974.301 Zeilen.

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
)

Es gibt einen Clustered-Index für tblFEStatsPaperHits, der keine BrowserID enthält. Das Ausführen der inneren Abfrage erfordert daher einen vollständigen Tabellenscan von tblFEStatsPaperHits - was völlig in Ordnung ist.

Derzeit wird in tblFEStatsBrowsers für jede Zeile ein vollständiger Scan ausgeführt. Das bedeutet, dass ich 553 vollständige Tabellenscans von tblFEStatsPaperHits habe.

Das Umschreiben auf einen WHERE EXISTS ändert den Plan nicht:

DELETE FROM tblFEStatsBrowsers WHERE NOT EXISTS (
    SELECT * FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID = tblFEStatsBrowsers.BrowserID
)

Wie von Adam Machanic vorgeschlagen, führt das Hinzufügen einer HASH JOIN-Option jedoch zu einem optimalen Ausführungsplan (nur ein einziger Scan von tblFEStatsPaperHits):

DELETE FROM tblFEStatsBrowsers WHERE NOT EXISTS (
    SELECT * FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID = tblFEStatsBrowsers.BrowserID
) OPTION (HASH JOIN)

Nun ist dies nicht so sehr eine Frage, wie man das behebt - ich kann entweder die OPTION (HASH JOIN) verwenden oder manuell eine temporäre Tabelle erstellen. Ich frage mich eher, warum das Abfrageoptimierungsprogramm jemals den Plan verwenden würde, den es derzeit ausführt.

Da die QO keine Statistiken in der BrowserID-Spalte hat, wird davon ausgegangen, dass es sich um die schlechtesten Werte handelt - 50 Millionen verschiedene Werte, weshalb eine ziemlich große In-Memory / Tempdb-Arbeitstabelle erforderlich ist. Daher ist es am sichersten, in tblFEStatsBrowsers für jede Zeile einen Scan durchzuführen. Es gibt keine Fremdschlüsselbeziehung zwischen den BrowserID-Spalten in den beiden Tabellen, sodass der QO keine Informationen von tblFEStatsBrowsers ableiten kann.

Ist das so einfach, wie es sich anhört, der Grund?

Update 1
Um ein paar Statistiken zu geben: OPTION (HASH JOIN):
208.711 logische Lesevorgänge (12 Scans)

OPTION (LOOP JOIN, HASH GROUP):
11.008.698 logische Lesevorgänge (~ Scan per BrowserID (339))

Keine Optionen:
11.008.775 logische Lesevorgänge (~ Scan per BrowserID (339))

Update 2
Hervorragende Antworten, ihr alle - danke! Schwierig, nur einen auszuwählen. Obwohl Martin der erste war und Remus eine ausgezeichnete Lösung bietet, muss ich es der Kiwi geben, um die Details im Kopf zu behalten :)

Mark S. Rasmussen
quelle
5
Können Sie die Statistiken wie folgt ausschreiben: Statistiken von einem Server auf einen anderen kopieren, damit wir sie replizieren können?
Mark Storey-Smith
2
@ MarkStorey-Smith Sure - pastebin.com/9HHRPFgK Angenommen, Sie führen das Skript in einer leeren Datenbank aus, dann kann ich die problematischen Abfragen reproduzieren, wenn der Ausführungsplan angezeigt wird. Beide Abfragen sind am Ende des Skripts enthalten.
Mark S. Rasmussen

Antworten:

61

"Ich frage mich eher, warum das Abfrageoptimierungsprogramm jemals den Plan verwenden würde, den es derzeit verwendet."

Anders ausgedrückt ist die Frage, warum der folgende Plan für den Optimierer im Vergleich zu den Alternativen (von denen es viele gibt ) am billigsten erscheint .

Ursprünglicher Plan

Auf der Innenseite des Joins wird für jeden korrelierten Wert von im Wesentlichen eine Abfrage der folgenden Form ausgeführt BrowserID:

DECLARE @BrowserID smallint;

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

Papier-Treffer-Scan

Beachten Sie, dass die geschätzte Anzahl der Zeilen 185.220 (nicht 289.013 ) beträgt, da der Gleichheitsvergleich implizit ausschließt NULL(sofern dies nicht der FallANSI_NULLS ist OFF). Die geschätzten Kosten des oben genannten Plans betragen 206,8 Einheiten.

Fügen wir nun eine TOP (1)Klausel hinzu:

DECLARE @BrowserID smallint;

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

Mit TOP (1)

Die geschätzten Kosten betragen jetzt 0,00452 Einheiten. Durch Hinzufügen des Operators "Top" wird beim Operator "Top" ein Zeilenziel von 1 Zeile festgelegt. Die Frage lautet dann, wie ein Zeilenziel für den Clustered Index Scan abgeleitet werden kann. Das heißt, wie viele Zeilen soll der Scan verarbeiten, bevor eine Zeile mit dem BrowserIDPrädikat übereinstimmt ?

Die verfügbaren statistischen Daten zeigen 166 verschiedene BrowserIDWerte (1 / [All Density] = 1 / 0.006024096 = 166). In der Kalkulation wird davon ausgegangen, dass die unterschiedlichen Werte gleichmäßig auf die physischen Zeilen verteilt sind, sodass das Zeilenziel beim Clustered Index Scan auf 166.302 festgelegt ist (unter Berücksichtigung der Änderung der Tabellenkardinalität seit der Erfassung der Stichprobenstatistik).

Die geschätzten Kosten für das Scannen der erwarteten 166 Zeilen sind nicht sehr hoch (sogar 339- maliges Ausführen bei jeder Änderung BrowserID). Der Clustered-Index-Scan weist geschätzte Kosten von 1,3219 Einheiten auf und zeigt den Skalierungseffekt des Zeilenziels . Die nicht skalierten Bedienerkosten für E / A und CPU werden mit 153,931 bzw. 52,8698 angegeben:

Zeilenziel skalierte geschätzte Kosten

In der Praxis ist es sehr unwahrscheinlich, dass die ersten 166 vom Index durchsuchten Zeilen (in welcher Reihenfolge auch immer sie zurückgegeben werden) jeweils einen der möglichen BrowserIDWerte enthalten. Der DELETEPlan kostet jedoch insgesamt 1.40921 Einheiten und wird aus diesem Grund vom Optimierer ausgewählt. Bart Duncan zeigt ein weiteres Beispiel dieses Typs in einem kürzlich veröffentlichten Beitrag mit dem Titel Row Goals Gone Rogue .

Es ist auch interessant festzustellen, dass der Top-Operator im Ausführungsplan nicht mit dem Antisemit verbunden ist (insbesondere die Erwähnungen von Martin zum Kurzschließen). Wir können herausfinden, woher das Top stammt, indem wir zuerst eine Erkundungsregel mit dem Namen GbAggToConstScanOrTop deaktivieren :

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 Deaktiviert

Dieser Plan hat geschätzte Kosten von 364,912 und zeigt, dass der obere eine Gruppe nach Aggregat (Gruppierung nach der korrelierten Spalte BrowserID) ersetzt hat. Das Aggregat ist nicht auf die Redundanz DISTINCTim Abfragetext zurückzuführen: Es handelt sich um eine Optimierung, die durch die beiden Explorationsregeln LASJNtoLASJNonDist und LASJOnLclDist eingeführt werden kann . Durch Deaktivieren dieser beiden Optionen wird der folgende Plan erstellt:

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');

Spool-Plan

Dieser Plan hat geschätzte Kosten von 40729,3 Einheiten.

Ohne die Umwandlung von Group By nach Top wählt der Optimierer "natürlich" einen Hash-Join-Plan mit BrowserIDAggregation vor dem Anti-Semi-Join:

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');

Kein Top DOP 1 Plan

Und ohne die MAXDOP 1-Einschränkung ein paralleler Plan:

Kein Top Parallel Plan

Eine andere Möglichkeit, die ursprüngliche Abfrage zu "reparieren", besteht darin, den fehlenden Index für BrowserIDden Ausführungsplan zu erstellen. Verschachtelte Schleifen funktionieren am besten, wenn die Innenseite indiziert ist. Das Schätzen der Kardinalität für Semi-Joins ist im besten Fall eine Herausforderung. Wenn Sie keine ordnungsgemäße Indizierung haben (die große Tabelle hat nicht einmal einen eindeutigen Schlüssel!), Hilft dies überhaupt nicht.

Ich habe mehr darüber in Row Goals, Part 4: The Anti Join Anti Pattern geschrieben .

Paul White
quelle
3
Ich verneige mich vor dir, du hast mir gerade einige neue Konzepte vorgestellt, denen ich noch nie begegnet bin. Nur wenn Sie das Gefühl haben, etwas zu wissen, wird Sie jemand da draußen niederwerfen - auf eine gute Weise :) Das Hinzufügen des Index würde definitiv helfen. Abgesehen von dieser einmaligen Operation wird auf das Feld jedoch nie von der BrowserID-Spalte zugegriffen / aggregiert. Daher möchte ich diese Bytes lieber speichern, da die Tabelle ziemlich groß ist (dies ist nur eine von vielen identischen Datenbanken). Es gibt keinen eindeutigen Schlüssel auf dem Tisch, da es keine natürliche Einzigartigkeit dafür gibt. Alle Auswahlmöglichkeiten beziehen sich auf die PaperID und optional auf einen Punkt.
Mark S. Rasmussen
22

Wenn ich Ihr Skript ausführe, um nur eine Statistikdatenbank und die Abfrage in der Frage zu erstellen, erhalte ich den folgenden Plan.

Planen

Die im Plan angegebenen Tabellenkardinalitäten sind

  • tblFEStatsPaperHits: 48063400
  • tblFEStatsBrowsers : 339

Daher wird geschätzt, dass der Scan tblFEStatsPaperHits339 Mal ausgeführt werden muss. Jeder Scan hat das korrelierte Prädikat tblFEStatsBrowsers.BrowserID=tblFEStatsPaperHits.BrowserID AND tblFEStatsPaperHits.BrowserID IS NOT NULL, das in den Scanoperator gedrückt wird.

Der Plan bedeutet jedoch nicht, dass es 339 vollständige Scans geben wird. Da es sich um einen Anti-Semi-Join-Operator handelt, kann der Rest kurzgeschlossen werden, sobald die erste übereinstimmende Zeile bei jedem Scan gefunden wird. Die geschätzten Teilbaumkosten für diesen Knoten betragen 1.32603und der gesamte Plan wird mit berechnet 1.41337.

Für den Hash Join gibt es den unten stehenden Plan

Hash Join

Der Gesamtplan kostet 418.415etwa 300-mal mehr als der Plan für verschachtelte Schleifen. Der einzelne vollständige Clustered-Index-Scan tblFEStatsPaperHitskostet nur 300-mal mehr 206.8. Vergleichen Sie dies mit der 1.32603Schätzung für 339 Teilscans, die zuvor angegeben wurde (Durchschnittliche geschätzte Kosten für Teilscans = 0.003911592).

Dies würde also bedeuten, dass jeder Teil-Scan 53.000-mal billiger ist als ein vollständiger Scan. Wenn die Kosten linear mit der Zeilenzahl skaliert würden, würde dies bedeuten, dass angenommen wird, dass durchschnittlich nur 900 Zeilen bei jeder Iteration verarbeitet werden müssten, bevor eine übereinstimmende Zeile gefunden wird und ein Kurzschluss auftreten kann.

Ich denke jedoch nicht, dass die Kosten auf diese lineare Weise skalieren. Ich denke, sie beinhalten auch einen Teil der festen Startkosten. TOPIn der folgenden Abfrage werden verschiedene Werte von ausprobiert

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

147gibt die nächsten geschätzten Teilbaumkosten 0.003911592an 0.0039113. Wie auch immer, es ist klar, dass die Kosten auf der Annahme basieren, dass jeder Scan nur einen winzigen Teil der Tabelle verarbeiten muss, in der Größenordnung von Hunderten von Zeilen anstatt von Millionen.

Ich bin mir nicht sicher, auf welcher Mathematik diese Annahme basiert, und sie stimmt nicht wirklich mit den Zeilenanzahlschätzungen im Rest des Plans überein Fälle, in denen überhaupt keine übereinstimmende Zeile gefunden wurde und ein vollständiger Scan erforderlich war). Ich gehe davon aus, dass dies nur ein Fall ist, in dem die getroffenen Modellierungsannahmen etwas nachlassen und den Plan für verschachtelte Schleifen erheblich unter den Kosten belassen.

Martin Smith
quelle
20

In meinem Buch ist sogar ein Scan von 50 Millionen Zeilen inakzeptabel ... Mein üblicher Trick besteht darin, die unterschiedlichen Werte zu materialisieren und die Engine zu delegieren, um sie auf dem neuesten Stand zu halten:

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

Auf diese Weise erhalten Sie einen materialisierten Index mit einer Zeile pro Browser-ID, sodass 50 Millionen Zeilen nicht mehr durchsucht werden müssen. Die Engine verwaltet es für Sie und der QO verwendet es in der von Ihnen veröffentlichten Anweisung "wie es ist" (ohne Hinweis oder Umschreiben der Abfrage).

Der Nachteil ist natürlich Streit. Jeder Einfüge- oder Löschvorgang in tblFEStatsPaperHits(und ich denke, es handelt sich um eine Protokollierungstabelle mit umfangreichen Einfügungen) muss den Zugriff auf eine bestimmte Browser-ID serialisieren. Es gibt Möglichkeiten, die dies möglich machen (verzögerte Updates, 2-stufige Protokollierung usw.), wenn Sie bereit sind, sich darauf einzulassen.

Remus Rusanu
quelle
Ich höre Sie, jeder so große Scan ist definitiv inakzeptabel. In diesem Fall handelt es sich um einige einmalige Datenbereinigungsvorgänge, daher entscheide ich mich, keine zusätzlichen Indizes zu erstellen (und kann dies nicht vorübergehend tun, da dies das System unterbrechen würde). Ich habe keine EE, aber da dies einmalig ist, wären Hinweise in Ordnung. Meine größte Neugier war, wie der QO auf den Plan gekommen ist :) Die Tabelle ist eine Protokollierungstabelle und es gibt schwere Beilagen. Es gibt jedoch eine separate asynchrone Protokollierungstabelle, mit der die Zeilen in tblFEStatsPaperHits später aktualisiert werden, damit ich sie bei Bedarf selbst verwalten kann.
Mark S. Rasmussen