Blog

Vizinho barulhento no SaaS multi-tenant: isolar o cliente que consome o banco de todos sem criar um banco por cliente

Um SaaS com 400 clientes roda em um único Postgres compartilhado e responde em 120 ms de p99 há um ano. Numa segunda-feira de fechamento, o cliente Atacado Norte pede o relatório mensal: seis consultas em paralelo, cada uma varrendo 38 milhões de linhas. A CPU do banco vai a 92%, o pool de 40 conexões fica inteiro nas mãos dele, e os outros 399 clientes veem telas de 4 segundos ou erros de timeout. O suporte abre 60 chamados em 20 minutos; nenhum deles é do Atacado Norte, que só está esperando o relatório. A reação comum é criar um banco por cliente, e essa é uma solução cara para um problema que quase sempre cabe em limites bem colocados. Este artigo mostra como identificar quem está consumindo o banco antes do incidente, como isolar a carga com limite de concorrência por tenant, pools separados por classe de carga e papéis com timeouts diferentes, quando faz sentido promover um cliente para banco próprio e como provar em teste que o isolamento funciona.

2026-10-25 / Arquitetura / 15 min

01

O cliente que usa o produto como previsto e derruba os outros

O vizinho barulhento raramente é abuso. É um cliente legítimo fazendo algo que o produto permite: exportar um ano de pedidos, importar uma planilha de 200 mil linhas, abrir um painel com filtro aberto demais, ou um script de integração que consulta a API em laço. O problema não está na intenção, e sim em um recurso compartilhado sem divisão de uso: CPU do banco, conexões, I/O, cache de buffers e locks. Quem chega primeiro e pede mais fica com a maior parte.

O pior efeito é o pool de conexões. Cada consulta pesada segura uma conexão por segundos, o pool tem tamanho fixo, e as requisições curtas dos outros clientes ficam na fila de conexão. O banco até teria capacidade para atendê-las, mas elas nem chegam a ele. O usuário do Atacado Norte, sem ver o relatório, clica em tentar de novo, e cada clique acrescenta mais seis consultas à tempestade. Sem limite por tenant, a tentativa de recuperação do cliente piora o incidente de todos.

Recurso compartilhadoComo o vizinho consomeO que os outros veem
Conexões do poolConsultas longas seguram conexões por segundosEspera por conexão, timeout antes de chegar ao banco
CPU e I/O do bancoVarredura grande, ordenação, agregação sem índiceToda consulta fica mais lenta, inclusive as indexadas
Cache de buffersVarredura sequencial expulsa as páginas quentes dos outrosConsultas antes rápidas passam a ler do disco
LocksTransação longa ou DDL no meio do expedienteEscritas simples esperando e depois falhando

02

Achar o vizinho antes do incidente: medir uso por tenant

O pg_stat_statements agrupa consultas pelo texto normalizado, então não separa um tenant do outro: o mesmo SELECT com tenant_id diferente vira uma linha só. Para atribuir o consumo ao cliente, a aplicação precisa carregar a identidade até o banco. O jeito mais barato é ajustar o application_name da conexão no momento em que ela é emprestada, o que a torna visível em pg_stat_activity, e somar na própria aplicação o tempo de banco por tenant. A primeira consulta responde "quem está ocupando o banco agora"; o segundo número responde "quem ocupa a maior parte do banco ao longo do dia", que é o que aparece no painel antes de virar incidente.

-- 1. Quem esta ocupando o banco agora (application_name = t:<tenant>, definido pela API).
SELECT application_name AS tenant,
       count(*) FILTER (WHERE state = 'active') AS ativas,
       count(*) FILTER (WHERE state = 'idle in transaction') AS ociosas_em_transacao,
       count(*) FILTER (WHERE wait_event_type = 'Lock') AS esperando_lock,
       max(now() - query_start) FILTER (WHERE state = 'active') AS consulta_mais_longa
FROM pg_stat_activity
WHERE datname = current_database()
  AND application_name LIKE 't:%'
GROUP BY application_name
ORDER BY ativas DESC
LIMIT 10;

