Como melhorei a performance de um processo em mais de 12000%

Recentemente tive que pegar num processo crucial, mas que deixava muito a desejar em termos de performance, inclusive dava inúmeras vezes Time out Exception do lado do SQL.

O desafio era super interessante e mexia em muita coisa core, pelo que tinha que ser uma intervenção quase que cirúrgica.

Os passos que fiz para melhorar esse processo, foram os seguintes:

Conexão com a BD

Por alguma razão, para processar uma quantidade bem elevada de registos (7 dígitos) muitas vezes tratavam tudo na mesma conexão, ou seja, abriam a conexão e executavam um monte de operações, com lógicas e chamadas de funções pelo meio.

Sendo que, a primeira coisa que fiz foi tratar da conexão com a BD, e nesse caso, as conexões eram abertas apenas quando eram necessárias e fechadas imediatamente a seguir.

Isso trouxe uma melhoria de 348% de performance no processo, só com essa “simples” mudança.

Limpeza e Otimização de código

Outra coisa que vi foi que havia muito código repetido, funções que podiam ser rapidamente otimizadas e foi exatamente isso que fiz.

O código também, em boa parte, não era testable (não gosto da palavra testável), então criei interfaces e modifiquei o código de forma a que tudo fosse testable.

Outro ponto que, em nada influencia na performance mas ajuda muito em termos de manutenção, foi o nome dos métodos, argumentos e variáveis para algo mais claro.

Clean Architecture

Apesar do projeto não ser muito grande, tinha a sua complexidade, e estava todo bagunçado, pelo que mudei e reestruturei o projeto inteiro para usar Clean Architecture (ou Onion Architecture), de forma a separar as lógicas e responsabilidades.

Separação em domínios

Analisando melhor o processo deu para entender que claramente existiam domínios diferentes e, principalmente, independentes , pelo que, o passo natural foi separar cada um em seu próprio domínio (um Console Application para cada) compartilhando entre si o core que era comum.

A este ponto fui capaz de ganhar uma melhoria de 652% de performance .

Mudança de Entity Framework para Dapper

Com tudo mais organizado e simples de entender, a próxima decisão foi trocar o Entity Framework para o Dapper, visto que o Entity Framework pouco, ou nada, agregava no processo em causa que exigia performance ao invés de facilidade de utilização (para mim a maior vantagem do Entity Framework).

Verdade que o Entity Framework evoluiu muito nos últimos tempos, mas ainda assim, em termos de performance fica atrás do Dapper.

Decisão fácil de tomar e que, teve como resultado uma melhoria de 1122% de performance .

Otimização de Queries e Tabelas

Após a mudança para o Dapper possibilitou compreender melhor a estrutura da Base de Dados e com isso ver que algumas queries não estavam otimizadas e que, principalmente, as tabelas não eram amigáveis para as queries a executar.

Com a ajuda do SQL Server Profiler fui vendo os maiores impactos e, com isso, pude criar alguns indexes que ajudaram em muito a execução das queries, inclusive em alguns casos criando uns clustered indexes.

A este ponto já tinha uma melhoria de 5336% de performance .

Bulk Operations

Outra coisa que percebi com o decorrer do refactor é que, a cada iteração era feito um insert, estilo de um log. Ou seja, um número muito elevado de operações (mais uma com a Base de Dados) por cada iteração.

A decisão foi agrupar em lotes de 1000 e fazer bulk inserts dessa forma seria 1 acesso extra por cada 1000 iterações, o que reduzia substancialmente o número de operações à Base de Dados.

Parallel.ForEachAsync

Last but not least em nenhuma parte do código era usado paralelismo. Uma vez que a ordem dos acontecimentos não eram relevantes para o caso, decidi começar a usar Parallel.ForEach, mas o problema é que, alguns dos métodos necessários a ser chamado dentro do loop eram async.

Apesar de o Parallel.ForEachAsync já estar disponível no NET6 ele não está disponível no NET5, pelo que a alternativa foi criar o meu próprio Parallel.ForEachAsync.

public static Task ParallelForEachAsync<T>(
  this IEnumerable<T> source, 
  Func<T, Task> body,
  int maxDegreeOfParallelism = 10)
{
  async Task AwaitPartition(IEnumerator<T> partition)
  {
    using (partition)
    {
      while (partition.MoveNext())
      {
        await body(partition.Current);
      }
    }
  }

  return Task.WhenAll(
    Partitioner
    .Create(source)
    .GetPartitions(maxDegreeOfParallelism)
    .AsParallel()
    .Select(AwaitPartition));
}

Isto trouxe uma melhoria de 12044% de performance .

Comparação dos resultados finais em termos de Performance

Após todo este processo, e os testes unitários estarem todos a 100% era altura de comparar resultados.

Corri o processo antigo a partir do zero (longas 31 horas), e noutra Base de Dados rodei o processo novo que demorou apenas 15 minutos face às 31 horas do antigo processo.

Chegou a altura de comparar as tabelas e as entradas que tinham que ser exatamente iguais.

Felizmente o Visual Studio tem uma ferramenta que nos auxilia a comparar duas bases de dados sobre o qual, inclusive, já escrevi um artigo. Além disso executei o EXCEPT do SQL SERVER para uma segunda prova e, felizmente, os resultados foram exatamente iguais.

Posted in .NET5, Arquitetura

Write a comment

Este site utiliza o Akismet para reduzir spam. Fica a saber como são processados os dados dos comentários.