select 'Hello, World!'

The single biggest challenge to learning SQL programming is unlearning procedural programming (Joe Celko)

STRING_SPLIT - новая встроенная функция в MS SQL Server 2016

Olaf Olaf
Привет, Всем!

На днях вышел в свет SQL Server 2016 Release Candidate (RC0). В качестве заявленных дополнений фигурирует встроенная функция STRING_SPLIT для деления строки на части с возможностью указать разделитель.

Syntax
STRING_SPLIT ( string , separator )

Arguments
string — Is an expression of any character type (i.e. nvarchar, varchar, nchar or char).
separator — Is a single character expression of any character type (e.g. nvarchar(1), varchar(1), nchar(1) or char(1)) that is used as separator for concatenated strings.

Return Types
Returns a single-column table with fragments. The name of the column is value. Returns nvarchar if any of the input arguments are either nvarchar or nchar. Otherwise returns varchar. The length of the return type is the same as the length of the string argument.


Казалось бы, в наши дни такой функциональностью уже никого не удивишь, т.к. существует около десятка способов реализовать аналог самостоятельно, причем один из вариантов появился совсем недавно в версии 2016 с функцией openjson. Тем не менее, хотелось бы посмотреть на реализацию из «коробки» и сравнить с производительностью существующих решений.

Мой тест будет основываться на постах из обсуждения split(somestring,',') в T-SQL, которое здесь недавно проходило, с учетом ошибки, которую я допустил для варианта №4 на большом количестве элементов. Кроме того, все пользовательские функции я снабдил дополнительным параметром – много символьный разделитель (!)

Рассматривается 8 способов поделить строку на части через разделитель:

1. Цикл
  Код функции
if object_id('dbo.Split1') is not null
    drop function dbo.Split1
go

create function dbo.Split1(@input varchar(max), @delimiter varchar(50))
returns @result table(value bigint)  
as
begin
    declare @str varchar(20)  
    declare @ind int

    if(@input is not null)  
    begin 
        set @ind = charindex(@delimiter, @input)  
        while @ind > 0  
        begin 
            set @str = substring(@input, 1, @ind - 1)
            set @input = substring(@input, @ind + datalength(@delimiter), len(@input) - @ind)  

            insert into @result values (@str)  
            set @ind = charindex(@delimiter, @input)  
        end  

        set @str = @input  
        insert into @result values (@str)  
    end
    return
end

go

2. CTE
  Код функции
if object_id('dbo.Split2') is not null
    drop function dbo.Split2
go

create function dbo.Split2(@strString varchar(max), @delimiter varchar(50))
returns @result table(value bigint)
as
begin  
    with StrCTE(start, stop) as
    (  
        select cast(1 as bigint), charindex(@delimiter, @strString)  
        union all
        select stop + datalength(@delimiter), charindex(@delimiter, @strString, stop + datalength(@delimiter))
        from StrCTE  
        where stop > 0  
    )  

    insert into @result  
    select substring(@strString, start, case when stop > 0 then stop - start else 2147483647 end) as stringValue  
    from StrCTE
    option (maxrecursion 0)

    return  
end

go

3. XML
  Код функции
if object_id('dbo.Split3') is not null
    drop function dbo.Split3
go

create function dbo.Split3(@strString varchar(max), @delimiter varchar(50))
returns @result table(value bigint)  
as
begin
    declare @x xml
    select @x = cast('<A>' + replace(@strString, @delimiter, '</A><A>') + '</A>' as xml)

    insert into @result              
    select t.value('.', 'int') as inVal  
    from @x.nodes('/A') as x(t)  
    
    return

end

go

4. Таблица
  Код функции
if object_id('dbo.Split4') is not null
    drop function dbo.Split4
go

