Como implementar multi-tenancy com isolamento por schema

1. Fundamentos do multi-tenancy por schema

Multi-tenancy é um padrão arquitetural onde uma única instância de software atende múltiplos clientes (tenants). O isolamento por schema é uma estratégia que oferece equilíbrio entre isolamento lógico e físico. Cada tenant possui seu próprio schema dentro do mesmo banco de dados, garantindo separação de dados sem os custos operacionais de bancos individuais.

Comparação com outras estratégias:

Estratégia Isolamento Custo Complexidade
Database por tenant Total Alto Baixa
Schema por tenant Alto Médio Médio
Shared schema (discriminador) Baixo Baixo Alta

Vantagens do schema por tenant:
- Isolamento completo de dados entre tenants
- Facilidade de backup/restore seletivo
- Possibilidade de customizações por schema
- Performance previsível (sem tabelas gigantescas)
- Migrações independentes por tenant

2. Arquitetura de banco de dados e design de schemas

A estrutura típica consiste em:

banco_multi_tenant/
├── public/          # Schema global (metadados, catálogo de tenants)
│   ├── tenants
│   ├── planos
│   └── logs_acesso
├── tenant_001/      # Schema do Tenant A
│   ├── usuarios
│   ├── pedidos
│   └── produtos
├── tenant_002/      # Schema do Tenant B
│   ├── usuarios
│   ├── pedidos
│   └── produtos
└── tenant_003/      # Schema do Tenant C
    ├── usuarios
    ├── pedidos
    └── produtos

Geração automática de schemas via migrations:

-- Exemplo de migration para criar schema de tenant
CREATE SCHEMA IF NOT EXISTS tenant_{id} AUTHORIZATION app_user;

-- Criar tabelas dentro do schema do tenant
CREATE TABLE tenant_{id}.usuarios (
    id SERIAL PRIMARY KEY,
    nome VARCHAR(100) NOT NULL,
    email VARCHAR(150) UNIQUE NOT NULL,
    criado_em TIMESTAMP DEFAULT NOW()
);

CREATE TABLE tenant_{id}.pedidos (
    id SERIAL PRIMARY KEY,
    usuario_id INTEGER REFERENCES tenant_{id}.usuarios(id),
    valor_total DECIMAL(10,2),
    status VARCHAR(20)
);

Gerenciamento de conexões:

-- Pool de conexões com schema dinâmico (PostgreSQL)
-- Configuração no application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/multi_tenant_db
spring.datasource.username=app_user
spring.datasource.password=secret
spring.jpa.properties.hibernate.multiTenancy=SCHEMA
spring.jpa.properties.hibernate.tenant_identifier_resolver=com.app.TenantResolver

3. Implementação da camada de roteamento e contexto do tenant

Identificação do tenant via header HTTP:

// Middleware para extrair tenant do header X-Tenant-ID
function tenantMiddleware(req, res, next) {
    const tenantId = req.headers['x-tenant-id'];

    if (!tenantId) {
        return res.status(400).json({ error: 'Tenant ID é obrigatório' });
    }

    // Validar se tenant existe no schema público
    const tenant = await db.query(
        'SELECT id, schema_name FROM public.tenants WHERE id = $1 AND ativo = true',
        [tenantId]
    );

    if (!tenant) {
        return res.status(404).json({ error: 'Tenant não encontrado' });
    }

    // Armazenar no contexto da requisição
    req.tenantId = tenant.id;
    req.schemaName = tenant.schema_name;

    next();
}

Propagação do contexto via thread-local (Java):

public class TenantContext {
    private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        currentTenant.set(tenantId);
    }

    public static String getTenantId() {
        return currentTenant.get();
    }

    public static void clear() {
        currentTenant.remove();
    }
}

4. Estratégias de consulta e manipulação de dados

Uso de SET search_path para queries dinâmicas:

-- Configurar o schema ativo para a sessão
SET search_path TO tenant_001, public;

-- Todas as queries subsequentes usarão o schema tenant_001
SELECT * FROM usuarios;  -- Resolve para tenant_001.usuarios
SELECT * FROM tenants;   -- Resolve para public.tenants

-- Exemplo em Node.js com pg-promise
async function queryWithTenant(tenantId, sql, params) {
    const schemaName = `tenant_${tenantId}`;

    await db.none(`SET search_path TO ${schemaName}, public`);
    return db.any(sql, params);
}

Abstração via ORM com Hibernate:

// Configuração de multi-tenancy no Hibernate
@Configuration
public class HibernateConfig {

    @Bean
    public MultiTenantConnectionProvider multiTenantConnectionProvider() {
        return new SchemaBasedMultiTenantConnectionProvider();
    }

    @Bean
    public CurrentTenantIdentifierResolver tenantIdentifierResolver() {
        return new TenantIdentifierResolver();
    }
}

// Resolver que retorna o tenant do contexto
public class TenantIdentifierResolver 
    implements CurrentTenantIdentifierResolver {

    @Override
    public String resolveCurrentTenantIdentifier() {
        return TenantContext.getTenantId();
    }
}

Cache por tenant com Redis:

// Cache com chave composta: tenant_id + recurso
async function getCachedData(tenantId, key) {
    const cacheKey = `tenant:${tenantId}:${key}`;
    const cached = await redis.get(cacheKey);

    if (cached) {
        return JSON.parse(cached);
    }

    const data = await db.any(`SELECT * FROM tenant_${tenantId}.dados`);
    await redis.setex(cacheKey, 3600, JSON.stringify(data));

    return data;
}

