Emuluj funkcję skalarną zdefiniowaną przez użytkownika w sposób, który nie zapobiega równoległości

12

Próbuję sprawdzić, czy istnieje sposób, aby oszukać SQL Server, aby używał określonego planu dla zapytania.

1. Środowisko

Wyobraź sobie, że masz jakieś dane, które są współużytkowane przez różne procesy. Załóżmy, że mamy wyniki eksperymentów, które zajmują dużo miejsca. Następnie dla każdego procesu wiemy, który rok / miesiąc wyniku eksperymentu chcemy zastosować.

if object_id('dbo.SharedData') is not null
    drop table SharedData

create table dbo.SharedData (
    experiment_year int,
    experiment_month int,
    rn int,
    calculated_number int,
    primary key (experiment_year, experiment_month, rn)
)
go

Teraz dla każdego procesu mamy parametry zapisane w tabeli

if object_id('dbo.Params') is not null
    drop table dbo.Params

create table dbo.Params (
    session_id int,
    experiment_year int,
    experiment_month int,
    primary key (session_id)
)
go

2. Dane testowe

Dodajmy dane testowe:

insert into dbo.Params (session_id, experiment_year, experiment_month)
select 1, 2014, 3 union all
select 2, 2014, 4 
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 3, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 4, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

3. Pobieranie wyników

Teraz bardzo łatwo jest uzyskać wyniki eksperymentu poprzez @experiment_year/@experiment_month:

create or alter function dbo.f_GetSharedData(@experiment_year int, @experiment_month int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.SharedData as d
    where
        d.experiment_year = @experiment_year and
        d.experiment_month = @experiment_month
)
go

Plan jest ładny i równoległy:

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(2014, 4)
group by
    calculated_number

zapytanie 0 plan

wprowadź opis zdjęcia tutaj

4. Problem

Ale aby korzystanie z danych było nieco bardziej ogólne, chcę mieć inną funkcję - dbo.f_GetSharedDataBySession(@session_id int). Tak więc najprostszym sposobem byłoby utworzenie funkcji skalarnych, tłumacząc @session_id-> @experiment_year/@experiment_month:

create or alter function dbo.fn_GetExperimentYear(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_year
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

create or alter function dbo.fn_GetExperimentMonth(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_month
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

A teraz możemy stworzyć naszą funkcję:

create or alter function dbo.f_GetSharedDataBySession1(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
        dbo.fn_GetExperimentYear(@session_id),
        dbo.fn_GetExperimentMonth(@session_id)
    ) as d
)
go

zapytanie 1 plan

wprowadź opis zdjęcia tutaj

Plan jest taki sam, ale oczywiście nie jest równoległy, ponieważ funkcje skalarne wykonujące dostęp do danych sprawiają, że cały plan jest szeregowy .

Wypróbowałem więc kilka różnych podejść, na przykład używając podkwerend zamiast funkcji skalarnych:

create or alter function dbo.f_GetSharedDataBySession2(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
       (select p.experiment_year from dbo.Params as p where p.session_id = @session_id),
       (select p.experiment_month from dbo.Params as p where p.session_id = @session_id)
    ) as d
)
go

zapytanie 2 plan

wprowadź opis zdjęcia tutaj

Lub używając cross apply

create or alter function dbo.f_GetSharedDataBySession3(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.Params as p
        cross apply dbo.f_GetSharedData(
            p.experiment_year,
            p.experiment_month
        ) as d
    where
        p.session_id = @session_id
)
go

plan zapytania 3

wprowadź opis zdjęcia tutaj

Ale nie mogę znaleźć sposobu, aby napisać to zapytanie tak dobre, jak to przy użyciu funkcji skalarnych.

Kilka myśli:

  1. Zasadniczo chciałbym móc w jakiś sposób powiedzieć SQL Serverowi, aby wstępnie obliczył pewne wartości, a następnie przekazał je dalej jako stałe.
  2. Pomocne może być, gdybyśmy mieli pośrednią wskazówkę dotyczącą materializacji . Sprawdziłem kilka wariantów (TVF z wieloma instrukcjami lub Cte z górą), ale żaden plan nie jest tak dobry jak ten z funkcjami skalarnymi
  3. Wiem o nadchodzącej poprawie SQL Server 2017 - Froid: Optymalizacja programów imperatywnych w relacyjnej bazie danych. Nie jestem jednak pewien, czy to pomoże. Fajnie byłoby jednak udowodnić, że się tutaj mylą.

Dodatkowe informacje