-- 2. Papeis separados, cada um com o seu limite.
CREATE ROLE app_interativo LOGIN PASSWORD :'senha_interativo';
CREATE ROLE app_pesado LOGIN PASSWORD :'senha_pesado';

ALTER ROLE app_interativo SET statement_timeout = '3s';
ALTER ROLE app_interativo SET idle_in_transaction_session_timeout = '10s';
ALTER ROLE app_interativo CONNECTION LIMIT 40;

ALTER ROLE app_pesado SET statement_timeout = '120s';
ALTER ROLE app_pesado SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE app_pesado SET work_mem = '64MB';
ALTER ROLE app_pesado CONNECTION LIMIT 6;

-- 3. Relatorio pesado sempre numa replica de leitura, nunca no primario (conecte o pool pesado nela).
-- Na replica, hot_standby_feedback = off evita que a consulta longa segure o vacuum do primario.

Com isso, a pergunta do plantão muda de "o banco está lento por quê?" para "qual tenant está acima de 30% do tempo de banco e há quanto tempo?". Um alerta nessa participação, e não só na CPU, dá aviso minutos antes de o p99 dos outros subir. Vale também guardar o ranking diário: ele mostra quais clientes crescem rápido e quais deveriam estar em outro plano.

03

Isolar com limites, sem um banco por cliente

A ordem de ataque é do mais barato ao mais caro. Primeiro, separar as classes de carga: tela interativa e relatório pesado não devem dividir pool nem papel. O relatório ganha um pool pequeno, um papel com timeout longo e, quando possível, uma réplica de leitura. A tela interativa fica com um pool grande e timeout curto de 3 segundos, o que mata consultas descontroladas antes que segurem a conexão por minutos.

Segundo, limitar a concorrência por tenant dentro de cada classe. O limitador abaixo dá a cada cliente um teto de consultas simultâneas e uma fila curta com prazo. O excesso espera pouco e depois recebe um erro explícito, que a API traduz em 429 com Retry-After. Quando o Atacado Norte dispara seis consultas, uma roda e as outras esperam na fila dele, sem tocar no pool dos demais.

// limitador-tenant.js
// Cada tenant tem um teto de consultas simultaneas e uma fila curta com prazo.
// O que passa do teto espera pouco e depois recebe um erro claro, em vez de ocupar o pool de todos.
export class ErroLimiteTenant extends Error {
  constructor(tenant, motivo) {
    super('limite do tenant ' + tenant + ': ' + motivo);
    this.name = 'ErroLimiteTenant';
    this.tenant = tenant;
    this.motivo = motivo;
  }
}

export function criarLimitadorPorTenant({ maxPorTenant = 4, maxFila = 20, esperaMaximaMs = 2000 } = {}) {
  const ativos = new Map(); // tenant -> consultas em andamento
  const filas = new Map(); // tenant -> [{ resolve, reject, timer }]
  const recusadas = new Map(); // tenant -> total recusado, para virar metrica

  function contarRecusa(tenant) {
    recusadas.set(tenant, (recusadas.get(tenant) ?? 0) + 1);
  }

  function adquirir(tenant) {
    const emAndamento = ativos.get(tenant) ?? 0;
    if (emAndamento < maxPorTenant) {
      ativos.set(tenant, emAndamento + 1);
      return Promise.resolve();
    }
    const fila = filas.get(tenant) ?? [];
    if (fila.length >= maxFila) {
      contarRecusa(tenant);
      return Promise.reject(new ErroLimiteTenant(tenant, 'fila cheia'));
    }
    return new Promise((resolve, reject) => {
      const item = { resolve, reject, timer: null };
      item.timer = setTimeout(() => {
        const posicao = fila.indexOf(item);
        if (posicao >= 0) fila.splice(posicao, 1);
        if (fila.length === 0) filas.delete(tenant);
        contarRecusa(tenant);
        reject(new ErroLimiteTenant(tenant, 'espera excedida'));
      }, esperaMaximaMs);
      fila.push(item);
      filas.set(tenant, fila);
    });
  }

  function liberar(tenant) {
    const fila = filas.get(tenant);
    if (fila && fila.length > 0) {
      // A vaga passa direto para o proximo da fila do mesmo tenant: o contador de ativos nao muda.
      const proximo = fila.shift();
      clearTimeout(proximo.timer);
      if (fila.length === 0) filas.delete(tenant);
      proximo.resolve();
      return;
    }
    const restantes = (ativos.get(tenant) ?? 1) - 1;
    if (restantes <= 0) ativos.delete(tenant);
    else ativos.set(tenant, restantes);
  }

  async function executar(tenant, fn) {
    await adquirir(tenant);
    try {
      return await fn();
    } finally {
      liberar(tenant);
    }
  }

  return {
    executar,
    estado: () => ({
      ativos: Object.fromEntries(ativos),
      filas: Object.fromEntries([...filas].map(([t, f]) => [t, f.length])),
      recusadas: Object.fromEntries(recusadas),
    }),
  };
}
// bancos.js
// Duas classes de carga, dois pools, dois papeis no banco. O relatorio pesado nunca compete
// com a tela que o usuario esta esperando.
import pg from 'pg';
import { criarLimitadorPorTenant } from './limitador-tenant.js';

