Problem optymalizacji z funkcją zdefiniowaną przez użytkownika

26

Mam problem ze zrozumieniem, dlaczego SQL Server decyduje się na wywołanie funkcji zdefiniowanej przez użytkownika dla każdej wartości w tabeli, mimo że należy pobrać tylko jeden wiersz. Rzeczywisty SQL jest o wiele bardziej złożony, ale udało mi się zredukować problem do tego stopnia:

select  
    S.GROUPCODE,
    H.ORDERCATEGORY
from    
    ORDERLINE L
    join ORDERHDR H on H.ORDERID = L.ORDERID
    join PRODUCT P  on P.PRODUCT = L.PRODUCT    
    cross apply dbo.GetGroupCode (P.FACTORY) S
where   
    L.ORDERNUMBER = 'XXX/YYY-123456' and
    L.RMPHASE = '0' and
    L.ORDERLINE = '01'

W przypadku tego zapytania SQL Server decyduje się wywołać funkcję GetGroupCode dla każdej wartości istniejącej w tabeli PRODUCT, nawet jeśli szacunkowa i rzeczywista liczba wierszy zwróconych z ORDERLINE wynosi 1 (jest to klucz podstawowy):

Plan zapytań

Ten sam plan w Eksploratorze planów, pokazujący liczbę wierszy:

Zaplanuj odkrywcę Stoły:

ORDERLINE: 1.5M rows, primary key: ORDERNUMBER + ORDERLINE + RMPHASE (clustered)
ORDERHDR:  900k rows, primary key: ORDERID (clustered)
PRODUCT:   6655 rows, primary key: PRODUCT (clustered)

Indeks używany do skanowania to:

create unique nonclustered index PRODUCT_FACTORY on PRODUCT (PRODUCT, FACTORY)

Ta funkcja jest w rzeczywistości nieco bardziej złożona, ale to samo dzieje się z fikcyjną funkcją składającą się z wielu instrukcji:

create function GetGroupCode (@FACTORY varchar(4))
returns @t table(
    TYPE        varchar(8),
    GROUPCODE   varchar(30)
)
as begin
    insert into @t (TYPE, GROUPCODE) values ('XX', 'YY')
    return
end

Byłem w stanie „naprawić” wydajność, zmuszając serwer SQL do pobrania 1 najlepszego produktu, chociaż 1 to maksimum, jakie kiedykolwiek można znaleźć:

select  
    S.GROUPCODE,
    H.ORDERCAT
from    
    ORDERLINE L
    join ORDERHDR H
        on H.ORDERID = M.ORDERID
    cross apply (select top 1 P.FACTORY from PRODUCT P where P.PRODUCT = L.PRODUCT) P
    cross apply dbo.GetGroupCode (P.FACTORY) S
where   
    L.ORDERNUMBER = 'XXX/YYY-123456' and
    L.RMPHASE = '0' and
    L.ORDERLINE = '01'

Następnie kształt planu również się zmienia i jest czymś, czego się spodziewałem:

Plan zapytań z górą

Wydawało mi się również, że indeks PRODUCT_FACTORY jest mniejszy niż indeks klastrowany PRODUCT_PK miałby wpływ, ale nawet przy zmuszaniu zapytania do użycia PRODUCT_PK, plan jest nadal taki sam jak oryginalny, z 6655 wywołaniami funkcji.

Jeśli całkowicie pomijam ORDERHDR, wówczas plan zaczyna się od zagnieżdżonej pętli między ORDERLINE a PRODUCT, a funkcja jest wywoływana tylko raz.

Chciałbym zrozumieć, co może być tego przyczyną, ponieważ wszystkie operacje są wykonywane przy użyciu kluczy podstawowych i jak to naprawić, jeśli dzieje się to w bardziej złożonym zapytaniu, którego nie można tak łatwo rozwiązać.

Edycja: Utwórz zestawienia tabel:

CREATE TABLE dbo.ORDERHDR(
    ORDERID varchar(8) NOT NULL,
    ORDERCATEGORY varchar(2) NULL,
    CONSTRAINT ORDERHDR_PK PRIMARY KEY CLUSTERED (ORDERID)
)

