Destino Dev

Blog

Otimização de consultas com Prisma e PostgreSQL para reduzir latência e custos em 2026

Por John Pires · 07 de março de 2026

Contexto

Em 2026 a pressão por latência baixa e custos operacionais reduzidos continua: aplicativos crescem, modelos de cobrança cloud evoluem (mais egressos, conexões, I/O) e arquiteturas serverless/edge ampliam restrições. Prisma é um ORM moderno muito usado em stacks TypeScript/Node, mas aplicar padrões e configurações incorretas com PostgreSQL pode aumentar latência e custos. Este artigo aborda técnicas avançadas para reduzir latência, diminuir gasto em I/O e conexões, e manter previsibilidade operacional.

Explicação técnica

Princípios que guiam otimização

  • Reduzir dados transferidos (selects, projeção, paginação eficiente).
  • Reduzir quantidade de queries (agregar, batch, evitar N+1).
  • Ajustar planos: índices, estatísticas, EXPLAIN ANALYZE.
  • Controlar conexões e pooling (PgBouncer, Neon pooling, serverless limitations).
  • Equilibrar consistência vs custo (read replicas, materialized views).

Como o Prisma impacta a performance

Prisma Client facilita queries complexas com include/select, mas abstrações geram queries adicionais se usadas de forma ingênua. Exemplos típicos: "include" recursivo que resulta em joins caros; uso de findMany sem select retorna colunas desnecessárias; executar findUnique dentro de loops gera N+1.

Como analisar: ferramentas e métricas

Use EXPLAIN ANALYZE para ver tempo real e custos estimados; pg_stat_statements para queries frequentes; pg_stat_activity para conexões; monitoring de I/O e CPU no banco. No Prisma, habilite log queries com prisma.$on('query', ...) em desenvolvimento e capture latência. Em ambientes serverless, monitore cold starts e latência de conexão.

Pooling e prepared statements

Connection pooling reduz overhead de conexão. PgBouncer em transaction pooling é padrão para reduzir conexões simultâneas, mas atenção: pooling por transação não combina bem com prepared statements sendo gerados por client no servidor (vários prepared statements por sessão podem exceder memória). Em setups serverless, alguns bancos (ex: Neon, Supabase serverless) oferecem pooling embutido; entenda limites de prepared statements e evite uso excessivo de server-side prepared statements quando for incompatível com o pool.

Exemplos práticos

Dois casos reais: otimizar listagens com joins e resolver N+1 em relacionamentos profundos; otimizar paginação e agregações em grandes tabelas.

Exemplo 1: Evitando N+1 com include/select e batching

Cenário: users com posts e comments. Código ingênuo que causa N+1:

for (const user of users) {
  const posts = await prisma.post.findMany({ where: { authorId: user.id } })
  // ...
}

Correção 1: trazer tudo em um único findMany com include (se o volume permitir) ou fazer batching por ids.

// usar include com select para reduzir colunas
const usersWithPosts = await prisma.user.findMany({
  where: { isActive: true },
  take: 50,
  select: {
    id: true,
    name: true,
    posts: {
      take: 20,
      select: { id: true, title: true, createdAt: true }
    }
  }
})

Se posts por usuário forem muitos, usar batching por chunks de user ids evita joins gigantes e evita transferir linhas desnecessárias:

const userIds = users.map(u => u.id)
const CHUNK = 200
const postsByUser = new Map()
for (let i = 0; i < userIds.length; i += CHUNK) {
  const chunk = userIds.slice(i, i + CHUNK)
  const posts = await prisma.post.findMany({ where: { authorId: { in: chunk } }, select: { id: true, authorId: true, title: true } })
  for (const p of posts) {
    if (!postsByUser.has(p.authorId)) postsByUser.set(p.authorId, [])
    postsByUser.get(p.authorId).push(p)
  }
}

Exemplo 2: Cursor-based pagination com índice composto

Offset pagination em tabelas grandes custa porque o banco precisa pular N linhas. Cursor-based pagination com índice composto é escalável.

// cursor pagination prisma
const pageSize = 50
const last = req.query.lastCursor // { createdAt: '2026-02-01T...', id: 123 }
const where = last
  ? { OR: [ { createdAt: { lt: last.createdAt } }, { createdAt: last.createdAt, id: { lt: last.id } } ] }
  : {}
const rows = await prisma.activity.findMany({
  where,
  orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
  take: pageSize,
  select: { id: true, createdAt: true, payload: true }
})

Crie índice composto para suportar ordenação e filtros:

create index idx_activity_createdat_id_desc on activity (created_at desc, id desc);

EXPLAIN ANALYZE: interpretar um plano

Exemplo de EXPLAIN ANALYZE para uma query que precisa de otimização:

explain analyze
select a.id, a.name, count(p.id) as posts
from author a
left join post p on p.author_id = a.id
where a.active = true
group by a.id, a.name;

