Database
 sql >> Baza danych >  >> RDS >> Database

Ograniczenia Optymalizatora z filtrowanymi indeksami

Jeden z przypadków użycia filtrowanego indeksu wspomniany w Books Online dotyczy kolumny zawierającej głównie NULLs wartości. Pomysł polega na utworzeniu filtrowanego indeksu, który wyklucza NULLs , co skutkuje mniejszym indeksem nieklastrowanym, który wymaga mniej konserwacji niż równoważny indeks niefiltrowany. Innym popularnym zastosowaniem filtrowanych indeksów jest filtrowanie NULLs z UNIQUE indeks, dający zachowanie, którego użytkownicy innych silników baz danych mogą oczekiwać od domyślnego UNIQUE indeks lub ograniczenie:unikalność jest wymuszana tylko dla wartości innych niż NULLs wartości.

Niestety optymalizator zapytań ma ograniczenia, jeśli chodzi o indeksy filtrowane. W tym poście przyjrzymy się kilku mniej znanym przykładom.

Przykładowe tabele

Będziemy używać dwóch tabel (A i B), które mają tę samą strukturę:zastępczy klastrowany klucz podstawowy, najczęściej NULLs kolumna, która jest unikalna (bez uwzględnienia NULLs ) oraz kolumnę wypełniającą, która reprezentuje inne kolumny, które mogą znajdować się w rzeczywistej tabeli.

Kolumna zainteresowania to najczęściej NULLs jeden, który zadeklarowałem jako SPARSE . Opcja rzadka nie jest wymagana, po prostu ją uwzględniam, ponieważ nie mam zbyt wielu szans, aby z niej skorzystać. W każdym razie SPARSE prawdopodobnie ma sens w wielu scenariuszach, w których oczekuje się, że dane kolumny będą w większości NULLs . Jeśli chcesz, możesz usunąć rzadki atrybut z przykładów.

CREATE TABLE dbo.TableA
(
    pk      integer IDENTITY PRIMARY KEY,
    data    bigint SPARSE NULL,
    padding binary(250) NOT NULL DEFAULT 0x
);
 
CREATE TABLE dbo.TableB
(
    pk      integer IDENTITY PRIMARY KEY,
    data    bigint SPARSE NULL,
    padding binary(250) NOT NULL DEFAULT 0x
);

Każda tabela zawiera liczby od 1 do 2000 w kolumnie danych z dodatkowymi 40 000 wierszami, w których kolumna danych ma wartość NULLs :

-- Numbers 1 - 2,000
INSERT
    dbo.TableA WITH (TABLOCKX)
    (data)
SELECT TOP (2000)
    ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
FROM sys.columns AS c
CROSS JOIN sys.columns AS c2
ORDER BY
    ROW_NUMBER() OVER (ORDER BY (SELECT NULL));
 
-- NULLs
INSERT TOP (40000)
    dbo.TableA WITH (TABLOCKX)
    (data)
SELECT
    CONVERT(bigint, NULL)
FROM sys.columns AS c
CROSS JOIN sys.columns AS c2;
 
-- Copy into TableB
INSERT dbo.TableB WITH (TABLOCKX)
    (data)
SELECT
    ta.data
FROM dbo.TableA AS ta;

Obie tabele otrzymują UNIQUE filtrowany indeks dla 2000 innych niż NULLs wartości danych:

CREATE UNIQUE NONCLUSTERED INDEX uqA
ON dbo.TableA (data) 
WHERE data IS NOT NULL;
 
CREATE UNIQUE NONCLUSTERED INDEX uqB
ON dbo.TableB (data) 
WHERE data IS NOT NULL;

Wyjście DBCC SHOW_STATISTICS podsumowuje sytuację:

DBCC SHOW_STATISTICS (TableA, uqA) WITH STAT_HEADER;
DBCC SHOW_STATISTICS (TableB, uqB) WITH STAT_HEADER;

Przykładowe zapytanie

Poniższe zapytanie wykonuje proste łączenie dwóch tabel — wyobraź sobie, że tabele są w jakiejś relacji rodzic-dziecko, a wiele kluczy obcych ma wartość NULL. W każdym razie coś w tym stylu.

SELECT
    ta.data,
    tb.data
