No início de uma plataforma SaaS onde trabalhei, o banco de dados era um detalhe. Qualquer query rodava rápido porque os dados eram poucos. Conforme a base de clientes cresceu, esse detalhe virou o problema central de performance.

Esse texto reúne as otimizações que mais impacto tiveram — não são teóricas; são coisas que coloquei em produção e medi. Muitas informações foram alteradas por questão de privacidade, mas acredito que a ideia seja a mesma.

O problema que você não vê até ser tarde

O erro clássico de SaaS multitenant é não perceber que sua aplicação está lenta até o cliente reclamar. Até lá, você já tem dezenas de queries problemáticas espalhadas pelo código.

A primeira coisa que implementamos foi log de queries lentas no PostgreSQL:

log_min_duration_statement = 200

Qualquer query acima de 200ms ganha entrada no log. Com isso, passamos de "não sabemos o que está lento" para "temos uma lista priorizada de problemas".

Índices: o remédio que também pode ser veneno

A resposta instintiva para query lenta é criar índice. Às vezes funciona. Às vezes piora.

Índices aceleram leitura e degradam escrita. Em tabelas com muitos inserts (logs de auditoria, eventos de sistema), índices demais podem ser piores do que índice nenhum.

O que aprendi:

O problema do N+1 escondido

O N+1 mais óbvio é fácil de encontrar: um loop com query dentro. O que mata é o N+1 escondido em camadas de abstração.

Na nossa stack PHP, tínhamos um método getCliente() que parecia inofensivo. Mas quando chamado dentro de uma listagem de 200 atendimentos, disparava 200 queries separadas para buscar o cliente de cada uma.

A solução foi sistemática: identifique os casos de uso de listagem, carregue em batch os dados relacionados antes do loop, e passe como parâmetro para a renderização.

-- Em vez de uma query por cliente:
SELECT * FROM clientes WHERE id = ANY($1::int[])

Uma query com array de IDs é quase sempre mais rápida que N queries individuais.

Paginação que não quebra em escala

LIMIT 20 OFFSET 1000 parece simples. Mas o banco lê e descarta os primeiros 1000 registros para devolver os 20 seguintes. Em tabelas grandes, a página 50 é 50x mais lenta que a página 1.

A alternativa é keyset pagination (também chamada cursor pagination):

SELECT * FROM atendimentos
WHERE tenant_id = $1
  AND id < $ultimo_id_da_pagina_anterior
ORDER BY id DESC
LIMIT 20

Performance constante independente de qual página você está. O custo: você perde a navegação por número de página. Para feeds e listagens cronológicas, é sempre a escolha certa.

Particionamento de tabelas de auditoria

Nossa tabela de log de auditoria cresceu para centenas de milhões de linhas. Queries começaram a demorar mesmo com índices corretos.

A solução foi particionamento por range de data no PostgreSQL:

CREATE TABLE audit_log (
  id bigserial,
  tenant_id int,
  acao text,
  criado_em timestamptz
) PARTITION BY RANGE (criado_em);

Com partições mensais, uma query que filtra por criado_em só lê a partição relevante. O que antes varria 300 milhões de linhas passou a varrer 20 milhões.

A lição mais importante

Performance de banco é um processo contínuo, não um projeto pontual.

Meça antes de otimizar. Tenha hipótese antes de criar índice. E monitore depois — o que era rápido hoje pode degradar quando os dados crescerem.

O banco é a memória do seu sistema. Cuide dele como se fosse.