Blog

Relatório pesado que derruba a API: mover a consulta para execução assíncrona com resultado em cache

Um ERP de varejo multi-tenant atende 1.900 lojas com uma API Node.js e um pool de 20 conexões com o PostgreSQL. Toda segunda-feira, entre 8h50 e 9h20, a API passa a responder 504 em rotas que não têm nada a ver com relatórios: login, consulta de estoque, emissão de pedido. A causa é um único endpoint, GET /relatorios/vendas, que agrega doze meses de vendas em 38 segundos no p95. Catorze gerentes abrindo o painel ao mesmo tempo ocupam catorze das vinte conexões, o balanceador corta a requisição em 30 segundos, o gerente clica de novo e a consulta antiga continua rodando no banco. Este artigo mostra como transformar esse endpoint em execução assíncrona: contrato com id de job, deduplicação de pedidos iguais, worker com fila no próprio Postgres e limite por tenant, resultado em cache com expiração pelo período consultado e a migração dos clientes atuais sem quebrar ninguém.

2026-11-03 / Arquitetura / 15 min

01

Por que um relatório síncrono derruba a API inteira

O problema não é a consulta ser lenta. Uma consulta de 38 segundos é aceitável para um relatório anual. O problema é ela rodar dentro do ciclo de vida de uma requisição HTTP e segurar um recurso compartilhado durante todo esse tempo. O pool de conexões é dimensionado para requisições que duram dezenas de milissegundos: com 20 conexões e tempo médio de 25 ms, ele sustenta cerca de 800 requisições por segundo. Uma única conexão presa por 38 segundos tira dessa conta o equivalente a 1.500 requisições curtas.

Três efeitos se somam. Primeiro, as requisições comuns passam a esperar na fila do pool, e o tempo de espera aparece como latência em rotas que não mudaram. Segundo, o timeout do balanceador ou do cliente encerra a conexão HTTP, mas não cancela a consulta: o driver só interrompe o Postgres se alguém chamar o cancelamento explicitamente, e a maioria das aplicações não faz isso. Terceiro, o usuário que vê erro clica de novo, e a mesma consulta passa a rodar duas, três vezes em paralelo, cada uma disputando CPU e I/O com as outras.

Antes de mudar qualquer código, confirme o diagnóstico no próprio banco durante o pico. As duas consultas abaixo mostram o que está rodando há mais tempo e quais consultas idênticas estão em execução simultânea, que é a assinatura do retry do usuário.

-- O que esta rodando agora, ha quanto tempo e esperando o que.
SELECT pid,
       now() - query_start AS duracao,
       state,
       wait_event_type,
       application_name,
       left(regexp_replace(query, '\s+', ' ', 'g'), 90) AS consulta
  FROM pg_stat_activity
 WHERE datname = current_database()
   AND state <> 'idle'
 ORDER BY duracao DESC
 LIMIT 20;

-- Mesma consulta pesada repetida varias vezes ao mesmo tempo e o sinal de retry do usuario.
SELECT left(regexp_replace(query, '\s+', ' ', 'g'), 90) AS consulta,
       count(*) AS em_execucao
  FROM pg_stat_activity
 WHERE state = 'active'
 GROUP BY 1
HAVING count(*) > 1
 ORDER BY em_execucao DESC;
Métrica no pico de segunda-feiraValor medido
Duração do relatório de 12 meses (p95)38 s
Conexões ocupadas por relatórios14 de 20
Cópias simultâneas da mesma consultaaté 4
Espera por conexão nas outras rotas (p99)11,2 s
Respostas 504 em rotas sem relação com relatórios6,8%

02

O contrato assíncrono: aceitar, devolver um id e deixar o cliente acompanhar

A mudança central é separar o pedido da execução. A requisição deixa de calcular o relatório e passa a apenas registrar que ele foi pedido, responde 202 Accepted com o id do job e um cabeçalho Location apontando para o recurso de acompanhamento. O cliente consulta esse recurso até o status virar pronto ou falhou. A requisição que antes durava 38 segundos passa a durar cerca de 15 ms, e o tempo de execução sai do caminho de qualquer timeout HTTP.