FROM dbo.TableA AS ta
JOIN dbo.TableB AS tb
    ON ta.data = tb.data;

Domyślny plan wykonania

Z SQL Server w domyślnej konfiguracji, optymalizator wybiera plan wykonania zawierający równoległe łączenie zagnieżdżonych pętli:

Ten plan ma szacunkowy koszt 7.7768 magic Optimizer Units™.

Jest jednak kilka dziwnych rzeczy związanych z tym planem. Wyszukiwanie indeksu używa naszego filtrowanego indeksu w tabeli B, ale zapytanie jest sterowane przez klastrowane skanowanie indeksu tabeli A. Predykat złączenia to test równości w kolumnach danych, który odrzuci NULLs (niezależnie od ANSI_NULLS ustawienie). Mogliśmy mieć nadzieję, że optymalizator przeprowadzi jakieś zaawansowane wnioskowanie na podstawie tej obserwacji, ale nie. Ten plan odczytuje każdy wiersz z tabeli A (w tym 40 000 NULLs ), przeprowadza wyszukiwanie w filtrowanym indeksie w tabeli B dla każdego z nich, opierając się na fakcie, że NULLs nie będzie pasować do NULLs w tym poszukiwaniu. To ogromna strata wysiłku.

Dziwne jest to, że optymalizator musiał zdać sobie sprawę, że łączenie odrzuca NULLs w celu wybrania filtrowanego indeksu dla tabeli B search, ale nie pomyślał o filtrowaniu NULLs z tabeli A najpierw – lub jeszcze lepiej, aby po prostu zeskanować NULLs -bezpłatny indeks filtrowany w tabeli A. Można się zastanawiać, czy jest to decyzja oparta na kosztach, może statystyki nie są zbyt dobre? Może powinniśmy wymusić stosowanie filtrowanego indeksu podpowiedzią? Wskazywanie na filtrowany indeks w tabeli A skutkuje tym samym planem z odwróconymi rolami – skanowanie tabeli B i wyszukiwanie w tabeli A. Wymuszenie filtrowanego indeksu dla obu tabel powoduje błąd 8622 :procesor zapytań nie mógł stworzyć planu zapytań.

Dodawanie predykatu NOT NULL

Podejrzewam, że przyczyna ma związek z dorozumianym NULLs - odrzucenie predykatu join, dodajemy jawny NOT NULL predykat do ON klauzula (lub WHERE klauzula jeśli wolisz, tutaj chodzi o to samo):

SELECT
    ta.data,
    tb.data
FROM dbo.TableA AS ta
JOIN dbo.TableB AS tb
    ON ta.data = tb.data
    AND ta.data IS NOT NULL;

Dodaliśmy NOT NULL sprawdź kolumnę tabeli A, ponieważ pierwotny plan przeskanował indeks klastrowy tej tabeli, zamiast używać naszego filtrowanego indeksu (przeszukiwanie do tabeli B było w porządku – używał filtrowanego indeksu). Nowe zapytanie jest semantycznie dokładnie takie samo jak poprzednie, ale plan wykonania jest inny:

Teraz mamy oczekiwane skanowanie przefiltrowanego indeksu w tabeli A, dające 2000 znaków innych niż NULLs wierszy do kierowania zagnieżdżoną pętlą do tabeli B. Obie tabele używają naszych filtrowanych indeksów najwyraźniej teraz optymalnie:nowy plan kosztuje tylko 0,362835 jednostek (spadek z 7.7768). Możemy jednak zrobić lepiej.

Dodawanie dwóch predykatów NOT NULL

Zbędny NOT NULL predykat dla tabeli A zdziałały cuda; co się stanie, jeśli dodamy jeden również do tabeli B?

SELECT
    ta.data,
    tb.data
FROM dbo.TableA AS ta
JOIN dbo.TableB AS tb
    ON ta.data = tb.data
    AND ta.data IS NOT NULL 
    AND tb.data IS NOT NULL;

To zapytanie jest nadal logicznie takie samo jak w dwóch poprzednich próbach, ale plan wykonania jest znowu inny:

