sql >> Databáze >  >> RDS >> Sqlserver

Agregace řetězců v průběhu let v SQL Server

Od SQL Server 2005 trik s použitím FOR XML PATH denormalizovat řetězce a spojit je do jednoho (obvykle čárkami odděleného) seznamu bylo velmi populární. V SQL Server 2017 však STRING_AGG() konečně odpověděl na dlouhotrvající a rozšířené prosby komunity o simulaci GROUP_CONCAT() a podobné funkce na jiných platformách. Nedávno jsem začal upravovat mnoho svých odpovědí Stack Overflow pomocí staré metody, abych vylepšil stávající kód a přidal další příklad, který se lépe hodí pro moderní verze.

Byl jsem trochu zděšen tím, co jsem našel.

Při více než jedné příležitosti jsem musel znovu zkontrolovat, zda je kód dokonce můj.

Rychlý příklad

Podívejme se na jednoduchou ukázku problému. Někdo má takovou tabulku:

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

Na stránce zobrazující oblíbené kapely každého uživatele chtějí, aby výstup vypadal takto:

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

Ve dnech SQL Server 2005 bych nabídl toto řešení:

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

Ale když se nyní podívám zpět na tento kód, vidím mnoho problémů, které nemohu odolat opravě.

VĚCI

Nejzávažnější chybou výše uvedeného kódu je, že zanechává koncovou čárku:

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

Abych to vyřešil, často vidím, že lidé zabalí dotaz do jiného a pak obklopí Bands výstup pomocí LEFT(Bands, LEN(Bands)-1) . Ale to jsou zbytečné dodatečné výpočty; místo toho můžeme čárku přesunout na začátek řetězce a odstranit první jeden nebo dva znaky pomocí STUFF . Pak nemusíme počítat délku řetězce, protože je irelevantní.

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

Toto můžete dále upravit, pokud používáte delší nebo podmíněný oddělovač.

DISTINCT

Dalším problémem je použití DISTINCT . Kód funguje tak, že odvozená tabulka generuje čárkami oddělený seznam pro každé UserID hodnotu, pak jsou duplikáty odstraněny. Můžeme to vidět, když se podíváme na plán a uvidíme, že operátor související s XML se provede sedmkrát, i když jsou nakonec vráceny pouze tři řádky:

Obrázek 1:Plán zobrazující filtr po agregaci

Pokud změníme kód tak, aby používal GROUP BY místo 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;
--^^^^^^^^^^^^^^^

Je to nepatrný rozdíl a nemění to výsledky, ale vidíme, že se plán zlepšuje. V zásadě jsou operace XML odloženy, dokud nebudou duplikáty odstraněny:

Obrázek 2:Plán zobrazující filtr před agregací

V tomto měřítku je rozdíl nepodstatný. Ale co když přidáme další data? V mém systému to přidává něco málo přes 11 000 řádků:

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

Pokud znovu spustíme tyto dva dotazy, rozdíly v trvání a CPU jsou okamžitě zřejmé:

Obrázek 3:Výsledky za běhu porovnávající DISTINCT a GROUP BY

V plánech jsou ale zřejmé i další vedlejší účinky. V případě DISTINCT , UDX se opět provádí pro každý řádek v tabulce, je zde příliš dychtivé zařazování indexů, existuje zřetelné řazení (pro mě vždy červená vlajka) a dotaz má vysokou paměť, což může způsobit vážnou trhlinu v souběžnosti :

Obrázek 4:DISTINCT plán v měřítku

Mezitím v GROUP BY dotaz, UDX se provede pouze jednou pro každé jedinečné UserID , dychtivá cívka čte mnohem menší počet řádků, neexistuje žádný odlišný operátor řazení (byl nahrazen hash match) a přidělení paměti je ve srovnání s tím nepatrné:

Obrázek 5:Plán GROUP BY v měřítku

Chvíli to trvá, než se vrátím a opravím starý kód, jako je tento, ale už nějakou dobu jsem velmi nucen vždy používat GROUP BY místo DISTINCT .

Předpona N

Příliš mnoho starých vzorků kódu, na které jsem narazil, předpokládalo, že se nikdy nebudou používat žádné znaky Unicode, nebo alespoň ukázková data tuto možnost nenaznačovala. Nabídl bych své řešení, jak je uvedeno výše, a pak by se uživatel vrátil a řekl:„ale na jednom řádku mám 'просто красный' a vrátí se jako '?????? ???????' !“ Často lidem připomínám, že vždy potřebují předponovat potenciální řetězcové literály Unicode předponou N, pokud absolutně neví, že budou mít co do činění pouze s varchar řetězce nebo celá čísla. Začal jsem být velmi explicitní a pravděpodobně i přehnaně opatrný:

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;

Entizace XML

Další "co kdyby?" scénář, který není vždy přítomen ve vzorových datech uživatele, jsou znaky XML. Například, co když se moje oblíbená kapela jmenuje „Bob & Sheila <> Strawberries? “? Výstup s výše uvedeným dotazem je bezpečný pro XML, což není to, co vždy chceme (např. Bob &amp; Sheila &lt;&gt; Strawberries ). Vyhledávání Google v té době naznačovalo:„Musíte přidat TYPE ,“ a vzpomínám si, že jsem zkusil něco takového:

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;

Bohužel výstupní datový typ z poddotazu je v tomto případě xml . To vede k následující chybové zprávě:

Msg 8116, Level 16, State 1
Datový typ argumentu xml je neplatný pro argument 1 funkce stuff.