CREATE TABLE dbo.ORDERLINE(
    ORDERNUMBER varchar(16) NOT NULL,
    RMPHASE char(1) NOT NULL,
    ORDERLINE char(2) NOT NULL,
    ORDERID varchar(8) NOT NULL,
    PRODUCT varchar(8) NOT NULL,
    CONSTRAINT ORDERLINE_PK PRIMARY KEY CLUSTERED (ORDERNUMBER,ORDERLINE,RMPHASE)
)

CREATE TABLE dbo.PRODUCT(
    PRODUCT varchar(8) NOT NULL,
    FACTORY varchar(4) NULL,
    CONSTRAINT PRODUCT_PK PRIMARY KEY CLUSTERED (PRODUCT)
)
James Z
źródło

Odpowiedzi:

30

Istnieją trzy główne techniczne powody, dla których otrzymujesz plan:

  1. Struktura kalkulacji kosztów optymalizatora nie ma rzeczywistego wsparcia dla funkcji innych niż wbudowane. Nie próbuje zaglądać do definicji funkcji, aby zobaczyć, jak może być drogo, po prostu przypisuje bardzo mały stały koszt i szacuje, że funkcja będzie generować 1 wiersz wyniku za każdym razem, gdy zostanie wywołana. Oba te założenia modelowania są bardzo często całkowicie niebezpieczne. Sytuacja uległa bardzo nieznacznej poprawie w 2014 r. Po włączeniu nowego estymatora liczności, ponieważ ustalone domysły 1-wierszowe są zastępowane przez domysły 100-rzędowe. Jednak wciąż nie ma wsparcia dla wyceny zawartości funkcji nie-wbudowanych.
  2. SQL Server początkowo zwija złączenia i stosuje je w jednym wewnętrznym n-ary logicznym złączeniu. Pomaga to optymalizatorowi uzasadnić późniejsze dołączanie zamówień. Poszerzenie pojedynczego łączenia n-ary na zamówienia dołączania kandydatów następuje później i jest w dużej mierze oparte na heurystyce. Na przykład połączenia wewnętrzne występują przed połączeniami zewnętrznymi, małe tabele i łączenia selektywne przed dużymi tabelami i połączenia mniej selektywne i tak dalej.
  3. Gdy SQL Server przeprowadza optymalizację opartą na kosztach, dzieli wysiłek na opcjonalne fazy, aby zminimalizować szanse spędzenia zbyt długiej optymalizacji optymalizacji tanich zapytań. Istnieją trzy główne fazy, wyszukiwanie 0, wyszukiwanie 1 i wyszukiwanie 2. Każda faza ma warunki wejścia, a kolejne fazy umożliwiają więcej eksploracji optymalizatora niż wcześniejsze. Twoje zapytanie kwalifikuje się do najmniej poszukiwanej fazy wyszukiwania, faza 0. Znaleziono tam wystarczająco niski koszt, aby nie wprowadzać późniejszych etapów.

Biorąc pod uwagę niewielkie oszacowanie liczności przypisane do UDF, heurystyka rozszerzenia n-ary dołącza ją niestety wcześniej w drzewie, niż byś sobie tego życzył.

Zapytanie kwalifikuje się również do optymalizacji wyszukiwania 0 dzięki temu, że ma co najmniej trzy sprzężenia (w tym dotyczy). Ostateczny plan fizyczny, jaki otrzymujesz, z dziwnie wyglądającym skanem, opiera się na heurystycznie wydanej kolejności łączenia. Koszt jest wystarczająco niski, aby optymalizator uznał plan za „wystarczająco dobry”. Niska ocena kosztów i liczność UDF przyczyniają się do tego wczesnego finiszu.