const { Pool } = pg;

// Interativo: muitas consultas curtas. Papel app_interativo tem statement_timeout de 3 s.
export const poolInterativo = new Pool({
  connectionString: process.env.DATABASE_URL_INTERATIVO,
  max: 30,
  application_name: 'api-interativo',
  connectionTimeoutMillis: 1500,
});

// Pesado: relatorios, exportacoes e importacoes. Poucas conexoes, timeout longo, papel proprio.
export const poolPesado = new Pool({
  connectionString: process.env.DATABASE_URL_PESADO,
  max: 4,
  application_name: 'api-pesado',
  connectionTimeoutMillis: 5000,
});

const limitadorInterativo = criarLimitadorPorTenant({ maxPorTenant: 4, maxFila: 20, esperaMaximaMs: 1500 });
const limitadorPesado = criarLimitadorPorTenant({ maxPorTenant: 1, maxFila: 5, esperaMaximaMs: 10_000 });

export const estadoLimitadores = () => ({
  interativo: limitadorInterativo.estado(),
  pesado: limitadorPesado.estado(),
});

export function consultar(classe, tenantId, sql, params = []) {
  const pool = classe === 'pesado' ? poolPesado : poolInterativo;
  const limitador = classe === 'pesado' ? limitadorPesado : limitadorInterativo;
  return limitador.executar(tenantId, async () => {
    const client = await pool.connect();
    try {
      // application_name aparece em pg_stat_activity: e assim que se descobre quem esta rodando o que.
      await client.query("SELECT set_config('application_name', $1, false)", ['t:' + tenantId]);
      const inicio = performance.now();
      try {
        return await client.query(sql, params);
      } finally {
        registrarTempoDeBanco(tenantId, classe, performance.now() - inicio);
      }
    } finally {
      await client.query("SELECT set_config('application_name', $1, false)", ['api-' + classe]).catch(() => {});
      client.release();
    }
  });
}

// Soma do tempo de banco por tenant: o ranking que mostra o vizinho barulhento antes do incidente.
const tempoPorTenant = new Map();
function registrarTempoDeBanco(tenantId, classe, ms) {
  const chave = classe + ':' + tenantId;
  tempoPorTenant.set(chave, (tempoPorTenant.get(chave) ?? 0) + ms);
}
export function rankingTempoDeBanco(topo = 5) {
  const total = [...tempoPorTenant.values()].reduce((a, b) => a + b, 0) || 1;
  return [...tempoPorTenant]
    .sort((a, b) => b[1] - a[1])
    .slice(0, topo)
    .map(([chave, ms]) => ({ chave, segundos: Math.round(ms / 1000), participacao: Number((ms / total).toFixed(2)) }));
}

