Escalando aplicações SaaS com PostgreSQL

Estratégias de arquitetura para crescer com confiança e previsibilidade.

O desafio de escalar um SaaS

Escalar um produto SaaS não é só adicionar mais servidores. A camada de dados é geralmente o primeiro gargalo quando você cresce. Vamos explorar estratégias testadas em produção para escalar PostgreSQL de centenas para milhões de usuários.

1. Estágios de crescimento típicos

Fase 1: MVP (0-1k usuários)

Arquitetura:
- 1 servidor PostgreSQL (2-4 vCPU, 8-16GB RAM)
- Aplicação e banco no mesmo servidor
- Sem cache, sem read replicas

Características:
✅ Simples de manter
✅ Custo baixo ($50-100/mês)
⚠️ Single point of failure
⚠️ Performance limitada

Fase 2: Product-Market Fit (1k-10k usuários)

Arquitetura:
- Aplicação separada do banco
- PostgreSQL dedicado (4-8 vCPU, 16-32GB RAM)
- Redis para cache e sessions
- Backups automáticos

Otimizações necessárias:
✅ Índices otimizados
✅ Connection pooling (PgBouncer)
✅ Cache de queries frequentes
✅ Monitoramento ativo

Fase 3: Escala (10k-100k usuários)

Arquitetura:
- Primary + 1-2 Read Replicas
- PgBouncer para pooling
- Redis cluster (cache distribuído)
- CDN para assets
- Background jobs (Sidekiq/Celery)

Características:
✅ Alta disponibilidade
✅ Leitura escalável horizontalmente
⚠️ Escritas ainda no primary
⚠️ Complexidade aumentada

Fase 4: Alta escala (100k+ usuários)

Arquitetura:
- Sharding ou particionamento
- Múltiplas read replicas (5-10+)
- Cache multi-camada (L1/L2)
- CQRS (separação read/write)
- Event sourcing opcional

Estratégias:
✅ Particionamento por tenant
✅ Separação de microserviços
✅ Data warehousing separado
✅ Observabilidade completa

2. Connection Pooling - A fundação

O problema de conexões

-- PostgreSQL cria 1 processo por conexão
-- Cada processo consome ~10MB RAM

# Exemplo: 1000 conexões simultâneas
1000 conexões × 10MB = 10GB RAM só para conexões! ❌

# Solução: PgBouncer
100 conexões da app → PgBouncer → 20 conexões reais ao Postgres ✅
Economia: 9.8GB RAM + performance melhor

Configurando PgBouncer

# pgbouncer.ini
[databases]
mydb = host=localhost port=5432 dbname=production

[pgbouncer]
listen_port = 6432
listen_addr = *
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

# Pool configuration
pool_mode = transaction        # Mais eficiente
max_client_conn = 1000         # Conexões da aplicação
default_pool_size = 25         # Conexões reais ao Postgres
reserve_pool_size = 5
reserve_pool_timeout = 3

# Timeout settings
server_idle_timeout = 600
query_timeout = 30

Connection string na aplicação

// Antes: Direto ao PostgreSQL
// postgres://user:pass@postgres:5432/mydb

