---
title: "O isolamento por tenant sem duplicar schemas no PostgreSQL"
author: "Pedro Dalben"
date: "2026-10-07T11:02:51Z"
category: "Arquitetura & CTO"
reading_time: "6 min"
canonical: "https://pedrodalben.com.br/blog/o-isolamento-por-tenant-sem-duplicar-schemas-no-postgresql"
---

# O isolamento por tenant sem duplicar schemas no PostgreSQL

> Separar tenants por schema cria um pesadelo operacional. O caminho viável em banco único combina tenant_id e Row-Level Security nativo.

**Autor:** Pedro Dalben (CTO no Integrar Plus)  
**Publicado em:** 07 de October de 2026  
**URL Canônica Oficial:** [https://pedrodalben.com.br/blog/o-isolamento-por-tenant-sem-duplicar-schemas-no-postgresql](https://pedrodalben.com.br/blog/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:

```sql
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:

```ruby
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.

```sql
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.