Wyszukiwanie 0 (znane również jako faza przetwarzania transakcji) kieruje zapytania o niskiej liczności typu OLTP, a ostateczne plany zwykle zawierają zagnieżdżone pętle. Co ważniejsze, wyszukiwanie 0 uruchamia tylko stosunkowo niewielki podzbiór zdolności eksploracyjnych optymalizatora. Ten podzbiór nie obejmuje przeciągania zastosowania w górę drzewa zapytań nad złączeniem (reguła PullApplyOverJoin). Jest to dokładnie to, co jest wymagane w przypadku testowym, aby zmienić położenie zastosowania UDF powyżej złączeń, aby pojawiło się jako ostatnie w sekwencji operacji (jakby było).

Istnieje również problem polegający na tym, że optymalizator może decydować między łączeniem naiwnych zagnieżdżonych pętli (predykat złączenia na samym złączu) a łączeniem indeksowanym skorelowanym (zastosowanie), w którym skorelowany predykat jest stosowany po wewnętrznej stronie złączenia za pomocą wyszukiwania indeksowego. Ten ostatni jest zwykle pożądanym kształtem planu, ale optymalizator jest w stanie zbadać oba te elementy. Przy niepoprawnych kosztorysach i szacunkach liczności może wybrać nieużywane sprzężenie NL, jak w przesłanych planach (wyjaśniając skan).

Istnieje wiele przyczyn interakcji obejmujących kilka ogólnych funkcji optymalizatora, które normalnie działają dobrze, aby znaleźć dobre plany w krótkim czasie bez użycia nadmiernych zasobów. Uniknięcie jednego z powodów wystarczy, aby utworzyć „oczekiwany” kształt planu dla przykładowego zapytania, nawet przy pustych tabelach:

Planuj puste tabele z wyłączonym wyszukiwaniem 0

Nie ma obsługiwanego sposobu uniknięcia wyboru planu wyszukiwania 0, wcześniejszego zakończenia optymalizatora lub poprawy kosztowania UDF (poza ograniczonymi ulepszeniami w tym modelu SQL Server 2014 CE). Pozostawia to takie rzeczy, jak przewodniki po planach, ręczne przepisywanie zapytań (w tym TOP (1)pomysł lub użycie pośrednich tabel tymczasowych) i unikanie źle kosztowanych „czarnych skrzynek” (z punktu widzenia QO), takich jak funkcje niewbudowane.

Przepisanie CROSS APPLYjak OUTER APPLYmoże również pracować, jako że obecnie uniemożliwia niektóre prace wcześnie join-zawaleniem, ale trzeba uważać, aby zachować oryginalne semantyki zapytania (np odrzucając jakiekolwiek NULL-extended wiersze, które mogą być wprowadzone bez optymalizator zapadającego powrotem do krzyżyk). Należy jednak pamiętać, że nie gwarantuje się, że to zachowanie pozostanie stabilne, dlatego należy pamiętać o ponownym testowaniu takich zaobserwowanych zachowań za każdym razem, gdy aktualizuje się lub aktualizuje SQL Server.

Ogólnie rzecz biorąc, właściwe dla Ciebie rozwiązanie zależy od wielu czynników, których nie jesteśmy w stanie ocenić. Zachęcam jednak do rozważenia rozwiązań, które na pewno będą zawsze działać w przyszłości, i które działają z (a nie przeciwko) optymalizatorowi w miarę możliwości.

Paul White mówi GoFundMonica
źródło
24

Wygląda na to, że jest to decyzja optymalizacyjna oparta na kosztach, ale raczej zła.

Jeśli dodasz 50000 wierszy do PRODUCT, optymalizator uważa, że ​​skanowanie jest zbyt pracochłonne i daje ci plan z trzema próbami i jednym wywołaniem do UDF.

Dostaję plan dla 6655 wierszy w PRODUCT

wprowadź opis zdjęcia tutaj

Mając 50000 wierszy w PRODUCT, zamiast tego otrzymuję ten plan.

wprowadź opis zdjęcia tutaj

Myślę, że koszt połączenia z UDF jest rażąco niedoceniany.

Jednym z obejść, które działa dobrze w tym przypadku, jest zmiana zapytania w celu użycia zewnętrznego zastosowania względem UDF. Dostaję dobry plan bez względu na to, ile wierszy jest w tabeli PRODUKT.