O limitador vive no processo, então o teto vale por instância. Com 12 instâncias e maxPorTenant 4, um tenant pode chegar a 48 consultas simultâneas. Para um teto global exato, divida o limite desejado pelo número de instâncias ou use um contador compartilhado no Redis; na prática, a divisão simples já resolve a maior parte dos incidentes, porque o objetivo é impedir que um cliente ocupe tudo, não medir a concorrência exata.

04

Isolamento de dados e quando promover um tenant para banco próprio

Limites protegem a latência dos outros, mas não eliminam a disputa por I/O e por cache em um banco único. A saída seguinte não é "banco por cliente" para todos, e sim camadas de isolamento que o cliente sobe quando precisa. A maioria dos tenants fica no pool compartilhado, que é o mais barato de operar. Os grandes migram para um nível mais isolado, e a aplicação resolve a conexão pelo tenant, então o código de negócio não muda.

NívelIsolamentoCusto operacionalQuando usar
Pool compartilhado, limites por tenantConcorrência e timeouts, sem isolar I/O e cacheBaixo: um banco, um esquemaPadrão para a grande maioria dos clientes
Réplica de leitura para relatóriosTira a carga analítica do primárioMédio: lag de réplica precisa ser toleradoRelatórios e exportações de qualquer tenant
Esquema por tenant no mesmo bancoOrganização e backup granular, mas CPU e I/O continuam divididosMédio a alto: migração aplicada em N esquemasExigência contratual de separação lógica
Banco dedicadoCPU, I/O, cache e janela de manutenção própriosAlto: migração, backup, monitoramento e custo por instânciaCliente que sozinho passa de 20% a 30% do uso, ou exige SLA próprio

O gatilho de promoção deve ser objetivo e vir do ranking que você já mede: participação acima de 25% do tempo de banco por mais de uma semana, ou necessidade de janela de manutenção própria, ou contrato que paga o suficiente para o custo da instância. Para que a promoção seja uma operação de rotina, e não um projeto, mantenha desde o início um catálogo de tenants com o campo de destino de conexão e o tenant_id em toda tabela. A migração de dados de um tenant então vira copiar as linhas por tenant_id para o banco novo, validar, trocar o destino no catálogo e apagar a origem depois de um período de segurança.

05

Provar que o isolamento funciona e não deixar regredir

Isolamento que não é testado se perde no primeiro refactor que usa o pool direto, sem passar pela camada de limite. O teste de propriedade é simples: um tenant satura o limitador enquanto outro faz uma chamada curta, e a espera do segundo precisa ficar próxima do tempo da própria consulta. O teste também confere que o excesso do barulhento foi recusado e contado, e que a vaga volta mesmo quando a consulta falha.

// limitador-tenant.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { setTimeout as dormir } from 'node:timers/promises';
import { criarLimitadorPorTenant, ErroLimiteTenant } from './limitador-tenant.js';

test('um tenant que satura nao atrasa os outros', async () => {
  const limitador = criarLimitadorPorTenant({ maxPorTenant: 2, maxFila: 5, esperaMaximaMs: 300 });
  const consultaLenta = () => dormir(200);

  // O tenant barulhento dispara 40 consultas de uma vez.
  const barulhento = Array.from({ length: 40 }, () =>
    limitador.executar('barulhento', consultaLenta).then(
      () => 'ok',
      (erro) => (erro instanceof ErroLimiteTenant ? 'recusada' : 'erro'),
    ),
  );

  // O tenant comum faz uma consulta curta no meio da tempestade.
  await dormir(20);
  const inicio = performance.now();
  await limitador.executar('comum', () => dormir(10));
  const esperaDoComum = performance.now() - inicio;

  const resultados = await Promise.all(barulhento);
  assert.ok(esperaDoComum < 60, 'o tenant comum esperou ' + Math.round(esperaDoComum) + ' ms');
  assert.ok(resultados.includes('recusada'), 'o excesso do barulhento deveria ser recusado');
  assert.equal(resultados.filter((r) => r === 'erro').length, 0);
  assert.ok(limitador.estado().recusadas.barulhento > 0, 'a recusa precisa virar metrica');
});

