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.