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 & Sheila <> 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:
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:
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!