sql >> Databasteknik >  >> RDS >> Sqlserver

Strängaggregation genom åren i SQL Server

Sedan SQL Server 2005, tricket att använda FOR XML PATH att denormalisera strängar och kombinera dem till en enda (vanligtvis kommaseparerad) lista har varit mycket populärt. I SQL Server 2017 däremot STRING_AGG() äntligen besvarade långvariga och utbredda vädjanden från samhället att simulera GROUP_CONCAT() och liknande funktionalitet som finns på andra plattformar. Jag började nyligen modifiera många av mina Stack Overflow-svar med den gamla metoden, både för att förbättra den befintliga koden och för att lägga till ytterligare ett exempel som är bättre lämpat för moderna versioner.

Jag blev lite bestört över vad jag hittade.

Vid mer än ett tillfälle var jag tvungen att dubbelkolla att koden till och med var min.

Ett snabbt exempel

Låt oss titta på en enkel demonstration av problemet. Någon har en sådan här tabell:

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');

På sidan som visar varje användares favoritband vill de att utdata ska se ut så här:

UserID   Bands
------   ---------------------------------------
1        Pink Floyd, New Order, The Hip
2        Zamfir, ABBA

Under SQL Server 2005-dagarna skulle jag ha erbjudit den här lösningen:

SELECT DISTINCT UserID, Bands = 
      (SELECT BandName + ', '
         FROM dbo.FavoriteBands
         WHERE UserID = fb.UserID
         FOR XML PATH('')) 
FROM dbo.FavoriteBands AS fb;

Men när jag ser tillbaka på den här koden nu ser jag många problem som jag inte kan motstå att fixa.

SAKER

Den mest ödesdigra bristen i koden ovan är att den lämnar ett kommatecken:

UserID   Bands
------   ---------------------------------------
1        Pink Floyd, New Order, The Hip, 
2        Zamfir, ABBA, 

För att lösa detta ser jag ofta människor lindar in frågan i en annan och sedan omger Bands utdata med LEFT(Bands, LEN(Bands)-1) . Men detta är onödig ytterligare beräkning; istället kan vi flytta kommatecken till början av strängen och ta bort de första ett eller två tecknen med STUFF . Sedan behöver vi inte beräkna längden på strängen eftersom den är irrelevant.

SELECT DISTINCT UserID, Bands = STUFF(
--------------------------------^^^^^^
      (SELECT ', ' + BandName
--------------^^^^^^
         FROM dbo.FavoriteBands
         WHERE UserID = fb.UserID
         FOR XML PATH('')), 1, 2, '')
--------------------------^^^^^^^^^^^
FROM dbo.FavoriteBands AS fb;

Du kan justera detta ytterligare om du använder en längre eller villkorlig avgränsare.

DISTINKT

Nästa problem är användningen av DISTINCT . Hur koden fungerar är att den härledda tabellen genererar en kommaseparerad lista för varje UserID värde, så tas dubbletterna bort. Vi kan se detta genom att titta på planen och se den XML-relaterade operatören köras sju gånger, även om endast tre rader till slut returneras:

Figur 1:Plan som visar filter efter aggregering

Om vi ​​ändrar koden till att använda GROUP BY istället för 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;
--^^^^^^^^^^^^^^^

Det är en subtil skillnad, och det förändrar inte resultaten, men vi kan se att planen förbättras. I grund och botten skjuts XML-operationerna upp tills efter att dubletterna har tagits bort:

Figur 2:Plan som visar filter före aggregering

I denna skala är skillnaden oväsentlig. Men vad händer om vi lägger till lite mer data? På mitt system lägger detta till lite över 11 000 rader:

INSERT dbo.FavoriteBands(UserID, BandName)
  SELECT [object_id], name FROM sys.all_columns;

Om vi ​​kör de två frågorna igen är skillnaderna i varaktighet och CPU omedelbart uppenbara:

Figur 3:Runtime-resultat som jämför DISTINCT och GROUP BY