SQL Serveru musíte sdělit, že chcete extrahovat výslednou hodnotu jako řetězec uvedením datového typu a že chcete první prvek. Tehdy bych to přidal následovně:

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;

To by vrátilo řetězec bez XML entitizace. Ale je to nejúčinnější? Minulý rok mi Charlieface připomněl, že pan Magoo provedl nějaké rozsáhlé testování a našel ./text()[1] byl rychlejší než ostatní (kratší) přístupy jako . a .[1] . (Původně jsem to slyšel z komentáře, který mi zde zanechal Mikael Eriksson.) Znovu jsem upravil svůj kód, aby vypadal takto:

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;

Můžete pozorovat, že extrahování hodnoty tímto způsobem vede k trochu složitějšímu plánu (nepoznáte to jen z pohledu na dobu trvání, která zůstává během výše uvedených změn poměrně konstantní):

Obrázek 6:Plán s ./text()[1]

Varování v kořenovém adresáři SELECT operátor pochází z explicitního převodu na nvarchar(max) .

Objednat

Občas by uživatelé vyjádřili objednávku, která je důležitá. Často je to jednoduše řazení podle sloupce, který připojujete – ale někdy to lze přidat i jinde. Lidé mají tendenci věřit, že pokud jednou z SQL Serveru viděli konkrétní objednávku, je to objednávka, kterou uvidí vždy, ale zde neexistuje žádná spolehlivost. Objednávka není nikdy zaručena, pokud to neřeknete. V tomto případě řekněme, že chceme objednávat podle BandName abecedně. Tuto instrukci můžeme přidat do poddotazu:

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;

Upozorňujeme, že to může zkrátit dobu provádění kvůli dalšímu operátoru řazení v závislosti na tom, zda existuje podpůrný index.

STRING_AGG()

Jak aktualizuji své staré odpovědi, které by měly stále fungovat na verzi, která byla relevantní v době otázky, poslední úryvek výše (s nebo bez ORDER BY ) je formulář, který pravděpodobně uvidíte. Ale můžete vidět další aktualizaci pro modernější formu.

STRING_AGG() je pravděpodobně jednou z nejlepších funkcí přidaných v SQL Server 2017. Je jednodušší a mnohem efektivnější než kterýkoli z výše uvedených přístupů, což vede k přehledným a dobře fungujícím dotazům, jako je tento:

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

To není vtip; a je to. Zde je plán – a co je nejdůležitější, existuje pouze jediné skenování tabulky:

Obrázek 7:Plán STRING_AGG()

Pokud chcete objednávat, STRING_AGG() podporuje to také (pokud máte úroveň kompatibility 110 nebo vyšší, jak zde zdůrazňuje Martin Smith):

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

Plán vypadá stejný jako ten bez řazení, ale dotaz je v mých testech o něco pomalejší. Stále je mnohem rychlejší než kterýkoli z FOR XML PATH variace.

Indexy

Hromada je stěží spravedlivá. Pokud máte dokonce i neshlukovaný index, který může dotaz použít, vypadá plán ještě lépe. Například:

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

Zde je plán pro stejný uspořádaný dotaz pomocí STRING_AGG() —Všimněte si, že chybí operátor řazení, protože skenování lze objednat:

Obrázek 8:Plán STRING_AGG() s podpůrným indexem

To také ušetří čas – ale abychom byli spravedliví, tento index pomáhá FOR XML PATH také variace. Zde je nový plán pro objednanou verzi tohoto dotazu:

Obrázek 9:Plán PRO XML PATH s podpůrným indexem

Plán je o něco přátelštější než dříve, zahrnuje vyhledávání namísto skenování na jednom místě, ale tento přístup je stále výrazně pomalejší než STRING_AGG() .

Upozornění

Při použití STRING_AGG() existuje malý trik kde, pokud je výsledný řetězec delší než 8 000 bajtů, zobrazí se tato chybová zpráva:

Msg 9829, Level 16, State 1
Výsledek agregace STRING_AGG překročil limit 8000 bajtů. Použijte typy LOB, abyste se vyhnuli zkrácení výsledku.

Chcete-li se tomuto problému vyhnout, můžete vložit explicitní konverzi:

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

Tím se do plánu přidá výpočetní skalární operace – a nepřekvapivé CONVERT varování v kořenovém adresáři SELECT operátor – ale jinak to má malý dopad na výkon.

Závěr

Pokud používáte SQL Server 2017+ a máte jakoukoli FOR XML PATH agregaci řetězců ve vaší kódové základně, velmi doporučuji přejít na nový přístup. Během veřejného náhledu SQL Server 2017 jsem provedl nějaké důkladnější testování výkonu zde a zde se možná budete chtít vrátit.

Častou námitkou, kterou jsem slyšel, je, že lidé používají SQL Server 2017 nebo vyšší, ale stále na starší úrovni kompatibility. Zdá se, že obavy jsou způsobeny STRING_SPLIT() je neplatný na úrovních kompatibility nižších než 130, takže si myslí, že STRING_AGG() funguje tímto způsobem také, ale je o něco mírnější. Je to problém pouze v případě, že používáte WITHIN GROUP a úroveň compat nižší než 110. Takže se zlepšujte!


  1. Převezměte kontrolu nad svými daty pomocí Microsoft Access

  2. Jak LocalTime() funguje v PostgreSQL

  3. GeneratedValue v Postgresu

  4. Jak funguje funkce SQL Server DIFFERENCE().