Antes: a consulta pesada roda dentro da requisicao
  navegador --GET /relatorios/vendas--> API --(1 conexao do pool por 38 s)--> primario
      timeout de 30 s no balanceador: o navegador desiste, a consulta continua no banco
      usuario clica de novo: mais uma conexao presa com a mesma consulta

Depois: a requisicao so registra o pedido
  navegador --POST /relatorios/vendas--> API --INSERT ... ON CONFLICT (chave)--> relatorio_jobs
      <-- 202 + Location: /relatorios/jobs/42 (em ~15 ms)
  worker (processo separado, 3 slots, 2 por tenant)
      --SKIP LOCKED--> pega o job --consulta--> replica (statement_timeout 120 s)
      --UPDATE status = 'pronto', resultado, expira_em--> relatorio_jobs
  navegador --GET /relatorios/jobs/42 (1 s, 1,5 s, 2,25 s ...)--> API --> resultado
StatusSignificadoResposta do GET de acompanhamento
pendenteRegistrado, aguardando um slot do worker200 com status e Retry-After: 2
executandoUm worker pegou o job e envia heartbeat200 com status e Retry-After: 2
prontoResultado gravado e dentro da validade200 com o resultado
falhouEsgotou tentativas ou estourou o teto de tempo200 com a mensagem de erro

Polling é a opção mais simples e funciona atrás de qualquer proxy. Para relatórios que levam segundos ou poucos minutos, consultar com intervalo crescente, de 1 segundo até um teto de 10, gera poucas requisições e todas baratas, porque leem uma linha por chave primária. Server-sent events ou WebSocket só compensam se a aplicação já tiver esse canal aberto por outro motivo. Para relatórios de dezenas de minutos, o melhor é avisar por e-mail ou notificação com o link do resultado.

// Front-end: pede o relatorio e acompanha o job com intervalo crescente.
export async function gerarRelatorio(tipo, params, { signal } = {}) {
  const resposta = await fetch(`/relatorios/${tipo}`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(params),
    signal,
  });
  if (!resposta.ok && resposta.status !== 202) {
    throw new Error(`pedido recusado: ${resposta.status}`);
  }

  let job = await resposta.json();
  let espera = 1000;
  while (job.status === 'pendente' || job.status === 'executando') {
    await new Promise((resolve) => setTimeout(resolve, espera));
    espera = Math.min(Math.round(espera * 1.5), 10_000);
    const consulta = await fetch(`/relatorios/jobs/${job.id}`, { signal });
    job = await consulta.json();
  }

  if (job.status === 'falhou') throw new Error(job.erro ?? 'relatorio falhou');
  return job.resultado;
}

03

Deduplicar pedidos iguais antes de executar qualquer consulta

Mover a consulta para um worker sem deduplicar só troca o lugar do congestionamento: cada clique vira um job, e a fila enche com cópias do mesmo trabalho. A chave do job deve ser derivada dos parâmetros normalizados, de modo que o mesmo pedido, feito pelo mesmo tenant, sempre caia na mesma linha. Normalizar significa reduzir as variações que não mudam o resultado: datas em formato canônico, listas ordenadas e sem repetição, valores padrão explícitos.

O tenant precisa fazer parte da chave. Uma chave baseada apenas nos filtros faria dois clientes com o mesmo período dividirem o mesmo resultado, o que é um vazamento de dados, não uma otimização. Pelo mesmo motivo, o GET de acompanhamento filtra por id e por tenant: o id sequencial é fácil de adivinhar.

CREATE TABLE relatorio_jobs (
  id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  chave         text        NOT NULL UNIQUE,
  tenant_id     bigint      NOT NULL,
  tipo          text        NOT NULL,
  params        jsonb       NOT NULL,
  status        text        NOT NULL
                CHECK (status IN ('pendente', 'executando', 'pronto', 'falhou')),
  tentativas    int         NOT NULL DEFAULT 0,
  erro          text,
  resultado     jsonb,
  criado_em     timestamptz NOT NULL DEFAULT now(),
  iniciado_em   timestamptz,
  heartbeat_em  timestamptz,
  concluido_em  timestamptz,
  expira_em     timestamptz
);

