Multi-tenant em Postgres: schema por tenant ou coluna tenant_id

Escrito por

em

O primeiro cliente enterprise pede isolamento de dados “de verdade” e a pergunta chega ao backlog: manter todos os tenants nas mesmas tabelas, com uma coluna tenant_id, ou dar a cada um o seu schema? A decisão parece reversível e não é. Migrar de um modelo para o outro com dados em produção costuma custar um trimestre de engenharia.

Os dois modelos funcionam. O que muda é onde o custo aparece: no schema por tenant ele aparece em migrações e conexões; na coluna compartilhada, aparece em disciplina de acesso e vizinho barulhento.

Na coluna compartilhada, todas as tabelas carregam tenant_id, e toda query filtra por ele. Existe um único conjunto de tabelas, um único conjunto de índices, uma única migração por deploy.

No schema por tenant, cada cliente ganha um schema próprio (tenant_acme, tenant_beta) com cópia completa das tabelas. A aplicação define o search_path no início da requisição e o resto do código nem sabe que existe multi-tenancy.

Existe um terceiro caminho, banco por tenant, que fica fora desta comparação: só se justifica quando o contrato exige isolamento físico, e o custo operacional sobe de patamar.

Onde a coluna compartilhada quebra

O risco central é o esquecimento. Uma query sem o filtro de tenant vaza dados entre clientes, e o erro não aparece em teste a menos que o teste use dois tenants. A mitigação séria é Row Level Security: a política no banco filtra por uma variável de sessão, e a aplicação passa a ter o filtro garantido mesmo quando alguém esquece.

Na prática: ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY; seguido de CREATE POLICY isola_tenant ON pedidos USING (tenant_id = current_setting('app.tenant_id')::uuid);. A aplicação executa SET LOCAL app.tenant_id = '...' no início de cada transação. Com pool de conexões em modo transação, o SET LOCAL é obrigatório; um SET simples vaza para a próxima requisição que reaproveitar a conexão.

O segundo problema é o vizinho barulhento. Um tenant com dez milhões de linhas dentro da mesma tabela degrada o plano de execução dos outros, porque as estatísticas do planner são globais para a tabela. Índices compostos começando por tenant_id resolvem a maior parte, mas relatórios pesados de um cliente ainda disputam cache e I/O com todo mundo.

O terceiro é a exclusão. Remover um tenant (pedido de LGPD, fim de contrato) vira um DELETE em dezenas de tabelas, em lote, sem travar o resto. Funciona, só exige cuidado e um roteiro testado.

Onde o schema por tenant quebra

O custo aparece em escala de quantidade. Cada schema com 40 tabelas e seus índices adiciona milhares de objetos ao catálogo do Postgres. Com algumas centenas de tenants a coisa segue tranquila; na casa dos milhares, o catálogo incha, o pg_dump demora, e o autovacuum passa a trabalhar sobre um número enorme de relações pequenas. O ponto exato varia com hardware e versão, e vale medir antes de prometer.

A migração é o segundo custo. Um ALTER TABLE precisa rodar em todos os schemas, em ordem, com tratamento para o caso em que 312 de 400 já foram e o 313º falha. Sem ferramenta própria de versionamento por schema, o time acaba mantendo um estado de migração paralelo, e tenants em versões diferentes viram fonte de bug difícil de reproduzir.

O terceiro é a conexão. Pool por schema não existe; o que existe é search_path por requisição, e isso conflita com alguns modos de pooling de conexão. Quem usa PgBouncer em modo transação precisa definir o schema dentro de cada transação, com a mesma atenção do SET LOCAL acima.

Comparativo direto

Critério Coluna tenant_id Schema por tenant
Isolamento lógico Depende de RLS ou de disciplina Forte por construção
Migração de esquema Uma execução por deploy N execuções, com controle de falha parcial
Escala em número de tenants Milhares sem problema estrutural Catálogo pesa a partir de centenas a milhares
Vizinho barulhento Maior risco Menor, ainda compartilha instância
Exportar ou apagar um cliente Queries por tabela Dump ou drop do schema
Customização por cliente Difícil Possível, mas cara de manter