create function dbo.Split4(@strString varchar(max), @delimiter varchar(50))  
returns @result table(value bigint)
as
begin
    insert into @result(value)  
    select substring(@strString, N, case when charindex(@delimiter, @strString, N) - N < 0 then datalength(@delimiter) + N else charindex(@delimiter, @strString, N) - N end)
    from dbo.Tally
    where N <= datalength(@strString) + datalength(@delimiter) and substring(@delimiter + @strString + @delimiter, N, datalength(@delimiter)) = @delimiter
    
    return
end

go

5. CLR
  Код функции
if object_id('dbo.Split5') is not null
    drop function dbo.Split5
go

create function dbo.Split5(@input nvarchar(max), @delimiter nvarchar(255))
returns table
(
    value nvarchar(4000) NULL
) with execute as caller
as 
external name Demo.UserDefinedFunctions.SplitString_Multi
go

6. OPENJSON

7. LIKE, PATHINDEX, CHARINDEX

8. STRING_SPLIT

Для сравнения производительности используется вспомогательная таблица dbo.table2, которая содержит массив элементов фиксированной длины. В качестве ключа id выступает идентификатор, совпадающий с количеством элементов в массиве. Разделителем элементов является запятая ',' Тест выполняется с помощью утилиты SQLQueryStress через запуск решения 10 раз, на основании полученных данных вычисляется среднее время выполнения.

Тестовые данные

Для подготовки данных использовал...
  Сценарий
if object_id('dbo.table1') is not null
    drop table dbo.table1
create table dbo.table1(id bigint)

create unique index UIX_Table1_Id on dbo.table1(id)

if object_id('dbo.table2') is not null
    drop table dbo.table2
create table dbo.table2(id int primary key, ids nvarchar(max))

/*** TABLE1 ***/
;with cte as
(
    select * 
    from (values (0),(1),(2),(3),(4),(5),(6),(7),(8),(9)) a(n)
)
-- Вставляем 10 000 000 записей в dbo.table1
insert into dbo.table1
select top 10000000 t1.n + t2.n * 10 + t3.n * 100 + t4.n * 1000 + t5.n * 10000 + t6.n * 100000 + t7.n * 1000000
from cte t1, cte t2, cte t3, cte t4, cte t5, cte t6, cte t7
order by 1

/*** TABLE2 ***/
declare @delimiter as char(1) = ',' -- Разделитель списка значений
declare @n int
declare numbers cursor fast_forward for

select *
from (values (500), (1000), (5000), (10000), (50000), (100000), (250000), (500000), (1000000)) a(N)

open numbers 
fetch next from numbers into @n

while @@fetch_status = 0
begin
    -- Создаем набор произвольных элементов равный по кол-ву @n
    insert into dbo.table2(id, ids)
    select @n, stuff(
    (
        select top(@n) @delimiter + cast(id as varchar(20))
        from dbo.table1
        order by row_number() over(order by newid())
        for xml path(''), type
    ).value('.', 'varchar(max)'), 1, 1, '')

    fetch next from numbers into @n
end
close numbers
deallocate numbers

Решение

Реализовано в запросах...
  Сценарий
/* 1-5 */
select tt.Value
from dbo.table2 t2 
cross apply dbo.SplitN(t2.ids, ',') tt
where t2.id = K

/* где N = 1 – Цикл, 2 – CTE, 3 – XML, 4 – Таблица, 5 – CLR */

/* 6 */
select j.value
from dbo.table2 t2
cross apply openjson('[' + t2.ids + ']') j
where t2.id = K

/* 7 */
select t1.*
from dbo.Tally t1, dbo.table2 t2
where t2.id = K and ',' + t2.ids + ',' like '%,' + cast(t1.N as varchar(20)) + ',%'
-- или
where t2.id = K and charindex(',' + cast(t1.N as varchar(20)) + ',', ',' + t2.ids + ',') > 0
-- или
where t2.id = K and patindex('%,' + cast(t1.N as varchar(20)) + ',%', ',' + t2.ids + ',') > 0

/* 8 */
select j.value
from dbo.table2 t2
cross apply string_split(t2.ids, ',') j
where t2.id = K