Ten plan tworzy tablicę mieszającą dla 2000 wierszy z tabeli A, a następnie sprawdza dopasowania przy użyciu 2000 wierszy z tabeli B. Szacunkowa liczba zwracanych wierszy jest znacznie lepsza niż poprzedni plan (czy zauważyłeś tam oszacowanie 7619?), a szacowany koszt wykonania ponownie spadł z 0,362835 do 0,0772056 .

Możesz spróbować wymusić sprzężenie haszujące, używając podpowiedzi na oryginale lub pojedynczego NOT NULL zapytań, ale nie dostaniesz taniego planu pokazanego powyżej. Optymalizator po prostu nie ma możliwości pełnego uzasadnienia NULLs -odrzucanie zachowania sprzężenia, ponieważ dotyczy ono naszych filtrowanych indeksów bez obu zbędnych predykatów.

Możesz być tym zaskoczony – nawet jeśli chodzi o pomysł, że jeden zbędny predykat nie wystarczył (na pewno, jeśli ta.data jest NOT NULL i ta.data = tb.data , wynika z tego, że tb.data jest również NOT NULL , prawda?)

Nadal nie idealny

To trochę zaskakujące, że dołączył tam hash. Jeśli znasz główne różnice między trzema fizycznymi operatorami złączenia, prawdopodobnie wiesz, że hash join jest najlepszym kandydatem, gdzie:

  1. Wstępnie posortowane dane wejściowe są niedostępne
  2. Wejście kompilacji skrótu jest mniejsze niż wejście sondy
  3. Wejście sondy jest dość duże

Żadna z tych rzeczy nie jest tutaj prawdziwa. Oczekujemy, że najlepszym planem dla tego zapytania i zestawu danych będzie sprzężenie scalające, wykorzystujące uporządkowane dane wejściowe dostępne z naszych dwóch filtrowanych indeksów. Możemy spróbować podpowiedzieć połączenie scalające, zachowując dwa dodatkowe ON predykaty klauzul:

SELECT 
    ta.data,
    tb.data
FROM dbo.TableA AS ta
JOIN dbo.TableB AS tb
    ON ta.data = tb.data
    AND ta.data IS NOT NULL 
    AND tb.data IS NOT NULL
OPTION (MERGE JOIN);

Kształt planu jest taki, jak się spodziewaliśmy:

Uporządkowane skanowanie obu filtrowanych indeksów, świetne szacunki kardynalności, fantastyczne. Tylko jeden mały problem:ten plan wykonania jest znacznie gorszy; szacowany koszt wzrósł z 0,0772056 do 0,741527 . Przyczyna skoku w szacowanym koszcie jest ujawniana przez sprawdzenie właściwości operatora łączenia scalającego:

Jest to kosztowne łączenie wiele-do-wielu, w którym silnik wykonawczy musi śledzić duplikaty z zewnętrznych danych wejściowych w tabeli roboczej i przewijać w razie potrzeby. Duplikaty? Skanujemy unikalny indeks! Okazuje się, że optymalizator nie wie, że filtrowany unikalny indeks daje unikalne wartości (podłącz element tutaj). W rzeczywistości jest to złączenie jeden-do-jednego, ale optymalizator kosztuje je tak, jakby było to wiele do wielu, wyjaśniając, dlaczego preferuje plan łączenia mieszającego.

Alternatywna strategia

Wygląda na to, że wciąż napotykamy ograniczenia optymalizatora podczas korzystania z filtrowanych indeksów (pomimo tego, że jest to wyróżniony przypadek użycia w Books Online). Co się stanie, jeśli zamiast tego spróbujemy użyć widoków?

Korzystanie z widoków

Następujące dwa widoki po prostu filtrują tabele podstawowe, aby pokazać wiersze, w których kolumna danych ma wartość NOT NULL :

CREATE VIEW dbo.VA
WITH SCHEMABINDING AS
SELECT
    pk,
    data,
    padding
FROM dbo.TableA
WHERE data IS NOT NULL;
GO
CREATE VIEW dbo.VB
WITH SCHEMABINDING AS
SELECT
    pk,
    data,
    padding
FROM dbo.TableB
WHERE data IS NOT NULL;

Przepisanie oryginalnego zapytania do widoków jest trywialne:

SELECT 
    v.data,
    v2.data