Como provar o isolamento antes do primeiro incidente

Qualquer que seja o modelo, o isolamento só existe quando é testado de forma adversarial. O teste mínimo cria dois tenants, popula ambos com dados parecidos e percorre cada endpoint autenticado como o tenant A tentando ler recursos do tenant B por identificador. A resposta esperada é 404, não 403: devolver 403 já confirma que o recurso existe.

No modelo de coluna, vale um segundo teste no nível do banco: conectar com o papel que a aplicação usa, sem definir a variável de sessão, e executar SELECT count(*) em cada tabela com tenant_id. O resultado precisa ser zero. Se vier qualquer outro número, existe tabela sem política, ou a aplicação está conectando com um papel dono das tabelas, que ignora RLS por padrão. Esse segundo caso é o erro mais comum: o papel de migração e o papel da aplicação precisam ser distintos.

No modelo de schema, o equivalente é verificar que o papel da aplicação não tem permissão em schemas de outros tenants, e que o search_path nunca inclui mais de um schema de cliente. Um search_path com dois schemas de tenant resolve nomes de forma silenciosa e errada, e o bug só aparece em produção.

A experiência de times que operam os dois modelos aponta para um padrão: a coluna compartilhada gera menos chamados de infraestrutura e mais incidentes de segurança potenciais, enquanto o schema por tenant faz o inverso. Falha de migração parcial, backup lento e catálogo inchado são problemas de plantão. Vazamento entre tenants é problema de reputação, e a conta da reputação costuma ser maior. Por isso, no desempate, a coluna só deve ganhar se RLS estiver ligado e testado, com o teste rodando no pipeline a cada deploy e não apenas na semana da auditoria. Um isolamento que ninguém verifica automaticamente tende a se degradar com cada tabela nova criada às pressas.

Os sinais que decidem

Alguns fatos do produto resolvem a escolha antes da discussão técnica. Se a base de clientes é grande e pulverizada, com muitos tenants pequenos (típico de SaaS self-service), a coluna compartilhada é a resposta. O custo por tenant precisa tender a zero, e criar um schema com 40 tabelas para cada trial que talvez nunca ative é desperdício.

Se a base é pequena e cada cliente é grande e exigente (dezenas de contas B2B com contrato anual), o schema por tenant ganha. O isolamento vira argumento comercial, a exportação de dados de um cliente é um comando só, e o número de schemas fica longe do limite incômodo.

Um sinal pouco discutido: customização. Se vendas promete campos e tabelas próprias por cliente, o schema por tenant parece atraente, mas o preço é cada cliente virar uma versão do produto. Em geral, resolve-se melhor com colunas JSONB ou tabelas de metadados na estrutura compartilhada.

O que escolher, por cenário

Para SaaS que vende para muitas empresas pequenas e médias: coluna tenant_id, com RLS ligado desde o primeiro dia e um teste automatizado que cria dois tenants e tenta ler os dados do outro em cada endpoint. Esse teste vale mais do que qualquer revisão de código.

Para produto com poucas dezenas de clientes grandes, regulados ou com exigência contratual de segregação: schema por tenant, com ferramenta de migração que registre a versão aplicada em cada schema e permita retomar do ponto de falha.

Para a zona do meio, com centenas de clientes de porte variado, o híbrido costuma funcionar: coluna compartilhada para a massa e schema (ou banco) dedicado para os poucos que pagam por isso. Exige que a camada de acesso aceite os dois caminhos desde o início, e é bem mais barato desenhar assim do que retrofitar depois.

Na dúvida sobre o futuro do produto, comece pela coluna compartilhada com RLS. Separar um tenant grande para um schema próprio depois é uma operação conhecida e pontual. Juntar quatrocentos schemas em tabelas únicas, não.

Comentários

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *