Sqlserver
 sql >> Datenbank >  >> RDS >> Sqlserver

Zeichenfolgenaggregation im Laufe der Jahre in SQL Server

Seit SQL Server 2005 gibt es den Trick, FOR XML PATH zu verwenden Zeichenfolgen zu denormalisieren und sie zu einer einzigen (normalerweise durch Kommas getrennten) Liste zusammenzufassen, war sehr beliebt. In SQL Server 2017 jedoch STRING_AGG() beantwortete endlich langjährige und weit verbreitete Bitten der Community, GROUP_CONCAT() zu simulieren und ähnliche Funktionen, die in anderen Plattformen zu finden sind. Ich habe vor kurzem begonnen, viele meiner Stack Overflow-Antworten mit der alten Methode zu ändern, sowohl um den vorhandenen Code zu verbessern als auch um ein zusätzliches Beispiel hinzuzufügen, das besser für moderne Versionen geeignet ist.

Ich war etwas entsetzt über das, was ich vorfand.

Bei mehr als einer Gelegenheit musste ich überprüfen, ob der Code überhaupt meiner war.

Ein kurzes Beispiel

Schauen wir uns eine einfache Demonstration des Problems an. Jemand hat eine Tabelle wie diese:

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

Auf der Seite mit den Lieblingsbands jedes Benutzers soll die Ausgabe so aussehen:

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

In den Tagen von SQL Server 2005 hätte ich diese Lösung angeboten:

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

Aber wenn ich jetzt auf diesen Code zurückblicke, sehe ich viele Probleme, denen ich nicht widerstehen kann, sie zu beheben.

MATERIAL

Der schwerwiegendste Fehler im obigen Code ist, dass er ein nachgestelltes Komma hinterlässt:

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

Um dies zu lösen, sehe ich oft Leute, die die Abfrage in eine andere packen und dann die Bänder umgeben Ausgabe mit LEFT(Bands, LEN(Bands)-1) . Dies ist jedoch eine unnötige zusätzliche Berechnung; Stattdessen können wir das Komma an den Anfang des Strings verschieben und die ersten ein oder zwei Zeichen mit STUFF entfernen . Dann müssen wir die Länge der Zeichenfolge nicht berechnen, da sie irrelevant ist.

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

Sie können dies weiter anpassen, wenn Sie ein längeres oder bedingtes Trennzeichen verwenden.

UNTERSCHIEDLICH

Das nächste Problem ist die Verwendung von DISTINCT . Der Code funktioniert so, dass die abgeleitete Tabelle eine durch Kommas getrennte Liste für jede UserID generiert Wert, dann werden die Duplikate entfernt. Wir können dies sehen, wenn wir uns den Plan ansehen und sehen, dass der XML-bezogene Operator siebenmal ausgeführt wird, obwohl letztendlich nur drei Zeilen zurückgegeben werden:

Abbildung 1:Plan, der den Filter nach der Aggregation zeigt

Wenn wir den Code ändern, um GROUP BY zu verwenden statt 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;
--^^^^^^^^^^^^^^^

Es ist ein subtiler Unterschied und ändert nichts an den Ergebnissen, aber wir können sehen, dass sich der Plan verbessert. Grundsätzlich werden die XML-Operationen zurückgestellt, bis die Duplikate entfernt wurden:

Abbildung 2:Plan zeigt den Filter vor der Aggregation

Auf dieser Skala ist der Unterschied unerheblich. Aber was ist, wenn wir weitere Daten hinzufügen? Auf meinem System fügt dies etwas mehr als 11.000 Zeilen hinzu:

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

Wenn wir die beiden Abfragen erneut ausführen, sind die Unterschiede in Dauer und CPU sofort offensichtlich:

Abbildung 3:Laufzeitergebnisse beim Vergleich von DISTINCT und GROUP BY

Aber auch andere Nebenwirkungen sind in den Plänen offensichtlich. Im Fall von DISTINCT , die UDX wird erneut für jede Zeile in der Tabelle ausgeführt, es gibt einen übermäßig eifrigen Index-Spool, es gibt eine eindeutige Sortierung (bei mir immer eine rote Flagge) und die Abfrage hat eine hohe Speicherzuweisung, was die Parallelität ernsthaft beeinträchtigen kann :

Abbildung 4:DISTINCT-Plan im Maßstab

In der Zwischenzeit im GROUP BY -Abfrage wird die UDX nur einmal für jede eindeutige UserID ausgeführt , die eifrige Spule liest eine viel geringere Anzahl von Zeilen, es gibt keinen eindeutigen Sortieroperator (er wurde durch einen Hash-Match ersetzt) ​​und die Speicherzuteilung ist im Vergleich dazu winzig:

Abbildung 5:GROUP BY-Plan im Maßstab

Es dauert eine Weile, um zurückzugehen und alten Code wie diesen zu reparieren, aber seit einiger Zeit bin ich sehr reglementiert, wenn es darum geht, immer GROUP BY zu verwenden statt DISTINCT .

N-Präfix

Zu viele alte Codebeispiele, auf die ich gestoßen bin, gingen davon aus, dass niemals Unicode-Zeichen verwendet werden würden, oder zumindest deuteten die Beispieldaten nicht auf diese Möglichkeit hin. Ich würde meine Lösung wie oben anbieten, und dann würde der Benutzer zurückkommen und sagen:„Aber in einer Zeile habe ich 'просто красный' , und es kommt zurück als '?????? ???????' !” Ich erinnere die Leute oft daran, dass sie potenziellen Unicode-String-Literalen immer das Präfix N voranstellen müssen, es sei denn, sie wissen absolut, dass sie es immer nur mit varchar zu tun haben werden Zeichenfolgen oder ganze Zahlen. Ich fing an, sehr explizit und wahrscheinlich sogar übervorsichtig zu sein:

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

Ein weiteres „Was wäre wenn?“ Szenario, das in den Beispieldaten eines Benutzers nicht immer vorhanden ist, sind XML-Zeichen. Was wäre zum Beispiel, wenn meine Lieblingsband „Bob &Sheila <> Strawberries“ heißt “? Die Ausgabe mit der obigen Abfrage wird XML-sicher gemacht, was wir nicht immer wollen (z. B. Bob & Sheila <> Strawberries ). Google-Suchanfragen schlugen damals vor, „Sie müssen TYPE hinzufügen “, und ich erinnere mich, dass ich so etwas versucht habe:

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;

Leider ist der Ausgabedatentyp der Unterabfrage in diesem Fall xml . Dies führt zu folgender Fehlermeldung:

Msg 8116, Level 16, State 1
Argumentdatentyp xml ist ungültig für Argument 1 der Stuff-Funktion.

Sie müssen SQL Server mitteilen, dass Sie den resultierenden Wert als Zeichenfolge extrahieren möchten, indem Sie den Datentyp und das erste Element angeben. Damals würde ich das wie folgt hinzufügen:

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;

