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

Czy powinienem używać NOT IN, OUTER APPLY, LEFT OUTER JOIN, EXCEPT lub NOT EXISTS?

Powiedzmy, że chcesz znaleźć wszystkich pacjentów, którzy nigdy nie otrzymali szczepionki przeciw grypie. Lub w AdventureWorks2012 , podobne pytanie może brzmieć:„pokaż wszystkich klientów, którzy nigdy nie złożyli zamówienia”. Wyrażone za pomocą NOT IN , zbyt często widuję wzorzec, który wyglądałby mniej więcej tak (używam powiększonych tabel nagłówków i szczegółów z tego skryptu autorstwa Jonathana Kehayiasa (@SQLPoolBoy)):

SELECT CustomerID 
FROM Sales.Customer 
WHERE CustomerID NOT IN 
(
  SELECT CustomerID 
  FROM Sales.SalesOrderHeaderEnlarged
);

Kiedy widzę ten wzór, wzdrygam się. Ale nie ze względu na wydajność – w końcu tworzy wystarczająco przyzwoity plan w tym przypadku:

Główny problem polega na tym, że wyniki mogą być zaskakujące, jeśli kolumna docelowa jest zgodna z wartością NULL (SQL Server przetwarza to jako lewe sprzężenie anty semi, ale nie może wiarygodnie stwierdzić, czy wartość NULL po prawej stronie jest równa – czy nie równa – odnośnik po lewej stronie). Ponadto optymalizacja może zachowywać się inaczej, jeśli kolumna jest dopuszczalna NULL, nawet jeśli w rzeczywistości nie zawiera żadnych wartości NULL (Gail Shaw mówił o tym w 2010 r.).

W tym przypadku kolumna docelowa nie dopuszcza wartości null, ale chciałem wspomnieć o potencjalnych problemach z NOT IN – Mogę dokładniej zbadać te kwestie w przyszłym poście.

TL;DR wersja

Zamiast NOT IN , użyj skorelowanego NOT EXISTS dla tego wzorca zapytania. Zawsze. Inne metody mogą konkurować z nim pod względem wydajności, gdy wszystkie inne zmienne są takie same, ale wszystkie inne metody wprowadzają albo problemy z wydajnością, albo inne wyzwania.

Alternatywne