Men även andra biverkningar är uppenbara i planerna. I fallet med DISTINCT UDX körs återigen för varje rad i tabellen, det finns en överdrivet ivrig indexspole, det finns en distinkt sort (alltid en röd flagga för mig), och frågan har ett högt minnestillstånd, vilket kan sätta en allvarlig buckla i samtidighet :

Figur 4:DISTINKT plan i skala

Under tiden i GROUP BY fråga, UDX körs bara en gång för varje unikt UserID , den ivriga spolen läser ett mycket lägre antal rader, det finns ingen distinkt sorteringsoperator (den har ersatts av en hash-matchning), och minnesbidraget är litet i jämförelse:

Figur 5:GROUP BY-plan i skala

Det tar ett tag att gå tillbaka och fixa gammal kod så här, men jag har sedan en tid tillbaka varit väldigt inställd på att alltid använda GROUP BY istället för DISTINCT .

N-prefix

För många gamla kodexempel jag stötte på antog att inga Unicode-tecken någonsin skulle användas, eller åtminstone antydde inte provdatan möjligheten. Jag skulle erbjuda min lösning enligt ovan, och sedan skulle användaren komma tillbaka och säga, "men på en rad har jag 'просто красный' , och det kommer tillbaka som '?????? ???????' !" Jag påminner ofta folk om att de alltid behöver prefixa potentiella Unicode-strängliteraler med N-prefixet om de inte absolut vet att de bara någonsin kommer att ha att göra med varchar strängar eller heltal. Jag började vara väldigt tydlig och förmodligen till och med över försiktig med det:

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;

XML-aktivering

Ett annat "tänk om?" scenario som inte alltid finns i en användares exempeldata är XML-tecken. Tänk till exempel om mitt favoritband heter "Bob & Sheila <> Strawberries ”? Utdata med ovanstående fråga görs XML-säker, vilket inte är vad vi alltid vill ha (t.ex. Bob &amp; Sheila &lt;&gt; Strawberries ). Google-sökningar vid den tiden tyder på "du måste lägga till TYPE ,” och jag minns att jag provade något sånt här:

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;

Tyvärr är utdatatypen från underfrågan i det här fallet xml . Detta leder till följande felmeddelande:

Msg 8116, Level 16, State 1
Argumentdatatypen xml är ogiltigt för argument 1 för grejfunktionen.

Du måste berätta för SQL Server att du vill extrahera det resulterande värdet som en sträng genom att ange datatypen och att du vill ha det första elementet. Då skulle jag lägga till detta som följande:

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;

Detta skulle returnera strängen utan XML-bekräftelse. Men är det det mest effektiva? Förra året påminde Charlieface mig om att Mister Magoo utförde några omfattande tester och hittade ./text()[1] var snabbare än de andra (kortare) tillvägagångssätten som . och .[1] . (Jag hörde ursprungligen detta från en kommentar Mikael Eriksson lämnade till mig här.) Jag justerade återigen min kod så att den ser ut så här:

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;

Du kan observera att extrahera värdet på detta sätt leder till en något mer komplex plan (du skulle inte veta det bara från att titta på varaktigheten, som förblir ganska konstant under ovanstående ändringar):

Figur 6:Planera med ./text()[1]

Varningen på roten SELECT operatorn kommer från den explicita konverteringen till nvarchar(max) .

Beställ

Ibland skulle användare uttrycka beställning är viktigt. Ofta är detta helt enkelt sortering efter kolumnen du lägger till - men ibland kan det läggas till någon annanstans. Människor tenderar att tro att om de såg en specifik beställning komma ut från SQL Server en gång, så är det den ordning de alltid kommer att se, men det finns ingen tillförlitlighet här. Beställning garanteras aldrig om du inte säger det. I det här fallet, låt oss säga att vi vill beställa efter BandName alfabetiskt. Vi kan lägga till denna instruktion i underfrågan:

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;

Observera att detta kan lägga till lite körningstid på grund av den extra sorteringsoperatorn, beroende på om det finns ett stödjande index.

STRING_AGG()