Korzystam z funkcji (zamiast wybierać dane bezpośrednio z tabel), ponieważ jest o wiele łatwiejsze w użyciu w wielu różnych zapytaniach, które zwykle mają @session_idjako parametr.

Poproszono mnie o porównanie faktycznych czasów wykonania. W tym konkretnym przypadku

  • zapytanie 0 działa przez ~ 500ms
  • zapytanie 1 działa przez ~ 1500ms
  • zapytanie 2 działa przez ~ 1500ms
  • zapytanie 3 działa przez ~ 2000ms.

Plan nr 2 ma skanowanie indeksu zamiast wyszukiwania, które jest następnie filtrowane według predykatów zagnieżdżonych pętli. Plan nr 3 nie jest taki zły, ale nadal działa więcej i działa wolniej niż plan nr 0.

Załóżmy, że dbo.Paramszmienia się to rzadko i zwykle ma około 1–200 wierszy, nie więcej niż, powiedzmy, że 2000 się kiedykolwiek spodziewamy. Teraz jest około 10 kolumn i nie spodziewam się, aby dodawać kolumnę zbyt często.

Liczba wierszy w Params nie jest stała, więc dla każdego @session_idbędzie wiersz. Liczba kolumn, które nie zostały naprawione, jest to jeden z powodów, dla których nie chcę dzwonić dbo.f_GetSharedData(@experiment_year int, @experiment_month int)zewsząd, dlatego mogę wewnętrznie dodać nową kolumnę do tego zapytania. Z przyjemnością usłyszę wszelkie opinie / sugestie na ten temat, nawet jeśli ma pewne ograniczenia.

Roman Pekar
źródło
Plan zapytań z Froidem byłby podobny do planu zapytania powyżej, więc tak, nie zabierze Cię do rozwiązania, które chcesz osiągnąć w tym przypadku.
Karthik,

Odpowiedzi:

13

Nie można naprawdę bezpiecznie osiągnąć dokładnie tego, co chcesz dzisiaj w SQL Server, tj. W pojedynczej instrukcji i przy równoległym wykonywaniu, w ramach ograniczeń określonych w pytaniu (tak jak je postrzegam).

Więc moja prosta odpowiedź brzmi: nie . Reszta tej odpowiedzi to głównie dyskusja, dlaczego tak jest, jeśli jest to interesujące.

Możliwe jest uzyskanie równoległego planu, jak zauważono w pytaniu, ale istnieją dwie główne odmiany, z których żadna nie jest odpowiednia dla twoich potrzeb:

  1. Skorelowane zagnieżdżone pętle łączą się z okrągłym robinem, który rozprowadza strumienie na najwyższym poziomie. Biorąc pod uwagę, że gwarantowany jest pojedynczy wiersz Paramsdla określonej session_idwartości, wewnętrzna strona będzie działać na jednym wątku, nawet jeśli jest oznaczona ikoną równoległości. Właśnie dlatego pozornie równoległy plan 3 nie działa tak dobrze; w rzeczywistości jest to serial.

  2. Inną alternatywą jest niezależna równoległość po wewnętrznej stronie połączenia zagnieżdżonych pętli. Niezależny tutaj oznacza, że ​​wątki są uruchamiane po wewnętrznej stronie, a nie tylko te same wątki, które wykonują zewnętrzną stronę zagnieżdżonych pętli. SQL Server obsługuje niezależne równoległości zagnieżdżonych pętli wewnętrznych tylko wtedy, gdy gwarantowany jest jeden rząd strony zewnętrznej i nie ma skorelowanych parametrów łączenia ( plan 2 ).

Mamy więc wybór planu równoległego, który jest szeregowy (z powodu jednego wątku) z pożądanymi skorelowanymi wartościami; lub plan równoległy po wewnętrznej stronie, który musi skanować, ponieważ nie ma parametrów do wyszukania. (Poza tym: naprawdę należy zezwolić na sterowanie równoległością od strony wewnętrznej przy użyciu dokładnie jednego zestawu skorelowanych parametrów, ale nigdy nie zostało to wdrożone, prawdopodobnie z ważnego powodu).

Naturalne pytanie brzmi zatem: dlaczego w ogóle potrzebujemy skorelowanych parametrów? Dlaczego SQL Server nie może po prostu szukać bezpośrednio wartości skalarnych dostarczanych np. Przez podzapytanie?