/* где K = 500, 1000, 5000, 10000, 50000, 100000, 250000, 500000, 1000000 */

Результат

Для наглядности результаты сравнения производительности представлены в виде графика. Ось Y — логарифмическая.

http://files.rsdn.org/3860/STRING_SPLIT_GRAPH.png

Исходные данные содержатся в таблице.

http://files.rsdn.org/3860/STRING_SPLIT_TABLE.png

Вывод

Испытание полностью выдержали только 6-ть решений из 8-и, т.е. решения у которых среднее время выполнения запроса меньше 10 минут, результат преобразования правильный и не возникло исключительных ситуаций в ходе выполнения. Первое место по-прежнему удерживает вариант номер 5 с CLR реализацией. Причем исходя из данных представленных в таблице видно, что время выполнения увеличивается пропорционально количеству данных. Второе место уверенно сохраняет OPENJSON решение. Стоит отметить, что начиная со значения в 50 000 элементов наблюдается расхождение в два раза по сравнению с CLR функцией, в то время как предыдущие результаты отличались в третьем знаке после запятой. Новая встроенная функция STRING_SPLIT незначительно отстает от варианта с OPENJSON и занимает 3-е место. XML подход показал стабильное время на всех участках эксперимента и переходит на 4-е место. 5-я строчка рейтинга достается решению с использованием CTE, причем до отметки в 100 000 элементов, данный подход отставал в третьем знаке после запятой от реализации с использованием справочной таблицы с числами. В итоге замыкает шестерку решение под номером 4, показатели которого стали ухудшаться с отметки в 100 000 элементов.

Еще пара слов о решениях, которые выбыли из конкурса.
Вариант номер 7 (LIKE, PATINDEX,CHARINDEX) — длительное время выполнения даже на маленьких объемах данных заставило исключить его. Для решения номер 1 с циклом начиная с 250 000 элементов увеличилось время выполнения и перевалило за 10 минут, поэтому было принято решение исключить и этот вариант.

P.S. Новая встроенная функция STRING_SPLIT обладающая всеми преимуществами варианта из "коробки" показала неплохую производительность. Пожалуй единственный недостаток заключается в сепараторе, который может быть не больше одного символа. Возможно при условии, что вы хорошо знаете свои данные этот недочет можно обойти.
xy012111
xy012111 [SQL Server] STRING_SPLIT - новая встроенная функция
12.03.2016 04:24
Здравствуйте, Olaf, Вы писали:

O>Рассматривается 8 способов поделить строку на части через разделитель:


O>5. CLR

O>create function dbo.Split5(@input nvarchar(max), @delimiter nvarchar(255))
O>returns table
O>(
O>    value nvarchar(4000) NULL
O>) with execute as caller
O>as 
O>external name Demo.UserDefinedFunctions.SplitString_Multi

Спасибо, а кодом самой функции не поделитесь?

Теперь вот только агрегата для строк, аналога String.Join из .NET, не хватает.
Olaf
Olaf
13.03.2016 07:21
Здравствуйте, xy012111, Вы писали:

O>>Рассматривается 8 способов поделить строку на части через разделитель:


O>>5. CLR


X>Спасибо, а кодом самой функции не поделитесь?


Я использовал вариант от Adam Machanic из статьи SQLCLR String Splitting Part 2: Even Faster, Even More Scalable
Sinix
Sinix
13.03.2016 07:46
Здравствуйте, Olaf, Вы писали:


O>Я использовал вариант от Adam Machanic из статьи SQLCLR String Splitting Part 2: Even Faster, Even More Scalable



Красивый код. Обычно в примерах постят что-то уровня "допилить напильником", тут прямо приятное исключение
Serginio1
Serginio1
20.11.2016 04:51
Здравствуйте, Olaf, Вы писали:

O>Здравствуйте, xy012111, Вы писали:


O>>>Рассматривается 8 способов поделить строку на части через разделитель:


O>>>5. CLR


