Estudo de caso: Performance de uma aplicação .Net

Rodrigo Obach
CWI Software
Published in
6 min readApr 17, 2019

O objetivo deste post é analisar os problemas de performance encontrados e as melhorias realizadas no projeto de um sistema para um curso online. O projeto consiste de um portal online onde os alunos têm acesso a videoaulas e fóruns para a interação com tutores e outros alunos. O sistema foi lançado no final de 2017 e apresentou sérios problemas de performance, os quais só foram completamente corrigidos mais de 3 meses após o lançamento. O portal está hospedado na Azure e consiste de um front-end desenvolvido em AngularJS com uma API em .Net consultado uma base de dados SQL Server.

Histórico de problemas

O portal foi lançado em agosto de 2017 e as matrículas iniciaram as 8:00 de uma segunda-feira. Logo após o lançamento, foi observado um aumento significativo do uso de CPU e IO do banco de dados. Nas primeiras 3 horas foi necessário aumentar o banco de uma instância Standard S3 de 100 DTUs para uma instância Premium P4 de 500 DTUs para evitar que a aplicação ficasse indisponível. A estimativa original era que o sistema pudesse rodar em uma base de 50 DTUs, mas durante o lançamento foi utilizada uma instância de 100 DTUs por precaução.

No primeiro dia, foram processadas pouco mais de 1.000 matrículas e o número de usuários simultâneos não ultrapassou 200 usuários. Essa quantidade de acesso é muito baixa, ainda mais considerando que o sistema estava usando uma base de dados premium com custo mensal superior a R$ 6.000,00.

Algumas pequenas melhorias fizeram com que o sistema ficasse estável na primeira semana. Com o início das aulas (após a semana de matrículas), os problemas voltaram a ocorrer por causa do aumento do volume de dados gerados e da quantidade acessos simultâneos.

Capacidade do banco de dados (azul claro) vs Utilização (azul) logo após o lançamento

Nas primeiras semanas, foram feitos vários ajustes emergenciais para manter a aplicação no ar, mas mesmo com esses ajustes houve problemas de indisponibilidade em eventos com muitos usuários simultâneos.

O problema só foi realmente solucionado alguns meses após o lançamento. Após a execução de todos os ajustes, o uso de banco caiu para menos de 5% em uma base de 100 DTUs e o sistema suportou um teste de carga com mais de 7.000 usuários acessando em um período de 15 minutos. A versão inicial já apresentava problemas com menos de 100 usuários simultâneos na mesma infraestrutura.

Todas as otimizações executadas exigiram várias modificações no sistema e levaram mais de 3 meses para serem realizadas. Os principais modificações foram:

  • Reescrita completa da camada de cache.
  • Reorganização da API para facilitar o gerenciamento de dependências entre dados em cache.
  • Reescrita de boa parte do código de acesso a dados incluindo repositórios, queries, views e procedures.
  • Otimizações do banco de dados incluindo a reestruturação de algumas tabelas.
  • Melhorias de performance do código para reduzir o uso CPU e memória.

Cache

O portal roda em duas ou mais máquinas com balanceamento de carga e utilizava cache em memória, o que gerava o problema de que cada instância podia ter uma versão diferente dos dados em cache. A “solução” adotada originalmente foi gerar uma cache key com base na data de modificação de uma entidade para garantir que todas instâncias possuíssem a mesma versão dos dados em cache.

Trecho de código que consulta a data de modificação de uma entidade para gerar uma cache key.

Essa estratégia de cache apresentava dois grandes problemas:

  1. Toda requisição continuava fazendo pelo menos uma consulta no banco para verificar se os dados foram alterados. Como alguns dados eram alterados com muita frequência, o cache acabava servindo somente para fazer uma consulta a mais.
  2. Para obter a maior data de modificação era necessário considerar a data de modificações dos registros das tabelas relacionadas. Isso, muitas vezes, gerava uma consulta tão pesada quanto a consulta para obter os dados. Por exemplo: o método ObterDataModificacaoCurso(int id) da imagem acima executava uma consulta envolvendo 14 tabelas.

