От SQL Server 2005, трикът за използване на FOR XML PATH
денормализирането на низове и комбинирането им в един (обикновено разделен със запетая) списък е много популярен. В SQL Server 2017 обаче STRING_AGG()
най-накрая отговори на дългогодишни и широко разпространени молби от общността за симулиране на GROUP_CONCAT()
и подобна функционалност, намираща се в други платформи. Наскоро започнах да променям много от отговорите си на Stack Overflow, използвайки стария метод, както за подобряване на съществуващия код, така и за добавяне на допълнителен пример, по-подходящ за съвременните версии.
Бях малко ужасен от това, което открих.
Повече от един път трябваше да проверя дали кодът е дори мой.
Бърз пример
Нека разгледаме проста демонстрация на проблема. Някой има таблица като тази:
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');
На страницата, показваща любимите групи на всеки потребител, те искат изходът да изглежда така:
UserID Bands ------ --------------------------------------- 1 Pink Floyd, New Order, The Hip 2 Zamfir, ABBA
В дните на SQL Server 2005 щях да предложа това решение:
SELECT DISTINCT UserID, Bands = (SELECT BandName + ', ' FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')) FROM dbo.FavoriteBands AS fb;
Но когато погледна назад към този код сега, виждам много проблеми, на които не мога да устоя да поправя.
НЕЩА
Най-фаталният недостатък в кода по-горе е, че оставя запетая в края:
UserID Bands ------ --------------------------------------- 1 Pink Floyd, New Order, The Hip, 2 Zamfir, ABBA,
За да разреша това, често виждам хора да обвиват заявката в друга и след това обграждат Bands
изход с LEFT(Bands, LEN(Bands)-1)
. Но това е излишно допълнително изчисление; вместо това можем да преместим запетаята в началото на низа и да премахнем първите един или два знака с помощта на STUFF
. След това не е нужно да изчисляваме дължината на низа, защото е без значение.
SELECT DISTINCT UserID, Bands = STUFF( --------------------------------^^^^^^ (SELECT ', ' + BandName --------------^^^^^^ FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')), 1, 2, '') --------------------------^^^^^^^^^^^ FROM dbo.FavoriteBands AS fb;
Можете да коригирате това допълнително, ако използвате по-дълъг или условен разделител.
РАЗЛИЧЕН
Следващият проблем е използването на DISTINCT
. Начинът, по който работи кодът, е, че извлечената таблица генерира разделен със запетая списък за всеки UserID
стойност, след което дубликатите се премахват. Можем да видим това, като погледнем плана и видим, че операторът, свързан с XML, се изпълнява седем пъти, въпреки че в крайна сметка се връщат само три реда:
Фигура 1:План, показващ филтър след агрегиране
Ако променим кода да използва GROUP BY
вместо 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; --^^^^^^^^^^^^^^^
Това е фина разлика и не променя резултатите, но можем да видим, че планът се подобрява. По принцип XML операциите се отлагат, докато не бъдат премахнати дубликатите:
Фигура 2:План, показващ филтър преди агрегиране
В този мащаб разликата е несъществена. Но какво ще стане, ако добавим още данни? В моята система това добавя малко над 11 000 реда:
INSERT dbo.FavoriteBands(UserID, BandName) SELECT [object_id], name FROM sys.all_columns;
Ако изпълним двете заявки отново, разликите в продължителността и процесора са веднага очевидни:
Фигура 3:Резултати по време на изпълнение, сравняващи DISTINCT и GROUP BY
Но други странични ефекти също са очевидни в плановете. В случай на DISTINCT
UDX отново се изпълнява за всеки ред в таблицата, има прекомерно нетърпелив пул с индекси, има отделно сортиране (винаги червен флаг за мен) и заявката има висока памет, което може да постави сериозна вдлъбнатина в паралелността :
Фигура 4:РАЗЛИЧЕН план в мащаб
Междувременно в GROUP BY
заявка, UDX се изпълнява само веднъж за всеки уникален UserID
, нетърпеливият буфер чете много по-малък брой редове, няма отделен оператор за сортиране (той е заменен от хеш съвпадение), а предоставената памет е малка в сравнение:
Фигура 5:План GROUP BY в мащаб
Отнема известно време, за да се върна назад и да поправя стария код като този, но от известно време бях много ограничен винаги да използвам GROUP BY
вместо DISTINCT
.
N префикс
Твърде много стари кодови образци, на които попаднах, предполагаха, че никога няма да се използват символи на Unicode или поне данните от примера не предполагаха възможността. Бих предложил моето решение, както е по-горе, а след това потребителят ще се върне и ще каже:„но на един ред имам 'просто красный'
, и се връща като '?????? ???????'
!” Често напомням на хората, че винаги трябва да поставят префикс на потенциалните Unicode низови литерали с префикса N, освен ако не знаят абсолютно, че ще имат работа само с varchar
низове или цели числа. Започнах да бъда много ясен и вероятно дори прекалено предпазлив по отношение на това:
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 Entitization
Още едно "ами ако?" Сценарият, който не винаги присъства в примерните данни на потребителя, са XML знаци. Например, какво ще стане, ако любимата ми група се казва „Bob & Sheila <> Strawberries
”? Резултатът с горната заявка е направен XML-безопасен, което не е това, което винаги искаме (напр. Bob & Sheila <> Strawberries
). Търсенията с Google по това време биха предложили „трябва да добавите TYPE
”, и си спомням, че опитах нещо подобно:
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;
За съжаление, изходният тип данни от подзаявката в този случай е xml
. Това води до следното съобщение за грешка:
Аргументният тип данни xml е невалиден за аргумент 1 на функцията stuff.
Трябва да кажете на SQL Server, че искате да извлечете получената стойност като низ, като посочите типа данни и че искате първия елемент. Тогава бих добавил това, както следва:
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;
Това ще върне низа без XML entitization. Но дали е най-ефективният? Миналата година Чарлифейс ми напомни, че господин Магу извърши някои обширни тестове и откри ./text()[1]
беше по-бърз от другите (по-кратки) подходи като .
и .[1]
. (Първоначално чух това от коментар, който Микаел Ериксон остави за мен тук.) Още веднъж коригирах кода си, за да изглежда така:
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;
Може да забележите, че извличането на стойността по този начин води до малко по-сложен план (няма да го разберете само като погледнете продължителността, която остава доста постоянна по време на горните промени):
Фигура 6:Планирайте с ./text()[1]
Предупреждението в основния SELECT
операторът идва от изричното преобразуване в nvarchar(max)
.
Поръчка
Понякога потребителите биха изразили поръчването е важно. Често това е просто подреждане по колоната, която добавяте, но понякога тя може да бъде добавена някъде другаде. Хората са склонни да вярват, че ако веднъж са видели определен ред да излиза от SQL Server, това е реда, който винаги ще виждат, но тук няма надеждност. Редът никога не е гарантиран, освен ако не го кажете. В този случай да кажем, че искаме да поръчаме по BandName
по азбучен ред. Можем да добавим тази инструкция в подзаявката:
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;
Имайте предвид, че това може да добави малко време за изпълнение поради допълнителния оператор за сортиране, в зависимост от това дали има поддържащ индекс.
STRING_AGG()
Докато актуализирам старите си отговори, които все още трябва да работят върху версията, която е била уместна към момента на въпроса, последният фрагмент по-горе (със или без ORDER BY
) е формата, която вероятно ще видите. Но може да видите и допълнителна актуализация за по-модерната форма.
STRING_AGG()
е може би една от най-добрите функции, добавени в SQL Server 2017. Той е едновременно по-прост и много по-ефективен от всеки от горните подходи, което води до подредени, добре работещи заявки като това:
SELECT UserID, Bands = STRING_AGG(BandName, N', ') FROM dbo.FavoriteBands GROUP BY UserID;
Това не е шега; това е. Ето плана – най-важното е, че има само едно сканиране спрямо масата:
Фигура 7:STRING_AGG() план
Ако искате да поръчате, STRING_AGG()
поддържа и това (стига да сте на ниво на съвместимост 110 или по-високо, както Мартин Смит посочва тук):
SELECT UserID, Bands = STRING_AGG(BandName, N', ') WITHIN GROUP (ORDER BY BandName) ----^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FROM dbo.FavoriteBands GROUP BY UserID;
Планът изглежда същата като тази без сортиране, но заявката е малко по-бавна в моите тестове. Все още е много по-бърз от който и да е от FOR XML PATH
вариации.
Индекси
Купчина едва ли е справедлива. Ако имате дори неклъстериран индекс, който заявката може да използва, планът изглежда още по-добре. Например:
CREATE INDEX ix_FavoriteBands ON dbo.FavoriteBands(UserID, BandName);
Ето плана за същата подредена заявка с помощта на STRING_AGG()
— обърнете внимание на липсата на оператор за сортиране, тъй като сканирането може да бъде поръчано:
Фигура 8:STRING_AGG() план с поддържащ индекс
Това също намалява известно време, но за да бъдем честни, този индекс помага на FOR XML PATH
вариации също. Ето новия план за поръчаната версия на тази заявка:
Фигура 9:ЗА XML PATH план с поддържащ индекс
Планът е малко по-удобен от преди, включващ търсене вместо сканиране на едно място, но този подход все още е значително по-бавен от STRING_AGG()
.
Предупреждение
Има малък трик за използване на STRING_AGG()
където, ако резултантният низ е повече от 8000 байта, ще получите това съобщение за грешка:
Резултатът от агрегиране STRING_AGG надхвърли ограничението от 8000 байта. Използвайте LOB типове, за да избегнете съкращаване на резултата.
За да избегнете този проблем, можете да инжектирате изрично преобразуване:
SELECT UserID, Bands = STRING_AGG(CONVERT(nvarchar(max), BandName), N', ') --------------------------^^^^^^^^^^^^^^^^^^^^^^ FROM dbo.FavoriteBands GROUP BY UserID;
Това добавя изчислителна скаларна операция към плана и неизненадващо CONVERT
предупреждение в основния SELECT
оператор, но в противен случай има малко влияние върху производителността.
Заключение
Ако сте на SQL Server 2017+ и имате някакъв FOR XML PATH
агрегиране на низове във вашата кодова база, силно препоръчвам да преминете към новия подход. Направих някои по-задълбочени тестове на производителността по време на публичната визуализация на SQL Server 2017 тук и тук може да искате да посетите отново.
Често срещано възражение, което чух, хората са на SQL Server 2017 или по-нова версия, но все още на по-старо ниво на съвместимост. Изглежда, че опасенията са защото STRING_SPLIT()
е невалиден при нива на съвместимост по-ниски от 130, така че те мислят, че STRING_AGG()
работи и по този начин, но е малко по-снизходителен. Проблем е само ако използвате WITHIN GROUP
и ниво на compat по-ниско от 110. Така че усъвършенствайте се!