X>>Спасибо, а кодом самой функции не поделитесь?


O>Я использовал вариант от Adam Machanic из статьи SQLCLR String Splitting Part 2: Even Faster, Even More Scalable


но он не CSV
http://rsdn.org/forum/dotnet/3303143.1
Olaf
Olaf
22.11.2016 06:41
Здравствуйте, Serginio1, Вы писали:

O>>Я использовал вариант от Adam Machanic из статьи SQLCLR String Splitting Part 2: Even Faster, Even More Scalable


S> но он не CSV

S>http://rsdn.org/forum/dotnet/3303143.1

Не понял вас, что означает — он не CSV? Разбирается строка любой длины, содержащая элементы с произвольным разделителем.
Serginio1
Serginio1
22.11.2016 07:11
Здравствуйте, Olaf, Вы писали:

O>Здравствуйте, Serginio1, Вы писали:


O>>>Я использовал вариант от Adam Machanic из статьи SQLCLR String Splitting Part 2: Even Faster, Even More Scalable


S>> но он не CSV

S>>http://rsdn.org/forum/dotnet/3303143.1

O>Не понял вас, что означает — он не CSV? Разбирается строка любой длины, содержащая элементы с произвольным разделителем.


CSV это не только разделитель строк
Но и QuoteChar


Значения, содержащие зарезервированные символы (двойная кавычка, запятая, точка с запятой, новая строка) обрамляются двойными кавычками ("). Если в значении встречаются кавычки — они представляются в файле в виде двух кавычек подряд.


https://ru.wikipedia.org/wiki/CSV
Olaf
Olaf
19.11.2016 06:41
Здравствуйте, xy012111, Вы писали:

X>Теперь вот только агрегата для строк, аналога String.Join из .NET, не хватает.


В новой версии SQL Server vNext CTP 1.0 появилась функция STRING_AGG, которая конкатенирует строки (значения) через разделитель.
wildwind
wildwind [SQL Server] STRING_SPLIT - новая встроенная функция
16.03.2016 07:10
Спасибо!

Неплохо бы еще сравнить потребление памяти. Это тоже важно при выборе решения в конкретной ситуации.
rm822
rm822 А теперь вернемся в реальный мир
20.11.2016 02:51
Для реальных задач (split(somestring,',') в T-SQL) как правило нужна работа запросов типа
select * from SomeTable where col in(<csv ids>)


При замене этого
на
select * from SomeTable where col in( select * from mySplitterFunc(<csv ids>) )


план строится исходя из средне-сатистических ожиданий о работе mySplitterFunc, и вот тут начинается интересное
у clr — estimated number of rows — 1000
у string_split рекордно низкие 50 (!)
у всех остальных в пределах 100
от аргументов оценки, естественно, никак не зависят

поэтому более менее нормально на больших списках работает только clr версия
и то ее нужно править, каждый раз задавая N
declare @csv as nvarchar(max) = ?
select * from SomeTable where col in( select top (N) from clr_SplitterFunc(@CSV) )
Olaf
Olaf
22.11.2016 06:36
Здравствуйте, rm822, Вы писали:

R>Для реальных задач (split(somestring,',') в T-SQL) как правило нужна работа запросов типа

R>
R>select * from SomeTable where col in(<csv ids>)
R>


R>При замене этого

R>на
R>
R>select * from SomeTable where col in( select * from mySplitterFunc(<csv ids>) )
R>


R>план строится исходя из средне-сатистических ожиданий о работе mySplitterFunc, и вот тут начинается интересное

R>у clr — estimated number of rows — 1000
R>у string_split рекордно низкие 50 (!)
R>у всех остальных в пределах 100
R>от аргументов оценки, естественно, никак не зависят

Безусловно это так, но часть пользовательских функций может быть переведена в inline table valued функции и тогда появится другой план и совсем другая оценка. А вы можете сказать, как поменяется план запроса и его производительность, если бы оптимизатор повысил точность оценки количества данных возвращаемых функцией (CLR, UDF,встроенная) с 50 до 1000 в примере запроса, который вы привели? Т.е. насколько велико влияние оценки предполагаемого количества записей, которые вернет функция на производительность в этом простом запросе?

R>поэтому более менее нормально на больших списках работает только clr версия

R>и то ее нужно править, каждый раз задавая N
R>
R>declare @csv as nvarchar(max) = ?
R>select * from SomeTable where col in( select top (N) from clr_SplitterFunc(@CSV) )
R>


Хотелось бы понять на каком объеме данных и по каким критериям вы проверяли производительность.
Да, CLR впереди всех, но встроенные функции от Microsoft (string_split, openjson) не сильно отстают от лидера. Кроме того, если возвращаться к реальным задачам, озвученным из другого топика, то у автора списки хранятся как записи в другой таблице, причем возможно произвольной длины, поэтому там предполагается решение вида, где top N не сработает.

select t1.*
from dbo.table2 t2 
cross apply dbo.SplitN[1-5](t2.ids) tt
join dbo.table1 t1 on t1.id = tt.Value

И вот что интересно, а вы пробовали задавать top N, где N > больше 1000 и на какой версии? Потому что, до 1000 оптимизатор справляется с оценкой предполагаемого количества возвращаемых записей, а вот для всех случаев свыше 1000 ставит estimated number of rows = 1000 в независимости от значения N.
rm822
rm822
22.11.2016 05:51
O>А вы можете сказать, как поменяется план запроса и его производительность, если бы оптимизатор повысил точность оценки количества данных возвращаемых функцией (CLR, UDF,встроенная) с 50 до 1000 в примере запроса, который вы привели?
Т.е. насколько велико влияние оценки предполагаемого количества записей, которые вернет функция на производительность в этом простом запросе?


Чтобы долго не разводить, я приведу один очень-очень тупой пример. Временная таблица на 100к значений и выборки по ней с фильтром на 2 колонки.
Предлагаю повторить и убедиться.

--слепим временную таблицу со 100к значений
if object_id('tempdb..#t') is not null
    drop table #t
go

SELECT top 100000
    a.id + b.id as id,
    cast(N'' as nvarchar(max)) as col1,
    cast(N'' as nvarchar(max)) as col2
INTO #t 
FROM master.dbo.syscolumns a CROSS JOIN master.dbo.syscolumns b
GO
 
 --50ms, прямой вызов
 select * from #t
 where
 id in (0,1)
 or col1 in (2,3)

 --500ms split_string 2 штуки. Тут я эксплуатирую косяк с оценкой стоимости самой функции. Ошибка больше чем нам порядок
 select * from #t
 where
 id in (select cast(value as int) from string_split(N'0,1',N','))
 or col1 in (select * from string_split(N'2,3',N','))

 --5000ms split_string 100 штук, а тут я помножил косяк со стоимостью, на ошибку в кол-ве строк
 select * from #t
 where
 id in (select cast(value as int) from string_split(N'1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100',N','))
 or col1 in (select * from string_split(N'2,3',N','))


 --60ms, clr-split + top. Стоимость вызова более-менее адекватная, оценка кол-ва строк скорректирована top-ами
 select * from #t
 where
 id in (select top 100 * from dbo.SplitInts(N'1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100', 0))
 or col1 in (select top 2 * from  dbo.SplitInts(N'2,3',0))


dbo.SplitInts — это наш внутренний clr-splitter уже сразу на инты. принципиально вряд ли чем-то отличается от любого другого.

O>И вот что интересно, а вы пробовали задавать top N, где N > больше 1000 и на какой версии? Потому что, до 1000 оптимизатор справляется с оценкой предполагаемого количества возвращаемых записей, а вот для всех случаев свыше 1000 ставит estimated number of rows = 1000 в независимости от значения N.

Да, есть методики обмана через cross apply. Где-то до 50-100к работают.