Więc jakie inne sposoby możemy napisać to zapytanie?

    ZASTOSOWANIE ZEWNĘTRZNE

    Jednym ze sposobów wyrażenia tego wyniku jest użycie skorelowanego OUTER APPLY .

    SELECT c.CustomerID 
    FROM Sales.Customer AS c
    OUTER APPLY 
    (
     SELECT CustomerID 
       FROM Sales.SalesOrderHeaderEnlarged
       WHERE CustomerID = c.CustomerID
    ) AS h
    WHERE h.CustomerID IS NULL;

    Logicznie rzecz biorąc, jest to również lewe anti semi join, ale w powstałym planie brakuje lewego operatora anti semi join i wydaje się, że jest on nieco droższy niż NOT IN równowartość. Dzieje się tak, ponieważ nie jest to już lewe sprzężenie anty-semi; w rzeczywistości jest przetwarzany w inny sposób:zewnętrzne sprzężenie wprowadza wszystkie pasujące i niepasujące wiersze, a *następnie* stosowany jest filtr w celu wyeliminowania dopasowań:

    LEWO ZEWNĘTRZNE POŁĄCZENIE

    Bardziej typową alternatywą jest LEFT OUTER JOIN gdzie prawa strona to NULL . W takim przypadku zapytanie będzie wyglądało następująco:

    SELECT c.CustomerID 
    FROM Sales.Customer AS c
    LEFT OUTER JOIN Sales.SalesOrderHeaderEnlarged AS h
    ON c.CustomerID = h.CustomerID
    WHERE h.CustomerID IS NULL;

    To zwraca te same wyniki; jednak, podobnie jak OUTER APPLY, wykorzystuje tę samą technikę łączenia wszystkich rzędów, a dopiero potem eliminowania dopasowań:

    Musisz jednak uważać na to, którą kolumnę sprawdzasz pod kątem NULL . W tym przypadku CustomerID jest logicznym wyborem, ponieważ jest to kolumna łącząca; zdarza się również, że jest indeksowany. Mogłem wybrać SalesOrderID , który jest kluczem klastrowania, więc znajduje się również w indeksie CustomerID . Ale mogłem wybrać inną kolumnę, której nie ma (lub która później zostanie usunięta) z indeksu używanego do łączenia, co prowadzi do innego planu. Lub nawet kolumna dopuszczalna NULL, prowadząca do niepoprawnych (lub przynajmniej nieoczekiwanych) wyników, ponieważ nie ma sposobu na rozróżnienie między wierszem, który nie istnieje, a wierszem, który istnieje, ale gdzie ta kolumna ma wartość NULL . I może nie być oczywiste dla czytelnika/programisty/narzędzia do rozwiązywania problemów, że tak właśnie jest. Dlatego też przetestuję te trzy WHERE klauzule:

    WHERE h.SalesOrderID IS NULL; -- clustered, so part of index
     
    WHERE h.SubTotal IS NULL; -- not nullable, not part of the index
     
    WHERE h.Comment IS NULL; -- nullable, not part of the index

    Pierwsza odmiana daje taki sam plan jak powyżej. Pozostałe dwa wybierają łączenie haszujące zamiast łączenia scalającego i węższy indeks w Customer tabeli, mimo że zapytanie ostatecznie kończy odczytanie dokładnie tej samej liczby stron i ilości danych. Jednak podczas gdy h.SubTotal zmiana daje prawidłowe wyniki:

    h.Comment odmiana nie, ponieważ zawiera wszystkie wiersze, w których h.Comment IS NULL , a także wszystkie wiersze, które nie istniały dla żadnego klienta. Podkreśliłem subtelną różnicę w liczbie wierszy w danych wyjściowych po zastosowaniu filtra:

    Oprócz konieczności zachowania ostrożności przy wyborze kolumn w filtrze, inny problem, który mam z LEFT OUTER JOIN formularz jest taki, że nie jest samodokumentujący, w taki sam sposób jak sprzężenie wewnętrzne w "starym stylu" postaci FROM dbo.table_a, dbo.table_b WHERE ... nie jest samodokumentacją. Rozumiem przez to, że łatwo zapomnieć o kryteriach łączenia, gdy są one umieszczane w WHERE lub pomieszać go z innymi kryteriami filtrowania. Zdaję sobie sprawę, że to dość subiektywne, ale tak jest.

    Z WYJĄTKIEM

    Jeśli interesuje nas tylko kolumna join (która z definicji znajduje się w obu tabelach), możemy użyć EXCEPT – alternatywa, która wydaje się nie pojawiać zbyt często w tych rozmowach (prawdopodobnie dlatego, że – zwykle – trzeba rozszerzyć zapytanie, aby uwzględnić kolumny, których nie porównujesz):

    SELECT CustomerID 
    FROM Sales.Customer AS c 
    EXCEPT
    SELECT CustomerID
    FROM Sales.SalesOrderHeaderEnlarged;

    To daje dokładnie ten sam plan, co NOT IN odmiana powyżej:

    Należy pamiętać, że EXCEPT zawiera niejawny DISTINCT – więc jeśli masz przypadki, w których chcesz, aby wiele wierszy miało tę samą wartość w tabeli „po lewej”, ten formularz wyeliminuje te duplikaty. Nie jest to problem w tym konkretnym przypadku, tylko coś, o czym należy pamiętać – tak jak UNION kontra UNION ALL .

    NIE ISTNIEJE

    Moje preferencje dla tego wzorca to zdecydowanie NOT EXISTS :

    SELECT CustomerID 
    FROM Sales.Customer AS c 
    WHERE NOT EXISTS 
    (
      SELECT 1 
        FROM Sales.SalesOrderHeaderEnlarged 
        WHERE CustomerID = c.CustomerID
    );

    (I tak, używam SELECT 1 zamiast SELECT * … nie ze względu na wydajność, ponieważ SQL Server nie dba o to, jakich kolumn używasz w EXISTS i optymalizuje je, ale po prostu w celu wyjaśnienia intencji:przypomina mi to, że to "podzapytanie" w rzeczywistości nie zwraca żadnych danych.)

    Jego wydajność jest podobna do NOT IN i EXCEPT , i tworzy identyczny plan, ale nie jest podatny na potencjalne problemy spowodowane przez wartości NULL lub duplikaty:

    Testy wydajności

    Przeprowadziłem wiele testów, zarówno z zimną, jak i ciepłą pamięcią podręczną, aby potwierdzić, że moje wieloletnie wyobrażenie o NOT EXISTS bycie właściwym wyborem pozostało prawdą. Typowe wyjście wyglądało tak:

    Wyjmę błędny wynik z miksu, pokazując średnią wydajność 20 przebiegów na wykresie (uwzględniłem go tylko po to, aby pokazać, jak błędne są wyniki) i wykonałem zapytania w różnej kolejności w testach, aby się upewnić że jedno zapytanie nie korzystało konsekwentnie z pracy poprzedniego zapytania. Koncentrując się na czasie trwania, oto wyniki:

    Jeśli spojrzymy na czas trwania i zignorujemy odczyty, zwycięzcą jest NIE ISTNIEJE, ale niewiele. OPRÓCZ i NIE NIE są daleko w tyle, ale znowu musisz spojrzeć nie tylko na wydajność, aby określić, czy te opcje są prawidłowe, i przetestować w swoim scenariuszu.

    Co, jeśli nie ma indeksu pomocniczego?

    Powyższe zapytania korzystają oczywiście z indeksu Sales.SalesOrderHeaderEnlarged.CustomerID . Jak zmienią się te wyniki, jeśli zrezygnujemy z tego indeksu? Po upuszczeniu indeksu ponownie przeprowadziłem ten sam zestaw testów:

    DROP INDEX [IX_SalesOrderHeaderEnlarged_CustomerID] 
    ON [Sales].[SalesOrderHeaderEnlarged];

    Tym razem różnice w wydajności pomiędzy różnymi metodami były znacznie mniejsze. Najpierw pokażę plany dla każdej metody (z których większość, nic dziwnego, wskazuje na przydatność brakującego indeksu, który właśnie porzuciliśmy). Następnie pokażę nowy wykres przedstawiający profil wydajności zarówno z zimną i ciepłą pamięcią podręczną.

    NIE W, Z WYJĄTKIEM, NIE ISTNIEJE (wszystkie trzy były identyczne)

    ZASTOSOWANIE ZEWNĘTRZNE

    LEWE ZŁĄCZENIE ZEWNĘTRZNE (wszystkie trzy były identyczne z wyjątkiem liczby rzędów)

    Wyniki wydajności

    Gdy spojrzymy na te nowe wyniki, od razu widzimy, jak przydatny jest ten indeks. We wszystkich przypadkach oprócz lewego sprzężenia zewnętrznego, które i tak wychodzi poza indeks, wyniki są wyraźnie gorsze, gdy porzuciliśmy indeks:

    Widzimy więc, że chociaż wpływ jest mniej zauważalny, NOT EXISTS nadal jest Twoim marginalnym zwycięzcą pod względem czasu trwania. A w sytuacjach, w których inne podejścia są podatne na zmienność schematu, jest to również najbezpieczniejszy wybór.

    Wniosek

    To był tylko bardzo długi sposób powiedzenia ci, że dla wzorca znajdowania wszystkich wierszy w tabeli A, gdzie jakiś warunek nie istnieje w tabeli B, NOT EXISTS zazwyczaj będzie najlepszym wyborem. Ale, jak zawsze, musisz przetestować te wzorce we własnym środowisku, korzystając ze swojego schematu, danych i sprzętu oraz zmieszać z własnymi obciążeniami.


  1. Database
  2.   
  3. Mysql
  4.   
  5. Oracle
  6.   
  7. Sqlserver
  8.   
  9. PostgreSQL
  10.   
  11. Access
  12.   
  13. SQLite
  14.   
  15. MariaDB
  1. SQL WYBIERZ ŚREDN

  2. Używanie folderu roboczego do bazy danych kontroli źródła

  3. Jak utworzyć tabelę tymczasową w SQL?

  4. PRZECIĘCIE SQL

  5. Wymiary wymiarów:spojrzenie na najpopularniejsze typy tabel wymiarowych w hurtowni danych