Nas primeiras semanas, como solução emergencial foi removido o cache de alguns lugares e em outros foi utilizado um tempo fixo de expiração normalmente menor do que 1 minuto. Isso reduziu significativamente o uso de banco e evitou que o sistema ficasse indisponível, mas gerou alguns efeitos colaterais porque cada instância da aplicação poderia estar com uma versão diferente dos dados em cache.

A solução definitiva foi utilizar o Redis para cache dos dados da API. Para utilização do Redis, foi necessário implementar a lógica para atualizar/limpar os dados do cache quando eles eram alterados e também foram feitas algumas alterações na estrutura da API para facilitar o gerenciamento de dependências entre as informações e separar os dados voláteis dos dados estáticos, que podiam ser mantidos em cache por muito mais tempo.

Entity Framework

Vários problemas encontrados estavam associados ao mau uso do Entity Framework e ocorreram devido à falta experiência da equipe com o EF. Os problemas mais impactantes foram:

Uso do método DbSet.Include

Um dos principais problemas foi o uso do método DbSet.Include para carregar todas as relações de uma entidade nos repositórios.

Método Find do repositório de turmas

O método do exemplo acima era utilizado muitas vezes somente para buscar as informações básicas de uma turma (nome, descrição e data de início), mas ele sempre carregava todos os dados do curso, a lista de matrículas, os usuários vinculados às matrículas, a última interação do usuário no sistema, os fóruns da turma, todos os comentários dos fóruns, …

Não demorou muito para consultas como essa começarem a retornar milhares de registros conforme os usuários interagiam com o sistema gerando mais dados. Infelizmente, existiam várias situações semelhantes e isso fez com que as funcionalidades do sistema começassem a parar de funcionar com o tempo.

Uso de IEnumerable e Func em vez de IQueryable e Expression

Os repositórios estavam fazendo filtros e paginação utilizando um IEnumerable e Func<TSource, bool> como predicado. Dessa forma, em vez de gerar uma query em SQL a aplicação estava enumerando todos os dados das tabelas e executando o filtro em memória. Mesmo nos casos em que o filtro estava correto, havia uma classe responsável pela paginação que enumerava todos os resultados da consulta para fazer a contagem de resultados.

Uso de CPU

Após as otimizações referentes ao uso de banco, foram encontrados alguns pontos de melhoria no código. Esses pontos não causaram muitos problemas em produção, mas foi necessário corrigi-los para a aplicação passar no teste de carga.

Hash code igual a zero

Em alguns lugares do código, era utilizada uma implementação de IEqualityComparer<T> que retornava sempre 0 como hash code. Fazia com os métodos do Linq que utilizam internamente estruturas de dados baseadas em hash (Distinct, GroupBy, …) fossem executados sempre com complexidade O(n²) ocasionando alguns picos de uso de CPU.

Task.Run

Durante as melhorias, foram removidas quase todas as chamadas ao método Task.Run reduzindo significantemente o uso CPU a pressão sobre o GC. Sugiro a leitura do seguinte artigo sobre este assunto: Está com dúvida no uso do Task.Run? NÃO USE!

Conclusão

A aplicação era pequena e possuía um número relativamente pequeno de usuários, porém a falta de testes de carga completos/realistas e a falta de alguns cuidados durante o desenvolvimento para garantir o desempenho causaram vários problemas durante a entrada do sistema em produção. A maioria destes problemas poderia ter sido evitada sem muito esforço com uso correto do EF, análise do plano de execução das queries, criação de índices, uso correto de async/await … Felizmente, fora os problemas de performance, a aplicação estava muito bem organizada e o código era de fácil entendimento. Isso facilitou muito a execução das melhorias principalmente para mim, que não havia participado do desenvolvimento do projeto.

--

--