Database
 sql >> Base de Dados >  >> RDS >> Database

Surpresas e suposições de desempenho:STRING_SPLIT()


Há mais de três anos, publiquei uma série de três partes sobre divisão de strings:
  • Divida as strings da maneira certa – ou da próxima melhor maneira
  • Separando strings:um acompanhamento
  • Divisão de strings:agora com menos T-SQL

Então, em janeiro, assumi um problema um pouco mais elaborado:
  • Comparando métodos de divisão/concatenação de strings

Ao longo, minha conclusão foi:PARE DE FAZER ISSO EM T-SQL . Use CLR ou, melhor ainda, passe parâmetros estruturados como DataTables de seu aplicativo para parâmetros com valor de tabela (TVPs) em seus procedimentos, evitando toda a construção e desconstrução de strings – que é realmente a parte da solução que causa problemas de desempenho.

E então surgiu o SQL Server 2016…


Quando o RC0 foi lançado, uma nova função foi documentada sem muito alarde:STRING_SPLIT . Um exemplo rápido:
SELECT * FROM STRING_SPLIT('a,b,cd', ','); /* resultado:valor -------- a b cd*/

Ele chamou a atenção de alguns colegas, incluindo Dave Ballantyne, que escreveu sobre os principais recursos – mas teve a gentileza de me oferecer o direito de preferência em uma comparação de desempenho.

Este é principalmente um exercício acadêmico, porque com um grande conjunto de limitações na primeira iteração do recurso, provavelmente não será viável para um grande número de casos de uso. Aqui está a lista das observações que Dave e eu fizemos, algumas das quais podem ser decisivas em certos cenários:
  • a função requer que o banco de dados esteja no nível de compatibilidade 130;
  • só aceita delimitadores de caractere único;
  • não há como adicionar colunas de saída (como uma coluna indicando a posição ordinal dentro da string);
    • relacionado, não há como controlar a classificação – as únicas opções são arbitrárias e alfabéticas ORDER BY value;
  • até agora, ele sempre estima 50 linhas de saída;
  • ao usá-lo para DML, em muitos casos, você obterá um carretel de mesa (para proteção do Halloween);
  • NULL entrada leva a um resultado vazio;
  • não há como empurrar predicados, como eliminar duplicatas ou strings vazias devido a delimitadores consecutivos;
  • não há como realizar operações nos valores de saída até depois do fato (por exemplo, muitas funções de divisão executam LTRIM/RTRIM ou conversões explícitas para você – STRING_SPLIT cospe de volta todo o feio, como espaços à esquerda).

Então, com essas limitações em aberto, podemos passar para alguns testes de desempenho. Dado o histórico da Microsoft com funções integradas que aproveitam o CLR nos bastidores (tosse FORMAT() tosse ), eu estava cético sobre se essa nova função poderia chegar perto dos métodos mais rápidos que eu testei até agora.

Vamos usar divisores de strings para separar strings de números separados por vírgulas, desta forma nosso novo amigo JSON pode vir e jogar também. E diremos que nenhuma lista pode exceder 8.000 caracteres, portanto, não MAX tipos são obrigatórios e, como são números, não precisamos lidar com nada exótico como Unicode.

