O módulo 03 cobriu banco de dados na perspectiva de correção: índices, transações, isolation levels, consistency. Este conceito complementa do outro lado — o ponto de vista da aplicação. Mesmo banco bem indexado pode ser lento por como a aplicação o consulta. Mesmo banco rápido pode ser sobrecarregado por chamadas mal estruturadas. A diferença entre uma API que escala e uma que cai sob carga frequentemente está em como a camada de aplicação fala com o banco — e os erros mais comuns têm nome reconhecível.
O caso clássico é N+1 query. Você
lista 100 pedidos do usuário, e para cada um, faz
query separada para carregar os itens. Total: 1
query (lista pedidos) + 100 queries (itens) = 101
queries para uma operação. Um banco que aguenta
facilmente 1 query em 10 ms vira gargalo em 1010 ms
cumulativo. ORM frameworks são especialmente
vulneráveis — a sintaxe que parece inocente
foreach (var p in pedidos)
Console.WriteLine(p.Items.Count) dispara
query por iteração.
Este conceito articula os padrões clássicos de performance de banco vistos da aplicação. N+1 e batching, prepared statements e query plan cache, eager loading vs lazy loading, dataloaders, materialized views, denormalização, read replicas, e connection pooling. Cada um responde a uma classe de problema previsível, e reconhecer o sintoma é trabalho de senior em revisão de código.
O escopo aqui é deliberadamente prático. Internals de query planner, execução paralela, vacuum em Postgres — tudo isso está em livros como Database Internals (Petrov) e PostgreSQL Internals. Aqui o foco é o que o engenheiro de aplicação precisa saber para consumir banco bem.
O caso clássico — N+1 query
N+1 é a armadilha mais frequente em código que consome banco. A causa estrutural: ORM (ou padrão de acesso) entrega objetos com navegação para relacionamentos, e cada acesso a relacionamento dispara query. Quando você itera, dispara N queries.
// caso ruim — N+1
var pedidos = await db.Pedidos.ToListAsync(); // 1 query
foreach (var p in pedidos)
{
Console.WriteLine($"{p.Id}: {p.Items.Count}"); // 1 query por iteração
}
// total: 1 + N queries
A defesa é eager loading: dizer ao ORM para carregar relacionamentos junto com a entidade principal, em uma única query (ou poucas).
// caso bom — eager loading via Include
var pedidos = await db.Pedidos
.Include(p => p.Items)
.ToListAsync(); // 1 query (com JOIN ou 2 queries pequenas)
foreach (var p in pedidos)
{
Console.WriteLine($"{p.Id}: {p.Items.Count}"); // sem queries adicionais
}
Em SQLAlchemy, joinedload() ou
selectinload() resolvem similarmente. Em
ORMs Go (GORM, ent), é
.Preload("Items"). Em GraphQL, o
dataloader pattern (Facebook,
2017) é a solução padrão — agrupa lookups por
ID em batch.
Detectando N+1 — não dependa de revisão visual
N+1 às vezes é óbvio no código; outras vezes é escondido em camadas. Detecção via ferramenta é essencial.
Logging de query no ORM. EF Core, SQLAlchemy, GORM — todos têm modo de log mostrar cada query executada. Em desenvolvimento, ligar e observar. Endpoint que dispara 50 queries para uma listagem é sinal claro.
APM tools. New Relic, Datadog APM, Application Insights detectam N+1 automaticamente — mostram "este endpoint executou 47 queries similares". Em times com APM ligado, tipicamente o sintoma aparece nos primeiros dias.
EFCore.Detector ou similar. Em .NET, EFCore.QueryDetector é uma biblioteca que falha em desenvolvimento se detectar N+1. Em outros stacks, há equivalentes (django-silk em Python, sqlc-detector em Go).
OpenTelemetry com auto-instrumentação. Conceito 08 do módulo 05 cobriu OTel. Auto-instrumentação de banco mostra spans de query no trace — endpoint que tem 50 spans de query é diagnóstico imediato.
Batching — evitar N idas ao banco
Quando precisa de dados em sequência mas a operação poderia ser paralela, batching agrupa em uma única ida ao banco. Padrões clássicos.
IN clause em vez de loop
// caso ruim — N queries
var ids = new[] { 1, 2, 3, 4, 5 };
var pedidos = new List<Pedido>();
foreach (var id in ids)
pedidos.Add(await db.Pedidos.FindAsync(id));
// caso bom — 1 query com IN
var pedidos = await db.Pedidos
.Where(p => ids.Contains(p.Id))
.ToListAsync();
O caso bom é uma única query
WHERE id IN (1, 2, 3, 4, 5) — banco
otimiza, retorna em uma viagem.
Bulk insert vs INSERT per row
// caso ruim — N inserts
foreach (var item in items)
await db.Items.AddAsync(item);
await db.SaveChangesAsync();
// EF Core moderno detecta e bate o caso ruim, mas em outros ORMs ou SQL puro:
// INSERT INTO items VALUES (...); -- N statements
// caso bom — bulk insert
await db.BulkInsertAsync(items); // EFCore.BulkExtensions ou similar
// SQL: INSERT INTO items VALUES (...), (...), ..., (...); -- 1 statement
Para milhares de inserts, bulk é ordens de magnitude
mais rápido — não pelo SQL em si, mas pela viagem de
rede e pela menor pressão no transaction log.
PostgreSQL COPY e SQL Server
BulkCopy são caminhos canônicos.
Dataloader pattern
Em GraphQL, ou em qualquer cenário onde múltiplos caminhos paralelos pedem dados, dataloader agrupa requests em batch automaticamente. Facebook formalizou em 2017; bibliotecas existem em todas as linguagens.
// pseudocódigo Python
from aiodataloader import DataLoader
class PedidoLoader(DataLoader):
async def batch_load_fn(self, pedido_ids):
# uma query para todos os ids do batch
pedidos = await db.fetch(
"SELECT * FROM pedidos WHERE id = ANY($1)",
pedido_ids
)
# retornar na ordem dos ids pedidos
by_id = {p.id: p for p in pedidos}
return [by_id.get(id) for id in pedido_ids]
# uso em handler GraphQL — vários paralelos viram 1 query agregada
async def resolver(parent, info):
return await pedido_loader.load(parent.pedido_id)
Prepared statements e query plan cache
Cada query enviada ao banco passa por: parsing, semantic check, query optimization, execution. Parsing e otimização juntos podem custar mais que a execução em queries simples. Prepared statement separa as duas fases — você "prepara" a query uma vez (banco compila plan), depois "executa" várias vezes com parâmetros diferentes.
-- preparação (uma vez)
PREPARE obter_pedido (uuid) AS
SELECT * FROM pedidos WHERE id = $1;
-- execuções subsequentes (cada uma reusa o plan)
EXECUTE obter_pedido('abc123');
EXECUTE obter_pedido('def456');
ORMs modernos usam prepared statements automaticamente. Drivers de banco (npgsql, psycopg2, pq em Go) cacheiam statements em LRU. O ganho é maior em queries pequenas executadas muitas vezes — pode dobrar o throughput para queries simples.
Query plan cache é o termo do banco
para "memória dos plans já calculados". Cada query
preparada vira um plan armazenado. Banco em produção
tipicamente tem milhares de plans em cache,
reaproveitados a cada execução. Limpar
(DISCARD ALL em Postgres) força
recompilação — operação cara, raramente apropriada em
runtime.
Há armadilhas. Em Postgres, prepared statement com
parâmetro pode produzir plan ruim se distribuição dos
dados é heterogênea (parameter sniffing problem). A
mitigação é
SET plan_cache_mode = force_custom_plan
em casos específicos.
Connection pooling — reutilizar conexões
Cada conexão de banco custa setup (handshake TCP + autenticação + metadata) — frequentemente 50–200 ms. Abrir conexão por request é proibitivo: o setup domina o tempo total. Connection pool mantém conexões abertas e as reutiliza entre requests.
ORMs e drivers modernos têm pool embutido.
Configurações típicas: max_connections
(limite superior), min_idle (manter
mínimo aberto), idle_timeout (fechar
conexões ociosas após N segundos),
max_lifetime (recriar conexões depois
de tempo, evita conexões antigas com problemas).
O dimensionamento do pool é decisão crítica. Pequeno demais → contention de conexão (requests esperam conexão livre). Grande demais → banco satura em conexões (cada conexão custa memória do banco; Postgres com 1000 conexões vive perto do limite). Heurística inicial: pool size = (cores do banco × 2) + spindle count. Para bancos modernos em SSD: cores × 2. Em servidor de 16 cores, ~32 conexões.
Para sistemas com várias instâncias de aplicação, PgBouncer (PostgreSQL) ou ProxySQL (MySQL) fica entre app e banco. Cada app usa seu pool pequeno; PgBouncer multiplica com pool maior para o banco. Permite escalar horizontalmente sem saturar conexões.
Read replicas — escalando leitura
Bancos modernos suportam streaming replication: réplica recebe log de transações do primário e aplica em modo só-leitura. Aplicação roteia reads para réplicas e writes para o primário.
Padrão típico: 1 primário (recebe writes) + 2-N réplicas (recebem reads). A aplicação usa connection string diferente para cada — alguns ORMs (EF Core 7+, SQLAlchemy 2) suportam routing automático com configuração.
// EF Core — configurar read replicas
services.AddDbContext<AppDbContext>(opt =>
{
opt.UseNpgsql(primaryConnString)
.UseReadReplica(replicaConnString); // pacote third-party
});
// uso
context.Database.UseReadReplica(); // próximas queries vão pra replica
var pedidos = await context.Pedidos.ToListAsync();
Trade-off central: replication lag. Réplicas tipicamente ficam alguns milissegundos atrás do primário (em condições normais; sob carga, pode subir para segundos). Read após write na mesma sessão pode ler dado obsoleto. Soluções:
Read your writes: rotear para primário em queries logo após write da mesma sessão.
Replica com sync: usar replicação
síncrona em pelo menos uma réplica (Postgres
synchronous_commit = on + replicação
sync). Mais latência em write, mas consistência
garantida.
Aceitar lag: para reads que toleram obsolescência (listagens, dashboards), a latência de poucos segundos é aceitável.
Materialized views — denormalização gerenciada pelo banco
Materialized view é uma view com resultado materializado em disco. Você define a query; banco computa e guarda. Reads vão à view (rápido); refresh acontece periodicamente ou sob comando. Útil para queries analíticas ou agregações caras.
-- PostgreSQL
CREATE MATERIALIZED VIEW vendas_diarias AS
SELECT
date_trunc('day', created_at) AS dia,
sum(valor) AS total,
count(*) AS quantidade
FROM pedidos
WHERE status = 'finalizado'
GROUP BY 1;
CREATE UNIQUE INDEX ON vendas_diarias (dia);
-- consumir (rápido — só leitura indexada)
SELECT * FROM vendas_diarias WHERE dia >= '2026-04-01';
-- refresh (periódico ou sob evento)
REFRESH MATERIALIZED VIEW CONCURRENTLY vendas_diarias;
CONCURRENTLY em Postgres permite refresh
sem bloquear leitura — mas exige índice unique. Em
sistemas com agregações pesadas
(financeiro, analytics), materialized view é a
diferença entre query de minutos e query de
milissegundos.
Limitação: dados são tão frescos quanto o último refresh. Para "agregação atualizada em tempo real", materialized view não é a ferramenta — considere stream processing (Kafka Streams, Materialize) ou manter agregados via application logic.
Denormalização para leitura
Bancos relacionais ensinam normalização: separar dados em tabelas distintas para evitar redundância. É bom para writes consistentes; é ruim para reads complexos. Cada JOIN custa.
Em sistemas com muito tráfego de leitura, vale
denormalizar — duplicar dados estrategicamente para
eliminar JOINs comuns. Exemplo: tabela
pedidos tem campo
cliente_nome além de
cliente_id. Mudança de nome do cliente
atualiza nos dois lugares.
Trade-off: writes ficam mais caros (precisa atualizar múltiplas tabelas), e há risco de inconsistência se a sincronização falhar. Defesa: trigger no banco que mantém sincronização, ou eventos de domínio que atualizam em background.
A regra: denormalize após medir que JOIN está sendo gargalo, não preventivamente. Code que parte normalizado e denormaliza pontualmente envelhece bem; code denormalizado preventivamente acumula complicação.
Os padrões em três linguagens
// caso ruim
var pedidos = await db.Pedidos.Where(p => p.ClienteId == clienteId).ToListAsync();
foreach (var p in pedidos)
Console.WriteLine(p.Items.Count); // N+1
// caso bom — Include carrega items na mesma query
var pedidos = await db.Pedidos
.Where(p => p.ClienteId == clienteId)
.Include(p => p.Items)
.ThenInclude(i => i.Produto)
.AsNoTracking()
.ToListAsync();
// AsSplitQuery para evitar cartesian explosion em vários Include
var pedidos = await db.Pedidos
.Include(p => p.Items)
.Include(p => p.Cliente)
.Include(p => p.Endereco)
.AsSplitQuery() // executa multiple queries em paralelo
.ToListAsync();
// raw SQL para queries pesadas onde EF gera ineficiente
var topClientes = await db.Database
.SqlQuery<ClienteResumo>($@"
SELECT cliente_id, count(*) qtd, sum(valor) total
FROM pedidos
WHERE created_at >= {dataInicio}
GROUP BY cliente_id
ORDER BY total DESC
LIMIT 10")
.ToListAsync();
AsNoTracking() reduz overhead de
change tracking quando dado é só-leitura.
AsSplitQuery evita JOIN com explosão
cartesiana em vários Include. SQL puro via
SqlQuery para casos complexos.
from sqlalchemy import select
from sqlalchemy.orm import joinedload, selectinload
# caso ruim — lazy loading dispara N+1
pedidos = (await session.execute(
select(Pedido).where(Pedido.cliente_id == cliente_id)
)).scalars().all()
for p in pedidos:
print(len(p.items)) # cada acesso é uma query
# caso bom — selectinload (1 + 1 query, não N+1)
pedidos = (await session.execute(
select(Pedido)
.where(Pedido.cliente_id == cliente_id)
.options(selectinload(Pedido.items)
.selectinload(Item.produto))
)).scalars().all()
# joinedload faz JOIN — bom para 1:1 ou 1:few
pedidos = (await session.execute(
select(Pedido)
.where(Pedido.cliente_id == cliente_id)
.options(joinedload(Pedido.cliente))
)).scalars().all()
# bulk insert (psycopg COPY ou ORM bulk)
await session.execute(
insert(Item),
[{"pedido_id": p.id, "sku": p.sku, "qtd": p.qtd} for p in items]
)
selectinload é o caminho moderno —
executa uma query separada com IN. Mais eficiente
que JOIN para coleções grandes. Aprender a
escolher entre selectinload, joinedload, e raw SQL
é parte da disciplina.
// queries.sql (compilado por sqlc para Go tipado)
-- name: ListPedidosWithItems :many
SELECT
p.id, p.cliente_id, p.valor,
json_agg(json_build_object('id', i.id, 'sku', i.sku, 'qtd', i.qtd)) AS items
FROM pedidos p
LEFT JOIN items i ON i.pedido_id = p.id
WHERE p.cliente_id = $1
GROUP BY p.id;
// uso (gerado por sqlc)
pedidos, err := q.ListPedidosWithItems(ctx, clienteID)
if err != nil { return nil, err }
// alternativa idiomática: scan manual com pgx, batch fetch de items
var pedidos []Pedido
rows, _ := pool.Query(ctx, "SELECT id, cliente_id, valor FROM pedidos WHERE cliente_id = $1", clienteID)
for rows.Next() { /* scan */ }
ids := make([]uuid.UUID, len(pedidos))
for i, p := range pedidos { ids[i] = p.ID }
itemRows, _ := pool.Query(ctx, "SELECT pedido_id, id, sku, qtd FROM items WHERE pedido_id = ANY($1)", ids)
// agrupa por pedido_id, atribui aos pedidos
Go tradicionalmente usa SQL puro (com pgx ou database/sql) e organiza queries em sqlc para gerar código tipado. Menos magic, controle total sobre query plan, e zero N+1 por padrão (você escreve a query, sabe o que executa).
Connection pool dimensionamento — fórmula prática
Brett Wooldridge (autor de HikariCP) formalizou uma fórmula prática para pool size de banco:
connections = ((core_count * 2) + effective_spindle_count)
Em SSD ou storage networked, "spindle count" é considerado 0 ou 1. Em servidor de 16 cores com SSD, pool ideal é ~32–33 conexões. Vale para PostgreSQL, MySQL, e a maioria dos bancos relacionais.
A intuição: com mais que isso, threads do banco esperam por CPU, não por I/O — e pool maior só aumenta contention. A fórmula é counterintuitiva mas sustentada por benchmarks de muitos casos. Para cargas read-heavy com I/O dominante, pode subir ligeiramente; para cargas com lock contention, pode descer.
Anti-padrões frequentes
SELECT *. Pega todas as colunas mesmo
quando você só usa duas. Custo: bandwidth de rede,
memória do banco, deserialização. Em hot path,
sempre liste colunas. ORMs com projection
(.Select(p => new { p.Id, p.Nome }))
ajudam.
Query em loop sem batching. Já visto. Sintoma de N+1 ou de não usar IN clause.
Connection leak. Conexão aberta sem
using/defer Close(). Pool
esgota; novos requests esperam timeout. Defesa:
sempre using em .NET, defer
Close() em Go, async with em
Python. Ferramentas de análise estática
(StyleCop, golangci-lint) pegam.
Transação envolvendo chamada externa. Já mencionado no módulo 05 conceito 12. Transação aberta enquanto cliente HTTP demora segura conexão e locks. Defesa: ler dado, fechar transação, chamar externa, abrir nova transação para escrita.
Index desnecessário. Cada index custa
em writes (precisa atualizar) e em espaço. Time
acumula índices "por garantia"; depois de meses, a
tabela tem 30 índices, dos quais 5 são usados. Defesa:
auditoria periódica via
pg_stat_user_indexes (Postgres) ou
equivalente.
OFFSET grande para paginação.
SELECT ... LIMIT 20 OFFSET 100000 faz
banco percorrer 100k registros antes de pegar 20.
Lento e piora linearmente. Defesa: paginação por
cursor (WHERE id > $last_id ORDER BY id LIMIT
20). Praticamente toda API moderna usa cursor.
Connection pool exhaustion durante incidente. Banco fica lento (qualquer motivo: lock, query escalonada, rede). Cada request da aplicação espera mais tempo pela conexão; pool fica todo ocupado. Novos requests ficam em fila esperando connection — timeout eventualmente, request falha. Métrica: pool utilization 100%, request queue crescendo. Defesa: circuit breaker em torno do banco (módulo 05 conceito 9), pool pequeno deliberado, monitoring de pool utilization, e graceful degradation (servir do cache quando banco está sob estresse).
EXPLAIN — a ferramenta de diagnóstico canônica
Toda query SQL pode ter seu plano de execução
analisado. EXPLAIN em Postgres,
EXPLAIN ANALYZE para incluir tempo real,
EXPLAIN PLAN em Oracle,
SET STATISTICS PROFILE ON em SQL Server.
Sêniores que tocam banco leem plans rotineiramente.
EXPLAIN ANALYZE
SELECT p.* FROM pedidos p
WHERE p.cliente_id = '...' AND p.created_at >= '2026-01-01'
ORDER BY p.created_at DESC LIMIT 10;
-- saída pode mostrar:
Limit (cost=0.43..16.55 rows=10 width=84) (actual time=0.045..0.12 rows=10 loops=1)
-> Index Scan Backward using idx_pedidos_cliente_created on pedidos p
Index Cond: (cliente_id = '...')
Filter: (created_at >= '2026-01-01')
Planning Time: 0.5 ms
Execution Time: 0.18 ms
Ler bem o output ensina o engenheiro a reconhecer: Index Scan (rápido, índice usado); Seq Scan em tabela grande (lento, falta índice apropriado); Hash Join (geralmente OK para conjuntos médios); Nested Loop em conjuntos grandes (pode ser problema). Identificar o operador caro e propor índice ou refatoração é trabalho de senior em revisão.
Ferramentas como explain.depesz.com e explain.dalibo.com visualizam plans Postgres em interface gráfica, facilitando leitura em queries complexas.
Toda query de hot path merece EXPLAIN
ANALYZE antes de chegar à produção. Olhar
plan e confirmar que: (1) índice apropriado é
usado; (2) tipo de operador é razoável para o
tamanho do conjunto; (3) tempo estimado bate
razoavelmente com tempo real (se diverge muito,
statistics estão desatualizadas — rodar
ANALYZE na tabela). Em ambiente
staging com dados representativos, esse hábito
captura a maioria das regressões antes de virem
incidente. Em produção, monitorar
pg_stat_statements ou equivalente para
identificar queries que mudaram comportamento.
Por que importa para a sua carreira
Performance de banco vista da aplicação é tema onipresente em entrevista de senior. "Qual a diferença entre eager e lazy loading?" "Como você resolveria N+1?" "Como dimensionaria connection pool?" — essas perguntas testam vocabulário e prática. A resposta forte cita ferramentas (Include, eager loading, IN clause, dataloader), articula trade-offs (eager vs lazy, normalizado vs denormalizado, replica lag), e mostra disciplina (EXPLAIN antes de produção). Em revisão de código, captar N+1 latente em PR de ORM é o tipo de trabalho que evita incidente futuro. Em pos-mortem, "pool exhaustion" e "query plan degradou" são causas-raiz frequentes — e quem articula em vocabulário maduro guia a investigação.
Como praticar
-
Audit de N+1 em projeto seu. Ative
log de queries no ORM (EF Core
LogTo(Console.WriteLine, LogLevel.Information); SQLAlchemyecho=True; GORMSetLogMode(true)). Acesse seu app e observe um endpoint complexo. Conte queries por request. Identifique pelo menos um endpoint com N+1 e refatore com eager loading. Mensure antes/depois. Esse exercício, com qualquer projeto não-trivial, sempre rende. - Connection pool experiment. Pegue seu app, configure pool size pequeno (5 conexões). Use ferramenta de carga (k6, ab) para enviar 1000 req/s. Observe latência crescer ao saturar. Aumente pool gradualmente, repita. Identifique o ponto onde aumentar não ajuda mais — esse é o knee. Compare com a fórmula de Wooldridge (cores × 2). Esse experimento torna concreto o trade-off de dimensionamento.
-
EXPLAIN ANALYZE em queries
existentes. Pegue 5 queries do hot path do
seu projeto. Para cada, rode
EXPLAIN ANALYZEem ambiente com dados representativos. Para cada uma: usa índice? Qual? Tempo estimado vs real. Identifique pelo menos uma que poderia melhorar com index novo ou refatoração. Esse hábito, internalizado, é diferencial em revisão.
Referências para aprofundar
- livro Database Internals — Alex Petrov (O'Reilly, 2019).
- livro Designing Data-Intensive Applications — Martin Kleppmann (O'Reilly, 2017).
- livro High Performance MySQL (4ª ed.) — Silvia Botros, Jeremy Tinley (O'Reilly, 2021).
- livro PostgreSQL 16 Internals — Egor Rogov (gratuito da Postgres Pro, 2024).
- artigo The N+1 Problem — Mike Bayer (SQLAlchemy creator, várias publicações).
- artigo About Pool Sizing — Brett Wooldridge (HikariCP wiki, 2014+).
- artigo Use the Index, Luke! — Markus Winand (use-the-index-luke.com).
- artigo DataLoader Pattern — Lee Byron (Facebook GraphQL, 2017).
- docs EF Core Performance.
- docs SQLAlchemy 2.0 — Loading Patterns.
- docs sqlc.
- vídeo SQL Performance Explained — Markus Winand (várias palestras).