Siden SQL Server 2005, tricket med at bruge FOR XML PATH
at denormalisere strenge og kombinere dem til en enkelt (normalt kommasepareret) liste har været meget populær. I SQL Server 2017 dog STRING_AGG()
endelig besvarede mangeårige og udbredte bønner fra fællesskabet om at simulere GROUP_CONCAT()
og lignende funktionalitet, der findes på andre platforme. Jeg begyndte for nylig at ændre mange af mine Stack Overflow-svar ved at bruge den gamle metode, både for at forbedre den eksisterende kode og for at tilføje et ekstra eksempel, der er bedre egnet til moderne versioner.
Jeg var lidt rystet over, hvad jeg fandt.
Ved mere end én lejlighed var jeg nødt til at dobbelttjekke, at koden endda var min.
Et hurtigt eksempel
Lad os se på en simpel demonstration af problemet. Nogen har en tabel som denne:
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å siden, der viser hver brugers yndlingsbånd, ønsker de, at outputtet skal se sådan ud:
UserID Bands ------ --------------------------------------- 1 Pink Floyd, New Order, The Hip 2 Zamfir, ABBA
I SQL Server 2005-dagene ville jeg have tilbudt denne løsning:
SELECT DISTINCT UserID, Bands = (SELECT BandName + ', ' FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')) FROM dbo.FavoriteBands AS fb;
Men når jeg ser tilbage på denne kode nu, ser jeg mange problemer, jeg ikke kan modstå at løse.
TING
Den mest fatale fejl i koden ovenfor er, at den efterlader et efterfølgende komma:
UserID Bands ------ --------------------------------------- 1 Pink Floyd, New Order, The Hip, 2 Zamfir, ABBA,
For at løse dette ser jeg ofte folk pakke forespørgslen ind i en anden og derefter omgive Bands
output med LEFT(Bands, LEN(Bands)-1)
. Men dette er unødvendig yderligere beregning; i stedet kan vi flytte kommaet til begyndelsen af strengen og fjerne det første eller to tegn ved hjælp af STUFF
. Så behøver vi ikke at beregne længden af strengen, fordi den er 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 justere dette yderligere, hvis du bruger en længere eller betinget afgrænsning.
DISTINKT
Det næste problem er brugen af DISTINCT
. Den måde koden fungerer på er, at den afledte tabel genererer en kommasepareret liste for hver UserID
værdi, så fjernes dubletterne. Vi kan se dette ved at se på planen og se den XML-relaterede operatør udføre syv gange, selvom kun tre rækker i sidste ende returneres:
Figur 1:Plan, der viser filter efter aggregering
Hvis vi ændrer koden til at bruge GROUP BY
i stedet for 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 er en subtil forskel, og det ændrer ikke resultaterne, men vi kan se, at planen forbedres. Grundlæggende udskydes XML-handlingerne, indtil dubletterne er fjernet:
Figur 2:Plan, der viser filter før aggregering
På denne skala er forskellen uvæsentlig. Men hvad nu hvis vi tilføjer nogle flere data? På mit system tilføjer dette lidt over 11.000 rækker:
INSERT dbo.FavoriteBands(UserID, BandName) SELECT [object_id], name FROM sys.all_columns;
Hvis vi kører de to forespørgsler igen, er forskellene i varighed og CPU umiddelbart indlysende:
Figur 3:Runtime-resultater, der sammenligner DISTINCT og GROUP BY
Men andre bivirkninger er også tydelige i planerne. I tilfælde af DISTINCT
UDX udføres igen for hver række i tabellen, der er en overdrevent ivrig indeksspole, der er en særskilt sortering (altid et rødt flag for mig), og forespørgslen har en høj hukommelsesbevilling, hvilket kan sætte et alvorligt indhug i samtidighed :
Figur 4:DISTINCT-plan i skala
I mellemtiden, i GROUP BY
forespørgsel, udføres UDX kun én gang for hver unikke UserID
, den ivrige spole læser et meget lavere antal rækker, der er ingen særskilt sorteringsoperator (den er blevet erstattet af en hash-match), og hukommelsesbevillingen er lille i sammenligning:
Figur 5:GROUP BY plan i skala
Det tager et stykke tid at gå tilbage og ordne gammel kode som denne, men i nogen tid nu har jeg været meget optaget af altid at bruge GROUP BY
i stedet for DISTINCT
.
N-præfiks
For mange gamle kodeeksempler, jeg stødte på, antog, at ingen Unicode-tegn nogensinde ville være i brug, eller i det mindste antydede prøvedataene ikke muligheden. Jeg ville tilbyde min løsning som ovenfor, og så ville brugeren komme tilbage og sige, "men på en række har jeg 'просто красный'
, og det kommer tilbage som '?????? ???????'
!" Jeg minder ofte folk om, at de altid skal præfikse potentielle Unicode-strengliteraler med N-præfikset, medmindre de absolut ved, at de kun nogensinde har at gøre med varchar
strenge eller heltal. Jeg begyndte at være meget eksplicit og sikkert endda overforsigtig 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
Endnu et "hvad nu hvis?" scenario, der ikke altid er til stede i en brugers eksempeldata, er XML-tegn. Hvad nu hvis mit yndlingsband hedder "Bob & Sheila <> Strawberries
”? Outputtet med ovenstående forespørgsel er gjort XML-sikkert, hvilket ikke er det, vi altid ønsker (f.eks. Bob & Sheila <> Strawberries
). Google-søgninger på det tidspunkt tyder på "du skal tilføje TYPE
", og jeg kan huske, at jeg prøvede noget som dette:
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;
Desværre er outputdatatypen fra underforespørgslen i dette tilfælde xml
. Dette fører til følgende fejlmeddelelse:
Argumentdatatypen xml er ugyldig for argument 1 af stuff-funktionen.
Du skal fortælle SQL Server, at du vil udtrække den resulterende værdi som en streng ved at angive datatypen, og at du vil have det første element. Dengang ville jeg tilføje dette som følgende:
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;
Dette ville returnere strengen uden XML-bekræftelse. Men er det det mest effektive? Sidste år mindede Charlieface mig om, at Mister Magoo udførte nogle omfattende tests og fandt ./text()[1]
var hurtigere end de andre (kortere) tilgange som .
og .[1]
. (Jeg hørte oprindeligt dette fra en kommentar Mikael Eriksson efterlod til mig her.) Jeg justerede endnu en gang min kode til at se sådan ud:
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 vil måske observere at udtrækning af værdien på denne måde fører til en lidt mere kompleks plan (du ville ikke vide det bare ved at se på varigheden, som forbliver ret konstant gennem ovenstående ændringer):
Figur 6:Planlæg med ./text()[1]
Advarslen på roden SELECT
operator kommer fra den eksplicitte konvertering til nvarchar(max)
.
Bestil
Nogle gange vil brugere udtrykke bestilling er vigtig. Ofte er dette simpelthen sortering efter den kolonne, du tilføjer - men nogle gange kan det tilføjes et andet sted. Folk har en tendens til at tro, at hvis de så en bestemt ordre komme ud af SQL Server én gang, er det den rækkefølge, de altid vil se, men der er ingen pålidelighed her. Ordren er aldrig garanteret, medmindre du siger det. Lad os i dette tilfælde sige, at vi vil bestille efter BandName
alfabetisk. Vi kan tilføje denne instruktion i underforespørgslen:
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;
Bemærk, at dette kan tilføje lidt eksekveringstid på grund af den ekstra sorteringsoperator, afhængigt af om der er et understøttende indeks.
STRING_AGG()
Mens jeg opdaterer mine gamle svar, som stadig burde fungere på den version, der var relevant på tidspunktet for spørgsmålet, vil det sidste uddrag ovenfor (med eller uden ORDER BY
) ) er den form, du sandsynligvis vil se. Men du vil muligvis også se en ekstra opdatering til den mere moderne form.
STRING_AGG()
er uden tvivl en af de bedste funktioner tilføjet i SQL Server 2017. Det er både enklere og langt mere effektivt end nogen af de ovennævnte tilgange, hvilket fører til ryddelige, velfungerende forespørgsler som denne:
SELECT UserID, Bands = STRING_AGG(BandName, N', ') FROM dbo.FavoriteBands GROUP BY UserID;
Dette er ikke en joke; det er det. Her er planen – vigtigst af alt er der kun en enkelt scanning mod bordet:
Figur 7:STRING_AGG() plan
Hvis du vil bestille, STRING_AGG()
understøtter også dette (så længe du er i kompatibilitetsniveau 110 eller højere, som Martin Smith påpeger her):
SELECT UserID, Bands = STRING_AGG(BandName, N', ') WITHIN GROUP (ORDER BY BandName) ----^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FROM dbo.FavoriteBands GROUP BY UserID;
Planen ser ud den samme som den uden sortering, men forespørgslen er en smule langsommere i mine tests. Det er stadig meget hurtigere end nogen af FOR XML PATH
variationer.
Indekser
En bunke er næppe rimelig. Hvis du selv har et ikke-klynget indeks, som forespørgslen kan bruge, ser planen endnu bedre ud. For eksempel:
CREATE INDEX ix_FavoriteBands ON dbo.FavoriteBands(UserID, BandName);
Her er planen for den samme ordnede forespørgsel ved hjælp af STRING_AGG()
—bemærk manglen på en sorteringsoperatør, da scanningen kan bestilles:
Figur 8:STRING_AGG() plan med et understøttende indeks
Dette barberer også noget fri - men for at være retfærdig hjælper dette indeks FOR XML PATH
også variationer. Her er den nye plan for den bestilte version af den forespørgsel:
Figur 9:FOR XML PATH-plan med et understøttende indeks
Planen er lidt venligere end før, inklusive en søgning i stedet for en scanning på ét sted, men denne tilgang er stadig betydeligt langsommere end STRING_AGG()
.
En advarsel
Der er et lille trick til at bruge STRING_AGG()
hvor, hvis den resulterende streng er mere end 8.000 bytes, vil du modtage denne fejlmeddelelse:
STRING_AGG-aggregationsresultat overskred grænsen på 8000 bytes. Brug LOB-typer for at undgå resultattrunkering.
For at undgå dette problem kan du injicere en eksplicit konvertering:
SELECT UserID, Bands = STRING_AGG(CONVERT(nvarchar(max), BandName), N', ') --------------------------^^^^^^^^^^^^^^^^^^^^^^ FROM dbo.FavoriteBands GROUP BY UserID;
Dette tilføjer en beregningsskalær operation til planen – og en ikke overraskende CONVERT
advarsel på roden SELECT
operatør – men ellers har det ringe indflydelse på ydeevnen.
Konklusion
Hvis du er på SQL Server 2017+, og du har nogen FOR XML PATH
strengaggregering i din kodebase, anbefaler jeg stærkt at skifte til den nye tilgang. Jeg udførte nogle mere grundige præstationstests tilbage under SQL Server 2017's offentlige forhåndsvisning her, og her vil du måske besøge igen.
En almindelig indvending, jeg har hørt, er, at folk er på SQL Server 2017 eller nyere, men stadig på et ældre kompatibilitetsniveau. Det ser ud til, at bekymringen skyldes STRING_SPLIT()
er ugyldig på kompatibilitetsniveauer lavere end 130, så de tror STRING_AGG()
fungerer også på denne måde, men det er lidt mere skånsomt. Det er kun et problem, hvis du bruger WITHIN GROUP
og et compat-niveau, der er lavere end 110. Så forbedre dig!