test('a vaga e devolvida quando a consulta falha', async () => {
  const limitador = criarLimitadorPorTenant({ maxPorTenant: 1, maxFila: 1, esperaMaximaMs: 100 });
  await assert.rejects(limitador.executar('a', async () => { throw new Error('timeout do banco'); }), /timeout do banco/);
  await limitador.executar('a', async () => 'segue'); // se a vaga vazou, isto estoura a espera
  assert.deepEqual(limitador.estado().ativos, {});
});
Um banco compartilhado, antes e depois do isolamento.

ANTES
  tenant A (relatorio) --6 consultas pesadas--+
  tenant B (tela)      --consultas curtas-----+--> pool unico (40) --> Postgres (CPU 92%)
  tenant C (tela)      --consultas curtas-----+        ^ cheio: B e C esperam por A

DEPOIS
  tenant A (relatorio) --> limitador (1 por tenant) --> pool pesado (4)  --> replica de leitura
  tenant B (tela)      --> limitador (4 por tenant) --+
  tenant C (tela)      --> limitador (4 por tenant) --+--> pool interativo (30) --> primario
                              |
                              +--> excesso: espera curta, depois erro 429 + contador por tenant
  1. Marque a conexão com o tenant (application_name) e some o tempo de banco por tenant; alerte pela participação, não só pela CPU.
  2. Separe interativo e pesado em pools e papéis diferentes, com statement_timeout curto para um e longo para o outro.
  3. Limite a concorrência por tenant em cada classe, com fila curta, erro explícito (429 com Retry-After) e contador de recusas.
  4. Mande relatórios e exportações para uma réplica de leitura, e exporte em lotes por chave, não com uma consulta gigante.
  5. Defina o gatilho objetivo de promoção para banco dedicado e mantenha o catálogo de tenants pronto para isso.
  6. Mantenha o teste de isolamento no CI e proíba o uso do pool fora da função de consulta.

FAQ

Perguntas frequentes

Row-level security do Postgres resolve o vizinho barulhento?

Não. Row-level security garante que um tenant só veja as próprias linhas, o que é isolamento de dados, não de recursos. Uma consulta de um tenant sob RLS continua usando a mesma CPU, o mesmo I/O e a mesma conexão do pool. O problema do vizinho barulhento é de capacidade compartilhada, e se resolve com limites de concorrência, pools separados, timeouts por papel e, no limite, banco dedicado.

Rate limit na borda por requisição não basta?

Ajuda, mas mede a coisa errada. Uma requisição de exportação pode custar mil vezes mais banco do que uma consulta de tela, então 100 requisições por minuto significam coisas muito diferentes. O limite de concorrência por tenant na camada de banco mede o que está realmente escasso, que é o tempo de conexão e de execução. Os dois se complementam: a borda protege contra volume, e o limitador de banco protege contra custo por requisição.

E se o cliente grande reclamar do 429 no relatório?

O 429 deve ser raro para uso normal e vir com orientação: Retry-After e, para trabalhos pesados, um fluxo assíncrono em que o relatório é gerado em segundo plano e entregue por link ou e-mail. Isso melhora também a experiência dele, que deixa de olhar um spinner por dois minutos. Se o cliente precisa de mais concorrência e paga por isso, a resposta é um teto maior no plano dele ou a promoção para banco dedicado, e não remover o limite para todos.

Em banco compartilhado, o limite por cliente é o que mantém o contrato com todos os outros

Um cliente legítimo com um relatório pesado pode ocupar o pool e a CPU do banco e deixar 399 clientes com telas de 4 segundos. O caminho é medir o uso por tenant com application_name e tempo de banco acumulado, separar carga interativa e pesada em pools e papéis com timeouts distintos, limitar a concorrência por tenant com fila curta, erro explícito e contador, mandar a carga analítica para uma réplica, definir um gatilho objetivo para promover o cliente que cresceu demais para um banco dedicado e manter um teste de isolamento no CI. Assim o custo de um banco por cliente só é pago por quem realmente precisa dele.