Primeiro, vamos criar nossas funções, várias das quais eu adaptei do primeiro artigo acima. Deixei de fora um casal que achava que não competiria; Vou deixar como exercício para o leitor testá-los.

    Tabela de Números


    Esta novamente precisa de alguma configuração, mas pode ser uma mesa bem pequena devido às limitações artificiais que estamos colocando:
    SET NOCOUNT ON; DECLARE @UpperLimit INT =8000;;WITH n AS( SELECT x =ROW_NUMBER() OVER (ORDER BY s1.[object_id]) FROM sys.all_objects AS s1 CROSS JOIN sys.all_objects AS s2)SELECT Número =x INTO dbo.Numbers FROM n WHERE x BETWEEN 1 AND @UpperLimit;GOCREATE UNIQUE CLUSTERED INDEX n ON dbo.Numbers(Number);

    Então a função:
    CREATE FUNCTION dbo.SplitStrings_Numbers( @List varchar(8000), @Delimiter char(1))RETURNS TABLE WITH SCHEMABINDINGAS RETURN ( SELECT [Value] =SUBSTRING(@List, [Number], CHARINDEX(@Delimiter, @List + @Delimiter, [Number]) - [Number]) FROM dbo.Numbers WHERE Number <=LEN(@List) AND SUBSTRING(@Delimiter + @List, [Number], 1) =@Delimiter );

    JSON


    Com base em uma abordagem revelada pela primeira vez pela equipe do mecanismo de armazenamento, criei um wrapper semelhante em torno de OPENJSON , apenas observe que o delimitador precisa ser uma vírgula neste caso, ou você precisa fazer alguma substituição de string pesada antes de passar o valor para a função nativa:
    CREATE FUNCTION dbo.SplitStrings_JSON( @List varchar(8000), @Delimiter char(1) -- ignorado, mas facilita o teste automatizado) RETURNS TABLE WITH SCHEMABINDINGAS RETURN (SELECT value FROM OPENJSON( CHAR(91) + @List + CHAR(93) ));

    Os CHAR(91)/CHAR(93) estão apenas substituindo [ e ] respectivamente devido a problemas de formatação.

    XML

    CREATE FUNCTION dbo.SplitStrings_XML( @List varchar(8000), @Delimiter char(1))RETURNS TABLE WITH SCHEMABINDINGAS RETURN (SELECT [valor] =y.i.value('(./text())[1]', 'varchar(8000)') FROM (SELECT x =CONVERT(XML, '' + REPLACE(@List, @Delimiter, '') + '').query ('.') ) COMO CROSS APPLY x.nodes('i') AS y(i));

    CLR


    Mais uma vez, peguei emprestado o confiável código de divisão de Adam Machanic de quase sete anos atrás, embora ele suporte Unicode, MAX tipos e delimitadores de vários caracteres (e, na verdade, porque não quero mexer no código da função, isso limita nossas strings de entrada a 4.000 caracteres em vez de 8.000):
    CREATE FUNCTION dbo.SplitStrings_CLR( @List nvarchar(MAX), @Delimiter nvarchar(255))RETURNS TABLE ( value nvarchar(4000) )EXTERNAL NAME CLRUtilities.UserDefinedFunctions.SplitString_Multi;

    STRING_SPLIT


    Apenas para consistência, coloquei um wrapper em torno de STRING_SPLIT :
    CREATE FUNCTION dbo.SplitStrings_Native( @List varchar(8000), @Delimiter char(1))RETURNS TABLE WITH SCHEMABINDINGAS RETURN (SELECT value FROM STRING_SPLIT(@List, @Delimiter));

Dados de origem e verificação de integridade


