Uma abordagem objetiva
Por onde começar?
Quem ainda não passou por isso um dia é porque ainda é muito novo nessa vida de desenvolvedor de software.
Aquele projeto que vem se arrastando há meses chega finalmente ao grande momento de ir para a produção, quebra-se a garrafa de champanhe no casco, liberam-se as amarras e em poucos minutos fica evidente que o nosso transatlântico não se move na velocidade desejada.
O próximo capítulo dessa história passa pelo inevitável apontar de dedos, afinal precisamos arrumar um culpado e esse negócio de análise objetiva dos fatos e definição de ações pode muito bem ficar para depois.
Pra encurtar a história o nosso navio cruzará os mares com o auxílio de um rebocador, alguns remadores e talvez um velame que tem que ser recolhido com vento forte pois a estrutura não aguenta os novos mastros.
Um conjunto de fatores leva a um baixo desempenho do software, arquitetura ruim, pouca experiência das pessoas, requisitos mal definidos, uso das ferramentas e práticas menos adequadas às necessidades, entre outros.
Não é o código
Não mesmo? É parte do meu trabalho analisar aplicações em produção e encontrar gargalos de performance. Muitas vezes eu me deparo com consultas ao banco de dados que não tem bom tempo de resposta ou infraestrutura de rede com restrições de banda.
"Ha ha, tá vendo? A culpa é do ambiente". Escuto isso com frequência, alguém se refere ao ambiente como sendo algum tipo de entidade de cujo humor dependem todos os problemas do software o que, sob o ponto de vista de uma análise objetiva, é tão preciso quanto dizer que um leprechaun está bagunçando os servidores.
Na grande maioria das vezes, os problemas de desempenho estão ligados diretamente à decisões feitas na hora de escrever o código. A consulta pouco eficiente é parte da aplicação ela é parte do código, se existe um requisito de limitação de banda de rede o tráfego tem que ser otimizado, comunicação tem custos, uso de memória tem custos, processamento tem custos e assim segue.
Na maior parte das vezes os problemas de desempenho que eu encontro estão ligados diretamente ao código.
E como desenvolvedor o que eu faço?
Se você escolheu ganhar a vida desenvolvendo software a primeira coisa que tem que ficar bem clara, se ainda não está, é que esta é uma carreira de aprendizado contínuo.
É fundamental entender como as coisas funcionam, eu não programo mais em nenhuma das linguagens com as quais eu comecei, mas os conceitos que aprendi eu uso até hoje. Linguagens e ferramentas surgem e desaparecem (ou viram legados), fundamentos se mantém.
No ferramental moderno de desenvolvimento ninguém programa usando somente a linguagem de programação. Stacks de desenvolvimento são compostos não apenas por compiladores, mas também por bibliotecas e outros componentes que simplificam o trabalho de escrever código. O desenvolvedor de software comercial trabalha quase sempre em um nível alto de abstração, ou seja, tudo que eu escrevo utiliza código que eu não escrevi.
É fundamental entender o que fazem as bibliotecas utilizadas. O que está por baixo do capô? Como funciona? Tem que saber o que acontece. Com o código fonte cada vez mais aberto fica mais fácil explorar o que está por trás de cada componente.
O código é executado em um sistema operacional, tem que entender pelo menos o básico do sistema operacional, a aplicação se comunica com outros serviços através da infraestrutura de rede, tem que entender alguma coisa de rede e protocolos, se acessa o banco de dados relacional precisa conhecer SQL, e assim vai. Não dá pra achar que basta saber uma linguagem de programação.
Em algum momento você atinge algum limite que é físico, o computador é um equipamento eletrônico que possui um numero limitado de ciclos de CPU ou uma quantidade limitada de bytes de memória que podem ser endereçados. O código não dispõe de recursos infinitos, mesmo na nuvem. Mais recursos nem sempre resolvem problemas de software mal escrito.
É muito comum encontrar cenários que podem ser resolvidos de maneiras diferentes, por isso um bom entendimento de algoritmos e estruturas de dados se faz necessário. Boa parte da otimização de código vem não dos passos executados, mas daqueles que são eliminados. Eu vejo muita gente preocupada com arquiteturas sofisticadas, design patterns e afins, mas que pulou por completo a parte dos conceitos.
Eu tenho que ser um cientista pra programar bem?
Não. Pelo contrário, as ferramentas atuais são feitas para que possamos trabalhar em nível mais alto, porém é muito importante entendê-las para utilizá-las da forma mais eficiente possível e saber escolher a ferramenta certa para cada trabalho.
"Pra quem tem martelo tudo é prego." (dito popular)
Otimização prematura em geral é ruim, em alguns cenários a otimização de código pode ter impactos negativos na legibilidade com um ganho pequeno em termos de eficiência. Evitar ineficiências conhecidas traz de imediato um impacto bastante positivo na busca por código melhor.
Próximos passos
A minha ideia é escrever artigos que ajudem a produzir código com melhor desempenho em C# e .NET que hoje são a minha especialidade, no futuro outras coisas poderão ser a minha especialidade. Também quero abordar ferramentas e técnicas que podem ser usadas para identificar e melhorar o desempenho de aplicações.
Pretendo começar com alguns tópicos sobre ORM usando o Entity Framework Core pois este é um assunto que é bastante comum no contexto de aplicações e que eu vejo as pessoas tropeçando com alguma frequência.
Nessa proposta pretendo ser sempre o mais objetivo e direto possível, tentando sempre usar o nível de detalhe necessário para ilustrar o meu ponto de vista.
Uma coisa que pra mim sempre foi muito clara é que eu não sei tudo, na verdade a quantidade de coisas que eu não sei é por certo muito maior do que as que eu sei. Não sou em momento algum dono da verdade e sempre posso estar sujeito a erros. As discussões civilizadas serão sempre bem-vindas.
Melhore as consultas com Entity Framework Core
Os problemas com o mapeamento objeto-relacional
Frameworks de mapeamento objeto-relacional (Object Relational Mapping – ORM) são ferramentas excelentes no trabalho de trazer os dados do modelo dos servidores de dados relacionais, como Sql Server, para o modelo orientado a objetos das linguagens de programação.
Por outro lado, esses frameworks criam uma impressão equivocada de que o programador não precisa mais entender o modelo relacional e sequer se preocupar em como escrever consultas da melhor forma até que os usuários comecem a reclamar da velocidade.
O comum nesses momentos é culpar o framework, dizer que é lento mesmo e que talvez na próxima versão o desempenho seja melhor, porém o mau uso responde essa questão mais vezes do que o contrário.
Assuma o controle
O desenvolvedor tem que entender SQL. Não dá para fugir, no final os dados são manipulados pelo SGBD (Sistema Gerenciador de Banco de Dados) e a interface padrão para esses sistemas é a linguagem SQL (Structured Query Language).
O que bibliotecas de ORM fazem é gerar as instruções da linguagem SQL de forma dinâmica a partir do código. Entity Framework (EF) e Entity Framework Core (EF Core), constroem as consultas SQL a partir do código na forma de instruções LINQ (Language Integrated Query) ou métodos de extensão.
Para assumir o controle é necessário ver as queries que são geradas e melhorá-las quando necessário ou identificar possíveis melhorias no esquema de banco de dados como índices ou modelagem mais eficiente. A ideia aqui é focar no EF Core 6.0, mas entender e otimizar consultas é um passo importante quando se investiga o desempenho de aplicações quaisquer que sejam.
Uma forma de visualizar as consultas em tempo de desenvolvimento é configurar o log no DbContext:
// Exemplo de configuração de log no DbContext usando uma aplicação console
using var loggerFactory = LoggerFactory.Create(builder => {
builder.AddConsole().AddFilter("",LogLevel.Information);
});
var options = new DbContextOptionsBuilder<AdvWorksDbContext>()
.UseLoggerFactory(loggerFactory)
.UseSqlServer("Data Source=(localdb)\\MSSQLLocalDB;Database=AdventureWorksLT2016;Integrated Security=true")
.Options;
using var context = new AdvWorksDbContext(options);
A classe DbContext pode ser configurada através de uma instância de DbContextOptions e nessa configuração pode ser passado um LoggerFactory que será usado em tempo de execução para obter uma instância de ILogger. Quando o nível de informação é configurado para LogLevel.Information, cada comando será registrado com o respectivo tempo de execução como no exemplo abaixo:
var qry = context.Customers;
foreach (var item in qry) {
...
}
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (25ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [c].[CustomerID], [c].[CompanyName], [c].[EmailAddress], [c].[FirstName], [c].[LastName], [c].[MiddleName], [c].[ModifiedDate], [c].[NameStyle], [c].[PasswordHash], [c].[PasswordSalt], [c].[Phone], [c].[rowguid], [c].[SalesPerson], [c].[Suffix], [c].[Title]
FROM [SalesLT].[Customer] AS [c]
Uma alternativa mais simples ao Microsoft.Extensions.Logging é o uso de Simple Logging, uma funcionalidade introduzida no EF Core 5.0 e que permite de forma simples associar uma ação (Action<T>
) que aceita uma string. O exemplo abaixo configura o DbContext para direcionar os logs para a console usando o método LogTo:
var options = new DbContextOptionsBuilder<AdvWorksDbContext>()
.LogTo(Console.WriteLine, LogLevel.Information)
.UseSqlServer("Data Source=(localdb)\\MSSQLLocalDB;Database=AdventureWorksLT2016;Integrated Security=true")
.Options;
Quando se usa injeção de dependências o Microsoft.Extensions.Log é configurado implicitamente:
services.AddDbContext<AdvWorksDbContext>(options => { options
.UseSqlServer("Configuration["ConnectionStrings:Default]");
});
No arquivo appsettings.json é possível configurar o nível de log desejado para o EF Core:
{
"Logging": {
"LogLevel": {
"Microsoft.EntityFrameworkCore": "Information"
}
},
...
Uma funcionalidade bastante útil para correlacionar as consultas nos logs com os comandos do código é o uso da função TagWith que possibilita adicionar um comentário em cada comando:
var qry = context.Customers.TagWith("Traz todos os clientes").
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (22ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
-- Traz todos os clientes
SELECT [c].[CustomerID], [c].[CompanyName], [c].[EmailAddress], [c].[FirstName], [c].[LastName], [c].[MiddleName], [c].[ModifiedDate], [c].[NameStyle], [c].[PasswordHash], [c].[PasswordSalt], [c].[Phone], [c].[rowguid], [c].[SalesPerson], [c].[Suffix], [c].[Title]
FROM [SalesLT].[Customer] AS [c]
Consultas com alto tempo de execução são as que devem ser investigadas de forma mais profunda. Se necessário peça ajuda a um especialista de banco de dados sobre como identificar as consultas mais pesadas e ações para melhorar o desempenho.
Sempre olhe as consultas geradas!
Trazendo só o necessário
A regra mais básica a ser respeitada na hora de escrever uma consulta a qualquer fonte de dados é traga somente os dados que for utilizar. Não escreva consultas sem uma cláusula WHERE e nunca escreva consultas usando SELECT *, lembre-se que os dados tem de ser transportados através da rede e mapeados para propriedades de classe em código e isso toma tempo e consome recursos desnecessários. Menos é mais.
var qry = context.Customers;
foreach (var item in qry) {
...
}
Partindo dessa premissa, qual o problema com a consulta do exemplo acima?
Se não colocarmos sob um contexto não há nenhum problema. Então vamos estipular um requisito: preciso obter uma lista com Id, nome e sobrenome de todos os clientes.
Nesse caso o erro fica mais evidente, a consulta traz todos os dados de todos os clientes. Se a empresa tem muitos clientes (situação bastante desejável) podemos ter tempos longos para trazer e mapear todos os dados.
Ao definir o modelo de objetos para as entidades de negócio, criamos classes que realmente mapeiam as informações do domínio de negócios para o código. Isso é perfeito quando visualizamos detalhes de uma entidade (um cliente por exemplo) ou atualizamos as informações no banco de dados, porém isso não é o ideal quando se necessita apenas de alguns dados.
Não é necessário usar todas as propriedades de uma classe ao efetuar uma consulta, LINQ oferece a possibilidade de utilizar projeções que é o equivalente direto para um select com lista de campos.
O exemplo abaixo utiliza o método Select com um tipo anônimo que contém apenas os dados desejados:
var qry = context.Customers.TagWith("Traz apenas Id, nome e sobrenome dos clientes")
.Select(c => new {
CustomerId = c.CustomerId,
FirstName = c.FirstName,
LastName = c.LastName
});
Implicitamente o compilador definirá uma classe que representa o tipo anônimo. Veja a diferença da consulta gerada no log:
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (24ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
-- Traz apenas Id, nome e sobrenome dos clientes
SELECT [c].[CustomerID] AS [CustomerId], [c].[FirstName], [c].[LastName]
FROM [SalesLT].[Customer] AS [c]
Tipos anônimos são bons quando utilizados dentro de um mesmo método, porém não são opção quando se deseja passar informações para outros métodos, seja como parâmetro ou como retorno.
Para esses cenários podem ser definidas classes específicas com apenas os dados que se deseja obter. Ex.:
...
internal class MinimalCustomer
{
public int CustomerId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
}
...
var qry = context.Customers
.Select(c => new MinimalCustomer{
CustomerId = c.CustomerId,
FirstName = c.FirstName,
LastName = c.LastName
});
...
Projeções resolvem a questão de trazer apenas as colunas necessárias, trazer apenas as linhas necessárias é função do Where. Sempre que possível gere consultas com critérios de filtro, lembre-se que a base de dados pode crescer ao longo do tempo.
Por exemplo, podemos trazer apenas os dados de clientes que sejam atendidos pelo vendedor que está usando a aplicação no momento.
var currentUser = "adventure-works\\david8";
var qry = context.Customers
.Where(c=>c.SalesPerson== currentUser)
.Select(c => new {
CustomerId = c.CustomerId,
FirstName = c.FirstName,
LastName = c.LastName
});
O método Where acrescenta a cláusula WHERE na consulta SQL e utiliza parâmetros para dificultar a injeção de SQL:
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (30ms) [Parameters=[@__currentUser_0='?' (Size = 256)], CommandType='Text', CommandTimeout='30']
SELECT [c].[CustomerID] AS [CustomerId], [c].[FirstName], [c].[LastName]
FROM [SalesLT].[Customer] AS [c]
WHERE [c].[SalesPerson] = @__currentUser_0
Se não for possível forçar o uso de filtros de consulta na aplicação é possível limitar o número de resultados retornados. Na maioria das vezes não faz nenhum sentido retornar milhares ou milhões de linhas. O método Take pode ser usado para colocar um limitador:
var qry = context.Customers
.Where(c=>c.SalesPerson== currentUser)
.Select(c => new {
CustomerId = c.CustomerId,
FirstName = c.FirstName,
LastName = c.LastName
})
.OrderBy(c=>c.LastName)
.Take(100);
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (26ms) [Parameters=[@__p_1='?' (DbType = Int32), @__currentUser_0='?' (Size = 256)], CommandType='Text', CommandTimeout='30']
SELECT TOP(@__p_1) [c].[CustomerID] AS [CustomerId], [c].[FirstName], [c].[LastName]
FROM [SalesLT].[Customer] AS [c]
WHERE [c].[SalesPerson] = @__currentUser_0
ORDER BY [c].[LastName]
Note no exemplo anterior o método Take insere a cláusula TOP no comando SQL. O uso de OrderBy é para garantir a ordem dos resultados. Se omitido teremos alertas no log indicando que não há garantia de ordem nos resultados de comandos SQL sem especificar ORDER BY:
warn: Microsoft.EntityFrameworkCore.Query[10102]
The query uses a row limiting operator ('Skip'/'Take') without an 'OrderBy' operator. This may lead to unpredictable results. If the 'Distinct' operator is used after 'OrderBy', then make sure to use the 'OrderBy' operator after 'Distinct' as the ordering would otherwise get erased.
Finalizando
Frameworks existem para facilitar o trabalho, mas é responsabilidade do programador entender o que eles fazem e utilizá-los da melhor forma. Esteja no controle.
Traga sempre apenas os dados necessários, mesmo que isso signifique escrever um pouco mais de código.
Aprenda SQL, é lenda urbana essa história de que o ORM nos livra de saber SQL.
Teste com massas de dados que reproduzam os cenários esperados em produção ou se possível que excedam esses cenários.
No caso do EF Core ou qualquer outro framework ORM, é necessário analisar as consultas geradas. Pelo menos as mais críticas.
Existem outras ferramentas que podem ser usadas para entender e otimizar comandos SQL, recomendo instalar o Azure Data Studio que permite conectar a bases de dados, executar comandos, investigar planos de acesso entre diversas funções. É possível ampliar a funcionalidade da ferramenta através de extensões e uma que eu recomendo para entender o que acontece por trás do ORM é a SQL Server Profiler.