-- A fila so olha para os pendentes; o indice parcial fica pequeno mesmo com milhoes de jobs antigos.
CREATE INDEX relatorio_jobs_fila ON relatorio_jobs (criado_em) WHERE status = 'pendente';

-- Usado no limite de concorrencia por tenant e na varredura de jobs orfaos.
CREATE INDEX relatorio_jobs_executando ON relatorio_jobs (tenant_id) WHERE status = 'executando';

O INSERT com ON CONFLICT resolve a corrida sem trava na aplicação. Se dois pedidos idênticos chegam no mesmo milissegundo, a restrição UNIQUE garante que só um cria a linha, e o outro recebe o mesmo job. A cláusula WHERE do DO UPDATE define quando um job existente pode ser reiniciado: apenas se falhou ou se o resultado expirou. Em qualquer outro caso o RETURNING volta vazio, e a API lê o job atual, que pode já estar pronto.

// relatorios/api.js - aceita o pedido, deduplica e responde sem tocar na consulta pesada.
// Express 5: erros lancados em handlers async chegam ao middleware de erro sem wrapper.
import crypto from 'node:crypto';
import express from 'express';
import { pool } from '../db.js';

export const router = express.Router();

const TIPOS = new Set(['vendas', 'comissoes', 'estoque']);

function normalizar(params) {
  const inicio = new Date(params.inicio);
  const fim = new Date(params.fim);
  if (Number.isNaN(inicio.getTime()) || Number.isNaN(fim.getTime()) || inicio > fim) {
    throw Object.assign(new Error('periodo invalido'), { status: 400 });
  }
  return {
    inicio: inicio.toISOString().slice(0, 10),
    fim: fim.toISOString().slice(0, 10),
    agrupar: params.agrupar === 'mes' ? 'mes' : 'dia',
    lojas: [...new Set((params.lojas ?? []).map(String))].sort(),
  };
}

// O tenant faz parte da chave: sem ele, dois clientes com o mesmo filtro dividiriam o resultado.
function chaveDoPedido(tenantId, tipo, normalizado) {
  const texto = JSON.stringify([tenantId, tipo, normalizado]);
  return crypto.createHash('sha256').update(texto).digest('hex');
}

router.post('/relatorios/:tipo', async (req, res) => {
  const { tipo } = req.params;
  if (!TIPOS.has(tipo)) return res.status(404).end();

  const tenantId = req.auth.tenantId;
  const params = normalizar(req.body);
  const chave = chaveDoPedido(tenantId, tipo, params);

  // Cria o job ou reaproveita o existente. So reinicia quando falhou ou quando o resultado expirou.
  const { rows } = await pool.query(
    `INSERT INTO relatorio_jobs (chave, tenant_id, tipo, params, status)
     VALUES ($1, $2, $3, $4, 'pendente')
     ON CONFLICT (chave) DO UPDATE
        SET status = 'pendente', tentativas = 0, erro = NULL, resultado = NULL,
            criado_em = now(), concluido_em = NULL, expira_em = NULL
      WHERE relatorio_jobs.status = 'falhou'
         OR (relatorio_jobs.status = 'pronto' AND relatorio_jobs.expira_em < now())
     RETURNING id, status`,
    [chave, tenantId, tipo, params],
  );

  // Sem linha no RETURNING: o job ja existe e esta pendente, executando ou pronto e valido.
  const job =
    rows[0] ??
    (
      await pool.query('SELECT id, status, erro, resultado FROM relatorio_jobs WHERE chave = $1', [
        chave,
      ])
    ).rows[0];

  const local = `/relatorios/jobs/${job.id}`;
  if (job.status === 'pronto') return res.status(200).location(local).json(job);
  res.status(202).location(local).set('Retry-After', '2').json(job);
});

