índices parciais em SaaS com muitos tenants: erros que geram falhas, lentidão ou retrabalho
Entenda oito erros ao usar índices parciais no PostgreSQL em SaaS multi-tenant. Veja como investigar planos parametrizados, unicidade com soft delete, diferenças entre clientes e falhas de migração.
Max Alex

Neste artigo (10 tópicos)
Como evitar consultas lentas, conflitos de unicidade e migrações problemáticas ao indexar tabelas compartilhadas entre clientes.
Índices parciais no PostgreSQL podem reduzir o conjunto indexado e atender consultas frequentes de um SaaS. O problema começa quando o índice é desenhado sem considerar o SQL da aplicação, a distribuição dos dados entre tenants e as regras de integridade.
Uma listagem pode funcionar bem no teste manual e ficar lenta com parâmetros. Um cadastro pode falhar porque a unicidade foi definida globalmente. Uma migração interrompida pode deixar um índice inválido, exigindo recuperação.
Este roteiro apresenta oito erros e exemplos para investigar cada um. Os cenários são didáticos: não representam medições de produção nem garantem ganhos de desempenho. Confirme a versão instalada com SHOW server_version; e reproduza os testes em um ambiente compatível.
Quando um índice parcial faz sentido em uma tabela multi-tenant
Tenant é o cliente ou organização que compartilha a infraestrutura. Em um PostgreSQL multi-tenant com tabelas compartilhadas, tenant_id identifica a organização de cada registro.
Considere esta estrutura simplificada para os exemplos:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL,
deleted_at timestamptz
);A chave do índice organiza a busca; o predicado define quais registros participam dele. Portanto, colocar tenant_id na chave é diferente de restringir o índice a um único tenant.
-- Alternativa completa.
CREATE INDEX orders_tenant_recent_idx
ON orders (tenant_id, created_at DESC, id DESC);
-- Alternativa parcial compartilhada.
CREATE INDEX orders_live_recent_idx
ON orders (tenant_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;O segundo índice reúne registros sem exclusão lógica de todos os clientes. Pedidos abertos também podem justificar um recorte, se esse for um acesso frequente.
Compare as alternativas em cópias equivalentes do banco, com apenas uma delas disponível em cada cenário. Use a mesma listagem:
SELECT id, created_at
FROM orders
WHERE tenant_id = 42
AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 50;Se quase todos os pedidos estiverem ativos, o recorte pode economizar pouco espaço. A decisão deve considerar consultas, proporção dos registros relevantes e escrita. Para revisar a estrutura compartilhada, consulte o guia de modelagem e isolamento multi-tenant em banco compartilhado.
Erro 1: criar um índice parcial para cada tenant
Uma coleção de índices com WHERE tenant_id = constante aumenta o trabalho do planejador para verificar aplicabilidade. A documentação de índices parciais alerta contra usar muitos recortes independentes como substituto de particionamento.
-- Padrão que se multiplica conforme entram clientes.
CREATE INDEX orders_t42_idx
ON orders (created_at DESC, id DESC)
WHERE tenant_id = 42;
CREATE INDEX orders_t84_idx
ON orders (created_at DESC, id DESC)
WHERE tenant_id = 84;Essa estratégia também transforma o cadastro e a remoção de clientes em tarefas de DDL. Nomes, definições, falhas e objetos remanescentes entram no processo operacional.
Para consultas filtradas por organização, comece avaliando um índice composto com tenant_id na primeira posição. O índice compartilhado da seção anterior é uma alternativa candidata.
Em laboratório, compare uma coleção de 300 índices por tenant com um índice compartilhado. Esse número é uma proposta sintética de experimento. Registre tempo de planejamento, execução e quantidade de operações necessárias para adicionar um cliente.
Uma exceção para um tenant específico precisa de uma consulta identificada, benefício medido e data de revisão. O tamanho de um cliente, sozinho, não justifica uma nova estrutura.
Erro 2: esperar que qualquer filtro aproveite o índice parcial
O planejador precisa reconhecer que o filtro da consulta implica o predicado do índice. Expressões equivalentes nem sempre são reconhecidas. Além disso, elegibilidade não garante escolha: outro plano pode ter custo estimado menor.
Com o índice restrito a deleted_at IS NULL, compare:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at
FROM orders
WHERE tenant_id = 42
AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 50;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at
FROM orders
WHERE tenant_id = 42
ORDER BY created_at DESC, id DESC
LIMIT 50;A segunda consulta também solicita pedidos excluídos. O índice parcial não contém todos os registros necessários para atendê-la sozinho.
Capture o SQL efetivamente enviado pelo ORM. Confira filtros de exclusão lógica, conversões de tipos e expressões geradas. Investigar somente o código que constrói a consulta pode esconder a diferença decisiva.
Nos planos, examine o índice escolhido, filtros e linhas descartadas. EXPLAIN ANALYZE executa a consulta; para inspeção sem execução, use EXPLAIN. O guia prático de índices parciais em SaaS complementa esse roteiro de implementação.
Erro 3: ignorar planos genéricos nas consultas parametrizadas
Prepared statements PostgreSQL podem usar planos genéricos ou personalizados. O genérico não conhece os valores dos parâmetros; o personalizado pode considerá-los.
Para um índice com predicado status = 'open', a expressão status = $2 não comprova esse recorte em um plano genérico. Já tenant_id = $1 pode coexistir com o status literal.
CREATE INDEX orders_open_recent_idx
ON orders (tenant_id, created_at DESC, id DESC)
WHERE status = 'open';
PREPARE orders_by_status (bigint, text) AS
SELECT id, created_at
FROM orders
WHERE tenant_id = $1 AND status = $2
ORDER BY created_at DESC, id DESC
LIMIT 50;
SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, BUFFERS)
EXECUTE orders_by_status(42, 'open');
SET plan_cache_mode = force_custom_plan;
EXPLAIN (ANALYZE, BUFFERS)
EXECUTE orders_by_status(42, 'open');
PREPARE open_orders (bigint) AS
SELECT id, created_at
FROM orders
WHERE tenant_id = $1 AND status = 'open'
ORDER BY created_at DESC, id DESC
LIMIT 50;
SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, BUFFERS)
EXECUTE open_orders(42);
RESET plan_cache_mode;
DEALLOCATE orders_by_status;
DEALLOCATE open_orders;Execute o bloco na mesma conexão de teste. O plano personalizado pode tornar o índice elegível para o valor informado, sem garantir sua escolha. A documentação de PREPARE descreve essa distinção.
Use plan_cache_mode para diagnóstico controlado. Uma alteração global exige avaliar também o custo de planejamento das demais consultas.
Se a operação do produto for especificamente listar pedidos abertos, manter esse estado literal no SQL é uma opção a testar. Se a operação aceitar qualquer estado, compare um índice completo adequado à busca. Verifique ainda como o driver prepara statements e reutiliza conexões.
Erro 4: validar desempenho apenas em tenants pequenos
Monte uma matriz que represente volumes e proporções diferentes. A tabela abaixo define dados sintéticos para um laboratório, sem apresentar resultados de desempenho:
| Perfil | tenant_id | Pedidos | Sem exclusão lógica |
|---|---|---|---|
| Pequeno | 42 | 1.000 | 90% |
| Médio | 84 | 100.000 | 50% |
| Grande | 126 | 1.000.000 | 10% |
Para carregar esses perfis na tabela vazia de laboratório:
WITH profiles (tenant_id, total, live_pct) AS (
VALUES (42::bigint, 1000, 90),
(84::bigint, 100000, 50),
(126::bigint, 1000000, 10)
)
INSERT INTO orders (tenant_id, status, created_at, deleted_at)
SELECT p.tenant_id,
CASE WHEN g.n % 5 = 0 THEN 'open' ELSE 'closed' END,
timestamptz '2026-01-01 00:00:00+00'
+ g.n * interval '1 minute',
CASE WHEN g.n % 100 < p.live_pct THEN NULL
ELSE timestamptz '2026-10-01 00:00:00+00' END
FROM profiles AS p
CROSS JOIN LATERAL generate_series(1, p.total) AS g(n);
ANALYZE orders;Essa distribuição é artificial e serve para reproduzir diferenças básicas. Acrescente cenários com outras proporções de pedidos abertos, concentração temporal e concorrência semelhantes às da aplicação.
Repita a listagem com ORDER BY created_at DESC, id DESC LIMIT 50 para cada tenant. Compare linhas estimadas e reais, tempo de planejamento, execução, buffers e presença de ordenação adicional. Separe testes com cache aquecido dos demais.
A ordem das colunas deve acompanhar filtros e ordenação. Neste exemplo, a igualdade em tenant_id delimita a busca, enquanto data e identificador organizam a listagem.
Se as estimativas estiverem distantes das linhas reais, confira a atualização das estatísticas. Investigue correlações entre tenant, status e exclusão lógica; estatísticas estendidas são uma possibilidade a validar, conforme o tipo de estimativa envolvido.
Um Sequential Scan pode ser adequado ao volume solicitado. Avalie seu custo observado. Se houver paginação por cursor, teste também páginas posteriores: o primeiro LIMIT 50 não representa todo o percurso.
Erro 5: definir unicidade sem respeitar o tenant e o soft delete
Um índice único parcial aplica a regra somente aos registros que satisfazem o predicado. Para permitir o mesmo e-mail em clientes diferentes, a chave deve incluir o tenant.
CREATE TABLE contacts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
email text NOT NULL,
deleted_at timestamptz
);
CREATE UNIQUE INDEX contacts_live_email_uidx
ON contacts (tenant_id, email)
WHERE deleted_at IS NULL;O exemplo pressupõe e-mails já normalizados pela política do produto. Defina explicitamente como tratar maiúsculas, espaços e comparação. Se optar por uma expressão como lower(email), alinhe cadastro, consulta e identificação de conflitos à mesma regra.
Teste a integridade em uma tabela vazia de laboratório:
-- Permitido: mesmo e-mail em tenants diferentes.
INSERT INTO contacts (tenant_id, email)
VALUES (42, 'ana@example.com'), (84, 'ana@example.com');
-- Permitido: registro excluído com o mesmo valor.
INSERT INTO contacts (tenant_id, email, deleted_at)
VALUES (42, 'ana@example.com', timestamptz '2026-10-01 00:00:00+00');Execute os próximos comandos separadamente, em autocommit, pois ambos devem produzir erro de unicidade:
-- Conflito entre registros ativos do mesmo tenant.
INSERT INTO contacts (tenant_id, email)
VALUES (42, 'ana@example.com');
-- Conflito ao restaurar o registro excluído.
UPDATE contacts
SET deleted_at = NULL
WHERE tenant_id = 42
AND email = 'ana@example.com'
AND deleted_at IS NOT NULL;A restauração precisa de uma resposta de produto: renomear o valor, reconciliar registros ou rejeitar a operação. Uma verificação prévia ajuda a experiência, mas a aplicação também deve tratar a violação de unicidade, inclusive sob concorrência.
O exemplo usa NOT NULL. Em colunas anuláveis, índices únicos normalmente permitem múltiplos valores nulos; avalie a semântica desejada e o suporte da versão a NULLS NOT DISTINCT.
Unicidade e isolamento de acesso atendem requisitos diferentes. O índice não substitui autorização nem políticas de Row-Level Security para impedir acesso a registros de outros tenants.
Erro 6: usar tempo ou contexto de sessão no predicado
Funções e operadores usados na definição de índices precisam cumprir a exigência de imutabilidade. now() depende do tempo da transação e não atende a esse requisito.
-- Exemplo inválido: o PostgreSQL rejeita essa definição.
CREATE INDEX orders_last_30_days_idx
ON orders (tenant_id, created_at)
WHERE created_at >= now() - interval '30 days';Uma janela móvel também exige que registros deixem o recorte conforme o tempo passa, mesmo sem atualização da linha. Não declare uma função IMMUTABLE para esconder dependência do relógio ou do tenant armazenado na sessão.
Uma alternativa é indexar tenant e data, deixando a janela na consulta:
CREATE INDEX orders_tenant_date_idx
ON orders (tenant_id, created_at);
SELECT id, created_at
FROM orders
WHERE tenant_id = 42
AND created_at >= now() - interval '30 days';Esse índice se sobrepõe parcialmente à alternativa completa apresentada no início. Compare as definições antes de acrescentar ambos.
Recortes com datas fixas exigem manutenção explícita e compatibilidade com as consultas. Particionamento por período pode entrar na avaliação quando retenção e acesso temporal justificarem a complexidade.
Erro 7: implantar sem conferir transações e índices inválidos
CREATE INDEX CONCURRENTLY permite escritas durante a construção, mas exige trabalho adicional e pode esperar transações. Ele não pode executar dentro de um bloco de transação.
Configure a migração específica para executar fora da transação automática do framework:
CREATE INDEX CONCURRENTLY orders_live_recent_v2_idx
ON public.orders (tenant_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;Acompanhe a operação pela conexão de observação:
SELECT pid, relid::regclass AS table_name,
index_relid::regclass AS index_name,
phase, blocks_done, blocks_total
FROM pg_stat_progress_create_index
WHERE relid = 'public.orders'::regclass;Os contadores dependem da fase; não os interprete como percentual único de toda a construção. Depois, confira estado e definição:
SELECT i.indexrelid::regclass AS index_name,
i.indisvalid,
i.indisready,
pg_get_indexdef(i.indexrelid) AS definition
FROM pg_index AS i
WHERE i.indrelid = 'public.orders'::regclass;Falhas podem deixar índices inválidos, ignorados para consultas e ainda com custo de atualização. Em determinadas falhas de construção concorrente de índices únicos, a unicidade pode continuar sendo exigida.
IF NOT EXISTS verifica a existência do nome, sem garantir definição ou validade. Antes de repetir a migração, confirme o objeto encontrado e a causa da falha.
A documentação de CREATE INDEX apresenta recuperação por remoção e nova criação, ou reconstrução concorrente. Escolha o procedimento após verificar estado, uso e dependências. Registre o resultado no controle de migrações; uma tentativa interrompida não deve ser marcada como concluída.
Erro 8: medir só leitura e esquecer escrita e manutenção
A avaliação de performance PostgreSQL precisa incluir o custo total da estratégia. Uma listagem mais rápida não encerra o teste se cadastro, atualização ou restauração piorarem.
Monte uma carga com inserções e pedidos alternando entre abertos, concluídos e excluídos. Mudanças em status ou deleted_at podem alterar a participação nos respectivos índices.
Compare os cenários com a mesma carga e registre:
- Latência de leitura e escrita, incluindo percentis e vazão.
- Volume de WAL produzido durante a janela de teste.
- Tamanho dos índices antes e depois da carga.
- Atividade de autovacuum, transações longas e crescimento físico.
- Erros de integridade e comportamento das restaurações.
Uma consulta simples ajuda a acompanhar o armazenamento:
SELECT indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan
FROM pg_stat_user_indexes
WHERE relid = 'public.orders'::regclass;Considere a janela das estatísticas e possíveis reinicializações ao interpretar idx_scan. Poucas leituras registradas não bastam para remover um índice que garante integridade ou atende uma rotina rara e crítica.
Acompanhe as consultas com pg_stat_statements, quando disponível, e registre perfis de tenant na observabilidade da aplicação. A agregação de consultas pode esconder diferenças entre clientes.
Antes de atribuir crescimento a bloat, verifique expansão do conjunto ativo, atualização frequente e manutenção. Reavalie índices sobrepostos e recortes quando o produto mudar: a proporção de pedidos abertos de hoje pode não representar a operação futura.
Como escolher a correção e validar antes da implantação
Associe cada índice a uma consulta ou regra de integridade. A matriz abaixo organiza hipóteses de diagnóstico; confirme a evidência antes de aplicar a correção candidata.
| Sintoma | Evidência necessária | Ação candidata |
|---|---|---|
| Funciona apenas no teste manual | SQL real, parâmetros e EXPLAIN EXECUTE | Comparar planos genérico e personalizado; revisar predicado ou índice completo |
| Índice parcial não aparece no plano | Filtro, definição, validade e estimativas | Corrigir incompatibilidade e depois comparar custos |
| Conflito entre clientes | Chave do índice único e registros envolvidos | Incluir tenant_id se a regra for por cliente |
| Restauração falha | Registro ativo que ocupa o mesmo valor | Definir e implementar resolução do conflito |
| Tenant grande apresenta lentidão | Linhas reais, buffers, ordenação e concorrência | Revisar chave, estatísticas e forma de paginação |
| Migração falhou | Erro, transação do framework e estado em pg_index | Recuperar o objeto e repetir fora da transação quando necessário |
| Leitura melhora, escrita piora | Latência, WAL, tamanho e índices sobrepostos | Reavaliar o conjunto de índices e seu custo operacional |
Um índice completo é candidato quando o acesso atravessa vários estados. Um parcial compartilhado é candidato quando existe um recorte recorrente e demonstrável no SQL. Particionamento merece estudo quando organização dos dados, retenção e consultas justificam uma mudança arquitetural.
A quantidade de tenants não decide essa escolha sozinha. Uma partição por cliente também precisa de avaliação de planejamento, manutenção e crescimento.
Antes da implantação, documente:
- Consulta, parâmetros e regra de negócio atendidos.
- Resultados por perfil de tenant e sob concorrência representativa.
- Limites aceitáveis para leitura, escrita e armazenamento.
- Procedimento de criação, verificação e recuperação.
- Período de observação após a mudança e critérios para revisão.
Use o roteiro para revisar uma consulta crítica do seu SaaS e documentar a decisão sobre o índice. Para aprofundar o diagnóstico, explore os conteúdos de PostgreSQL do blog Max Alex.
Max Alex
Desenvolvedor full stack especializado em SaaS, Laravel, IA aplicada, WhatsApp API, automações e integrações. Criação de sistemas web escaláveis para empresas.
Continue lendo

Como implementar índices parciais em SaaS com muitos tenants: guia prático para produção
Aprenda a usar índices parciais no PostgreSQL para acelerar consultas críticas em SaaS multi-tenant, reduzir o tamanho dos índices e validar ganhos com segurança em produção.

OpenTelemetry em microserviços: erros que geram falhas, lentidão ou retrabalho
Traces incompletos, overhead e perda de telemetria podem ter causas diferentes. Entenda oito erros de OpenTelemetry em microserviços e use um checklist para diagnosticar, corrigir e validar a instrumentação em produção.

GitHub Actions em monorepos: erros que geram falhas, lentidão ou retrabalho
Seu CI executa projetos demais, ignora dependências ou bloqueia merges? Conheça dez erros de GitHub Actions em monorepos e como verificar filtros, checks, cache, matrizes e permissões.