No início deste mês, publiquei uma dica sobre algo que provavelmente todos gostaríamos de não ter que fazer:classificar ou remover duplicatas de strings delimitadas, geralmente envolvendo funções definidas pelo usuário (UDFs). Às vezes, você precisa remontar a lista (sem as duplicatas) em ordem alfabética e, às vezes, pode precisar manter a ordem original (pode ser a lista de colunas-chave em um índice incorreto, por exemplo).
Para minha solução, que aborda os dois cenários, usei uma tabela de números, juntamente com um par de funções definidas pelo usuário (UDFs) – uma para dividir a string e outra para remontá-la. Você pode ver essa dica aqui:
- Removendo duplicatas de strings no SQL Server
Claro, existem várias maneiras de resolver esse problema; Eu estava apenas fornecendo um método para tentar se você estiver preso a esses dados de estrutura. O @Phil_Factor da Red-Gate seguiu com um post rápido mostrando sua abordagem, que evita as funções e a tabela de números, optando pela manipulação de XML inline. Ele diz que prefere ter consultas de instrução única e evitar funções e processamento linha por linha:
- Desduplicando listas delimitadas no SQL Server
Então um leitor, Steve Mangiameli, postou uma solução em loop como um comentário na dica. Seu raciocínio era que o uso de uma tabela de números parecia super-engenharia para ele.
Nós três falhamos em abordar um aspecto disso que geralmente será muito importante se você estiver executando a tarefa com frequência suficiente ou em qualquer nível de escala:desempenho .
Teste
Curioso para ver o desempenho das abordagens XML inline e de looping em comparação com minha solução baseada em tabela de números, construí uma tabela fictícia para realizar alguns testes; meu objetivo era 5.000 linhas, com um comprimento médio de string maior que 250 caracteres e pelo menos 10 elementos em cada string. Com um ciclo muito curto de experimentos, consegui algo muito próximo disso com o seguinte código:
CREATE TABLE dbo.SourceTable ( [RowID] int IDENTITY(1,1) PRIMARY KEY CLUSTERED, DelimitedString varchar(8000) ); GO ;WITH s(s) AS ( SELECT TOP (250) o.name + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( ( SELECT N'/column_' + c.name FROM sys.all_columns AS c WHERE c.[object_id] = o.[object_id] ORDER BY NEWID() FOR XML PATH(N''), TYPE).value(N'.[1]', N'nvarchar(max)' ), -- make fake duplicates using 5 most common column names: N'/column_name/', N'/name/name/foo/name/name/id/name/'), N'/column_status/', N'/id/status/blat/status/foo/status/name/'), N'/column_type/', N'/type/id/name/type/id/name/status/id/type/'), N'/column_object_id/', N'/object_id/blat/object_id/status/type/name/'), N'/column_pdw_node_id/', N'/pdw_node_id/name/pdw_node_id/name/type/name/') FROM sys.all_objects AS o WHERE EXISTS ( SELECT 1 FROM sys.all_columns AS c WHERE c.[object_id] = o.[object_id] ) ORDER BY NEWID() ) INSERT dbo.SourceTable(DelimitedString) SELECT s FROM s; GO 20
Isso produziu uma tabela com linhas de amostra assim (valores truncados):
RowID DelimitedString ----- --------------- 1 master_files/column_redo_target_fork_guid/.../column_differential_base_lsn/... 2 allocation_units/column_used_pages/.../column_data_space_id/type/id/name/type/... 3 foreign_key_columns/column_parent_object_id/column_constraint_object_id/...
Os dados como um todo tinham o seguinte perfil, que deve ser bom o suficiente para descobrir possíveis problemas de desempenho:
;WITH cte([Length], ElementCount) AS ( SELECT 1.0*LEN(DelimitedString), 1.0*LEN(REPLACE(DelimitedString,'/','')) FROM dbo.SourceTable ) SELECT row_count = COUNT(*), avg_size = AVG([Length]), max_size = MAX([Length]), avg_elements = AVG(1 + [Length]-[ElementCount]), sum_elements = SUM(1 + [Length]-[ElementCount]) FROM cte; EXEC sys.sp_spaceused N'dbo.SourceTable'; /* results (numbers may vary slightly, depending on SQL Server version the user objects in your database): row_count avg_size max_size avg_elements sum_elements --------- ---------- -------- ------------ ------------ 5000 299.559000 2905.0 17.650000 88250.0 reserved data index_size unused -------- ------- ---------- ------ 1672 KB 1648 KB 16 KB 8 KB */
Observe que mudei para
varchar
aqui de nvarchar
no artigo original, porque as amostras que Phil e Steve forneceram assumiram varchar
, strings com limite de apenas 255 ou 8.000 caracteres, delimitadores de caractere único etc. Aprendi minha lição da maneira mais difícil, que se você pegar a função de alguém e incluí-la em comparações de desempenho, você mudará tão pouco quanto possível – idealmente nada. Na realidade, eu sempre usaria nvarchar
e não assumir nada sobre a string mais longa possível. Nesse caso, eu sabia que não estava perdendo nenhum dado porque a string mais longa tem apenas 2.905 caracteres e, nesse banco de dados, não tenho tabelas ou colunas que usam caracteres Unicode. Em seguida, criei minhas funções (que exigem uma tabela de números). Um leitor detectou um problema na função na minha dica, onde eu assumi que o delimitador seria sempre um único caractere, e corrigi isso aqui. Eu também converti quase tudo para
varchar(8000)
para nivelar o campo de jogo em termos de tipos e comprimentos de cordas. DECLARE @UpperLimit INT = 1000000; ;WITH n(rn) AS ( SELECT ROW_NUMBER() OVER (ORDER BY s1.[object_id]) FROM sys.all_columns AS s1 CROSS JOIN sys.all_columns AS s2 ) SELECT [Number] = rn INTO dbo.Numbers FROM n WHERE rn <= @UpperLimit; CREATE UNIQUE CLUSTERED INDEX n ON dbo.Numbers([Number]); GO CREATE FUNCTION [dbo].[SplitString] -- inline TVF ( @List varchar(8000), @Delim varchar(32) ) RETURNS TABLE WITH SCHEMABINDING AS RETURN ( SELECT rn, vn = ROW_NUMBER() OVER (PARTITION BY [Value] ORDER BY rn), [Value] FROM ( SELECT rn = ROW_NUMBER() OVER (ORDER BY CHARINDEX(@Delim, @List + @Delim)), [Value] = LTRIM(RTRIM(SUBSTRING(@List, [Number], CHARINDEX(@Delim, @List + @Delim, [Number]) - [Number]))) FROM dbo.Numbers WHERE Number <= LEN(@List) AND SUBSTRING(@Delim + @List, [Number], LEN(@Delim)) = @Delim ) AS x ); GO CREATE FUNCTION [dbo].[ReassembleString] -- scalar UDF ( @List varchar(8000), @Delim varchar(32), @Sort varchar(32) ) RETURNS varchar(8000) WITH SCHEMABINDING AS BEGIN RETURN ( SELECT newval = STUFF(( SELECT @Delim + x.[Value] FROM dbo.SplitString(@List, @Delim) AS x WHERE (x.vn = 1) -- filter out duplicates ORDER BY CASE @Sort WHEN 'OriginalOrder' THEN CONVERT(int, x.rn) WHEN 'Alphabetical' THEN CONVERT(varchar(8000), x.[Value]) ELSE CONVERT(SQL_VARIANT, NULL) END FOR XML PATH(''), TYPE).value(N'(./text())[1]',N'varchar(8000)'),1,LEN(@Delim),'') ); END GO
Em seguida, criei uma única função inline com valor de tabela que combinava as duas funções acima, algo que agora gostaria de ter feito no artigo original, para evitar completamente a função escalar. (Embora seja verdade que nem todos funções escalares são terríveis em escala, há muito poucas exceções.)
CREATE FUNCTION [dbo].[RebuildString] ( @List varchar(8000), @Delim varchar(32), @Sort varchar(32) ) RETURNS TABLE WITH SCHEMABINDING AS RETURN ( SELECT [Output] = STUFF(( SELECT @Delim + x.[Value] FROM ( SELECT rn, [Value], vn = ROW_NUMBER() OVER (PARTITION BY [Value] ORDER BY rn) FROM ( SELECT rn = ROW_NUMBER() OVER (ORDER BY CHARINDEX(@Delim, @List + @Delim)), [Value] = LTRIM(RTRIM(SUBSTRING(@List, [Number], CHARINDEX(@Delim, @List + @Delim, [Number]) - [Number]))) FROM dbo.Numbers WHERE Number <= LEN(@List) AND SUBSTRING(@Delim + @List, [Number], LEN(@Delim)) = @Delim ) AS y ) AS x WHERE (x.vn = 1) ORDER BY CASE @Sort WHEN 'OriginalOrder' THEN CONVERT(int, x.rn) WHEN 'Alphabetical' THEN CONVERT(varchar(8000), x.[Value]) ELSE CONVERT(sql_variant, NULL) END FOR XML PATH(''), TYPE).value(N'(./text())[1]',N'varchar(8000)'),1,LEN(@Delim),'') ); GO
Também criei versões separadas do TVF inline que foram dedicadas a cada uma das duas opções de classificação, a fim de evitar a volatilidade do
CASE
expressão, mas acabou não tendo um impacto dramático. Então criei as duas funções de Steve:
CREATE FUNCTION [dbo].[gfn_ParseList] -- multi-statement TVF (@strToPars VARCHAR(8000), @parseChar CHAR(1)) RETURNS @parsedIDs TABLE (ParsedValue VARCHAR(255), PositionID INT IDENTITY) AS BEGIN DECLARE @startPos INT = 0 , @strLen INT = 0 WHILE LEN(@strToPars) >= @startPos BEGIN IF (SELECT CHARINDEX(@parseChar,@strToPars,(@startPos+1))) > @startPos SELECT @strLen = CHARINDEX(@parseChar,@strToPars,(@startPos+1)) - @startPos ELSE BEGIN SET @strLen = LEN(@strToPars) - (@startPos -1) INSERT @parsedIDs SELECT RTRIM(LTRIM(SUBSTRING(@strToPars,@startPos, @strLen))) BREAK END SELECT @strLen = CHARINDEX(@parseChar,@strToPars,(@startPos+1)) - @startPos INSERT @parsedIDs SELECT RTRIM(LTRIM(SUBSTRING(@strToPars,@startPos, @strLen))) SET @startPos = @startPos+@strLen+1 END RETURN END GO CREATE FUNCTION [dbo].[ufn_DedupeString] -- scalar UDF ( @dupeStr VARCHAR(MAX), @strDelimiter CHAR(1), @maintainOrder BIT ) -- can't possibly return nvarchar, but I'm not touching it RETURNS NVARCHAR(MAX) AS BEGIN DECLARE @tblStr2Tbl TABLE (ParsedValue VARCHAR(255), PositionID INT); DECLARE @tblDeDupeMe TABLE (ParsedValue VARCHAR(255), PositionID INT); INSERT @tblStr2Tbl SELECT DISTINCT ParsedValue, PositionID FROM dbo.gfn_ParseList(@dupeStr,@strDelimiter); WITH cteUniqueValues AS ( SELECT DISTINCT ParsedValue FROM @tblStr2Tbl ) INSERT @tblDeDupeMe SELECT d.ParsedValue , CASE @maintainOrder WHEN 1 THEN MIN(d.PositionID) ELSE ROW_NUMBER() OVER (ORDER BY d.ParsedValue) END AS PositionID FROM cteUniqueValues u JOIN @tblStr2Tbl d ON d.ParsedValue=u.ParsedValue GROUP BY d.ParsedValue ORDER BY d.ParsedValue DECLARE @valCount INT , @curValue VARCHAR(255) ='' , @posValue INT=0 , @dedupedStr VARCHAR(4000)=''; SELECT @valCount = COUNT(1) FROM @tblDeDupeMe; WHILE @valCount > 0 BEGIN SELECT @posValue=a.minPos, @curValue=d.ParsedValue FROM (SELECT MIN(PositionID) minPos FROM @tblDeDupeMe WHERE PositionID > @posValue) a JOIN @tblDeDupeMe d ON d.PositionID=a.minPos; SET @dedupedStr+=@curValue; SET @valCount-=1; IF @valCount > 0 SET @dedupedStr+='/'; END RETURN @dedupedStr; END GO
Em seguida, coloco as consultas diretas de Phil em meu equipamento de teste (observe que as consultas dele codificam
<
como <
para protegê-los de erros de análise de XML, mas eles não codificam >
ou &
– Adicionei espaços reservados caso você precise se proteger contra strings que podem conter esses caracteres problemáticos):-- Phil's query for maintaining original order SELECT /*the re-assembled list*/ stuff( (SELECT '/'+TheValue FROM (SELECT x.y.value('.','varchar(20)') AS Thevalue, row_number() OVER (ORDER BY (SELECT 1)) AS TheOrder FROM XMLList.nodes('/list/i/text()') AS x ( y ) )Nodes(Thevalue,TheOrder) GROUP BY TheValue ORDER BY min(TheOrder) FOR XML PATH('') ),1,1,'') as Deduplicated FROM (/*XML version of the original list*/ SELECT convert(XML,'<list><i>' --+replace(replace( +replace(replace(ASCIIList,'<','<') --,'>','>'),'&','&') ,'/','</i><i>')+'</i></list>') FROM (SELECT DelimitedString FROM dbo.SourceTable )XMLlist(AsciiList) )lists(XMLlist); -- Phil's query for alpha SELECT stuff( (SELECT DISTINCT '/'+x.y.value('.','varchar(20)') FROM XMLList.nodes('/list/i/text()') AS x ( y ) FOR XML PATH('')),1,1,'') as Deduplicated FROM ( SELECT convert(XML,'<list><i>' --+replace(replace( +replace(replace(ASCIIList,'<','<') --,'>','>'),'&','&') ,'/','</i><i>')+'</i></list>') FROM (SELECT AsciiList FROM (SELECT DelimitedString FROM dbo.SourceTable)ListsWithDuplicates(AsciiList) )XMLlist(AsciiList) )lists(XMLlist);
O equipamento de teste era basicamente essas duas consultas e também as seguintes chamadas de função. Depois de validar que todos retornaram os mesmos dados, intercalei o script com
DATEDIFF
output e registrei em uma tabela:-- Maintain original order -- My UDF/TVF pair from the original article SELECT UDF_Original = dbo.ReassembleString(DelimitedString, '/', 'OriginalOrder') FROM dbo.SourceTable ORDER BY RowID; -- My inline TVF based on the original article SELECT TVF_Original = f.[Output] FROM dbo.SourceTable AS t CROSS APPLY dbo.RebuildString(t.DelimitedString, '/', 'OriginalOrder') AS f ORDER BY t.RowID; -- Steve's UDF/TVF pair: SELECT Steve_Original = dbo.ufn_DedupeString(DelimitedString, '/', 1) FROM dbo.SourceTable; -- Phil's first query from above -- Reassemble in alphabetical order -- My UDF/TVF pair from the original article SELECT UDF_Alpha = dbo.ReassembleString(DelimitedString, '/', 'Alphabetical') FROM dbo.SourceTable ORDER BY RowID; -- My inline TVF based on the original article SELECT TVF_Alpha = f.[Output] FROM dbo.SourceTable AS t CROSS APPLY dbo.RebuildString(t.DelimitedString, '/', 'Alphabetical') AS f ORDER BY t.RowID; -- Steve's UDF/TVF pair: SELECT Steve_Alpha = dbo.ufn_DedupeString(DelimitedString, '/', 0) FROM dbo.SourceTable; -- Phil's second query from above
E, em seguida, executei testes de desempenho em dois sistemas diferentes (um quad core com 8 GB e uma VM de 8 núcleos com 32 GB) e, em cada caso, no SQL Server 2012 e no SQL Server 2016 CTP 3.2 (13.0.900.73).
Resultados
Os resultados que observei estão resumidos no gráfico a seguir, que mostra a duração em milissegundos de cada tipo de consulta, em média sobre a ordem alfabética e original, as quatro combinações de servidor/versão e uma série de 15 execuções para cada permutação. Clique para ampliar:
Isso mostra que a tabela de números, embora considerada com engenharia excessiva, na verdade rendeu a solução mais eficiente (pelo menos em termos de duração). Isso foi melhor, é claro, com o único TVF que implementei mais recentemente do que com as funções aninhadas do artigo original, mas ambas as soluções circulam em torno das duas alternativas.
Para entrar em mais detalhes, aqui estão os detalhamentos para cada máquina, versão e tipo de consulta, para manter o pedido original:
…e para remontar a lista em ordem alfabética:
Isso mostra que a escolha de classificação teve pouco impacto no resultado – ambos os gráficos são praticamente idênticos. E isso faz sentido porque, dada a forma dos dados de entrada, não há índice que eu possa imaginar que tornaria a classificação mais eficiente – é uma abordagem iterativa, não importa como você os divide ou como retorna os dados. Mas está claro que algumas abordagens iterativas podem ser geralmente piores que outras, e não é necessariamente o uso de uma UDF (ou uma tabela de números) que as torna assim.
Conclusão
Até que tenhamos a funcionalidade de divisão e concatenação nativa no SQL Server, usaremos todos os tipos de métodos não intuitivos para realizar o trabalho, incluindo funções definidas pelo usuário. Se você estiver manipulando uma única string por vez, não verá muita diferença. Mas à medida que seus dados aumentam, valerá a pena testar várias abordagens (e não estou sugerindo que os métodos acima sejam os melhores que você encontrará - eu nem olhei para o CLR, por exemplo, ou outras abordagens T-SQL desta série).