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.
Como melhoria, foi sempre considerado única e exclusivamente o tempo de execução do processo a partir do zero.