Criei esta tabela para servir como fonte de strings de entrada para as funções:
CREATE TABLE dbo.SourceTable( RowNum int IDENTITY(1,1) PRIMARY KEY, StringValue varchar(8000));;WITH x AS ( SELECT TOP (60000) x =STUFF((SELECT TOP (ABS(o.[object_id] % 20))) ',' + CONVERT(varchar(12), c.[object_id]) FROM sys.all_columns AS c WHERE c.[object_id]  
Apenas para referência, vamos validar que 50.000 linhas chegaram à tabela e verificar o comprimento médio da string e o número médio de elementos por string:
SELECT [Valores] =COUNT(*), AvgStringLength =AVG(1.0*LEN(StringValue)), AvgElementCount =AVG(1.0*LEN(StringValue)-LEN(REPLACE(StringValue, ',','')) ) DE dbo.SourceTable; /* resultado:Valores AvgStringLength AbgElementCount ------ --------------- --------------- 50000 108.476380 8.911840*/ 
E, finalmente, vamos garantir que cada função retorne os dados corretos para qualquer RowNum , então vamos escolher um aleatoriamente e comparar os valores obtidos por meio de cada método. Seus resultados irão variar, é claro.
SELECT f.value FROM dbo.SourceTable AS s CROSS APPLY dbo.SplitStrings_/* método */(s.StringValue, ',') AS f WHERE s.RowNum =37219 ORDER BY f.value;

Com certeza, todas as funções funcionam como esperado (a classificação não é numérica; lembre-se, as strings de saída das funções):

Conjunto de amostra de saída de cada uma das funções

Teste de desempenho

SELECT SYSDATETIME();GODECLARE @x VARCHAR(8000);SELECT @x =f.value FROM dbo.SourceTable AS s CROSS APPLY dbo.SplitStrings_/* método */(s.StringValue,',') AS f;GO 100SELECT SYSDATETIME();

Executei o código acima 10 vezes para cada método e calculei a média dos tempos para cada um. E foi aí que veio a surpresa para mim. Dadas as limitações do STRING_SPLIT nativo função, minha suposição era que ela foi montada rapidamente, e que o desempenho daria credibilidade a isso. Rapaz foi o resultado diferente do que eu esperava:

Duração média de STRING_SPLIT em comparação com outros métodos

Atualização de 20-03-2016


Com base na pergunta abaixo do Lars, executei os testes novamente com algumas alterações:
  • Monitorei minha instância com o SQL Sentry Performance Advisor para capturar o perfil da CPU durante o teste;
  • Capturei estatísticas de espera no nível da sessão entre cada lote;
  • Inseri um atraso entre os lotes para que a atividade ficasse visualmente distinta no painel do Performance Advisor.

Eu criei uma nova tabela para capturar informações de estatísticas de espera:
CREATE TABLE dbo.Timings( dt datetime, test varchar(64), point varchar(64), session_id smallint, wait_type nvarchar(60), wait_time_ms bigint,);

Em seguida, o código para cada teste mudou para isso:
AGUARDE ATRASO '00:00:30'; DECLARE @d DATETIME =SYSDATETIME(); INSERT dbo.Timings(dt, test, point, wait_type, wait_time_ms)SELECT @d, test =/* 'method' */, point ='Start', wait_type, wait_time_msFROM sys.dm_exec_session_wait_stats WHERE session_id =@@SPID;GO DECLARE @x VARCHAR(8000);SELECT @x =f.value FROM dbo.SourceTable AS s CROSS APPLY dbo.SplitStrings_/* método */(s.StringValue, ',') AS fGO 100 DECLARE @d DATETIME =SYSDATETIME(); INSERT dbo.Timings(dt, test, point, wait_type, wait_time_ms)SELECT @d, /* 'method' */, 'End', wait_type, wait_time_msFROM sys.dm_exec_session_wait_stats WHERE session_id =@@SPID;

Fiz o teste e depois fiz as seguintes consultas:
-- validar que os tempos estavam no mesmo estádio que os testes anterioresSELECT test, DATEDIFF(SECOND, MIN(dt), MAX(dt)) FROM dbo.Timings WITH (NOLOCK)GROUP BY test ORDER BY 2 DESC; -- determina a janela a ser aplicada ao painel do Performance AdvisorSELECT MIN(dt), MAX(dt) FROM dbo.Timings; -- obtém estatísticas de espera registradas para cada sessãoSELECT test, wait_type, delta FROM( SELECT f.test, rn =RANK() OVER (PARTITION BY f.point ORDER BY f.dt), f.wait_type, delta =f.wait_time_ms - COALESCE(s.wait_time_ms, 0) FROM dbo.Timings AS f LEFT OUTER JOIN dbo.Timings AS s ON s.test =f.test AND s.wait_type =f.wait_type AND s.point ='Start' WHERE f.point ='Fim') AS x WHERE delta> 0ORDER BY rn, delta DESC;

Desde a primeira consulta, os tempos permaneceram consistentes com os testes anteriores (eu os mapearia novamente, mas isso não revelaria nada de novo).

A partir da segunda consulta, consegui destacar esse intervalo no painel do Performance Advisor e, a partir daí, foi fácil identificar cada lote:

Lotes capturados no gráfico de CPU no painel do Performance Advisor

Claramente, todos os métodos *exceto* STRING_SPLIT fixou um único núcleo para a duração do teste (esta é uma máquina quad-core, e a CPU estava constantemente em 25%). É provável que Lars estivesse insinuando abaixo que STRING_SPLIT é mais rápido ao custo de martelar a CPU, mas não parece que este seja o caso.

Por fim, na terceira consulta, pude ver as seguintes estatísticas de espera acumuladas após cada lote:

Esperas por sessão, em milissegundos

As esperas capturadas pelo DMV não explicam totalmente a duração das consultas, mas servem para mostrar onde adicionais esperas são incorridas.

Conclusão


Embora o CLR personalizado ainda mostre uma enorme vantagem sobre as abordagens T-SQL tradicionais, e o uso de JSON para essa funcionalidade pareça ser nada mais do que uma novidade, STRING_SPLIT foi o vencedor claro - por uma milha. Então, se você só precisa dividir uma string e pode lidar com todas as suas limitações, parece que essa é uma opção muito mais viável do que eu esperava. Espero que em compilações futuras veremos funcionalidades adicionais, como uma coluna de saída indicando a posição ordinal de cada elemento, a capacidade de filtrar duplicatas e strings vazias e delimitadores de vários caracteres.

Eu abordo vários comentários abaixo em duas postagens de acompanhamento:
  • STRING_SPLIT() no SQL Server 2016:acompanhamento nº 1
  • STRING_SPLIT() no SQL Server 2016:acompanhamento nº 2