sql >> Database >  >> RDS >> Sqlserver

String-aggregatie door de jaren heen in SQL Server

Sinds SQL Server 2005 is de truc van het gebruik van FOR XML PATH om strings te denormaliseren en ze te combineren tot een enkele (meestal door komma's gescheiden) lijst is erg populair geweest. In SQL Server 2017 echter, STRING_AGG() eindelijk gehoor gegeven aan al lang bestaande en wijdverbreide pleidooien van de gemeenschap om GROUP_CONCAT() te simuleren en vergelijkbare functionaliteit die op andere platforms wordt gevonden. Ik ben onlangs begonnen met het aanpassen van veel van mijn Stack Overflow-antwoorden met behulp van de oude methode, zowel om de bestaande code te verbeteren als om een ​​extra voorbeeld toe te voegen dat beter geschikt is voor moderne versies.

Ik was een beetje geschrokken van wat ik vond.

Meer dan eens moest ik controleren of de code zelfs van mij was.

Een snel voorbeeld

Laten we eens kijken naar een eenvoudige demonstratie van het probleem. Iemand heeft een tabel als deze:

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

Op de pagina met de favoriete bands van elke gebruiker, willen ze dat de uitvoer er als volgt uitziet:

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

In de dagen van SQL Server 2005 zou ik deze oplossing hebben aangeboden:

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

Maar als ik nu terugkijk op deze code, zie ik veel problemen die ik niet kan laten om ze op te lossen.

DINGEN

De meest fatale fout in de bovenstaande code is dat er een komma achter blijft:

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

Om dit op te lossen, zie ik vaak dat mensen de zoekopdracht in een andere wikkelen en dan de Bands omringen uitvoer met LEFT(Bands, LEN(Bands)-1) . Maar dit is een onnodige extra berekening; in plaats daarvan kunnen we de komma naar het begin van de tekenreeks verplaatsen en de eerste een of twee tekens verwijderen met STUFF . Dan hoeven we de lengte van de string niet te berekenen omdat deze niet relevant is.

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

Je kunt dit verder aanpassen als je een langer of voorwaardelijk scheidingsteken gebruikt.

VERSCHILLEND

Het volgende probleem is het gebruik van DISTINCT . De manier waarop de code werkt, is dat de afgeleide tabel een door komma's gescheiden lijst genereert voor elke UserID waarde, dan worden de duplicaten verwijderd. We kunnen dit zien door naar het plan te kijken en te zien dat de XML-gerelateerde operator zeven keer wordt uitgevoerd, ook al worden er uiteindelijk slechts drie rijen geretourneerd:

Figuur 1:Plan met filter na aggregatie

Als we de code wijzigen om GROUP BY te gebruiken in plaats van 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;
--^^^^^^^^^^^^^^^

Het is een subtiel verschil en het verandert de resultaten niet, maar we kunnen zien dat het plan verbetert. In principe worden de XML-bewerkingen uitgesteld tot nadat de duplicaten zijn verwijderd:

Figuur 2:Plan met filter vóór aggregatie

Op deze schaal is het verschil niet materieel. Maar wat als we wat meer gegevens toevoegen? Op mijn systeem voegt dit iets meer dan 11.000 rijen toe:

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

Als we de twee query's opnieuw uitvoeren, zijn de verschillen in duur en CPU meteen duidelijk:

Figuur 3:Runtime-resultaten die DISTINCT en GROUP BY vergelijken

Maar ook andere bijwerkingen zijn duidelijk in de plannen. In het geval van DISTINCT , de UDX wordt opnieuw uitgevoerd voor elke rij in de tabel, er is een overdreven enthousiaste index-spool, er is een duidelijke sortering (altijd een rode vlag voor mij), en de query heeft een hoge geheugentoelage, wat een ernstige deuk in gelijktijdigheid kan zijn :

Figuur 4:DISTINCT plan op schaal

Ondertussen, in de GROUP BY query, wordt de UDX slechts één keer uitgevoerd voor elke unieke UserID , de enthousiaste spoel leest een veel lager aantal rijen, er is geen duidelijke sorteeroperator (deze is vervangen door een hash-overeenkomst) en de geheugentoekenning is klein in vergelijking:

Figuur 5:GROUP BY-plan op schaal

Het duurt een tijdje om terug te gaan en oude code op deze manier te repareren, maar ik ben al een tijdje erg gedisciplineerd om altijd GROUP BY te gebruiken in plaats van DISTINCT .

N-voorvoegsel

Te veel oude codevoorbeelden die ik tegenkwam, gingen ervan uit dat er nooit Unicode-tekens in gebruik zouden zijn, of de voorbeeldgegevens suggereerden in ieder geval niet de mogelijkheid. Ik zou mijn oplossing aanbieden zoals hierboven, en dan zou de gebruiker terugkomen en zeggen:"maar op één rij heb ik 'просто красный' , en het komt terug als '?????? ???????' !” Ik herinner mensen er vaak aan dat ze potentiële Unicode-tekenreeksen altijd moeten voorvoegen met het N-voorvoegsel, tenzij ze absoluut weten dat ze alleen te maken hebben met varchar tekenreeksen of gehele getallen. Ik begon er heel expliciet en waarschijnlijk zelfs te voorzichtig over te zijn:

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-entitisatie

Nog een "wat als?" scenario dat niet altijd aanwezig is in de voorbeeldgegevens van een gebruiker, zijn XML-tekens. Wat als mijn favoriete band bijvoorbeeld "Bob & Sheila <> Strawberries" heet? ”? De uitvoer met de bovenstaande query is XML-veilig gemaakt, wat niet is wat we altijd willen (bijv. Bob &amp; Sheila &lt;&gt; Strawberries ). Google-zoekopdrachten op dat moment suggereren "je moet TYPE toevoegen" ," en ik herinner me dat ik zoiets als dit probeerde:

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;

Helaas is het uitvoergegevenstype van de subquery in dit geval xml . Dit leidt tot de volgende foutmelding:

Msg 8116, Level 16, State 1
Argumentgegevenstype xml is ongeldig voor argument 1 van de stuff-functie.

U moet SQL Server vertellen dat u de resulterende waarde als een tekenreeks wilt extraheren door het gegevenstype aan te geven en dat u het eerste element wilt. Destijds zou ik dit als volgt toevoegen:

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;

Dit zou de tekenreeks retourneren zonder XML-entitisatie. Maar is dit het meest efficiënt? Vorig jaar herinnerde Charlieface me eraan dat meneer Magoo een aantal uitgebreide tests heeft uitgevoerd en ./text()[1] heeft gevonden. was sneller dan de andere (kortere) benaderingen zoals . en .[1] . (Ik hoorde dit oorspronkelijk van een opmerking die Mikael Eriksson hier voor me achterliet.) Ik heb mijn code opnieuw aangepast om er als volgt uit te zien:

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;

Je zou kunnen zien dat het op deze manier extraheren van de waarde leidt tot een iets complexer plan (je zou het niet weten als je alleen naar de duur kijkt, die vrij constant blijft tijdens de bovenstaande wijzigingen):

Figuur 6:Plannen met ./text()[1]

De waarschuwing op de root SELECT operator komt van de expliciete conversie naar nvarchar(max) .

Bestellen

Af en toe geven gebruikers aan dat bestellen belangrijk is. Vaak is dit eenvoudig sorteren op de kolom die u toevoegt, maar soms kan het ergens anders worden toegevoegd. Mensen hebben de neiging om te geloven dat als ze een specifieke bestelling eenmaal uit SQL Server hebben gezien, dit de volgorde is die ze altijd zullen zien, maar hier is geen betrouwbaarheid. Bestelling is nooit gegarandeerd, tenzij u het zegt. Laten we in dit geval zeggen dat we willen bestellen op BandName alfabetisch. We kunnen deze instructie toevoegen aan de subquery:

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;

Houd er rekening mee dat dit wat uitvoeringstijd kan toevoegen vanwege de extra sorteeroperator, afhankelijk van of er een ondersteunende index is.

STRING_AGG()

Terwijl ik mijn oude antwoorden update, die nog steeds zouden moeten werken met de versie die relevant was op het moment van de vraag, het laatste fragment hierboven (met of zonder de ORDER BY ) is het formulier dat u waarschijnlijk zult zien. Maar mogelijk ziet u ook een extra update voor de modernere vorm.

STRING_AGG() is misschien wel een van de beste functies die in SQL Server 2017 zijn toegevoegd. Het is zowel eenvoudiger als veel efficiënter dan een van de bovenstaande benaderingen, wat leidt tot nette, goed presterende query's zoals deze:

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

Dit is geen grap; dat is het. Dit is het plan - het belangrijkste is dat er maar één scan is tegen de tafel:

Figuur 7:STRING_AGG()-plan

Als je wilt bestellen, STRING_AGG() ondersteunt dit ook (zolang je in compatibiliteitsniveau 110 of hoger zit, zoals Martin Smith hier aangeeft):

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

Het plan ziet eruit hetzelfde als die zonder sorteren, maar de query is een beetje langzamer in mijn tests. Het is nog steeds veel sneller dan een van de FOR XML PATH variaties.

Indexen

Een hoop is niet eerlijk. Als u zelfs een niet-geclusterde index heeft die de query kan gebruiken, ziet het plan er nog beter uit. Bijvoorbeeld:

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

Hier is het plan voor dezelfde geordende zoekopdracht met STRING_AGG() — let op het ontbreken van een sorteeroperator, aangezien de scan kan worden besteld:

Figuur 8:STRING_AGG()-plan met een ondersteunende index

Dit scheelt ook wat tijd, maar om eerlijk te zijn helpt deze index de FOR XML PATH variaties ook. Dit is het nieuwe plan voor de bestelde versie van die zoekopdracht:

Figuur 9:FOR XML PATH-plan met een ondersteunende index

Het plan is een beetje vriendelijker dan voorheen, inclusief een zoekactie in plaats van een scan op één plek, maar deze aanpak is nog steeds aanzienlijk langzamer dan STRING_AGG() .

Een waarschuwing

Er is een kleine truc om STRING_AGG() te gebruiken waar, als de resulterende string meer dan 8.000 bytes is, je deze foutmelding krijgt:

Msg 9829, niveau 16, staat 1
STRING_AGG-aggregatieresultaat heeft de limiet van 8000 bytes overschreden. Gebruik LOB-typen om het afkappen van resultaten te voorkomen.

Om dit probleem te voorkomen, kunt u een expliciete conversie injecteren:

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

Dit voegt een scalaire rekenbewerking toe aan het plan - en een niet-verrassende CONVERT waarschuwing op de root SELECT operator, maar verder heeft het weinig invloed op de prestaties.

Conclusie

Als je SQL Server 2017+ gebruikt en je hebt een FOR XML PATH tekenreeksaggregatie in uw codebase, raad ik u ten zeerste aan om over te schakelen naar de nieuwe aanpak. Ik heb hier tijdens de openbare preview van SQL Server 2017 wat meer grondige prestatietests uitgevoerd en hier wil je misschien nog een keer naar kijken.

Een veelgehoord bezwaar dat ik heb gehoord, is dat mensen SQL Server 2017 of hoger gebruiken, maar nog steeds op een ouder compatibiliteitsniveau. Het lijkt erop dat de vrees is omdat STRING_SPLIT() is ongeldig op compatibiliteitsniveaus lager dan 130, dus denken ze STRING_AGG() werkt ook op deze manier, maar het is een beetje soepeler. Het is alleen een probleem als je WITHIN GROUP . gebruikt en een compat-niveau lager dan 110. Dus verbeter maar!


  1. PostgreSQL:tussen met datetime

  2. Voeg een samenvattingsrij toe met totalen

  3. Hoe localdb afzonderlijk te installeren?

  4. Omgaan met onbetrouwbare netwerken bij het maken van een HA-oplossing voor MySQL of MariaDB