router.get('/relatorios/jobs/:id', async (req, res) => {
  const { rows } = await pool.query(
    `SELECT id, status, erro, resultado, concluido_em, expira_em
       FROM relatorio_jobs
      WHERE id = $1 AND tenant_id = $2`,
    [req.params.id, req.auth.tenantId],
  );
  const job = rows[0];
  if (!job) return res.status(404).end();
  if (job.status === 'pendente' || job.status === 'executando') {
    return res.set('Retry-After', '2').json({ id: job.id, status: job.status });
  }
  res.json(job);
});

No ERP do exemplo, 61% dos pedidos da segunda-feira passaram a ser atendidos por um job já existente, pendente, em execução ou pronto. Gerentes da mesma rede abrem o mesmo painel com o mesmo filtro, e esse padrão é comum em qualquer produto com relatórios padronizados.

04

O worker: fila no próprio Postgres, pool separado e limite por tenant

Para algumas centenas de jobs por hora, uma tabela no Postgres com SELECT FOR UPDATE SKIP LOCKED é uma fila suficiente e evita introduzir um broker só para isso. O registro do job e o estado ficam na mesma base, consultáveis com SQL, e a deduplicação pela chave continua valendo. SKIP LOCKED faz cada worker pular as linhas já reservadas por outro, então vários processos consomem a fila sem disputa e sem pegar o mesmo job duas vezes.

O worker roda em processo separado da API, com seus próprios pools. O pool da fila fala com o primário, porque grava o estado. O pool da consulta aponta para uma réplica de leitura quando existe, e define statement_timeout no servidor: um teto de 120 segundos que o Postgres aplica mesmo que o worker morra. A concorrência total fica em três consultas pesadas simultâneas, e nenhum tenant ocupa mais de duas, para que uma rede com 300 lojas não bloqueie a fila de todas as outras.

// relatorios/worker.js - processo separado da API, com pools proprios e concorrencia limitada.
import pg from 'pg';
import { executarRelatorio } from './consultas.js';

const CONCORRENCIA = 3;
const MAX_POR_TENANT = 2;
const MAX_TENTATIVAS = 3;

// Fila e estado do job no primario; a consulta pesada na replica, com teto de tempo no servidor.
const filaPool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 2 });
const consultaPool = new pg.Pool({
  connectionString: process.env.REPLICA_URL ?? process.env.DATABASE_URL,
  max: CONCORRENCIA,
  application_name: 'relatorios-worker',
  options: '-c statement_timeout=120000',
});

const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let parar = false;
process.on('SIGTERM', () => {
  parar = true;
});

// SKIP LOCKED: dois workers nunca pegam o mesmo job e nenhum espera o outro.
// O limite por tenant e aproximado: dois workers simultaneos podem passar do teto por um job.
const PEGAR_SQL = `
  UPDATE relatorio_jobs j
     SET status = 'executando', iniciado_em = now(), heartbeat_em = now(),
         tentativas = j.tentativas + 1
   WHERE j.id = (
     SELECT p.id
       FROM relatorio_jobs p
      WHERE p.status = 'pendente'
        AND (SELECT count(*) FROM relatorio_jobs e
              WHERE e.tenant_id = p.tenant_id AND e.status = 'executando') < $1
      ORDER BY p.criado_em
      FOR UPDATE SKIP LOCKED
      LIMIT 1)
  RETURNING j.id, j.tenant_id, j.tipo, j.params, j.tentativas`;

async function processar(job) {
  const batida = setInterval(() => {
    filaPool
      .query('UPDATE relatorio_jobs SET heartbeat_em = now() WHERE id = $1', [job.id])
      .catch(() => {});
  }, 10_000);

  try {
    const resultado = await executarRelatorio(consultaPool, job.tenant_id, job.tipo, job.params);
    // Periodo fechado nao muda mais: o resultado vale 24 horas. Periodo com hoje: 5 minutos.
    await filaPool.query(
      `UPDATE relatorio_jobs
          SET status = 'pronto', resultado = $2, erro = NULL, concluido_em = now(),
              expira_em = now() + CASE WHEN $3::date < current_date
                                       THEN interval '24 hours'
                                       ELSE interval '5 minutes' END
        WHERE id = $1`,
      [job.id, JSON.stringify(resultado), job.params.fim],
    );
  } catch (erro) {
    // 57014 = cancelada pelo statement_timeout. Tentar de novo so repete os mesmos 120 segundos.
    const definitivo = erro.code === '57014' || job.tentativas >= MAX_TENTATIVAS;
    await filaPool.query('UPDATE relatorio_jobs SET status = $2, erro = $3 WHERE id = $1', [
      job.id,
      definitivo ? 'falhou' : 'pendente',
      String(erro.message).slice(0, 500),
    ]);
  } finally {
    clearInterval(batida);
  }
}

