Ciclo de Vida do DbContext e Change Tracking
Objective
Um DbContext é uma unit of work: ele rastreia as entidades que você carrega,
percebe o que mudou e grava a diferença no SaveChanges. A maioria dos bugs do
EF Core que parecem aleatórios vem de entender mal duas coisas sobre ele.
Primeiro, quanto tempo uma instância vive e quem a compartilha, porque ele não é
thread-safe e acumula toda entidade que já viu. Segundo, o que o change tracker
faz com cada entidade, porque isso decide qual SQL é enviado, quanta memória é
retida e se uma query devolve o objeto que você já modificou.
Use Cases
- Registrar
OrdersDbContextnuma aplicação ASP.NET Core para que cada requisição receba sua própria instância e nunca a compartilhe com outra. - Usar um
DbContextnumBackgroundServiceou num componente Blazor Server, onde não há escopo de requisição para tomar emprestado. - Tornar mais barato um endpoint de listagem somente leitura, sem rastrear o que ele carrega.
- Entender por que uma segunda query para a mesma linha devolve a instância que você já alterou, e não o que está no banco.
Deep Dive
Um contexto por unidade de trabalho
AddDbContext registra o contexto como scoped, o que numa aplicação web
significa uma instância por requisição. Isso combina com a ideia de unit of
work: carregar, alterar, salvar, descartar. Um DbContext não é thread-safe,
então rodar duas queries ao mesmo tempo na mesma instância lança exceção:
csharp// Errado: duas operações no mesmo contexto ao mesmo tempo.
var a = db.Orders.ToListAsync(ct);
var b = db.Customers.ToListAsync(ct);
await Task.WhenAll(a, b); // InvalidOperationException: A second operation started on this context before a previous operation completed
// Certo: dê await em cada uma, ou entregue a cada task seu próprio contexto de uma factory.
var orders = await db.Orders.ToListAsync(ct);
var customers = await db.Customers.ToListAsync(ct);Pooling e factories
AddDbContextPool mantém um pool de instâncias e reinicia o estado delas entre
os usos, o que elimina o custo de construir uma por requisição. O preço é uma
regra: um contexto do pool não pode guardar estado por requisição em seus
próprios campos, porque a instância sobrevive à requisição. Para código sem
escopo, injete IDbContextFactory<T> e crie um contexto de vida curta onde
precisar:
csharpbuilder.Services.AddDbContextPool<OrdersDbContext>(o =>
o.UseNpgsql(connectionString));
builder.Services.AddPooledDbContextFactory<ReportsDbContext>(o =>
o.UseNpgsql(connectionString));
internal sealed class NightlyJob(IDbContextFactory<ReportsDbContext> factory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await using var db = await factory.CreateDbContextAsync(ct); // contexto novo a cada execução
// ... consultar e salvar
}
}Um BackgroundService é singleton, então injetar nele um DbContext scoped
falha na inicialização ou, pior, captura uma única instância pela vida inteira
da aplicação.
O que o change tracker registra
Toda entidade rastreada tem um estado: Detached, Unchanged, Added,
Modified ou Deleted. Uma query que devolve entidades as inicia como
Unchanged e guarda um snapshot dos valores. No SaveChanges, o EF Core compara
cada entidade com seu snapshot (DetectChanges), e só as propriedades que
diferem entram no UPDATE:
csharpvar order = await db.Orders.SingleAsync(o => o.Id == id, ct);
order.Rename("Rush order"); // Modified, só a coluna alterada é gravada
await db.SaveChangesAsync(ct); // UPDATE orders SET name = @p0 WHERE id = @p1
var state = db.Entry(order).State; // Unchanged de novo depois do saveO tracker também faz identity resolution: pedir a mesma chave duas vezes devolve a mesma instância, e a segunda query não sobrescreve o que você já mudou em memória.
Pare de rastrear quando só lê
Rastrear custa memória e CPU, porque cada entidade ganha um snapshot. Numa query
somente leitura, AsNoTracking pula isso, e uma projeção para um DTO pula as
entidades por completo:
csharpvar rows = await db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.Select(o => new OrderRow(o.Id.Value, o.Status, o.Total.Amount))
.ToListAsync(ct);Para contextos de vida longa, como uma importação em lote que carrega milhares de
linhas, chame db.ChangeTracker.Clear() entre os lotes para que o tracker não
guarde todas as entidades até o fim.
Trade-offs
- Pooling reaproveita instâncias, então estado vazado vaza entre
requisições. Um campo atribuído ao contexto numa requisição pode ficar
visível na seguinte.csharp
public sealed class OrdersDbContext : DbContext { public Guid CurrentTenant { get; set; } // perigoso num contexto com pool } // Resolva o tenant a partir de um serviço scoped dentro do query filter. - Queries com
AsNoTrackingdevolvem entidades desconectadas. Alterá-las e chamarSaveChangesnão grava nada, porque o contexto nunca as viu.csharpvar o = await db.Orders.AsNoTracking().SingleAsync(x => x.Id == id, ct); o.Rename("X"); await db.SaveChangesAsync(ct); // 0 linhas afetadas, sem erro - Queries sem tracking perdem a identity resolution. Duas linhas que apontam
para o mesmo cliente geram dois objetos
Customerseparados, a menos que você useAsNoTrackingWithIdentityResolution, que gasta parte da economia. - Um contexto de vida longa cresce sem limite e fica desatualizado. Manter um único contexto por uma sessão inteira numa aplicação desktop ou Blazor Server retém toda entidade que ele já carregou e mostra dados que outros usuários alteraram desde então.
DetectChangesé linear no número de entidades rastreadas. Rastrear dezenas de milhares de entidades deixa todoSaveChangeslento, outro motivo para processar em lotes e limpar o tracker em vez de carregar tudo de uma vez.