Uwzględnienie ORDER BY w zapytaniu, które nie zwraca wierszy, drastycznie wpływa na wydajność

15

Biorąc pod uwagę proste łączenie trzech tabel, wydajność zapytania zmienia się drastycznie, gdy ORDER BY jest uwzględniony, nawet bez zwracania wierszy. Rzeczywisty scenariusz problemu zajmuje 30 sekund, aby zwrócić zero wierszy, ale jest natychmiastowy, gdy nie uwzględniono ORDER BY. Dlaczego?

SELECT * 
FROM tinytable t                          /* one narrow row */
JOIN smalltable s on t.id=s.tinyId        /* one narrow row */
JOIN bigtable b on b.smallGuidId=s.GuidId /* a million narrow rows */
WHERE t.foreignId=3                       /* doesn't match */
ORDER BY b.CreatedUtc          /* try with and without this ORDER BY */

Rozumiem, że mógłbym mieć indeks na bigtable.smallGuidId, ale wierzę, że tak naprawdę pogorszyłoby to sytuację w tym przypadku.

Oto skrypt do tworzenia / wypełniania tabel do testowania. Co ciekawe, wydaje się mieć znaczenie, że smalltable ma pole nvarchar (max). Wydaje się również mieć znaczenie, że dołączam do bigtable z przewodnikiem (co, jak sądzę, sprawia, że ​​chce używać dopasowywania skrótów).

CREATE TABLE tinytable
  (
     id        INT PRIMARY KEY IDENTITY(1, 1),
     foreignId INT NOT NULL
  )

CREATE TABLE smalltable
  (
     id     INT PRIMARY KEY IDENTITY(1, 1),
     GuidId UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID(),
     tinyId INT NOT NULL,
     Magic  NVARCHAR(max) NOT NULL DEFAULT ''
  )

CREATE TABLE bigtable
  (
     id          INT PRIMARY KEY IDENTITY(1, 1),
     CreatedUtc  DATETIME NOT NULL DEFAULT GETUTCDATE(),
     smallGuidId UNIQUEIDENTIFIER NOT NULL
  )

INSERT tinytable
       (foreignId)
VALUES(7)

INSERT smalltable
       (tinyId)
VALUES(1)

-- make a million rows 
DECLARE @i INT;

SET @i=20;

INSERT bigtable
       (smallGuidId)
SELECT GuidId
FROM   smalltable;

WHILE @i > 0
  BEGIN
      INSERT bigtable
             (smallGuidId)
      SELECT smallGuidId
      FROM   bigtable;

      SET @i=@i - 1;
  END 

Testowałem na SQL 2005, 2008 i 2008R2 z tymi samymi wynikami.

Hafthor
źródło

Odpowiedzi:

32

Zgadzam się z odpowiedzią Martina Smitha, ale problemem nie są po prostu statystyki. Statystyki dla kolumny ForeignId (zakładając, że automatyczne statystyki są włączone) dokładnie pokazują, że nie istnieją wiersze dla wartości 3 (jest tylko jeden, o wartości 7):

DBCC SHOW_STATISTICS (tinytable, foreignId) WITH HISTOGRAM

dane statystyczne

SQL Server wie, że rzeczy mogły się zmienić od czasu przechwycenia statystyk, więc może się zdarzyć być wiersz o wartości 3, gdy plan jest wykonywany . Ponadto między kompilacją planu a jego wykonaniem może upłynąć dowolny czas (w końcu plany są buforowane w celu ponownego użycia). Jak mówi Martin, SQL Server zawiera logikę pozwalającą wykryć, kiedy wprowadzono wystarczające modyfikacje uzasadniające rekompilację dowolnego buforowanego planu ze względów optymalizacyjnych.

Jednak to ostatecznie nie ma znaczenia. Z jednym wyjątkiem od krawędzi, optymalizator nigdy nie oszacuje liczby wierszy wygenerowanych przez operację na tabeli na zero. Jeśli może statycznie ustalić, że wyjście musi zawsze mieć zero wierszy, operacja jest zbędna i zostanie całkowicie usunięta.

Zamiast tego model optymalizatora szacuje minimum jeden wiersz. Zastosowanie tej heurystyki zwykle daje lepsze plany średnio niż byłoby to możliwe, gdyby możliwe było niższe oszacowanie. Plan, który generuje oszacowanie rzędu zerowego na pewnym etapie, byłby bezużyteczny od tego momentu w strumieniu przetwarzania, ponieważ nie byłoby podstaw do podejmowania decyzji opartych na kosztach (rzędy zerowe bez względu na wszystko). Jeśli oszacowanie okaże się błędne, kształt planu powyżej oszacowania rzędu zerowego prawie nie ma szans na racjonalność.

Drugim czynnikiem jest inne założenie modelowania zwane Założeniem Ograniczenia. Zasadniczo mówi to, że jeśli zapytanie łączy zakres wartości z innym zakresem wartości, dzieje się tak, ponieważ zakresy nakładają się. Innym sposobem na wyrażenie tego jest stwierdzenie, że łączenie jest określone, ponieważ oczekuje się, że wiersze zostaną zwrócone. Bez tego uzasadnienia koszty byłyby ogólnie niedoceniane, co skutkowałoby złymi planami dotyczącymi szerokiego zakresu typowych zapytań.

Zasadniczo masz tutaj zapytanie, które nie pasuje do modelu optymalizatora. Nic nie możemy zrobić, aby „poprawić” szacunki za pomocą indeksów wielokolumnowych lub filtrowanych; nie ma sposobu, aby uzyskać oszacowanie poniżej 1 rzędu. Prawdziwa baza danych może mieć klucze obce, aby zapobiec wystąpieniu takiej sytuacji, ale zakładając, że nie ma to zastosowania w tym przypadku, pozostaje nam użycie wskazówek, aby poprawić stan poza modelem. Z tym zapytaniem będzie działać dowolna liczba różnych podejść do podpowiedzi. OPTION (FORCE ORDER)to taki, który działa dobrze z zapytaniem, jak napisano.