När jag uppdaterar mina gamla svar, som fortfarande borde fungera på den version som var relevant vid tidpunkten för frågan, kommer det sista utdraget ovan (med eller utan ORDER BY ) är formuläret du troligen kommer att se. Men du kanske ser ytterligare en uppdatering för den modernare formen också.

STRING_AGG() är utan tvekan en av de bästa funktionerna som lagts till i SQL Server 2017. Det är både enklare och mycket effektivare än någon av ovanstående tillvägagångssätt, vilket leder till snygga, välpresterande frågor som denna:

SELECT UserID, Bands = STRING_AGG(BandName, N', ')
  FROM dbo.FavoriteBands
  GROUP BY UserID;

Detta är inte ett skämt; det är allt. Här är planen – viktigast av allt, det finns bara en enda skanning mot bordet:

Figur 7:STRING_AGG() plan

Om du vill beställa, STRING_AGG() stöder detta också (så länge du är i kompatibilitetsnivå 110 eller högre, som Martin Smith påpekar här):

SELECT UserID, Bands = STRING_AGG(BandName, N', ')
    WITHIN GROUP (ORDER BY BandName)
----^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  FROM dbo.FavoriteBands
  GROUP BY UserID;

Planen ser ut samma som den utan sortering, men frågan är något långsammare i mina tester. Det är fortfarande mycket snabbare än någon av FOR XML PATH variationer.

Index

En hög är knappast rättvist. Om du till och med har ett icke-klustrat index som frågan kan använda, ser planen ännu bättre ut. Till exempel:

CREATE INDEX ix_FavoriteBands ON dbo.FavoriteBands(UserID, BandName);

Här är planen för samma ordnade fråga med STRING_AGG() — Notera avsaknaden av en sorteringsoperatör, eftersom skanningen kan beställas:

Figur 8:STRING_AGG()-plan med ett stödjande index

Detta ger också lite ledighet – men för att vara rättvis hjälper detta index FOR XML PATH variationer också. Här är den nya planen för den beställda versionen av den frågan:

Figur 9:FÖR XML PATH-plan med ett stödjande index

Planen är lite vänligare än tidigare, inklusive en sökning istället för en skanning på ett ställe, men detta tillvägagångssätt är fortfarande betydligt långsammare än STRING_AGG() .

En varning

Det finns ett litet knep för att använda STRING_AGG() där, om den resulterande strängen är mer än 8 000 byte, får du det här felmeddelandet:

Msg 9829, Level 16, State 1
STRING_AGG-aggregationsresultat överskred gränsen på 8000 byte. Använd LOB-typer för att undvika trunkering av resultatet.

För att undvika detta problem kan du injicera en explicit konvertering:

SELECT UserID, 
       Bands = STRING_AGG(CONVERT(nvarchar(max), BandName), N', ')
--------------------------^^^^^^^^^^^^^^^^^^^^^^
  FROM dbo.FavoriteBands
  GROUP BY UserID;

Detta lägger till en skalär beräkningsoperation till planen – och en föga överraskande CONVERT varning på roten SELECT operatör – men annars har det liten inverkan på prestandan.

Slutsats

Om du använder SQL Server 2017+ och du har någon FOR XML PATH strängaggregation i din kodbas rekommenderar jag starkt att du byter till det nya tillvägagångssättet. Jag utförde några mer grundliga prestandatester under den offentliga förhandsvisningen av SQL Server 2017 här och här kanske du vill besöka igen.

En vanlig invändning jag har hört är att människor är på SQL Server 2017 eller senare men fortfarande på en äldre kompatibilitetsnivå. Det verkar som att farhågan beror på att STRING_SPLIT() är ogiltig på kompatibilitetsnivåer lägre än 130, så de tror att STRING_AGG() fungerar på det här sättet också, men det är lite mildare. Det är bara ett problem om du använder WITHIN GROUP och en compat-nivå lägre än 110. Så förbättra dig!


  1. Undersöker ett ORA 02063 DG4ODBC-fel

  2. Hur undviker man dela med noll-fel i SQL?

  3. Hur man konfigurerar postgresql postgresql.conf listen_addresses för flera ip-adresser

  4. Introduktion till PL/SQL-paket i Oracle Database