Home Projetos & Releases BigBangcraft Blog Sobre & Carreira
Arquitetura & CTO 6 min de leitura 20 visualizações
Raw .md

O isolamento por tenant sem duplicar schemas no PostgreSQL

PD

Pedro Dalben

CTO no Integrar Plus • Publicado em 07 de October de 2026

O isolamento por tenant sem duplicar schemas no PostgreSQL

A discussão sobre multi-tenancy quase sempre descamba para o mesmo beco sem saída: criar um schema separado no PostgreSQL para cada cliente ou aceitar o risco de vazar dados colocando todo mundo na mesma tabela com uma coluna tenant_id.

Quem defende schemas separados costuma vender a ideia de isolamento limpo. Na teoria, cada cliente ganha seu namespace, os dados nunca se misturam nas queries e o backup de uma conta específica parece trivial. Quem prefere uma coluna única argumenta que a complexidade do schema dinâmico não compensa e que o ActiveRecord, ou qualquer outro ORM, resolve tudo com escopo global.

As duas posições ignoram a realidade operacional de manter um banco de dados vivo em produção por anos.

A abordagem de schema por tenant parece elegante até a aplicação atingir algumas centenas de contas. Nesse ponto, rodar uma migration deixa de ser uma operação rotineira de deploy e vira um pesadelo de concorrência. Se você tem 500 tenants, são 500 execuções da mesma alteração estrutural, cada uma disputando conexões, abrindo transações e acumulando locks na tabela de metadados do PostgreSQL. O pool de conexões do PgBouncer perde eficiência porque a troca de search_path quebra o reuso limpo de sessões em modo de transação. Além disso, a memória gasta pelo catálogo interno do banco para rastrear milhares de tabelas idênticas degrada o cache de planos de execução.

Eu prefiro manter um único schema público para todos os tenants. O segredo não está em confiar cegamente que nenhum desenvolvedor vai esquecer um where(tenant_id: ...) em uma query complexa. Confiar apenas na camada de aplicação para garantir isolamento de dados é uma aposta perigosa, principalmente quando relatórios, jobs assíncronos e subqueries começam a se acumular no repositório.

A solução madura para esse problema existe dentro do próprio PostgreSQL: Row-Level Security (RLS).

O RLS permite definir políticas de acesso no nível das tabelas que o motor do banco aplica antes de retornar qualquer registro. A aplicação continua operando sobre um schema unificado, com um conjunto único de tabelas e migrations que rodam uma única vez por deploy. A garantia de isolamento, no entanto, desce para o próprio processo do banco.

Para fazer isso funcionar com Ruby on Rails sem reinventar a infraestrutura, o padrão envolve três engrenagens: a coluna de tenant em cada tabela compartilhada, a política de segurança no banco e a definição do contexto na conexão antes de cada requisição.

O primeiro passo é habilitar o recurso na tabela e forçar a regra inclusive para o dono da tabela, o que evita que conexões privilegiadas ignorem a trava acidentalmente:

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_policy ON documents
  USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);

A função current_setting lê uma variável de sessão que a aplicação injeta na conexão. O segundo parâmetro como true impede que o PostgreSQL lance um erro caso a variável ainda não tenha sido definida na sessão atual, retornando nulo e, consequentemente, bloqueando o acesso aos registros por padrão.

No lado do Rails, o trabalho consiste em interceptar o ciclo de vida da requisição e configurar essa variável na conexão ativa antes que o controller processe os dados. Isso pode ser feito em um middleware ou em um around_action no ApplicationController:

class ApplicationController < ActionController::Base
  around_action :scope_current_tenant

  private

  def scope_current_tenant
    tenant = Current.tenant

    if tenant
      ActiveRecord::Base.connection.execute(
        ActiveRecord::Base.sanitize_sql(["SET LOCAL app.current_tenant_id = %s", tenant.id])
      )
    end

    yield
  ensure
    ActiveRecord::Base.connection.execute("RESET app.current_tenant_id")
  end
end

O uso de SET LOCAL garante que a configuração fique restrita à transação corrente. Se você opera com PgBouncer em modo de transação (transaction pooling), esse detalhe impede que o ID de um tenant contamine a próxima requisição atendida pela mesma conexão física.

Essa arquitetura tem desvantagens que precisam ser tratadas com clareza. A principal delas é o custo de planejamento e indexação. Como todas as contas compartilham os mesmos índices, queries que filtram por tenant precisam de índices compostos começando invariavelmente por tenant_id. Se você esquecer essa coluna no prefixo do índice, o planejador pode optar por varreduras sequenciais completas à medida que a tabela cresce.

CREATE INDEX index_documents_on_tenant_id_and_created_at
  ON documents (tenant_id, created_at);

Outro ponto de fricção surge com rotinas em segundo plano. Em jobs assíncronos processados pelo Solid Queue ou Sidekiq, não existe uma requisição HTTP para acionar o controller. O worker precisa explicitamente envelopar a execução do job no mesmo bloco que define a variável de sessão do banco. Se alguém disparar um job sem associar o tenant correto, a query simplesmente retorna zero registros, o que pode mascarar bugs de processamento em lote.

Existe também a preocupação legítima com o problema do vizinho barulhento. Quando um cliente gigante consome recursos desproporcionais, a abordagem de schema único não isola o volume de I/O de disco. Se a sua operação atende empresas com ordens de grandeza completamente distintas (um cliente gravando dez registros por dia e outro gravando dez milhões), o particionamento declarativo do PostgreSQL por lista ou hash sobre a coluna tenant_id resolve a segregação física dos arquivos sem exigir a duplicação de estruturas lógicas.

Manter a modelagem limpa com uma coluna de identificação e delegar a barreira de contenção para as políticas de RLS elimina a fragilidade dos escopos em código sem introduzir o caos de gerenciar centenas de schemas. O banco de dados foi construído para filtrar dados com base em regras relacionais; tentar gerenciar isolamento multiplicando namespaces é transferir para a infraestrutura um problema que o motor relacional resolve internamente.

PD

Sobre Pedro Dalben

CTO e arquiteto full stack especializado em Ruby on Rails, Vue.js, sistemas multi-tenant e infraestrutura distribuída.