Sqlserver
 sql >> Database >  >> RDS >> Sqlserver

Aggregazione di stringhe nel corso degli anni in SQL Server

Da SQL Server 2005, il trucco dell'utilizzo di FOR XML PATH denormalizzare le stringhe e combinarle in un unico elenco (solitamente separato da virgole) è stato molto popolare. In SQL Server 2017, tuttavia, STRING_AGG() finalmente ha risposto alle richieste di lunga data e diffuse della comunità di simulare GROUP_CONCAT() e funzionalità simili presenti in altre piattaforme. Di recente ho iniziato a modificare molte delle mie risposte Stack Overflow utilizzando il vecchio metodo, sia per migliorare il codice esistente sia per aggiungere un ulteriore esempio più adatto alle versioni moderne.

Sono rimasto un po' sconvolto da ciò che ho trovato.

In più di un'occasione ho dovuto ricontrollare che il codice fosse anche mio.

Un rapido esempio

Diamo un'occhiata a una semplice dimostrazione del problema. Qualcuno ha un tavolo come questo:

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

Nella pagina che mostra le bande preferite di ogni utente, vogliono che l'output assomigli a questo:

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

Nei giorni di SQL Server 2005, avrei offerto questa soluzione:

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

Ma quando guardo indietro a questo codice ora, vedo molti problemi che non riesco a resistere a risolvere.

ROBA

Il difetto più fatale nel codice sopra è che lascia una virgola finale:

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

Per risolvere questo problema, vedo spesso persone che avvolgono la query all'interno di un'altra e quindi circondano le Bands output con LEFT(Bands, LEN(Bands)-1) . Ma questo è un calcolo aggiuntivo inutile; possiamo invece spostare la virgola all'inizio della stringa e rimuovere i primi uno o due caratteri usando STUFF . Quindi, non dobbiamo calcolare la lunghezza della stringa perché è irrilevante.

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

Puoi modificarlo ulteriormente se stai utilizzando un delimitatore più lungo o condizionale.

DISTINTA

Il prossimo problema è l'uso di DISTINCT . Il modo in cui funziona il codice è che la tabella derivata genera un elenco separato da virgole per ogni UserID valore, i duplicati vengono rimossi. Possiamo vederlo osservando il piano e osservando che l'operatore relativo a XML viene eseguito sette volte, anche se alla fine vengono restituite solo tre righe:

Figura 1:piano che mostra il filtro dopo l'aggregazione

Se cambiamo il codice per utilizzare GROUP BY invece di 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;
--^^^^^^^^^^^^^^^

È una sottile differenza e non cambia i risultati, ma possiamo vedere che il piano migliora. Fondamentalmente, le operazioni XML vengono posticipate fino a dopo la rimozione dei duplicati:

Figura 2:piano che mostra il filtro prima dell'aggregazione

A questa scala, la differenza è irrilevante. Ma cosa succede se aggiungiamo altri dati? Sul mio sistema, questo aggiunge poco più di 11.000 righe:

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

Se eseguiamo nuovamente le due query, le differenze di durata e CPU sono immediatamente evidenti:

Figura 3:risultati di runtime confrontando DISTINCT e GROUP BY

Ma anche altri effetti collaterali sono evidenti nei piani. Nel caso di DISTINCT , l'UDX viene eseguito ancora una volta per ogni riga della tabella, c'è uno spool di indice eccessivamente ansioso, c'è un ordinamento distinto (sempre una bandiera rossa per me) e la query ha una concessione di memoria elevata, che può intaccare seriamente la concorrenza :

Figura 4:piano DISTINCT su larga scala

Nel frattempo, nel GROUP BY query, l'UDX viene eseguito solo una volta per ogni UserID univoco , lo spool desideroso legge un numero molto inferiore di righe, non esiste un operatore di ordinamento distinto (è stato sostituito da una corrispondenza hash) e la concessione di memoria è minima in confronto:

Figura 5:piano GROUP BY su larga scala

Ci vuole un po' per tornare indietro e correggere il vecchio codice come questo, ma da un po' di tempo sono stato molto irregimentato nell'usare sempre GROUP BY invece di DISTINCT .

Prefisso N

Troppi vecchi esempi di codice in cui mi sono imbattuto presumevano che nessun carattere Unicode sarebbe mai stato utilizzato, o almeno i dati di esempio non ne suggerivano la possibilità. Offrirei la mia soluzione come sopra, quindi l'utente torna indietro e dice:"ma su una riga ho 'просто красный' , e torna come '?????? ???????' !” Ricordo spesso alle persone che hanno sempre bisogno di anteporre potenziali stringhe letterali Unicode con il prefisso N a meno che non sappiano assolutamente che avranno a che fare solo con varchar stringhe o numeri interi. Ho iniziato a essere molto esplicito e probabilmente anche eccessivamente cauto al riguardo:

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;

Entitizzazione XML

Un altro "e se?" lo scenario non sempre presente nei dati di esempio di un utente è rappresentato dai caratteri XML. Ad esempio, cosa succede se la mia band preferita si chiama "Bob & Sheila <> Strawberries ”? L'output con la query sopra è reso sicuro per XML, che non è quello che vogliamo sempre (ad esempio, Bob &amp; Sheila &lt;&gt; Strawberries ). Le ricerche su Google in quel momento suggerirebbero "è necessario aggiungere TYPE ”, e ricordo di aver provato qualcosa del genere:

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;

Sfortunatamente, il tipo di dati di output dalla sottoquery in questo caso è xml . Questo porta al seguente messaggio di errore:

Msg 8116, livello 16, stato 1
Il tipo di dati dell'argomento xml non è valido per l'argomento 1 della funzione stuff.

È necessario indicare a SQL Server che si desidera estrarre il valore risultante come una stringa indicando il tipo di dati e che si desidera il primo elemento. Allora, lo aggiungerei come segue:

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;

Ciò restituirebbe la stringa senza autorizzazione XML. Ma è il più efficiente? L'anno scorso, Charlieface mi ha ricordato che il signor Magoo ha eseguito dei test approfonditi e ha trovato ./text()[1] era più veloce degli altri approcci (più brevi) come . e .[1] . (Inizialmente l'ho sentito da un commento che Mikael Eriksson mi ha lasciato qui.) Ancora una volta ho modificato il mio codice in modo che assomigli a questo:

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;

Potresti osservare che estrarre il valore in questo modo porta a un piano leggermente più complesso (non lo sapresti solo guardando la durata, che rimane abbastanza costante durante le modifiche precedenti):

Figura 6:piano con ./text()[1]

L'avviso sulla radice SELECT operatore deriva dalla conversione esplicita in nvarchar(max) .

Ordine

Occasionalmente, gli utenti esprimerebbero l'importanza dell'ordine. Spesso, questo è semplicemente un ordine in base alla colonna che stai aggiungendo, ma a volte può essere aggiunto da qualche altra parte. Le persone tendono a credere che se hanno visto un ordine specifico uscire una volta da SQL Server, è l'ordine che vedranno sempre, ma non c'è affidabilità qui. L'ordine non è mai garantito a meno che tu non lo dica. In questo caso, supponiamo di voler ordinare per BandName in ordine alfabetico. Possiamo aggiungere questa istruzione all'interno della sottoquery:

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;

Tieni presente che questo potrebbe aggiungere un po' di tempo di esecuzione a causa dell'operatore di ordinamento aggiuntivo, a seconda che sia presente un indice di supporto.

STRING_AGG()

Mentre aggiorno le mie vecchie risposte, che dovrebbero ancora funzionare sulla versione rilevante al momento della domanda, lo snippet finale sopra (con o senza ORDER BY ) è il modulo che probabilmente vedrai. Ma potresti vedere anche un aggiornamento aggiuntivo per il modulo più moderno.

STRING_AGG() è probabilmente una delle migliori funzionalità aggiunte in SQL Server 2017. È sia più semplice che molto più efficiente di qualsiasi approccio sopra, portando a query ordinate e ben eseguite come questa:

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

Questo non è uno scherzo; questo è tutto. Ecco il piano:soprattutto, c'è solo una singola scansione sul tavolo:

Figura 7:piano STRING_AGG()

Se vuoi ordinare, STRING_AGG() supporta anche questo (purché tu sia nel livello di compatibilità 110 o superiore, come sottolinea Martin Smith qui):

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

Il piano sembra lo stesso di quello senza ordinamento, ma la query è leggermente più lenta nei miei test. È ancora molto più veloce di qualsiasi FOR XML PATH variazioni.

Indici

Un mucchio non è giusto. Se hai anche un indice non cluster che la query può utilizzare, il piano sembra ancora migliore. Ad esempio:

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

Ecco il piano per la stessa query ordinata utilizzando STRING_AGG() —notare la mancanza di un operatore di ordinamento, poiché la scansione può essere ordinata:

Figura 8:piano STRING_AGG() con un indice di supporto

Anche questo fa risparmiare tempo, ma per essere onesti, questo indice aiuta il FOR XML PATH anche variazioni. Ecco il nuovo piano per la versione ordinata di quella query:

Figura 9:PER piano PATH XML con un indice di supporto

Il piano è un po' più amichevole rispetto a prima, includendo una ricerca invece di una scansione in un punto, ma questo approccio è ancora significativamente più lento di STRING_AGG() .

Un avvertimento

C'è un piccolo trucco per usare STRING_AGG() dove, se la stringa risultante è superiore a 8.000 byte, riceverai questo messaggio di errore:

Il risultato dell'aggregazione Msg 9829, livello 16, stato 1
STRING_AGG ha superato il limite di 8000 byte. Utilizzare i tipi LOB per evitare il troncamento dei risultati.

Per evitare questo problema, puoi inserire una conversione esplicita:

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

Ciò aggiunge un'operazione scalare di calcolo al piano e un non sorprendente CONVERT avviso sulla radice SELECT operatore, ma per il resto ha scarso impatto sulle prestazioni.

Conclusione

Se utilizzi SQL Server 2017+ e hai qualsiasi FOR XML PATH aggregazione di stringhe nella tua base di codice, consiglio vivamente di passare al nuovo approccio. Ho eseguito alcuni test delle prestazioni più approfonditi durante l'anteprima pubblica di SQL Server 2017 qui e qui potresti voler rivisitare.

Un'obiezione comune che ho sentito è che le persone utilizzano SQL Server 2017 o versioni successive ma sono ancora a un livello di compatibilità precedente. Sembra che l'apprensione sia dovuta al fatto che STRING_SPLIT() non è valido su livelli di compatibilità inferiori a 130, quindi pensano STRING_AGG() funziona anche in questo modo, ma è un po' più indulgente. È solo un problema se stai usando WITHIN GROUP e un livello di compatibilità inferiore a 110. Quindi migliora!