-- Saída (resumida):
-- Hash Join  (cost=... rows=... time=...)
--  Hash Cond: (p.author_id = a.id)
--  ->  Seq Scan on post p  (cost=...)
--  ->  Hash  (cost=...)
--        ->  Seq Scan on author a  (cost=...)

Se plan mostra seq scan em tabelas grandes, adicione índices para colunas usadas em joins/where, ou reestruture a query. Para agregações por fk, um índice na coluna post.author_id reduz custo.

Erros comuns

  • Usar findMany sem select: transfere colunas grandes (jsonb, text) desnecessariamente.
  • Paginação por offset em tabelas com milhões de linhas: latência cresce linearmente com offset.
  • Preparar statements em pooling por transação: caches de prepared statements por sessão podem quebrar com PgBouncer em transaction mode.
  • Negligenciar autovacuum/statistics: índices se tornam ineficazes sem analyze/vacuum.
  • Excesso de includes aninhados: gera joins amplos e leituras duplicadas; prefira consultas separadas com limites claros ou agregações pré-computadas.
  • Esperar que 'count' seja barato: count(*) em tabelas grandes sem índices apropriados é caro; considere aproximations ou mantenha contadores incrementais.

Estratégias avançadas e tradeoffs

Materialized views e cache de agregação

Para relatórios e dashboards, materialized views ou tabelas pré-agregadas reduzem consultas pesadas. Atualize via triggers ou jobs programados (cron). Materialized views têm custo de refresh e requerem monitoramento de consistência.

Read replicas e roteamento

Replica offloading reduz latência de escrita, mas aumenta complexidade de consistência eventual. Em leituras analíticas heavy, a replicação baixa custo de I/O no primário. Em arquiteturas multi-região, prefira roteamento por proximidade e considere latência de replicação.

Compressão e coluna específica (JSONB indexing)

Para colunas jsonb, crie índices GIN com paths relevantes, ou expression indexes para campos usados em filtros. Evite indexar todo jsonb sem necessidade — custo de manutenção.

create index idx_post_payload_userid on post ((payload->>'userId'))
create index idx_post_tags_gin on post using gin (tags);

Bulk updates e prisma.$queryRaw

Operações massivas com prisma Client podem ser lentas se iteradas. Use prisma.$executeRaw ou $queryRaw para executar SQL set-based com performance e controle de transações.

// exemplo: atualizar status em lote
await prisma.$executeRaw`
  update job set status = 'expired' where run_at < now() - interval '30 days'
`;

Exemplos adicionais: batch com transação e PgBouncer config

Batch com prisma.$transaction usando timestamped chunking para evitar locks longos:

async function archiveOld () {
  const chunkSize = 1000
  while (true) {
    const ids = await prisma.item.findMany({ where: { createdAt: { lt: cutoff } }, take: chunkSize, select: { id: true } })
    if (ids.length === 0) break
    await prisma.$transaction([
      prisma.item.deleteMany({ where: { id: { in: ids.map(i => i.id) } } }),
      // other related cleanup
    ])
  }
}

Exemplo mínimo de config PgBouncer em production (transaction pooling recomendado):

[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
pool_mode = transaction
max_client_conn = 10000
default_pool_size = 100
server_reset_query = DISCARD ALL

Checklist final

  • Medir: capture latência real e frequências com pg_stat_statements e logs do Prisma.
  • Projeção: use select para retornar apenas colunas necessárias.
  • Agrupar e agregar no banco: prefira operações set-based a loops no app.
  • Evitar offset grande: implemente cursor-based pagination com índices compostos.
  • N+1: identifique com tracing e resolva com include, batching ou data loader.
  • Índices: crie índices para joins, filtros e ordenações; valide com EXPLAIN ANALYZE.
  • Pooling: use PgBouncer/pooled native offerings; ajuste pool_mode considerando prepared statements.
  • Prepared statements: monitore número de prepared statements no servidor; prefira client-side parameterization quando compatível com o pool.
  • Materialized views/reports: use para queries caras e atualize incrementalmente.
  • Autovacuum/ANALYZE: verifique e ajuste thresholds para tabelas com alto churn.
  • Cost-awareness: reveja custo de egress e I/O na nuvem; prefira reduzir leituras e transferências de dados.

Conclusão

Reduzir latência e custos usando Prisma + PostgreSQL exige entender tanto o ORM quanto o banco. Otimizações eficazes combinam: projeção de dados, batching, paginação por cursor, índices adequados, pooling bem configurado e uso criterioso de materialized views. Em 2026, com modelos de custo e arquiteturas diversificadas, foco em reduzir I/O, conexões e tráfego entre camadas é essencial.

Use a checklist como base e aplique um ciclo contínuo: medir, ajustar, validar com EXPLAIN ANALYZE e reavaliar quando o padrão de uso mudar.

Assuntos: Prisma, PostgreSQL, Performance, SQL, backend, Carreira

Leia também