5. Migrações e versionamento de esquemas

Estratégia centralizada com aplicação em massa:

-- Script para aplicar migration em todos os tenants
DO $$
DECLARE
    tenant RECORD;
    migration_sql TEXT;
BEGIN
    -- Migration a ser aplicada
    migration_sql := '
        ALTER TABLE {schema}.usuarios 
        ADD COLUMN telefone VARCHAR(20);
    ';

    FOR tenant IN 
        SELECT schema_name 
        FROM public.tenants 
        WHERE ativo = true
    LOOP
        -- Substituir placeholder pelo schema do tenant
        EXECUTE REPLACE(migration_sql, '{schema}', tenant.schema_name);

        -- Registrar migration aplicada
        INSERT INTO public.migrations_log 
            (schema_name, migration_name, applied_at)
        VALUES 
            (tenant.schema_name, 'v2_add_telefone', NOW());
    END LOOP;
END $$;

Aplicação assíncrona com filas:

// Enfileirar migrações para execução paralela
async function applyMigrationAsync(migrationName, sqlTemplate) {
    const tenants = await db.any('SELECT schema_name FROM public.tenants');

    const jobs = tenants.map(tenant => ({
        schema: tenant.schema_name,
        sql: sqlTemplate.replace('{schema}', tenant.schema_name)
    }));

    // Enviar para fila de processamento
    await queue.addBulk(jobs.map(job => ({
        name: 'migration',
        data: job
    })));
}

// Worker que executa a migration
queue.process('migration', async (job) => {
    const { schema, sql } = job.data;

    try {
        await db.none(`SET search_path TO ${schema}`);
        await db.none(sql);

        await db.none(`
            INSERT INTO public.migrations_log 
            VALUES ($1, $2, NOW())
        `, [schema, job.name]);
    } catch (error) {
        console.error(`Falha na migration para ${schema}:`, error);
        throw error; // Re-tentar via fila
    }
});

6. Segurança e isolamento entre tenants

Validação obrigatória de tenant em todas as queries:

// Interceptor que força o filtro por tenant
class TenantInterceptor {

    beforeQuery(event) {
        const tenantId = TenantContext.getTenantId();

        // Adicionar WHERE tenant_id em toda query
        if (event.query.toLowerCase().includes('from') && 
            !event.query.toLowerCase().includes('tenant_id')) {

            // Modificar query para incluir tenant_id
            event.query = event.query.replace(
                /FROM\s+(\w+)/gi,
                `FROM $1 WHERE tenant_id = '${tenantId}'`
            );
        }
    }
}

Roles e permissões por schema:

-- Criar role específica para cada tenant
CREATE ROLE tenant_001_user WITH LOGIN PASSWORD 'senha_segura';
GRANT USAGE ON SCHEMA tenant_001 TO tenant_001_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA tenant_001 TO tenant_001_user;

-- Revogar acesso ao schema público
REVOKE ALL ON SCHEMA public FROM tenant_001_user;

Monitoramento com identificação do tenant:

// Logger que inclui tenant ID
function logWithTenant(level, message, data = {}) {
    const logEntry = {
        timestamp: new Date().toISOString(),
        level,
        tenant_id: TenantContext.getTenantId(),
        user_id: UserContext.getUserId(),
        message,
        ...data
    };

    // Enviar para sistema de logging centralizado
    logger.log(logEntry);

    // Armazenar também no schema público para auditoria
    db.none(`
        INSERT INTO public.audit_logs 
        (tenant_id, user_id, action, details)
        VALUES ($1, $2, $3, $4)
    `, [logEntry.tenant_id, logEntry.user_id, message, JSON.stringify(data)]);
}

7. Operações avançadas e manutenção

Backup seletivo por schema:

#!/bin/bash
# Script para backup de schema específico

TENANT_ID=$1
SCHEMA_NAME="tenant_${TENANT_ID}"
BACKUP_FILE="backup_${SCHEMA_NAME}_$(date +%Y%m%d).sql"

pg_dump \
    --host=localhost \
    --port=5432 \
    --username=app_user \
    --dbname=multi_tenant_db \
    --schema=${SCHEMA_NAME} \
    --file=${BACKUP_FILE} \
    --format=custom

echo "Backup do schema ${SCHEMA_NAME} concluído: ${BACKUP_FILE}"

Rebalanceamento de tenants entre databases:

-- Estratégia de sharding quando um tenant cresce demais
-- 1. Criar novo banco de dados
CREATE DATABASE shard_tenant_001;

-- 2. Migrar dados do schema para o novo banco
-- (usar pg_dump/pg_restore ou replicação lógica)

-- 3. Atualizar roteamento no schema público
UPDATE public.tenants 
SET database_url = 'postgres://.../shard_tenant_001',
    schema_name = 'public'
WHERE id = 'tenant_001';

Monitoramento de performance por tenant:

-- Query para identificar tenants com alto consumo
SELECT 
    t.id,
    t.schema_name,
    COUNT(*) as total_queries,
    SUM(EXTRACT(EPOCH FROM (query_end - query_start))) as total_time
FROM public.tenants t
JOIN public.query_log q ON q.tenant_id = t.id
WHERE q.query_start > NOW() - INTERVAL '1 hour'
GROUP BY t.id, t.schema_name
ORDER BY total_time DESC
LIMIT 10;

Referências