async function laco() {
  while (!parar) {
    const { rows } = await filaPool.query(PEGAR_SQL, [MAX_POR_TENANT]);
    if (rows.length === 0) {
      await esperar(1000);
      continue;
    }
    await processar(rows[0]);
  }
}

// Job cujo worker morreu sem avisar: sem heartbeat ha 1 minuto, volta para a fila ou falha.
const varredura = setInterval(() => {
  filaPool
    .query(
      `UPDATE relatorio_jobs
          SET status = CASE WHEN tentativas >= $1 THEN 'falhou' ELSE 'pendente' END,
              erro = 'worker parou sem concluir'
        WHERE status = 'executando' AND heartbeat_em < now() - interval '1 minute'`,
      [MAX_TENTATIVAS],
    )
    .catch((erro) => console.error('varredura falhou', erro));
}, 30_000);

await Promise.all(Array.from({ length: CONCORRENCIA }, laco));
clearInterval(varredura);
await Promise.all([filaPool.end(), consultaPool.end()]);
  • Heartbeat a cada 10 segundos e varredura a cada 30: um job cujo worker foi encerrado por deploy ou falta de memória volta para a fila em até 90 segundos, sem intervenção manual.
  • Erro 57014, consulta cancelada pelo statement_timeout, é tratado como falha definitiva. Repetir a mesma consulta com os mesmos parâmetros só consome mais 120 segundos de banco para chegar ao mesmo resultado.
  • O resultado é gravado com JSON.stringify porque o driver pg converte arrays JavaScript em arrays do Postgres, não em JSON, e a gravação em uma coluna jsonb falharia para relatórios que retornam uma lista.
  • SIGTERM interrompe a busca de novos jobs, mas deixa o atual terminar. Se o orquestrador matar o processo antes, o heartbeat para e a varredura devolve o job para a fila.

05

Resultado em cache: onde guardar e quando expirar

O job pronto já é o cache. Enquanto expira_em estiver no futuro, qualquer pedido com a mesma chave recebe o resultado gravado sem executar nada. A pergunta que importa é quanto tempo esse resultado continua correto, e a resposta depende do período consultado, não de um TTL fixo. Vendas de um mês fechado não mudam mais, salvo estorno retroativo. Vendas que incluem o dia de hoje mudam a cada pedido.

Por isso o worker calcula a validade ao concluir: 24 horas quando a data final é anterior a hoje, 5 minutos quando o período inclui o dia corrente. Quando existe processo que altera o passado, como importação de vendas atrasadas ou estorno, ele deve expirar explicitamente os jobs afetados com um UPDATE que zera expira_em para o tenant e o intervalo de datas. Isso é mais confiável do que encurtar a validade de todos os relatórios para cobrir um caso raro.

Onde guardar o resultadoQuando faz sentidoCuidado
Coluna jsonb no próprio jobResultados agregados de até alguns megabytes, como totais por dia ou por lojaLimpar jobs expirados com rotina periódica para não inchar a tabela
Storage de objetos com URL assinadaResultados grandes ou exportações para CSV e planilhaA URL assinada também expira; gerar no GET, não gravar no job
Redis com TTLLeitura muito frequente do mesmo resultado, acima de centenas por segundoPerde o resultado em reinício; o job no Postgres continua sendo a fonte da verdade
Tabela de agregados pré-calculadosO mesmo relatório pedido por quase todos os tenants, todos os diasVira um pipeline com atualização incremental, não um cache de consulta

A última linha da tabela é o sinal para mudar de estratégia. Se 90% dos pedidos são o mesmo relatório mensal, vale mais manter uma tabela de vendas por dia e por loja, atualizada a cada hora, do que executar a agregação sob demanda. O fluxo assíncrono continua útil para os filtros raros, e o pré-cálculo atende o caso comum em milissegundos.