FROM dbo.VA AS v
JOIN dbo.VB AS v2
    ON v.data = v2.data;

Pamiętaj, że to zapytanie pierwotnie wygenerowało plan równoległych pętli zagnieżdżonych, którego koszt wynosił 7.7768 jednostki. Z referencjami widoków otrzymujemy ten plan wykonania:

To jest dokładnie ten sam plan łączenia haszowego, który musieliśmy dodać zbędny NOT NULL predykaty do uzyskania z filtrowanymi indeksami (koszt to 0,0772056 jednostek jak poprzednio). Jest to oczekiwane, ponieważ zasadniczo wszystko, co tutaj zrobiliśmy, to wypchnięcie dodatkowego NOT NULL predykaty z zapytania do widoku.

Indeksowanie widoków

Możemy również spróbować zmaterializować widoki, tworząc unikalny indeks klastrowy w kolumnie pk:

CREATE UNIQUE CLUSTERED INDEX cuq ON dbo.VA (pk);
CREATE UNIQUE CLUSTERED INDEX cuq ON dbo.VB (pk);

Teraz możemy dodać unikalne indeksy nieklastrowane do filtrowanej kolumny danych w widoku indeksowanym:

CREATE UNIQUE NONCLUSTERED INDEX ix ON dbo.VA (data);
CREATE UNIQUE NONCLUSTERED INDEX ix ON dbo.VB (data);

Zauważ, że filtrowanie jest przeprowadzane w widoku, te indeksy nieklastrowane same w sobie nie są filtrowane.

Idealny plan

Jesteśmy teraz gotowi do uruchomienia naszego zapytania względem widoku za pomocą NOEXPAND wskazówka do tabeli:

SELECT 
    v.data,
    v2.data
FROM dbo.VA AS v WITH (NOEXPAND)
JOIN dbo.VB AS v2 WITH (NOEXPAND)
    ON v.data = v2.data;

Plan wykonania to:

Optymalizator widzi niefiltrowane Indeksy widoku nieklastrowego są unikatowe, więc łączenie scalające wiele do wielu nie jest potrzebne. Ten ostateczny plan wykonania ma szacunkowy koszt 0,0310929 jednostek – nawet mniej niż plan łączenia haszowego (0.0772056 jednostek). To potwierdza nasze oczekiwania, że ​​połączenie scalające powinno mieć najniższy szacowany koszt dla tego zapytania i przykładowego zestawu danych.

NOEXPAND wskazówki są potrzebne nawet w wersji Enterprise, aby zapewnić, że gwarancja unikalności zapewniana przez indeksy widoków jest używana przez optymalizator.

Podsumowanie

Ten post podkreśla dwa ważne ograniczenia optymalizatora z filtrowanymi indeksami:

  • Zbędne predykaty złączeń mogą być konieczne do dopasowania filtrowanych indeksów
  • Przefiltrowane unikalne indeksy nie dostarczają optymalizatorowi informacji o niepowtarzalności

W niektórych przypadkach może być praktyczne dodanie nadmiarowych predykatów do każdego zapytania. Alternatywą jest enkapsulacja żądanych domniemanych predykatów w widoku nieindeksowanym. Plan dopasowywania skrótów w tym poście był znacznie lepszy niż plan domyślny, mimo że optymalizator powinien być w stanie znaleźć nieco lepszy plan łączenia przez scalanie. Czasami może być konieczne zindeksowanie widoku i użycie NOEXPAND podpowiedzi (wymagane w przypadku instancji w wersji Standard Edition). W jeszcze innych okolicznościach żadne z tych podejść nie będzie odpowiednie. Przepraszam za to :)


  1. Database
  2.   
  3. Mysql
  4.   
  5. Oracle
  6.   
  7. Sqlserver
  8.   
  9. PostgreSQL
  10.   
  11. Access
  12.   
  13. SQLite
  14.   
  15. MariaDB
  1. Jak ponumerować wiersze w SQL

  2. Poradnik dotyczący analizy danych:Nadszedł czas, aby osiągnąć sukces w programie Excel!

  3. Pliki DSN i oprogramowanie IRI

  4. SQL WYBIERZ MIN

  5. Cele wierszy, część 4:Wzorzec przeciwdziałania sprzężeniu