Od SQL Server 2005 trik polegający na użyciu FOR XML PATH
denormalizowanie łańcuchów i łączenie ich w jedną (zwykle oddzieloną przecinkami) listę jest bardzo popularne. Jednak w SQL Server 2017 STRING_AGG()
wreszcie odpowiedział na długotrwałe i powszechne prośby społeczności o symulację GROUP_CONCAT()
i podobne funkcje znalezione na innych platformach. Niedawno zacząłem modyfikować wiele moich odpowiedzi Stack Overflow przy użyciu starej metody, zarówno w celu ulepszenia istniejącego kodu, jak i dodania dodatkowego przykładu lepiej dopasowanego do nowoczesnych wersji.
Byłem trochę zbulwersowany tym, co znalazłem.
Niejednokrotnie musiałem dwukrotnie sprawdzić, czy kod jest nawet mój.
Szybki przykład
Spójrzmy na prostą demonstrację problemu. Ktoś ma taki stół:
CREATE TABLE dbo.FavoriteBands ( UserID int, BandName nvarchar(255) ); INSERT dbo.FavoriteBands ( UserID, BandName ) VALUES (1, N'Pink Floyd'), (1, N'New Order'), (1, N'The Hip'), (2, N'Zamfir'), (2, N'ABBA');
Na stronie pokazującej ulubione zespoły każdego użytkownika chcą, aby wynik wyglądał tak:
UserID Bands ------ --------------------------------------- 1 Pink Floyd, New Order, The Hip 2 Zamfir, ABBA
W czasach SQL Server 2005 zaoferowałbym to rozwiązanie:
SELECT DISTINCT UserID, Bands = (SELECT BandName + ', ' FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')) FROM dbo.FavoriteBands AS fb;
Ale kiedy teraz patrzę wstecz na ten kod, widzę wiele problemów, których nie mogę się oprzeć.
RZECZY
Najbardziej krytyczną wadą powyższego kodu jest pozostawienie końcowego przecinka:
UserID Bands ------ --------------------------------------- 1 Pink Floyd, New Order, The Hip, 2 Zamfir, ABBA,
Aby rozwiązać ten problem, często widzę, jak ludzie zawijają zapytanie w inne, a następnie otaczają Bands
wyjście z LEFT(Bands, LEN(Bands)-1)
. Ale to niepotrzebne dodatkowe obliczenia; zamiast tego możemy przenieść przecinek na początek ciągu i usunąć pierwszy lub dwa znaki za pomocą STUFF
. Wtedy nie musimy obliczać długości ciągu, ponieważ jest to nieistotne.
SELECT DISTINCT UserID, Bands = STUFF( --------------------------------^^^^^^ (SELECT ', ' + BandName --------------^^^^^^ FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')), 1, 2, '') --------------------------^^^^^^^^^^^ FROM dbo.FavoriteBands AS fb;
Możesz to dalej dostosować, jeśli używasz dłuższego lub warunkowego ogranicznika.
ODRÓŻNE
Następnym problemem jest użycie DISTINCT
. Sposób działania kodu polega na tym, że tabela pochodna generuje listę oddzieloną przecinkami dla każdego UserID
wartość, a następnie duplikaty są usuwane. Możemy to zobaczyć, patrząc na plan i widząc, że operator związany z XML jest wykonywany siedem razy, mimo że ostatecznie zwracane są tylko trzy wiersze:
Rysunek 1:Plan pokazujący filtr po agregacji
Jeśli zmienimy kod, aby użyć GROUP BY
zamiast DISTINCT
:
SELECT /* DISTINCT */ UserID, Bands = STUFF( (SELECT ', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')), 1, 2, '') FROM dbo.FavoriteBands AS fb GROUP BY UserID; --^^^^^^^^^^^^^^^
To subtelna różnica i nie zmienia wyników, ale widzimy, że plan się poprawia. Zasadniczo operacje XML są odraczane do momentu usunięcia duplikatów:
Rysunek 2:Plan pokazujący filtr przed agregacją
W tej skali różnica jest nieistotna. Ale co, jeśli dodamy więcej danych? W moim systemie dodaje to nieco ponad 11 000 wierszy:
INSERT dbo.FavoriteBands(UserID, BandName) SELECT [object_id], name FROM sys.all_columns;
Jeśli ponownie uruchomimy te dwa zapytania, różnice w czasie trwania i procesorze są od razu oczywiste:
Rysunek 3:Wyniki w czasie wykonywania porównujące DISTINCT i GROUP BY
Ale inne skutki uboczne są również oczywiste w planach. W przypadku DISTINCT
, UDX ponownie wykonuje się dla każdego wiersza w tabeli, jest nadmiernie zajęty bufor indeksowania, istnieje odrębne sortowanie (dla mnie zawsze czerwona flaga), a zapytanie ma wysoki przydział pamięci, co może poważnie wpłynąć na współbieżność :
Rysunek 4:DISTINCT plan w skali
Tymczasem w GROUP BY
zapytanie, UDX wykonuje się tylko raz dla każdego unikalnego UserID
, gorliwy bufor odczytuje znacznie mniejszą liczbę wierszy, nie ma wyraźnego operatora sortowania (zastąpił go hash match), a przyznanie pamięci jest w porównaniu z tym niewielkie:
Rysunek 5:Plan GROUP BY w skali
Powrót i naprawienie starego kodu w ten sposób zajmuje trochę czasu, ale od jakiegoś czasu byłem bardzo ostrożny, aby zawsze używać GROUP BY
zamiast DISTINCT
.
N Prefiks
Zbyt wiele starych próbek kodu, na które natknąłem się, zakładało, że żadne znaki Unicode nigdy nie będą używane, a przynajmniej dane przykładowe nie sugerowały takiej możliwości. Zaoferowałbym swoje rozwiązanie jak powyżej, a następnie użytkownik wróciłby i powiedział:„ale w jednym wierszu mam 'просто красный'
i wraca jako '?????? ???????'
!” Często przypominam ludziom, że zawsze muszą poprzedzać potencjalne literały ciągów Unicode przedrostkiem N, chyba że absolutnie wiedzą, że będą mieli do czynienia tylko z varchar
ciągi lub liczby całkowite. Zacząłem być bardzo dosadny i prawdopodobnie nawet nadmiernie ostrożny:
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName --------------^ FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH(N'')), 1, 2, N'') ----------------------^ -----------^ FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Entityzacja XML
Kolejne „a co jeśli?” scenariusz nie zawsze obecny w przykładowych danych użytkownika to znaki XML. Na przykład, co jeśli mój ulubiony zespół nazywa się „Bob & Sheila <> Strawberries
”? Dane wyjściowe z powyższego zapytania są bezpieczne dla XML, co nie zawsze jest tym, czego chcemy (np. Bob & Sheila <> Strawberries
). Wyszukiwania Google w tym czasie sugerowałyby „musisz dodać TYPE
” i pamiętam, że próbowałem czegoś takiego:
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH(N''), TYPE), 1, 2, N'') --------------------------^^^^^^ FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Niestety typ danych wyjściowych z podzapytania w tym przypadku to xml
. Prowadzi to do następującego komunikatu o błędzie:
Typ danych argumentu xml jest nieprawidłowy dla argumentu 1 funkcji stuff.
Musisz powiedzieć SQL Server, że chcesz wyodrębnić wynikową wartość jako ciąg znaków, wskazując typ danych i że chcesz mieć pierwszy element. Wtedy dodałbym to w następujący sposób:
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH(N''), TYPE).value(N'.', N'nvarchar(max)'), --------------------------^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 1, 2, N'') FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Spowoduje to zwrócenie ciągu bez encji XML. Ale czy jest najbardziej wydajny? W zeszłym roku Charlieface przypomniał mi, że pan Magoo przeprowadził obszerne testy i znalazł ./text()[1]
był szybszy niż inne (krótsze) podejścia, takie jak .
i .[1]
. (Pierwotnie usłyszałem to z komentarza, który zostawił mi Mikael Eriksson.) Jeszcze raz dostosowałem swój kod, aby wyglądał tak:
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH(N''), TYPE).value(N'./text()[1]', N'nvarchar(max)'), ------------------------------------------^^^^^^^^^^^ 1, 2, N'') FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Możesz zaobserwować, że wyodrębnianie wartości w ten sposób prowadzi do nieco bardziej złożonego planu (nie poznasz tego tylko patrząc na czas trwania, który pozostaje dość stały podczas powyższych zmian):
Rysunek 6:Planowanie za pomocą ./text()[1]
Ostrzeżenie w katalogu głównym SELECT
operator pochodzi z jawnej konwersji do nvarchar(max)
.
Zamów
Czasami użytkownicy wyrażają zamówienie jest ważne. Często jest to po prostu porządkowanie według kolumny, którą dołączasz, ale czasami można ją dodać gdzie indziej. Ludzie mają tendencję do myślenia, że jeśli raz zobaczyli określone zamówienie wychodzące z SQL Server, będą to widzieć zawsze, ale nie ma tu pewności. Zamówienie nigdy nie jest gwarantowane, chyba że tak powiesz. W tym przypadku powiedzmy, że chcemy zamówić przez BandName
alfabetycznie. Możemy dodać tę instrukcję w podzapytaniu:
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID ORDER BY BandName ---------^^^^^^^^^^^^^^^^^ FOR XML PATH(N''), TYPE).value(N'./text()[1]', N'nvarchar(max)'), 1, 2, N'') FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Pamiętaj, że może to wydłużyć czas wykonania ze względu na dodatkowy operator sortowania, w zależności od tego, czy istnieje indeks pomocniczy.
STRING_AGG()
Gdy aktualizuję moje stare odpowiedzi, które nadal powinny działać w wersji, która była odpowiednia w momencie pytania, końcowy fragment powyżej (z lub bez ORDER BY
) to formularz, który prawdopodobnie zobaczysz. Ale możesz zobaczyć również dodatkową aktualizację dla bardziej nowoczesnej formy.
STRING_AGG()
jest prawdopodobnie jedną z najlepszych funkcji dodanych w SQL Server 2017. Jest zarówno prostsze, jak i znacznie wydajniejsze niż którekolwiek z powyższych podejść, co prowadzi do uporządkowanych, dobrze działających zapytań, takich jak:
SELECT UserID, Bands = STRING_AGG(BandName, N', ') FROM dbo.FavoriteBands GROUP BY UserID;
To nie żart; Otóż to. Oto plan — co najważniejsze, w tabeli jest tylko jeden skan:
Rysunek 7:Plan STRING_AGG()
Jeśli chcesz złożyć zamówienie, STRING_AGG()
obsługuje to również (o ile masz poziom zgodności 110 lub wyższy, jak wskazuje tutaj Martin Smith):
SELECT UserID, Bands = STRING_AGG(BandName, N', ') WITHIN GROUP (ORDER BY BandName) ----^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FROM dbo.FavoriteBands GROUP BY UserID;
Plan wygląda taki sam jak ten bez sortowania, ale zapytanie jest nieco wolniejsze w moich testach. Nadal jest o wiele szybszy niż którykolwiek z FOR XML PATH
odmiany.
Indeksy
Kupa nie jest sprawiedliwa. Jeśli masz nawet indeks nieklastrowany, którego może użyć zapytanie, plan wygląda jeszcze lepiej. Na przykład:
CREATE INDEX ix_FavoriteBands ON dbo.FavoriteBands(UserID, BandName);
Oto plan dla tego samego uporządkowanego zapytania przy użyciu STRING_AGG()
—zwróć uwagę na brak operatora sortowania, ponieważ skanowanie można zamówić:
Rysunek 8:Plan STRING_AGG() z indeksem pomocniczym
To również skraca czas — ale żeby być uczciwym, ten indeks pomaga FOR XML PATH
wariacje również. Oto nowy plan dla uporządkowanej wersji tego zapytania:
Rysunek 9:Plan FOR XML PATH z indeksem pomocniczym
Plan jest trochę bardziej przyjazny niż wcześniej, zawiera wyszukiwanie zamiast skanowania w jednym miejscu, ale to podejście jest nadal znacznie wolniejsze niż STRING_AGG()
.
Zastrzeżenie
Jest mały trik z użyciem STRING_AGG()
gdzie, jeśli wynikowy ciąg ma więcej niż 8000 bajtów, otrzymasz następujący komunikat o błędzie:
wynik agregacji STRING_AGG przekroczył limit 8000 bajtów. Użyj typów LOB, aby uniknąć obcinania wyników.
Aby uniknąć tego problemu, możesz wprowadzić jawną konwersję:
SELECT UserID, Bands = STRING_AGG(CONVERT(nvarchar(max), BandName), N', ') --------------------------^^^^^^^^^^^^^^^^^^^^^^ FROM dbo.FavoriteBands GROUP BY UserID;
Dodaje to do planu operację obliczeniową skalarną — i niespodziankę CONVERT
ostrzeżenie w katalogu głównym SELECT
operatora — ale poza tym ma niewielki wpływ na wydajność.
Wniosek
Jeśli korzystasz z SQL Server 2017+ i masz dowolną FOR XML PATH
agregacji ciągów w bazie kodu, gorąco polecam przejście na nowe podejście. Przeprowadziłem dokładniejsze testy wydajności podczas publicznej wersji zapoznawczej SQL Server 2017 tutaj i tutaj możesz chcieć wrócić.
Częstym zarzutem, który słyszałem, jest to, że ludzie korzystają z SQL Server 2017 lub nowszego, ale wciąż mają starszy poziom zgodności. Wygląda na to, że obawa jest spowodowana tym, że STRING_SPLIT()
jest nieprawidłowy na poziomach zgodności niższych niż 130, więc uważają, że STRING_AGG()
działa w ten sposób, ale jest nieco łagodniejszy. Jest to problem tylko wtedy, gdy używasz WITHIN GROUP
i poziom kompatybilności niższy niż 110. Więc popraw!