Padrões de Performance em Banco de Dados
Quando Usar Este Skill
Use este skill quando:
- Projetando camadas de acesso a dados
- Otimizando consultas lentas de banco de dados
- Escolhendo entre EF Core e Dapper
- Evitando armadilhas comuns de performance
Princípios Fundamentais
- Separe modelos de leitura e escrita – Não use os mesmos tipos para ambos
- Pense em lotes – Evite consultas N+1
- Recupere apenas o que precisa – Nada de SELECT *
- Aplique limites de linhas – Sempre tenha um Take/Limit configurável
- Faça joins no SQL – Nunca no código da aplicação
- AsNoTracking para leituras – O rastreamento de alterações do EF Core é caro
Separação de Modelos de Leitura/Escrita (Padrão CQRS)
Modelos de leitura e escrita são fundamentalmente diferentes – eles têm formas, colunas e propósitos distintos. Não crie uma única entidade "User" e reutilize-a em todos os lugares.
- Modelos de leitura são desnormalizados, otimizados para eficiência de consulta e retornam múltiplos tipos de projeção (UserProfile, UserSummary, UserDetailForAdmin)
- Modelos de escrita são normalizados, focados em validação e aceitam comandos fortemente tipados (CreateUserCommand, UpdateUserCommand)
Arquitetura
src/
MyApp.Data/
Users/
# Lado de leitura – múltiplas projeções otimizadas
IUserReadStore.cs
PostgresUserReadStore.cs
# Lado de escrita – handlers de comando
IUserWriteStore.cs
PostgresUserWriteStore.cs
# DTOs de leitura – leves, desnormalizados
UserProfile.cs
UserSummary.cs
# Comandos de escrita – focados em validação
CreateUserCommand.cs
UpdateUserCommand.cs
Orders/
IOrderReadStore.cs
IOrderWriteStore.cs
(estrutura similar...)
Interface do Armazenamento de Leitura
// Modelos de leitura: Múltiplas projeções especializadas otimizadas para diferentes casos de uso
public interface IUserReadStore
{
// Retorna perfil detalhado para visualização de usuário único
Task<UserProfile?> GetByIdAsync(UserId id, CancellationToken ct = default);
// Retorna informações leves para buscas
Task<UserProfile?> GetByEmailAsync(EmailAddress email, CancellationToken ct = default);
// Retorna sumários paginados – apenas o que a visualização de lista precisa
Task<IReadOnlyList<UserSummary>> GetAllAsync(int limit, UserId? cursor = null, CancellationToken ct = default);
// Consulta booleana – nenhuma entidade necessária
Task<bool> EmailExistsAsync(EmailAddress email, CancellationToken ct = default);
}
Interface do Armazenamento de Escrita
// Modelo de escrita: Aceita comandos fortemente tipados, valores de retorno mínimos
public interface IUserWriteStore
{
// Retorna apenas o ID criado – quem chama não precisa da entidade completa
Task<UserId> CreateAsync(CreateUserCommand command, CancellationToken ct = default);
// Atualização valida o comando, retorna void (sucesso ou lança exceção)
Task UpdateAsync(UserId id, UpdateUserCommand command, CancellationToken ct = default);
// Exclusão é simples e explícita
Task DeleteAsync(UserId id, CancellationToken ct = default);
}
Diferenças estruturais chave ilustradas:
- O armazenamento de leitura retorna múltiplos DTOs diferentes (UserProfile, UserSummary, flag bool)
- O armazenamento de escrita retorna dados mínimos (apenas UserId na criação) ou void
- Consultas de leitura são projeções sem estado – nenhum rastreamento necessário
- Operações de escrita focam na validação do comando, não na recuperação de dados posteriormente
- Diferentes bancos de dados/tabelas podem suportar leitura vs escrita (padrão de consistência eventual)
Sempre Aplique Limites de Linhas
Nunca retorne conjuntos de resultados sem limites. Cada método de leitura deve ter um limite configurável.
Padrão: Parâmetro de Limite
public interface IOrderReadStore
{
// Limite é obrigatório, não opcional
Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(
CustomerId customerId,
int limit,
OrderId? cursor = null,
CancellationToken ct = default);
}
// Implementação
public async Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(
CustomerId customerId,
int limit,
OrderId? cursor = null,
CancellationToken ct = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(ct);
const string sql = """
SELECT id, customer_id, total, status, created_at
FROM orders
WHERE customer_id = @CustomerId
AND (@Cursor IS NULL OR created_at < (SELECT created_at FROM orders WHERE id = @Cursor))
ORDER BY created_at DESC
LIMIT @Limit
""";
var rows = await connection.QueryAsync<OrderRow>(sql, new
{
CustomerId = customerId.Value,
Cursor = cursor?.Value,
Limit = limit
});
return rows.Select(r => r.ToOrderSummary()).ToList();
}
EF Core com Paginação
public async Task<PaginatedList<OrderSummary>> GetOrdersAsync(
CustomerId customerId,
Paginator paginator,
CancellationToken ct = default)
{
var query = _context.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId.Value)
.OrderByDescending(o => o.CreatedAt);
var totalCount = await query.CountAsync(ct);
var orders = await query
.Skip((paginator.PageNumber - 1) * paginator.PageSize)
.Take(paginator.PageSize) // Sempre limitar!
.Select(o => new OrderSummary(
new OrderId(o.Id),
o.Total,
o.Status,
o.CreatedAt))
.ToListAsync(ct);
return new PaginatedList<OrderSummary>(
orders,
totalCount,
paginator.PageSize,
paginator.PageNumber);
}
AsNoTracking para Consultas de Leitura
O rastreamento de alterações do EF Core é caro. Desabilite-o para consultas somente leitura.
// FAÇA: Desabilitar rastreamento para leituras
var users = await _context.Users
.AsNoTracking()
.Where(u => u.IsActive)
.ToListAsync();
// NÃO FAÇA: Rastrear entidades que você não vai modificar
var users = await _context.Users
.Where(u => u.IsActive)
.ToListAsync(); // Rastreamento de alterações habilitado – desperdício
Configurar Comportamento Padrão
// Para aplicações com muitas leituras, considere isso no DbContext
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
Então habilite o rastreamento explicitamente quando necessário:
var user = await _context.Users
.AsTracking() // Explícito – pretendemos modificar
.FirstOrDefaultAsync(u => u.Id == userId);
Evite Consultas N+1
O problema N+1: buscar uma lista e depois consultar os dados relacionados de cada item.
O Problema
// RUIM: Consultas N+1
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
// Cada iteração atinge o banco de dados!
var items = await _context.OrderItems
.Where(i => i.OrderId == order.Id)
.ToListAsync();
}
Solução 1: Include (EF Core)
// BOM: Única consulta com join
var orders = await _context.Orders
.AsNoTracking()
.Include(o => o.Items)
.ToListAsync();
Solução 2: Consulta em Lote (Dapper)
// BOM: Duas consultas, sem N+1
const string sql = """
SELECT id, customer_id, total FROM orders WHERE customer_id = @CustomerId;
SELECT oi.* FROM order_items oi
INNER JOIN orders o ON oi.order_id = o.id
WHERE o.customer_id = @CustomerId;
""";
using var multi = await connection.QueryMultipleAsync(sql, new { CustomerId = customerId });
var orders = (await multi.ReadAsync<OrderRow>()).ToList();
var items = (await multi.ReadAsync<OrderItemRow>()).ToList();
// Join em memória (aceitável – dados já foram buscados)
foreach (var order in orders)
{
order.Items = items.Where(i => i.OrderId == order.Id).ToList();
}
Nunca Faça Joins no Lado da Aplicação
Joins devem acontecer no SQL, não no C#.
// RUIM: Join na aplicação – duas consultas, desperdício de memória
var customers = await _context.Customers.ToListAsync();
var orders = await _context.Orders.ToListAsync();
var result = customers.Select(c => new
{
Customer = c,
Orders = orders.Where(o => o.CustomerId == c.Id).ToList() // O(n*m) em memória!
});
// BOM: Join no SQL – única consulta
var result = await _context.Customers
.AsNoTracking()
.Include(c => c.Orders)
.ToListAsync();
// BOM: Join explícito (Dapper)
const string sql = """
SELECT c.id, c.name, COUNT(o.id) as order_count
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name
""";
Evite Explosões Cartesianas
Múltiplas chamadas a Include podem causar produtos cartesianos.
// PERIGOSO: Pode explodir em milhões de linhas
var product = await _context.Products
.Include(p => p.Reviews) // 100 reviews
.Include(p => p.Images) // 20 imagens
.Include(p => p.Categories) // 5 categorias
.FirstOrDefaultAsync(p => p.Id == id);
// Resultado: 100 * 20 * 5 = 10.000 linhas transferidas!
Solução: Consultas Divididas
// BOM: Múltiplas consultas, sem explosão cartesiana
var product = await _context.Products
.AsSplitQuery()
.Include(p => p.Reviews)
.Include(p => p.Images)
.Include(p => p.Categories)
.FirstOrDefaultAsync(p => p.Id == id);
// Resultado: 4 consultas separadas, ~125 linhas no total
Solução: Projeção Explícita
// MELHOR: Busque apenas o que precisa
var product = await _context.Products
.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProductDetail(
p.Id,
p.Name,
p.Description,
p.Reviews.OrderByDescending(r => r.CreatedAt).Take(10).ToList(),
p.Images.Take(5).ToList(),
p.Categories.Select(c => c.Name).ToList()))
.FirstOrDefaultAsync();
Restrinja Tamanhos de Colunas
Defina comprimentos máximos no seu modelo EF Core para evitar dados superdimensionados.
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.Property(u => u.Email)
.HasMaxLength(254) // Limite RFC 5321
.IsRequired();
builder.Property(u => u.Name)
.HasMaxLength(100)
.IsRequired();
builder.Property(u => u.Bio)
.HasMaxLength(500);
// Para conteúdo realmente grande, use explicitamente o tipo text
builder.Property(u => u.Notes)
.HasColumnType("text");
}
}
Não Construa Repositórios Genéricos
Repositórios genéricos escondem a complexidade das consultas e dificultam a otimização.
// RUIM: Repositório genérico
public interface IRepository<T>
{
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync(); // Sem limite!
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate); // Não pode otimizar
}
// BOM: Armazenamentos de leitura específicos
public interface IOrderReadStore
{
Task<OrderDetail?> GetByIdAsync(OrderId id, CancellationToken ct = default);
Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(CustomerId id, int limit, CancellationToken ct = default);
Task<IReadOnlyList<OrderSummary>> GetPendingAsync(int limit, CancellationToken ct = default);
}
Problemas com repositórios genéricos:
- Não é possível otimizar consultas específicas
- Nenhuma maneira de impor limites
- Escondem problemas N+1
- Facilitam buscar dados demais
- Incentivam pensamento preguiçoso sobre acesso a dados
Dapper para Cargas de Trabalho com Muitas Leituras
Para consultas de leitura complexas, Dapper com SQL explícito geralmente é mais limpo e rápido.
public sealed class PostgresUserReadStore : IUserReadStore
{
private readonly NpgsqlDataSource _dataSource;
public PostgresUserReadStore(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
public async Task<UserProfile?> GetByIdAsync(UserId id, CancellationToken ct = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(ct);
const string sql = """
SELECT id, email, name, bio, created_at
FROM users
WHERE id = @Id
""";
var row = await connection.QuerySingleOrDefaultAsync<UserRow>(
sql, new { Id = id.Value });
return row?.ToUserProfile();
}
// Tipo de linha interno para mapeamento Dapper
private sealed class UserRow
{
public Guid id { get; set; }
public string email { get; set; } = null!;
public string name { get; set; } = null!;
public string? bio { get; set; }
public DateTime created_at { get; set; }
public UserProfile ToUserProfile() => new(
Id: new UserId(id),
Email: new EmailAddress(email),
Name: new PersonName(name),
Bio: bio,
CreatedAt: new DateTimeOffset(created_at, TimeSpan.Zero));
}
}
Quando Usar EF Core vs Dapper
| Cenário | Recomendação |
|---|---|
| CRUD simples | EF Core |
| Consultas de leitura complexas | Dapper |
| Escritas com validação | EF Core |
| Operações em lote | Dapper ou SQL bruto |
| Relatórios/analítica | Dapper |
| Escritas com domínio pesado | EF Core |
Você pode usar ambos no mesmo projeto – EF Core para escritas, Dapper para leituras.
Referência Rápida
| Anti-Padrão | Solução |
|---|---|
| Sem limite de linhas | Adicione parâmetro limit em todo método de leitura |
| SELECT * | Projete apenas as colunas necessárias |
| Consultas N+1 | Use Include ou consultas em lote |
| Joins na aplicação | Faça joins no SQL |
| Explosão cartesiana | Use AsSplitQuery ou projeção |
| Rastreamento de dados somente leitura | Use AsNoTracking |
| Repositório genérico | Armazenamentos de leitura/escrita específicos |
| Strings sem limites | Configure MaxLength no modelo |
Recursos
- Performance do EF Core: https://learn.microsoft.com/en-us/ef/core/performance/
- Dapper: https://github.com/DapperLib/Dapper
- AsSplitQuery: https://learn.microsoft.com/en-us/ef/core/querying/single-split-queries