Sqlserver
 sql >> база данни >  >> RDS >> Sqlserver

Агрегация на низове през годините в SQL Server

От 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 &amp; Sheila &lt;&gt; 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 . Това води до следното съобщение за грешка:

Съобщение 8116, ниво 16, състояние 1
Аргументният тип данни 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 байта, ще получите това съобщение за грешка:

Съобщение 9829, ниво 16, състояние 1
Резултатът от агрегиране 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. Така че усъвършенствайте се!


  1. Database
  2.   
  3. Mysql
  4.   
  5. Oracle
  6.   
  7. Sqlserver
  8.   
  9. PostgreSQL
  10.   
  11. Access
  12.   
  13. SQLite
  14.   
  15. MariaDB
  1. Какво е изчислена колона в SQL Server?

  2. Как да получите всички таблици, които имат ограничение за първичен ключ, създадени в база данни на SQL Server - SQL Server / TSQL Урок 57

  3. Разберете дали даден обект е външен ключ с OBJECTPROPERTY() в SQL Server

  4. Размисли за сигурността на SQL Server

  5. DATEFROMPARTS() Примери в SQL Server (T-SQL)