Algumas discussões interessantes sempre evoluem em torno do tópico de divisão de strings. Em duas postagens anteriores do blog, "Dividir strings da maneira certa - ou da próxima melhor maneira" e "Splitting Strings:A Follow-Up", espero ter demonstrado que perseguir a função de divisão T-SQL de "melhor desempenho" é infrutífera . Quando a divisão é realmente necessária, o CLR sempre vence, e a próxima melhor opção pode variar dependendo da tarefa real em mãos. Mas nesses posts eu insinuei que a divisão no lado do banco de dados pode não ser necessária em primeiro lugar.
O SQL Server 2008 introduziu parâmetros com valor de tabela, uma maneira de passar uma "tabela" de um aplicativo para um procedimento armazenado sem precisar criar e analisar uma string, serializar para XML ou lidar com qualquer uma dessas metodologias de divisão. Então pensei em verificar como esse método se compara ao vencedor de nossos testes anteriores – já que pode ser uma opção viável, se você pode usar o CLR ou não. (Para a bíblia definitiva sobre TVPs, consulte o artigo abrangente do colega MVP do SQL Server Erland Sommarskog.)
Os testes
Para este teste, vou fingir que estamos lidando com um conjunto de strings de versão. Imagine um aplicativo C# que passa em um conjunto dessas strings (digamos, que foram coletadas de um conjunto de usuários) e precisamos comparar as versões com uma tabela (digamos, que indica as versões de serviço aplicáveis a um conjunto específico de versões). Obviamente, um aplicativo real teria mais colunas do que isso, mas apenas para criar algum volume e ainda manter a tabela magra (eu também uso NVARCHAR porque é isso que a função de divisão CLR leva e quero eliminar qualquer ambiguidade devido à conversão implícita) :
CREATE TABLE dbo.VersionStrings(left_post NVARCHAR(5), right_post NVARCHAR(5)); CREATE CLUSTERED INDEX x ON dbo.VersionStrings(left_post, right_post); ;WITH x AS ( SELECT lp = CONVERT(DECIMAL(4,3), RIGHT(RTRIM(s1.[object_id]), 3)/1000.0) FROM sys.all_objects AS s1 CROSS JOIN sys.all_objects AS s2 ) INSERT dbo.VersionStrings ( left_post, right_post ) SELECT lp - CASE WHEN lp >= 0.9 THEN 0.1 ELSE 0 END, lp + (0.1 * CASE WHEN lp >= 0.9 THEN -1 ELSE 1 END) FROM x;
Agora que os dados estão no lugar, a próxima coisa que precisamos fazer é criar um tipo de tabela definido pelo usuário que possa conter um conjunto de strings. O tipo de tabela inicial para armazenar essa string é bem simples:
CREATE TYPE dbo.VersionStringsTVP AS TABLE (VersionString NVARCHAR(5));
Em seguida, precisamos de alguns procedimentos armazenados para aceitar as listas do C#. Para simplificar, novamente, faremos uma contagem para que possamos ter certeza de realizar uma verificação completa e ignoraremos a contagem no aplicativo:
CREATE PROCEDURE dbo.SplitTest_UsingCLR @list NVARCHAR(MAX) AS BEGIN SET NOCOUNT ON; SELECT c = COUNT(*) FROM dbo.VersionStrings AS v INNER JOIN dbo.SplitStrings_CLR(@list, N',') AS s ON s.Item BETWEEN v.left_post AND v.right_post; END GO CREATE PROCEDURE dbo.SplitTest_UsingTVP @list dbo.VersionStringsTVP READONLY AS BEGIN SET NOCOUNT ON; SELECT c = COUNT(*) FROM dbo.VersionStrings AS v INNER JOIN @list AS l ON l.VersionString BETWEEN v.left_post AND v.right_post; END GO
Observe que um TVP passado para um procedimento armazenado deve ser marcado como READONLY – atualmente não há como executar DML nos dados como você faria para uma variável de tabela ou tabela temporária. No entanto, Erland enviou um pedido muito popular para que a Microsoft tornasse esses parâmetros mais flexíveis (e muito mais profundos por trás de seu argumento aqui).
A beleza aqui é que o SQL Server não precisa mais lidar com a divisão de uma string – nem em T-SQL nem em entregá-la ao CLR – pois já está em uma estrutura definida onde se destaca.
Em seguida, um aplicativo de console C# que faz o seguinte:
- Aceita um número como argumento para indicar quantos elementos string devem ser definidos
- Cria uma string CSV desses elementos, usando StringBuilder, para passar para o procedimento armazenado CLR
- Cria um DataTable com os mesmos elementos para passar para o procedimento armazenado TVP
- Também testa a sobrecarga de converter uma string CSV em um DataTable e vice-versa antes de chamar os procedimentos armazenados apropriados
O código para o aplicativo C# é encontrado no final do artigo. Posso soletrar C#, mas não sou um guru; Tenho certeza de que há ineficiências que você pode identificar que podem fazer o código funcionar um pouco melhor. Mas tais alterações devem afetar todo o conjunto de testes de maneira semelhante.
Executei o aplicativo 10 vezes usando 100, 1.000, 2.500 e 5.000 elementos. Os resultados foram os seguintes (isso mostra a duração média, em segundos, nos 10 testes):
Desempenho à parte…
Além da clara diferença de desempenho, os TVPs têm outra vantagem – os tipos de tabela são muito mais simples de implantar do que os assemblies CLR, especialmente em ambientes onde o CLR foi proibido por outros motivos. Espero que as barreiras ao CLR estejam desaparecendo gradualmente e que novas ferramentas estejam tornando a implantação e a manutenção menos dolorosas, mas duvido que a facilidade de implantação inicial do CLR seja mais fácil do que as abordagens nativas.
Por outro lado, além da limitação somente leitura, os tipos de tabela são como tipos de alias, pois são difíceis de modificar após o fato. Se você deseja alterar o tamanho de uma coluna ou adicionar uma coluna, não há comando ALTER TYPE e, para DROP o tipo e recriá-lo, você deve primeiro remover as referências ao tipo de todos os procedimentos que o estão usando . Então, por exemplo, no caso acima, se precisássemos aumentar a coluna VersionString para NVARCHAR(32), teríamos que criar um tipo fictício e alterar o procedimento armazenado (e qualquer outro procedimento que o esteja usando):
CREATE TYPE dbo.VersionStringsTVPCopy AS TABLE (VersionString NVARCHAR(32)); GO ALTER PROCEDURE dbo.SplitTest_UsingTVP @list dbo.VersionStringsTVPCopy READONLY AS ... GO DROP TYPE dbo.VersionStringsTVP; GO CREATE TYPE dbo.VersionStringsTVP AS TABLE (VersionString NVARCHAR(32)); GO ALTER PROCEDURE dbo.SplitTest_UsingTVP @list dbo.VersionStringsTVP READONLY AS ... GO DROP TYPE dbo.VersionStringsTVPCopy; GO
(Ou, alternativamente, elimine o procedimento, elimine o tipo, recrie o tipo e recrie o procedimento.)
Conclusão
O método TVP superou consistentemente o método de divisão CLR e em uma porcentagem maior à medida que o número de elementos aumentou. Mesmo adicionando a sobrecarga de converter uma string CSV existente em um DataTable rendeu um desempenho de ponta a ponta muito melhor. Portanto, espero que, se já não o tivesse convencido a abandonar suas técnicas de divisão de strings T-SQL em favor do CLR, eu o tenha incentivado a dar uma chance aos parâmetros com valor de tabela. Deve ser fácil testar mesmo se você não estiver usando um DataTable (ou algum equivalente).
O código C# usado para esses testes
Como eu disse, não sou nenhum guru do C#, então provavelmente há muitas coisas ingênuas que estou fazendo aqui, mas a metodologia deve ser bem clara.
using System; using System.IO; using System.Data; using System.Data.SqlClient; using System.Text; using System.Collections; namespace SplitTester { class SplitTester { static void Main(string[] args) { DataTable dt_pure = new DataTable(); dt_pure.Columns.Add("Item", typeof(string)); StringBuilder sb_pure = new StringBuilder(); Random r = new Random(); for (int i = 1; i <= Int32.Parse(args[0]); i++) { String x = r.NextDouble().ToString().Substring(0,5); sb_pure.Append(x).Append(","); dt_pure.Rows.Add(x); } using ( SqlConnection conn = new SqlConnection(@"Data Source=.; Trusted_Connection=yes;Initial Catalog=Splitter") ) { conn.Open(); // four cases: // (1) pass CSV string directly to CLR split procedure // (2) pass DataTable directly to TVP procedure // (3) serialize CSV string from DataTable and pass CSV to CLR procedure // (4) populate DataTable from CSV string and pass DataTable to TCP procedure // ********** (1) ********** // write(Environment.NewLine + "Starting (1)"); SqlCommand c1 = new SqlCommand("dbo.SplitTest_UsingCLR", conn); c1.CommandType = CommandType.StoredProcedure; c1.Parameters.AddWithValue("@list", sb_pure.ToString()); c1.ExecuteNonQuery(); c1.Dispose(); write("Finished (1)"); // ********** (2) ********** // write(Environment.NewLine + "Starting (2)"); SqlCommand c2 = new SqlCommand("dbo.SplitTest_UsingTVP", conn); c2.CommandType = CommandType.StoredProcedure; SqlParameter tvp1 = c2.Parameters.AddWithValue("@list", dt_pure); tvp1.SqlDbType = SqlDbType.Structured; c2.ExecuteNonQuery(); c2.Dispose(); write("Finished (2)"); // ********** (3) ********** // write(Environment.NewLine + "Starting (3)"); StringBuilder sb_fake = new StringBuilder(); foreach (DataRow dr in dt_pure.Rows) { sb_fake.Append(dr.ItemArray[0].ToString()).Append(","); } SqlCommand c3 = new SqlCommand("dbo.SplitTest_UsingCLR", conn); c3.CommandType = CommandType.StoredProcedure; c3.Parameters.AddWithValue("@list", sb_fake.ToString()); c3.ExecuteNonQuery(); c3.Dispose(); write("Finished (3)"); // ********** (4) ********** // write(Environment.NewLine + "Starting (4)"); DataTable dt_fake = new DataTable(); dt_fake.Columns.Add("Item", typeof(string)); string[] list = sb_pure.ToString().Split(','); for (int i = 0; i < list.Length; i++) { if (list[i].Length > 0) { dt_fake.Rows.Add(list[i]); } } SqlCommand c4 = new SqlCommand("dbo.SplitTest_UsingTVP", conn); c4.CommandType = CommandType.StoredProcedure; SqlParameter tvp2 = c4.Parameters.AddWithValue("@list", dt_fake); tvp2.SqlDbType = SqlDbType.Structured; c4.ExecuteNonQuery(); c4.Dispose(); write("Finished (4)"); } } static void write(string msg) { Console.WriteLine(msg + ": " + DateTime.UtcNow.ToString("HH:mm:ss.fffff")); } } }