select  
    S.GROUPCODE,
    H.ORDERCATEGORY
from    
    ORDERLINE L
    join ORDERHDR H on H.ORDERID = L.ORDERID
    join PRODUCT P  on P.PRODUCT = L.PRODUCT    
    outer apply dbo.GetGroupCode (P.FACTORY) S
where   
    L.ORDERNUMBER = 'XXX/YYY-123456' and
    L.RMPHASE = '0' and
    L.ORDERLINE = '01' and
    S.GROUPCODE is not null

wprowadź opis zdjęcia tutaj

Najlepszym rozwiązaniem w twoim przypadku jest prawdopodobnie umieszczenie potrzebnych wartości w tabeli tymczasowej, a następnie wysłanie zapytania do tabeli tymczasowej za pomocą krzyżyka dla UDF. W ten sposób masz pewność, że UDF nie zostanie wykonany więcej niż to konieczne.

select  
    P.FACTORY,
    H.ORDERCATEGORY
into #T
from    
    ORDERLINE L
    join ORDERHDR H on H.ORDERID = L.ORDERID
    join PRODUCT P  on P.PRODUCT = L.PRODUCT
where   
    L.ORDERNUMBER = 'XXX/YYY-123456' and
    L.RMPHASE = '0' and
    L.ORDERLINE = '01'

select  
    S.GROUPCODE,
    T.ORDERCATEGORY
from #T as T
  cross apply dbo.GetGroupCode (T.FACTORY) S

drop table #T

Zamiast utrwalać się w tabeli tymczasowej, można użyć top()tabeli pochodnej, aby zmusić SQL Server do oceny wyniku złączeń przed wywołaniem UDF. Wystarczy użyć naprawdę wysokiej liczby w górnej części, dzięki czemu SQL Server musi policzyć wiersze dla tej części zapytania, zanim będzie mógł kontynuować korzystanie z UDF.

select S.GROUPCODE,
       T.ORDERCATEGORY
from (
     select top(2147483647)
         P.FACTORY,
         H.ORDERCATEGORY
     from    
         ORDERLINE L
         join ORDERHDR H on H.ORDERID = L.ORDERID
         join PRODUCT P  on P.PRODUCT = L.PRODUCT    
     where   
         L.ORDERNUMBER = 'XXX/YYY-123456' and
         L.RMPHASE = '0' and
         L.ORDERLINE = '01'
     ) as T
  cross apply dbo.GetGroupCode (T.FACTORY) S

wprowadź opis zdjęcia tutaj

Chciałbym zrozumieć, co może być tego przyczyną, ponieważ wszystkie operacje są wykonywane przy użyciu kluczy podstawowych i jak to naprawić, jeśli dzieje się to w bardziej złożonym zapytaniu, którego nie można tak łatwo rozwiązać.

Naprawdę nie mogę na to odpowiedzieć, ale pomyślałem, że i tak powinienem podzielić się tym, co wiem. Nie wiem, dlaczego w ogóle rozważany jest skan tabeli PRODUCT. Mogą istnieć przypadki, w których jest to najlepsza rzecz do zrobienia i istnieją rzeczy dotyczące tego, jak optymalizatory traktują UDF, o których nie wiem.

Dodatkowym spostrzeżeniem było to, że zapytanie otrzymało dobry plan w SQL Server 2014 z nowym estymatorem liczności. Jest tak, ponieważ szacowana liczba wierszy dla każdego wywołania UDF wynosi 100 zamiast 1, tak jak w SQL Server 2012 i wcześniejszych. Ale nadal będzie podejmować tę samą decyzję opartą na kosztach między wersją skanowania a wersją wyszukiwania planu. Mając mniej niż 500 (w moim przypadku 497) wierszy w PRODUCT, otrzymasz wersję skanowania planu nawet w SQL Server 2014.

Mikael Eriksson
źródło
2
Jakoś przypomina mi sesję Adama Machanica w SQL Bits: sqlbits.com/Sessions/Event14/…
James Z