Paul White 9
źródło
21

Podstawowym problemem tutaj jest statystyka.

W przypadku obu zapytań szacunkowa liczba wierszy pokazuje, że uważa, że ​​końcowy SELECTzwróci 1 048 580 wierszy (tyle samo szacuje się, że istnieje bigtable), a nie 0, które faktycznie nastąpią.

Oba JOINwarunki są zgodne i zachowałyby wszystkie wiersze. W końcu zostają wyeliminowani, ponieważ pojedynczy wiersz tinytablenie pasuje do t.foreignId=3predykatu.

Jeśli uciekniesz

SELECT * 
FROM tinytable t  
WHERE t.foreignId=3  AND id=1 

i spójrz na szacunkową liczbę wierszy, 1a nie 0ten błąd rozpowszechnia się w całym planie. tinytableobecnie zawiera 1 wiersz. Statystyki nie zostaną ponownie skompilowane dla tej tabeli, dopóki nie wystąpi 500 modyfikacji wierszy , aby można było dodać pasujący wiersz i nie spowodowałoby to ponownej kompilacji.

Powodem, dla którego kolejność łączenia zmienia się, gdy dodajesz ORDER BYklauzulę i jest varchar(max)kolumna, smalltablejest to, że szacuje, że varchar(max)kolumny zwiększą rozmiar wierszy średnio o 4000 bajtów. Pomnóż to przez 1048580 wierszy, a to oznacza, że ​​operacja sortowania wymagałaby około 4 GB, więc rozsądnie decyduje się ją wykonać SORTprzed JOIN.

Możesz zmusić ORDER BYzapytanie do przyjęcia ORDER BYstrategii nieprzyłączenia za pomocą podanych poniżej wskazówek.

SELECT *
FROM   tinytable t /* one narrow row */
       INNER MERGE JOIN smalltable s /* one narrow row */
                        INNER LOOP JOIN bigtable b
                          ON b.smallGuidId = s.GuidId /* a million narrow rows */
         ON t.id = s.tinyId
WHERE  t.foreignId = 3 /* doesn't match */
ORDER  BY b.CreatedUtc
OPTION (MAXDOP 1) 

Plan pokazuje operator sortowania z szacowanym kosztem sub-drzewa wynoszącym prawie 12,000i błędne szacunkowe liczby wierszy i szacowany rozmiar danych.

Plan

BTW: UNIQUEIDENTIFIERW moim teście nie znalazłem zastępowania kolumn liczbami całkowitymi.

Martin Smith
źródło
2

Włącz przycisk Pokaż plan wykonania, aby zobaczyć, co się dzieje. Oto plan dla „wolnego” zapytania: wprowadź opis zdjęcia tutaj

A oto „szybkie” zapytanie: wprowadź opis zdjęcia tutaj

Spójrz na to - uruchomione razem, pierwsze zapytanie jest ~ 33 razy droższe (stosunek 97: 3). SQL optymalizuje pierwsze zapytanie, aby uporządkować BigTable według daty, a następnie uruchamia małą pętlę „szukaj” nad SmallTable i TinyTable, wykonując je 1 milion razy (możesz najechać kursorem na ikonę „Clustered Index Seek”, aby uzyskać więcej statystyk). Tak więc sortowanie (27%) i 2 x 1 milion „szuka” na małych stolikach (23% i 46%) stanowią ogromną część drogiego zapytania. Dla porównania, niekwerenda ORDER BYwykonuje w sumie 3 skany.

Zasadniczo znalazłeś dziurę w logice optymalizatora SQL dla konkretnego scenariusza. Ale jak stwierdził TysHTTP, jeśli dodasz indeks (który spowalnia wstawianie / aktualizuje niektóre), skanowanie staje się szalone szybko.

jklemmack
źródło
2

To, co się dzieje, to SQL decyduje się uruchomić zamówienie przed ograniczeniem.

Spróbuj tego:

SELECT *
(
SELECT * 
FROM tinytable t
    INNER JOIN smalltable s on t.id=s.tinyId
    INNER JOIN bigtable b on b.smallGuidId=s.GuidId
WHERE t.foreignId=3
) X
ORDER BY b.CreatedUtc

Zapewnia to lepszą wydajność (w tym przypadku, gdy liczba zwracanych wyników jest bardzo mała), bez faktycznego spadku wydajności po dodaniu kolejnego indeksu. Chociaż dziwne jest, gdy optymalizator SQL decyduje się na wykonanie zamówienia przed złączeniem, prawdopodobnie jest tak, ponieważ gdybyś rzeczywiście miał dane zwrotne, sortowanie ich po złączeniach trwałoby dłużej niż sortowanie bez.

Na koniec spróbuj uruchomić następujący skrypt, a następnie sprawdź, czy zaktualizowane statystyki i indeksy rozwiązują występujący problem:

EXEC [sp_MSforeachtable] @command1="RAISERROR('UPDATE STATISTICS(''?'') ...',10,1) WITH NOWAIT UPDATE STATISTICS ? "

EXEC [sp_MSforeachtable] @command1="RAISERROR('DBCC DBREINDEX(''?'') ...',10,1) WITH NOWAIT DBCC DBREINDEX('?')"

EXEC [sp_MSforeachtable] @command1="RAISERROR('UPDATE STATISTICS(''?'') ...',10,1) WITH NOWAIT UPDATE STATISTICS ? "
Seph
źródło