// Depois: Via PgBouncer
const pool = new Pool({
  host: 'pgbouncer',
  port: 6432,  // Porta do PgBouncer
  database: 'mydb',
  user: 'app_user',
  password: 'senha',
  max: 20,  // Pool da aplicação
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

3. Read Replicas - Escale leituras

Quando usar read replicas

Roteamento inteligente

// Node.js - Separação read/write
const writePool = new Pool({ host: 'primary.postgres', port: 5432 });
const readPool = new Pool({ host: 'replica.postgres', port: 5432 });

// Função auxiliar
function getPool(operation = 'read') {
  return operation === 'write' ? writePool : readPool;
}

// Uso
async function getUser(id) {
  const pool = getPool('read');
  return pool.query('SELECT * FROM users WHERE id = $1', [id]);
}

async function updateUser(id, data) {
  const pool = getPool('write');
  return pool.query('UPDATE users SET name = $1 WHERE id = $2', [data.name, id]);
}

// Rails - config/database.yml
production:
  primary:
    host: primary.postgres
  replica:
    host: replica.postgres
    replica: true

# Uso automático
User.find(123)  # Vai para replica
User.update(123, name: 'João')  # Vai para primary

Lidando com replication lag

// Problema: "Read your writes"
// User acabou de criar post, mas replica ainda não tem

// Solução 1: Force primary após write
async function createPost(userId, data) {
  const pool = getPool('write');
  const result = await pool.query(
    'INSERT INTO posts (user_id, title) VALUES ($1, $2) RETURNING *',
    [userId, data.title]
  );
  
  // Próximas leituras vão para primary (5 segundos)
  setReadPreference(userId, 'primary', 5000);
  
  return result.rows[0];
}

// Solução 2: Sticky sessions
// Requests do mesmo user sempre na mesma replica

// Solução 3: Mostre loading state
// "Seu post está sendo processado..."

4. Particionamento - Dividir para conquistar

Particionamento por data (Time-series)

-- Exemplo: Tabela de logs
CREATE TABLE logs (
  id BIGSERIAL,
  user_id INT,
  event_type VARCHAR(50),
  created_at TIMESTAMP NOT NULL,
  data JSONB
) PARTITION BY RANGE (created_at);

-- Crie partições mensais
CREATE TABLE logs_2026_01 PARTITION OF logs
  FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE logs_2026_02 PARTITION OF logs
  FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

-- Índices em cada partição
CREATE INDEX idx_logs_2026_01_user ON logs_2026_01(user_id);
CREATE INDEX idx_logs_2026_02_user ON logs_2026_02(user_id);

-- Queries automáticas na partição correta
SELECT * FROM logs 
WHERE created_at >= '2026-02-10' 
  AND created_at < '2026-02-15';
-- PostgreSQL busca só em logs_2026_02 ✅

Particionamento por tenant (Multi-tenancy)

-- Estratégia 1: Particionamento por tenant_id
CREATE TABLE orders (
  id BIGSERIAL,
  tenant_id INT NOT NULL,
  customer_id INT,
  amount DECIMAL,
  created_at TIMESTAMP
) PARTITION BY HASH (tenant_id);

-- Crie 16 partições (ajuste conforme necessário)
CREATE TABLE orders_p0 PARTITION OF orders FOR VALUES WITH (MODULUS 16, REMAINDER 0);
CREATE TABLE orders_p1 PARTITION OF orders FOR VALUES WITH (MODULUS 16, REMAINDER 1);
-- ... até p15

-- Queries incluem tenant_id (obrigatório!)
SELECT * FROM orders WHERE tenant_id = 123 AND status = 'pending';
-- Busca só na partição correta

Estratégia 2: Schema por tenant

-- Cada tenant tem seu próprio schema
CREATE SCHEMA tenant_123;
CREATE TABLE tenant_123.orders (...);
CREATE TABLE tenant_123.customers (...);

CREATE SCHEMA tenant_456;
CREATE TABLE tenant_456.orders (...);

-- Na aplicação
const tenantId = req.user.tenantId;
await pool.query(\`SET search_path TO tenant_\${tenantId}\`);
await pool.query('SELECT * FROM orders');  // Busca no schema correto

Estratégia 3: Banco por tenant (ultimate isolation)

-- Para tenants enterprise/grandes
tenant_123 → postgres://db1.cloudpg.com.br/tenant_123
tenant_456 → postgres://db2.cloudpg.com.br/tenant_456

// Roteamento dinâmico
const connectionString = getTenantDatabase(tenantId);
const pool = new Pool({ connectionString });

5. Caching estratégico

Camadas de cache

┌─────────────────────┐
│   Application       │
│   (Memory cache)    │ ← L1: 1-5ms (Node cache, dict em Python)
└─────────────────────┘
          ↓
┌─────────────────────┐
│   Redis/Memcached   │ ← L2: 5-20ms
└─────────────────────┘
          ↓
┌─────────────────────┐
│   PostgreSQL        │ ← L3: 20-100ms
└─────────────────────┘

Implementação de cache

// Node.js com Redis
const redis = require('redis').createClient();

async function getUser(id) {
  // 1. Tenta cache L1 (memória)
  if (memoryCache.has(\`user:\${id}\`)) {
    return memoryCache.get(\`user:\${id}\`);
  }
  
  // 2. Tenta cache L2 (Redis)
  const cached = await redis.get(\`user:\${id}\`);
  if (cached) {
    const user = JSON.parse(cached);
    memoryCache.set(\`user:\${id}\`, user);  // Popula L1
    return user;
  }
  
  // 3. Busca no PostgreSQL
  const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
  const user = result.rows[0];
  
  // 4. Popula caches
  await redis.setex(\`user:\${id}\`, 3600, JSON.stringify(user));  // TTL 1h
  memoryCache.set(\`user:\${id}\`, user);
  
  return user;
}

// Invalidação ao atualizar
async function updateUser(id, data) {
  await pool.query('UPDATE users SET name = $1 WHERE id = $2', [data.name, id]);
  
  // Invalida cache
  memoryCache.delete(\`user:\${id}\`);
  await redis.del(\`user:\${id}\`);
}

6. Background jobs e filas

Separe operações assíncronas

// ❌ Ruim: Processamento síncrono
app.post('/upload-file', async (req, res) => {
  const file = req.file;
  await processFile(file);  // 30 segundos ⏳
  await generateThumbnails(file);  // 15 segundos ⏳
  await extractMetadata(file);  // 10 segundos ⏳
  await sendNotification(req.user);  // 2 segundos ⏳
  res.json({ success: true });  // Usuário esperou 57 segundos!
});

// ✅ Bom: Background jobs
app.post('/upload-file', async (req, res) {
  const file = req.file;
  
  // Salva referência no banco
  await pool.query('INSERT INTO uploads (user_id, filename) VALUES ($1, $2)', 
    [req.user.id, file.name]);
  
  // Enfileira jobs
  await queue.add('process-file', { fileId: file.id });
  await queue.add('generate-thumbnails', { fileId: file.id });
  await queue.add('extract-metadata', { fileId: file.id });
  
  res.json({ success: true, processing: true });  // Resposta instantânea ✅
});

// Worker processa em background
worker.process('process-file', async (job) => {
  const { fileId } = job.data;
  await processFile(fileId);
});

7. Monitoramento e alertas

KPIs essenciais para SaaS

Métrica Alerta Warning Alerta Crítico
Query P95 latency > 200ms > 1000ms
Conexões ativas > 70% max > 90% max
Replication lag > 5s > 30s
Disco usado > 70% > 85%
Cache hit ratio < 90% < 80%
Deadlocks > 5/hora > 20/hora

8. Escalando com CloudPG

CloudPG foi projetado para crescer com seu SaaS, desde MVP até enterprise:

FREE → BASIC (0-10k users)

PRO → GIGA (10k-100k users)

ENTERPRISE (100k+ users)

Conclusão

Escalar um SaaS com PostgreSQL é totalmente viável seguindo as estratégias certas:

  1. ✅ Connection pooling desde o dia 1
  2. ✅ Read replicas quando leitura > 70%
  3. ✅ Cache inteligente (L1 + L2)
  4. ✅ Particionamento para tabelas grandes
  5. ✅ Background jobs para operações pesadas
  6. ✅ Monitoramento ativo com alertas

Com CloudPG, você tem toda infraestrutura gerenciada para escalar sem dor de cabeça.

Escale com confiança

Comece grátis no CloudPG e escale de 0 a milhões de usuários com a mesma plataforma.