06

Migrar sem quebrar quem já consome o endpoint síncrono

O GET síncrono costuma ter mais consumidores do que o front-end: integrações de clientes, planilhas conectadas, scripts de BI. Trocar o contrato de uma vez quebra todos eles. A migração segura mantém o endpoint antigo funcionando enquanto o tráfego muda de caminho.

  1. Publicar o POST de job e o GET de acompanhamento sem alterar o endpoint antigo, e mudar o front-end primeiro, atrás de uma feature flag por tenant.
  2. Fazer o GET síncrono passar pelo mesmo fluxo: ele cria ou reaproveita o job e espera até 25 segundos pelo resultado. Se ficar pronto, responde como antes. Se não, responde 202 com o Location do job, o que é melhor do que o 504 atual.
  3. Aplicar statement_timeout de 5 segundos ao pool da API. A partir daqui, nenhuma consulta da requisição consegue segurar uma conexão por mais tempo do que isso, e qualquer relatório esquecido aparece como erro em log, não como lentidão geral.
  4. Avisar os consumidores de integração com prazo e documentação do contrato novo, acompanhando o uso do GET antigo por application_name ou por chave de API.
  5. Remover o caminho síncrono quando o uso chegar a zero por quatro semanas.

No ERP do exemplo, a segunda-feira seguinte à mudança terminou com p99 de 180 ms nas rotas comuns, nenhuma resposta 504 e no máximo três consultas de relatório simultâneas no banco, todas na réplica. O tempo para o gerente ver o relatório anual ficou praticamente igual na primeira abertura e caiu para menos de um segundo nas seguintes, servidas pelo resultado gravado.

MétricaAntesDepois
Duração da requisição de relatório38 s (p95)15 ms para registrar o job
Espera por conexão nas outras rotas (p99)11,2 s4 ms
Respostas 504 no pico6,8%0%
Consultas pesadas simultâneas no bancoaté 14, no primárioaté 3, na réplica
Pedidos atendidos sem executar consulta0%61%

FAQ

Perguntas frequentes

Não seria mais simples só otimizar a consulta com índices?

Vale tentar primeiro, e às vezes resolve. Mas uma agregação de doze meses sobre dezenas de milhões de linhas continua levando segundos mesmo bem indexada, e qualquer consulta que dure mais do que a requisição deveria durar tem o mesmo problema estrutural: ocupa uma conexão compartilhada e sobrevive ao timeout do cliente. Otimizar reduz o custo de cada execução; o fluxo assíncrono controla quantas rodam ao mesmo tempo e onde.

Preciso de RabbitMQ, SQS ou Kafka para isso?

Não para esse volume. Uma tabela com SKIP LOCKED atende com folga algumas centenas de jobs por hora, mantém deduplicação e estado na mesma transação e não adiciona um componente novo para operar. Um broker passa a fazer sentido quando o volume chega a milhares de jobs por minuto, quando outros serviços precisam reagir à conclusão ou quando a empresa já opera um e tem padrão estabelecido para ele.

Como cancelar um relatório que o usuário não quer mais?

Grave o pid do backend no job quando a execução começar, obtido com SELECT pg_backend_pid() na mesma conexão da consulta, e exponha um DELETE no recurso do job que marca o status como cancelado e chama pg_cancel_backend com esse pid. Só cancele se nenhum outro pedido estiver esperando o mesmo job: com deduplicação, o relatório de um gerente pode ser o de outros cinco.

O relatório pode ser lento; a API não pode esperar por ele

O endpoint que derrubava a API toda segunda-feira não precisou ficar mais rápido. Precisou sair do caminho da requisição: o pedido virou um job com chave derivada dos parâmetros e do tenant, a execução passou para um worker com concorrência limitada e teto de tempo no servidor, e o resultado ficou gravado com validade que respeita o período consultado. As rotas comuns voltaram a responder em milissegundos, e o relatório passou a custar uma execução para cada pedido distinto, não uma para cada clique.