Cóż, SQL Server może „indeksować wyszukiwanie” tylko przy użyciu prostych odniesień skalarnych, np. Stałych, zmiennych, kolumn lub odwołań do wyrażeń (więc wynik funkcji skalarnej może się również kwalifikować). Podzapytanie (lub inna podobna konstrukcja) jest po prostu zbyt skomplikowane (i potencjalnie niebezpieczne), aby wcisnąć je do całego silnika pamięci masowej. Tak więc wymagane są osobne operatory planu zapytań. To z kolei wymaga korelacji, co oznacza brak równoległości, jakiej byś chciał.

Podsumowując, naprawdę nie ma obecnie lepszego rozwiązania niż metody takie jak przypisywanie wartości odnośników do zmiennych, a następnie używanie ich w parametrach funkcji w osobnej instrukcji.

Teraz możesz mieć specyficzne uwarunkowania lokalne, co oznacza, że SESSION_CONTEXTwarto buforować bieżące wartości roku i miesiąca, np .:

SELECT FGSD.calculated_number, COUNT_BIG(*)
FROM dbo.f_GetSharedData
(
    CONVERT(integer, SESSION_CONTEXT(N'experiment_year')), 
    CONVERT(integer, SESSION_CONTEXT(N'experiment_month'))
) AS FGSD
GROUP BY FGSD.calculated_number;

Ale należy to do kategorii obejścia.

Z drugiej strony, jeśli wydajność agregacji ma podstawowe znaczenie, można rozważyć zastosowanie funkcji wbudowanych i utworzenie indeksu magazynu kolumn (podstawowego lub dodatkowego) w tabeli. Może się okazać, że zalety przechowywania magazynu kolumn, przetwarzania w trybie wsadowym i przesuwania agregacji zapewniają większe korzyści niż w przypadku wyszukiwania równoległego w trybie wierszowym.

Ale uważaj na skalarne funkcje T-SQL, szczególnie w przypadku magazynu magazynu kolumn, ponieważ łatwo jest skończyć z ocenianą funkcją dla każdego wiersza w osobnym filtrze w trybie wiersza. Generalnie dość trudne jest zagwarantowanie, ile razy SQL Server wybierze ocenę skalarów i lepiej nie próbować.

Paul White 9
źródło
Dzięki, Paul, świetna odpowiedź! Myślałem o użyciu, session_contextale zdecydowałem, że to dla mnie trochę zbyt szalony pomysł i nie jestem pewien, jak będzie pasować do mojej obecnej architektury. Przydałaby się jednak pewna wskazówka, której mógłbym użyć, aby poinformować optymalizatora, że ​​powinien traktować wynik podzapytania jak zwykłe odwołanie skalarne.
Roman Pekar,
8

O ile wiem, pożądany kształt planu nie jest możliwy tylko za pomocą T-SQL. Wygląda na to, że chcesz, aby oryginalny kształt planu (plan kwerendy 0) z podzapytaniami z twoich funkcji był stosowany jako filtry bezpośrednio względem skanowania indeksu klastrowanego. Nigdy nie otrzymasz takiego planu zapytań, jeśli nie użyjesz zmiennych lokalnych do przechowywania wartości zwracanych przez funkcje skalarne. Filtrowanie zostanie zamiast tego zaimplementowane jako zagnieżdżone połączenie pętli. Istnieją trzy różne sposoby (z punktu widzenia paralelizmu), w których można zastosować łączenie w pętli:

  1. Cały plan jest szeregowy. To jest dla ciebie nie do przyjęcia. Taki plan otrzymujesz dla zapytania 1.
  2. Sprzężenie pętli działa szeregowo. Wierzę, że w tym przypadku wewnętrzna strona może przebiegać równolegle, ale nie można przekazać do niej żadnych predykatów. Tak więc większość pracy będzie wykonywana równolegle, ale skanujesz całą tabelę, a częściowa agregacja jest znacznie droższa niż wcześniej. Taki plan otrzymujesz dla zapytania 2.
  3. Sprzężenie pętli działa równolegle. Z równolegle zagnieżdżonymi połączeniami pętli wewnętrzna strona pętli działa szeregowo, ale jednocześnie możesz mieć do wątków DOP działających na wewnętrznej stronie. Zewnętrzny zestaw wyników będzie miał tylko jeden wiersz, więc plan równoległy będzie efektywnie szeregowy. Taki plan otrzymujesz dla zapytania 3.

To jedyne możliwe kształty planu, o których wiem. Możesz uzyskać inne, jeśli używasz tabeli tymczasowej, ale żadna z nich nie rozwiązuje podstawowego problemu, jeśli chcesz, aby wydajność zapytania była tak dobra, jak w przypadku zapytania 0.