Dies würde die Zeichenfolge ohne XML-Entität zurückgeben. Aber ist es das effizienteste? Letztes Jahr erinnerte mich Charlieface daran, dass Mister Magoo einige umfangreiche Tests durchgeführt und ./text()[1] gefunden hatte war schneller als die anderen (kürzeren) Ansätze wie . und .[1][code> . (Ich habe dies ursprünglich aus einem Kommentar gehört, den Mikael Eriksson hier für mich hinterlassen hat.) Ich habe meinen Code noch einmal so angepasst, dass er so aussieht:

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;

Sie können beobachten, dass das Extrahieren des Werts auf diese Weise zu einem etwas komplexeren Plan führt (Sie würden es nicht erkennen, wenn Sie nur die Dauer betrachten, die während der obigen Änderungen ziemlich konstant bleibt):

Abbildung 6:Planen mit ./text()[1]

Die Warnung auf der Wurzel SELECT Operator stammt aus der expliziten Konvertierung in nvarchar(max) .

Bestellung

Gelegentlich würden Benutzer zum Ausdruck bringen, dass die Bestellung wichtig ist. Oft wird einfach nach der Spalte sortiert, die Sie anhängen – aber manchmal kann es an anderer Stelle hinzugefügt werden. Die Leute neigen dazu zu glauben, wenn sie einmal eine bestimmte Reihenfolge aus SQL Server kommen sehen, ist es die Reihenfolge, die sie immer sehen werden, aber hier gibt es keine Zuverlässigkeit. Ordnung ist nie garantiert, es sei denn, Sie sagen es. Nehmen wir in diesem Fall an, wir möchten nach BandName sortieren alphabetisch. Wir können diese Anweisung in die Unterabfrage einfügen:

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;

Beachten Sie, dass dies aufgrund des zusätzlichen Sortieroperators möglicherweise etwas Ausführungszeit hinzufügt, je nachdem, ob ein unterstützender Index vorhanden ist.

STRING_AGG()

Da ich meine alten Antworten aktualisiere, die immer noch auf der Version funktionieren sollten, die zum Zeitpunkt der Frage relevant war, wird das letzte Snippet oben (mit oder ohne ORDER BY ) ist das Formular, das Sie wahrscheinlich sehen werden. Möglicherweise sehen Sie aber auch ein zusätzliches Update für die modernere Form.

STRING_AGG() ist wohl eine der besten Funktionen, die in SQL Server 2017 hinzugefügt wurden. Es ist sowohl einfacher als auch weitaus effizienter als alle oben genannten Ansätze und führt zu sauberen, gut funktionierenden Abfragen wie dieser:

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

Das ist kein Witz; das ist es. Hier ist der Plan – am wichtigsten ist, dass es nur einen einzigen Scan gegen die Tabelle gibt:

Abbildung 7:STRING_AGG()-Plan

Wenn Sie bestellen möchten, STRING_AGG() unterstützt dies auch (solange Sie sich in Kompatibilitätsstufe 110 oder höher befinden, wie Martin Smith hier betont):

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

Der Plan sieht aus das gleiche wie ohne Sortierung, aber die Abfrage ist in meinen Tests etwas langsamer. Es ist immer noch viel schneller als jeder der FOR XML PATH Variationen.

Indizes

Ein Haufen ist kaum fair. Wenn Sie sogar einen nicht gruppierten Index haben, den die Abfrage verwenden kann, sieht der Plan noch besser aus. Zum Beispiel:

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

Hier ist der Plan für dieselbe geordnete Abfrage mit STRING_AGG() – Beachten Sie das Fehlen eines Sortieroperators, da der Scan bestellt werden kann:

Abbildung 8:STRING_AGG()-Plan mit einem unterstützenden Index

Das spart auch etwas Zeit – aber um fair zu sein, dieser Index hilft dem FOR XML PATH auch Variationen. Hier ist der neue Plan für die geordnete Version dieser Abfrage:

Abbildung 9:FOR XML PATH-Plan mit einem unterstützenden Index

Der Plan ist etwas freundlicher als zuvor, einschließlich einer Suche anstelle eines Scans an einer Stelle, aber dieser Ansatz ist immer noch deutlich langsamer als STRING_AGG() .

Eine Einschränkung

Es gibt einen kleinen Trick bei der Verwendung von STRING_AGG() Wenn die resultierende Zeichenfolge mehr als 8.000 Bytes beträgt, erhalten Sie diese Fehlermeldung:

Msg 9829, Level 16, State 1
Das STRING_AGG-Aggregationsergebnis hat die Grenze von 8000 Byte überschritten. Verwenden Sie LOB-Typen, um das Abschneiden von Ergebnissen zu vermeiden.

Um dieses Problem zu vermeiden, können Sie eine explizite Konvertierung einfügen:

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

Dies fügt dem Plan eine skalare Berechnungsoperation hinzu – und ein wenig überraschendes CONVERT Warnung auf der Wurzel SELECT -Operator – aber ansonsten hat es kaum Auswirkungen auf die Leistung.

Schlussfolgerung

Wenn Sie SQL Server 2017+ verwenden und einen FOR XML PATH haben String-Aggregation in Ihrer Codebasis, empfehle ich dringend, auf den neuen Ansatz umzusteigen. Ich habe einige gründlichere Leistungstests während der öffentlichen Vorschau von SQL Server 2017 hier und hier durchgeführt, die Sie vielleicht noch einmal besuchen möchten.

Ein häufiger Einwand, den ich gehört habe, ist, dass die Leute SQL Server 2017 oder höher verwenden, sich aber immer noch auf einem älteren Kompatibilitätslevel befinden. Es scheint, dass die Befürchtung auf STRING_SPLIT() zurückzuführen ist ist auf Kompatibilitätsstufen unter 130 ungültig, also denken sie STRING_AGG() geht auch so, ist aber etwas milder. Es ist nur ein Problem, wenn Sie WITHIN GROUP verwenden und ein Kompatibilitätslevel unter 110. Also besser werden!