Sqlserver
 sql >> Baza danych >  >> RDS >> Sqlserver

Agregacja ciągów na przestrzeni lat w SQL Server

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 &amp; Sheila &lt;&gt; 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:

Msg 8116, Poziom 16, Stan 1
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:

Msg 9829, poziom 16, stan 1
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!


  1. Database
  2.   
  3. Mysql
  4.   
  5. Oracle
  6.   
  7. Sqlserver
  8.   
  9. PostgreSQL
  10.   
  11. Access
  12.   
  13. SQLite
  14.   
  15. MariaDB
  1. EF5:Nie można dołączyć pliku „{0}” jako bazy danych „{1}”

  2. Zwróć informacje o wersji systemu operacyjnego w SQL Server za pomocą dynamicznego widoku zarządzania sys.dm_os_host_info

  3. Użyj APP_NAME(), aby uzyskać nazwę aplikacji bieżącej sesji w SQL Server

  4. Jak połączyć się z bazą danych SQL Server z poziomu JavaScript w przeglądarce?

  5. Baza danych + uwierzytelnianie Windows + nazwa użytkownika/hasło?