Można osiągnąć równoważną wydajność zapytania, używając skalarnych funkcji UDF do przypisywania zwracanych wartości do zmiennych lokalnych i używając tych zmiennych lokalnych w zapytaniu. Możesz owinąć ten kod w procedurę składowaną lub w UDF z wieloma instrukcjami, aby uniknąć problemów z utrzymaniem. Na przykład:

DECLARE @experiment_year int = dbo.fn_GetExperimentYear(@session_id);
DECLARE @experiment_month int = dbo.fn_GetExperimentMonth(@session_id);

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(@experiment_year, @experiment_month)
group by
    calculated_number;

Skalarne funkcje UDF zostały przeniesione poza zapytanie, które ma kwalifikować się do równoległości. Wydaje mi się, że otrzymuję plan zapytań:

równoległy plan zapytań

Oba podejścia mają wady, jeśli trzeba użyć tego zestawu wyników w innych zapytaniach. Nie można bezpośrednio dołączyć do procedury składowanej. Będziesz musiał zapisać wyniki do tabeli tymczasowej, która ma własny zestaw problemów. Możesz dołączyć do MS-TVF, ale w SQL Server 2016 mogą wystąpić problemy z oszacowaniem liczności. SQL Server 2017 oferuje przeplatane wykonywanie MS-TVF, co może całkowicie rozwiązać problem.

Aby wyjaśnić kilka rzeczy: Skalarne UDF T-SQL zawsze zabraniają równoległości, a Microsoft nie powiedział, że FROID będzie dostępny w SQL Server 2017.

Joe Obbish
źródło
dotyczące Froida w SQL 2017 - nie jestem pewien, dlaczego tak myślałem. Potwierdzono, że jest w vNext - brentozar.com/archive/2018/01/…
Roman Pekar
4

Najprawdopodobniej można to zrobić za pomocą SQLCLR. Jedną z zalet skalarnych UDF SQLCLR jest to, że nie zapobiegają one równoległości, jeśli nie mają dostępu do danych (a czasami muszą być również oznaczone jako „deterministyczne”). Jak więc wykorzystać coś, co nie wymaga dostępu do danych, gdy sama operacja wymaga dostępu do danych?

Ponieważ dbo.Paramstabela ma:

  1. generalnie nigdy nie ma w nim więcej niż 2000 wierszy,
  2. rzadko zmieniają strukturę,
  3. tylko (obecnie) muszą mieć dwie INTkolumny

możliwe jest buforowanie trzech kolumn - session_id, experiment_year int, experiment_month- w zbiorczej kolekcji (np. Słowniku), która jest zapełniana poza procesem i odczytywana przez Skalarne UDF, które otrzymują wartości experiment_year inti experiment_month. Rozumiem przez „poza procesem”: możesz mieć całkowicie oddzielny Skalarny UDF SQLCLR lub Procedurę Składowaną, która może uzyskiwać dostęp do danych i odczyty z dbo.Paramstabeli w celu zapełnienia kolekcji statycznej. Ta funkcja UDF lub procedura przechowywana byłaby wykonana przed użyciem funkcji UDF, które otrzymują wartości „rok” i „miesiąc”, w ten sposób funkcje UDF, które otrzymują wartości „rok” i „miesiąc”, nie zapewniają dostępu do danych DB.

Procedura UDF lub procedura zapisana, która odczytuje dane, może najpierw sprawdzić, czy kolekcja ma 0 wpisów, a jeśli tak, to zapełnij, w przeciwnym razie pomiń. Możesz nawet śledzić czas, w którym był wypełniany, a jeśli minął ponad X minut (lub coś w tym rodzaju), a następnie wyczyść i ponownie wypełnij, nawet jeśli w kolekcji znajdują się wpisy. Ale pomijanie populacji pomoże, ponieważ będzie musiała być często wykonywana, aby upewnić się, że zawsze jest zapełniana dla dwóch głównych UDF, z których można uzyskać wartości.

Głównym problemem jest, gdy SQL Server zdecyduje się zwolnić domenę aplikacji z dowolnego powodu (lub jest wyzwalany przez coś przy użyciu DBCC FREESYSTEMCACHE('ALL');). Nie chcesz ryzykować, że kolekcja zostanie wyczyszczona między wykonaniem funkcji „wypełniania” UDF lub procedury składowanej a UDF, aby uzyskać wartości „rok” i „miesiąc”. W takim przypadku można sprawdzić na samym początku tych dwóch UDF, aby zgłosić wyjątek, jeśli kolekcja jest pusta, ponieważ lepiej jest pomylić błędy, niż zapewnić fałszywe wyniki.

