Tradução de Queries e Performance no EF Core
Objective
Uma query LINQ no EF Core é uma árvore de expressão que o provider traduz para SQL. O que você escreve não é o que roda: parte vira SQL, parte pode rodar no .NET e algumas coisas não podem ser traduzidas de jeito nenhum. Problemas de performance vêm da distância entre as duas, do overhead por query e do uso do change tracker para um trabalho que o banco faz em um único comando. Este conceito cobre onde a tradução termina, como reduzir o overhead, como atualizar e apagar em massa, como escrever SQL cru sem abrir uma brecha de injection e como paginar sem o custo de um offset crescente.
Use Cases
- Um
Whereque chama um método C# próprio e lança "could not be translated" em produção, e não no teste unitário. - Um endpoint quente que roda a mesma query milhares de vezes por segundo.
- Encerrar todas as sessões expiradas com um comando, em vez de carregar e apagar uma a uma.
- Uma tela de busca que precisa de uma query que o EF Core não consegue expressar, e por isso usa SQL cru.
- Um feed de rolagem infinita que fica mais lento quanto mais o usuário rola.
Deep Dive
Onde a tradução termina
Operadores que o EF Core conhece viram SQL. Qualquer outra coisa no filtro ou na ordenação lança exceção, em vez de carregar silenciosamente a tabela e filtrar em memória (o comportamento antes do EF Core 3.0). O único lugar onde a avaliação no cliente ainda é permitida é a projeção final:
csharp// Traduzido: StartsWith, Length, Contains numa lista de parâmetros.
var ok = await db.Customers.Where(c => c.Name.StartsWith("A")).ToListAsync(ct);
// Lança: IsVip é um método C# que o provider não consegue transformar em SQL.
var bad = await db.Customers.Where(c => IsVip(c)).ToListAsync(ct);
// Permitido: a projeção roda no .NET depois que as linhas chegam.
var names = await db.Customers.Select(c => Format(c.Name)).ToListAsync(ct);Se você precisa rodar algo em memória, cruze a fronteira de forma explícita com
AsEnumerable depois de filtrar no SQL, para saber exatamente quantas linhas
cruzam:
csharpvar vips = db.Customers
.Where(c => c.CreatedAt > cutoff) // SQL
.AsEnumerable() // fronteira: as linhas passam a ser lidas pelo .NET daqui
.Where(IsVip) // em memória
.ToList();Compiled queries
O EF Core faz cache da tradução de cada formato de query, mas toda execução
ainda percorre a árvore de expressão para achar os parâmetros e a entrada do
cache. Num caminho quente, EF.CompileAsyncQuery faz esse trabalho uma vez:
csharpprivate static readonly Func<OrdersDbContext, int, CancellationToken, Task<Order?>> GetByNumber =
EF.CompileAsyncQuery((OrdersDbContext db, int number, CancellationToken ct) =>
db.Orders.SingleOrDefault(o => o.Number == number));
var order = await GetByNumber(db, number, ct);Meça antes. O ganho só é real em queries muito frequentes e simples, a
documentação limita os parâmetros de compiled queries a escalares simples (por
isso o parâmetro aqui é um int e não um ID fortemente tipado), e deixa o
código mais difícil de ler.
Updates e deletes em massa
ExecuteUpdateAsync e ExecuteDeleteAsync enviam um único UPDATE ou
DELETE com o filtro no WHERE, sem carregar entidades nem rastrear nada:
csharpawait db.Sessions
.Where(s => s.ExpiresAt < now)
.ExecuteDeleteAsync(ct);
await db.Products
.Where(p => p.CategoryId == categoryId)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, p => p.Price * 1.1m), ct);Eles rodam na hora, cada um em sua própria transação a menos que você tenha
aberto uma, e ignoram o change tracker, então uma entidade rastreada já em
memória mantém seus valores antigos. O EF Core 10 deixa o update receber uma
lambda comum, o que torna chamadas condicionais de SetProperty muito mais
fáceis de compor do que montar uma árvore de expressão à mão.
SQL cru, com segurança
FromSql recebe uma string interpolada e transforma cada valor interpolado em
parâmetro, então é seguro. FromSqlRaw recebe uma string simples e a executa
como está, então concatenar entrada do usuário nela é SQL injection:
csharp// Seguro: {term} vira parâmetro.
var rows = await db.Products
.FromSql($"SELECT * FROM products WHERE name ILIKE {term}")
.ToListAsync(ct);
// Perigoso: a string é concatenada antes de o EF Core vê-la.
var evil = await db.Products
.FromSqlRaw("SELECT * FROM products WHERE name ILIKE '" + term + "'")
.ToListAsync(ct);Desde o EF Core 10, um analyzer avisa quando você concatena strings dentro de um
método de SQL cru como FromSqlRaw.
Paginação por keyset
Skip(n) faz o banco ler e descartar n linhas, então a página 500 custa
muito mais que a página 1. A paginação por keyset lembra a última chave vista
(aqui o par CreatedAt e um Number único) e pede o que vem depois dela, o que
um índice consegue responder diretamente:
csharpvar page = await db.Orders
.Where(o => o.CreatedAt < lastCreatedAt
|| (o.CreatedAt == lastCreatedAt && o.Number < lastNumber))
.OrderByDescending(o => o.CreatedAt).ThenByDescending(o => o.Number)
.Take(20)
.ToListAsync(ct);Trade-offs
AsEnumerablecedo demais carrega a tabela. Colocá-lo antes doWheretraz todas as linhas para o .NET e filtra lá.csharpdb.Customers.AsEnumerable().Where(c => c.Name.StartsWith("A")); // toda linha cruza a rede- Operações em massa pulam o modelo de domínio.
ExecuteUpdatenão executa métodos do aggregate, interceptors que olham entidades rastreadas nem checagens de concorrência, então regras que vivem no aggregate não são aplicadas. - Operações em massa deixam entidades rastreadas desatualizadas. Uma entidade
carregada antes da chamada ainda mostra o valor antigo, e um
SaveChangesposterior pode gravá-lo de volta. - Paginação por offset é simples e fica mais lenta. Ela permite "ir para a página 37", o que a paginação por keyset não permite, então keyset serve para feeds e exportações, não para paginação numerada.
- SQL cru prende a query a um banco. Compor LINQ por cima dele também exige
um
SELECTcomposável: o EF Core embrulha o seu SQL como subquery, então uma chamada de stored procedure, um ponto e vírgula no fim ou (no SQL Server) umORDER BYsemTOPouOFFSETquebra a query composta. Depois de uma stored procedure, chameAsEnumerableouAsAsyncEnumerablelogo após oFromSql. Reserve SQL cru para os casos que o LINQ não consegue expressar.