Oczywiście wyżej wspomniane obawy zakładają, że pragnieniem jest, aby Zgromadzenie oznaczono jako SAFE. Jeśli EXTERNAL_ACCESSzestaw można oznaczyć jako , możliwe jest, aby konstruktor statyczny wykonał metodę, która odczytuje dane i zapełnia kolekcję, dzięki czemu wystarczy tylko ręcznie wykonać to, aby odświeżyć wiersze, ale zawsze będą one wypełnione (ponieważ konstruktor klasy statycznej zawsze działa, gdy klasa jest ładowana, co dzieje się za każdym razem, gdy metoda w tej klasie jest wykonywana po restarcie lub domenie App jest zwolnione). Wymaga to użycia zwykłego połączenia, a nie bezpośredniego połączenia kontekstowego (które nie jest dostępne dla konstruktorów statycznych, stąd potrzeba EXTERNAL_ACCESS).

Uwaga: aby nie wymagać oznaczania zestawu jako UNSAFE, należy oznaczyć dowolne zmienne klasy statycznej jako readonly. Oznacza to przynajmniej kolekcję. Nie stanowi to problemu, ponieważ w kolekcjach tylko do odczytu można dodawać lub usuwać z nich elementy, po prostu nie można ich zainicjować poza konstruktorem lub początkowym ładowaniem. Śledzenie czasu ładowania kolekcji w celu jej wygaśnięcia po X minutach jest trudniejsze, ponieważ static readonly DateTimezmiennej klasy nie można zmienić poza konstruktorem lub ładowaniem początkowym. Aby obejść to ograniczenie, musisz użyć statycznej kolekcji tylko do odczytu, która zawiera pojedynczy element o DateTimewartości umożliwiającej usunięcie i ponowne dodanie podczas odświeżania.

Solomon Rutzky
źródło
Nie wiem, dlaczego ktoś to ocenił. Chociaż nie bardzo ogólny, myślę, że może mieć zastosowanie w mojej obecnej sprawie. Wolałbym mieć czyste rozwiązanie SQL, ale zdecydowanie przyjrzę się temu bliżej i spróbuję sprawdzić, czy działa
Roman Pekar,
@RomanPekar Nie jestem pewien, ale jest wielu ludzi, którzy są przeciw SQLCLR. A może kilka przeciw mnie ;-). Tak czy inaczej, nie mogę wymyślić, dlaczego to rozwiązanie nie zadziała. Rozumiem preferencje dla czystego T-SQL, ale nie wiem, jak to zrobić, a jeśli nie ma konkurencyjnej odpowiedzi, być może nikt inny też tego nie robi. Nie wiem, czy tabele zoptymalizowane pod kątem pamięci i natywnie skompilowane UDF przydałyby się tutaj lepiej. Dodałem także akapit z uwagami dotyczącymi implementacji, o których należy pamiętać.
Solomon Rutzky
1
Nigdy nie byłem w pełni przekonany, że używanie readonly staticsjest bezpieczne lub mądre w SQLCLR. Tym bardziej nie jestem przekonany, by oszukać system, czyniąc readonlygo typem odniesienia, który następnie zmieniacie . Daje mi absolutną wolę.
Paul White 9
@PaulWhite Zrozumiałem i przypominam sobie o tym w prywatnej rozmowie sprzed lat. Biorąc pod uwagę wspólny charakter domen aplikacji (a więc i staticobiektów) w SQL Server, tak, istnieje ryzyko warunków wyścigu. Właśnie dlatego po raz pierwszy ustaliłem z PO, że te dane są minimalne i stabilne, i dlatego zakwalifikowałem to podejście jako wymagające „rzadko zmieniających się” i dałem możliwość odświeżenia w razie potrzeby. W tym przypadku użycia nie widzę zbyt dużego ryzyka. Znalazłem post wiele lat temu na temat możliwości aktualizacji kolekcji tylko do odczytu jako zaprojektowanych (w C #, brak dyskusji na temat: SQLCLR). Spróbuję to znaleźć.
Solomon Rutzky
2
Nie ma potrzeby, nie ma mowy, żebyś mi to zapewnił poza oficjalną dokumentacją SQL Server, mówiącą, że jest w porządku, ale jestem pewien, że nie istnieje.
Paul White 9