Substituí Redis por PostgreSQL (E É Mais Rápido)

Substituí Redis por PostgreSQL (E É Mais Rápido)

Eu tinha uma stack típica de aplicação web:

  • PostgreSQL para dados persistentes
  • Redis para cache, pub/sub e jobs em background

Dois bancos de dados. Duas coisas para gerenciar. Dois pontos de falha.

Então percebi: PostgreSQL pode fazer tudo que Redis faz.

Removi Redis completamente. Veja o que aconteceu.


A Configuração: Para Que Eu Usava Redis

Antes da mudança, Redis cuidava de três coisas:

1. Cache (70% do uso)

// Cache de respostas de API
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);

2. Pub/Sub (20% do uso)

// Notificações em tempo real
redis.publish('notifications', JSON.stringify({ userId, message }));

3. Fila de Jobs em Background (10% do uso)

// Usando Bull/BullMQ
queue.add('send-email', { to, subject, body });

Os pontos de dor:

  • Dois bancos de dados para fazer backup
  • Redis usa RAM (caro em escala)
  • Persistência do Redis é… complicada
  • Salto de rede entre Postgres e Redis

Por Que Considerei Substituir Redis

Razão #1: Custo

Minha configuração Redis:

  • AWS ElastiCache: $45/mês (2GB)
  • Crescendo para 5GB custaria $110/mês

PostgreSQL:

  • Já pagando por RDS: $50/mês (20GB de armazenamento)
  • Adicionando 5GB de dados: $0,50/mês

Economia potencial: ~$100/mês

Razão #2: Complexidade Operacional

Com Redis:

Backup do Postgres ✅
Backup do Redis ❓ (RDB? AOF? Ambos?)
Monitoramento do Postgres ✅
Monitoramento do Redis ❓
Failover do Postgres ✅
Redis Sentinel/Cluster ❓

Sem Redis:

Backup do Postgres ✅
Monitoramento do Postgres ✅
Failover do Postgres ✅

Uma peça móvel a menos.

Razão #3: Consistência de Dados

O problema clássico:

// Atualizar banco de dados
await db.query('UPDATE users SET name = $1 WHERE id = $2', [name, id]);

// Invalidar cache
await redis.del(`user:${id}`);

// ⚠️ E se Redis estiver fora?
// ⚠️ E se isso falhar?
// Agora cache e DB estão fora de sincronismo

Com tudo em Postgres: transações resolvem isso.


Recurso PostgreSQL #1: Cache com Tabelas UNLOGGED

Redis:

await redis.set('session:abc123', JSON.stringify(sessionData), 'EX', 3600);

PostgreSQL:

CREATE UNLOGGED TABLE cache (
  key TEXT PRIMARY KEY,
  value JSONB NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_cache_expires ON cache(expires_at);

Inserir:

INSERT INTO cache (key, value, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '1 hour')
ON CONFLICT (key) DO UPDATE
  SET value = EXCLUDED.value,
      expires_at = EXCLUDED.expires_at;

Ler:

SELECT value FROM cache
WHERE key = $1 AND expires_at > NOW();

Limpeza (executar periodicamente):

DELETE FROM cache WHERE expires_at < NOW();

O que é UNLOGGED?

Tabelas UNLOGGED:

  • Pulam o Write-Ahead Log (WAL)
  • Escritas muito mais rápidas
  • Não sobrevivem a crashes (perfeito para cache!)

Performance:

Redis SET: 0,05ms
Postgres UNLOGGED INSERT: 0,08ms

Próximo o suficiente para cache.


Recurso PostgreSQL #2: Pub/Sub com LISTEN/NOTIFY

Aqui é onde fica interessante.

PostgreSQL tem pub/sub nativo que a maioria dos desenvolvedores não conhece.

Redis Pub/Sub

// Publisher
redis.publish('notifications', JSON.stringify({ userId: 123, msg: 'Hello' }));

// Subscriber
redis.subscribe('notifications');
redis.on('message', (channel, message) => {
  console.log(message);
});

PostgreSQL Pub/Sub

-- Publisher
NOTIFY notifications, '{"userId": 123, "msg": "Hello"}';
// Subscriber (Node.js com pg)
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();

await client.query('LISTEN notifications');

client.on('notification', (msg) => {
  const payload = JSON.parse(msg.payload);
  console.log(payload);
});

Comparação de performance:

Latência Redis pub/sub: 1-2ms
Latência Postgres NOTIFY: 2-5ms

Um pouco mais lento, mas:

  • Sem infraestrutura extra
  • Pode usar em transações
  • Pode combinar com queries

Exemplo do Mundo Real: Live Tail

Na minha aplicação de gerenciamento de logs, eu precisava de streaming de logs em tempo real.

Com Redis:

// Quando novo log chega
await db.query('INSERT INTO logs ...');
await redis.publish('logs:new', JSON.stringify(log));

// Frontend escuta
redis.subscribe('logs:new');

Problema: Duas operações. E se publish falhar?

Com PostgreSQL:

CREATE FUNCTION notify_new_log() RETURNS TRIGGER AS $$
BEGIN
  PERFORM pg_notify('logs_new', row_to_json(NEW)::text);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER log_inserted
AFTER INSERT ON logs
FOR EACH ROW EXECUTE FUNCTION notify_new_log();

Agora é atômico. Insert e notify acontecem juntos ou não acontecem.

// Frontend (via SSE)
app.get('/logs/stream', async (req, res) => {
  const client = await pool.connect();
  
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
  });
  
  await client.query('LISTEN logs_new');
  
  client.on('notification', (msg) => {
    res.write(`data: ${msg.payload}\n\n`);
  });
});

Resultado: Streaming de logs em tempo real sem Redis.


Recurso PostgreSQL #3: Filas de Jobs com SKIP LOCKED

Redis (usando Bull/BullMQ):

queue.add('send-email', { to, subject, body });

queue.process('send-email', async (job) => {
  await sendEmail(job.data);
});

PostgreSQL:

CREATE TABLE jobs (
  id BIGSERIAL PRIMARY KEY,
  queue TEXT NOT NULL,
  payload JSONB NOT NULL,
  attempts INT DEFAULT 0,
  max_attempts INT DEFAULT 3,
  scheduled_at TIMESTAMPTZ DEFAULT NOW(),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_jobs_queue ON jobs(queue, scheduled_at) 
WHERE attempts < max_attempts;

Enfileirar:

INSERT INTO jobs (queue, payload)
VALUES ('send-email', '{"to": "user@example.com", "subject": "Hi"}');

Worker (desenfileirar):

WITH next_job AS (
  SELECT id FROM jobs
  WHERE queue = $1
    AND attempts < max_attempts
    AND scheduled_at <= NOW()
  ORDER BY scheduled_at
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs
SET attempts = attempts + 1
FROM next_job
WHERE jobs.id = next_job.id
RETURNING *;

A mágica: FOR UPDATE SKIP LOCKED

Isso torna PostgreSQL uma fila sem lock:

  • Múltiplos workers podem puxar jobs concorrentemente
  • Nenhum job é processado duas vezes
  • Se um worker falhar, o job fica disponível novamente

Performance:

Redis BRPOP: 0,1ms
Postgres SKIP LOCKED: 0,3ms

Diferença negligenciável para a maioria dos workloads.


Recurso PostgreSQL #4: Rate Limiting

Redis (rate limiter clássico):

const key = `ratelimit:${userId}`;
const count = await redis.incr(key);
if (count === 1) {
  await redis.expire(key, 60); // 60 segundos
}

if (count > 100) {
  throw new Error('Rate limit exceeded');
}

PostgreSQL:

CREATE TABLE rate_limits (
  user_id INT PRIMARY KEY,
  request_count INT DEFAULT 0,
  window_start TIMESTAMPTZ DEFAULT NOW()
);

-- Verificar e incrementar
WITH current AS (
  SELECT 
    request_count,
    CASE 
      WHEN window_start < NOW() - INTERVAL '1 minute'
      THEN 1  -- Resetar contador
      ELSE request_count + 1
    END AS new_count
  FROM rate_limits
  WHERE user_id = $1
  FOR UPDATE
)
UPDATE rate_limits
SET 
  request_count = (SELECT new_count FROM current),
  window_start = CASE
    WHEN window_start < NOW() - INTERVAL '1 minute'
    THEN NOW()
    ELSE window_start
  END
WHERE user_id = $1
RETURNING request_count;

Ou mais simples com uma window function:

CREATE TABLE api_requests (
  user_id INT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Verificar rate limit
SELECT COUNT(*) FROM api_requests
WHERE user_id = $1
  AND created_at > NOW() - INTERVAL '1 minute';

-- Se sob limite, inserir
INSERT INTO api_requests (user_id) VALUES ($1);

-- Limpeza de requisições antigas periodicamente
DELETE FROM api_requests WHERE created_at < NOW() - INTERVAL '5 minutes';
```**Quando o Postgres é melhor:**
- Precisa fazer rate limit baseado em lógica complexa (não apenas contagens)
- Quer dados de rate limit na mesma transação que a lógica de negócio

**Quando o Redis é melhor:**
- Precisa de rate limiting sub-milissegundo
- Throughput extremamente alto (milhões de requisições/seg)

---

## Recurso PostgreSQL #5: Sessões com JSONB

**Redis:**
```javascript
await redis.set(`session:${sessionId}`, JSON.stringify(sessionData), 'EX', 86400);

PostgreSQL:

CREATE TABLE sessions (
  id TEXT PRIMARY KEY,
  data JSONB NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_sessions_expires ON sessions(expires_at);

-- Inserir/Atualizar
INSERT INTO sessions (id, data, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '24 hours')
ON CONFLICT (id) DO UPDATE
  SET data = EXCLUDED.data,
      expires_at = EXCLUDED.expires_at;

-- Ler
SELECT data FROM sessions
WHERE id = $1 AND expires_at > NOW();

Bônus: Operadores JSONB

Você pode consultar dentro da sessão:

-- Encontrar todas as sessões de um usuário específico
SELECT * FROM sessions
WHERE data->>'userId' = '123';

-- Encontrar sessões com função específica
SELECT * FROM sessions
WHERE data->'user'->>'role' = 'admin';

Você não consegue fazer isso com Redis!


Benchmarks do Mundo Real

Executei benchmarks no meu dataset de produção:

Configuração do Teste

  • Hardware: AWS RDS db.t3.medium (2 vCPU, 4GB RAM)
  • Dataset: 1 milhão de entradas de cache, 10k sessões
  • Ferramenta: pgbench (scripts customizados)

Resultados

Operação Redis PostgreSQL Diferença
Cache SET 0.05ms 0.08ms +60% mais lento
Cache GET 0.04ms 0.06ms +50% mais lento
Pub/Sub 1.2ms 3.1ms +158% mais lento
Queue push 0.08ms 0.15ms +87% mais lento
Queue pop 0.12ms 0.31ms +158% mais lento

PostgreSQL é mais lento… mas:

  • Todas as operações ainda abaixo de 1ms
  • Elimina salto de rede para Redis
  • Reduz complexidade da infraestrutura

Operações Combinadas (A Verdadeira Vitória)

Cenário: Inserir dados + invalidar cache + notificar subscribers

Com Redis:

await db.query('INSERT INTO posts ...');       // 2ms
await redis.del('posts:latest');                // 1ms (salto de rede)
await redis.publish('posts:new', data);         // 1ms (salto de rede)
// Total: ~4ms

Com PostgreSQL:

BEGIN;
INSERT INTO posts ...;                          -- 2ms
DELETE FROM cache WHERE key = 'posts:latest';  -- 0.1ms (mesma conexão)
NOTIFY posts_new, '...';                        -- 0.1ms (mesma conexão)
COMMIT;
-- Total: ~2.2ms

PostgreSQL é mais rápido quando operações são combinadas.


Quando Manter o Redis

Não substitua Redis se:

1. Você Precisa de Performance Extrema

Redis: 100.000+ ops/seg (instância única)
Postgres: 10.000-50.000 ops/seg

Se você está fazendo milhões de leituras de cache/seg, mantenha Redis.

2. Você Está Usando Estruturas de Dados Específicas do Redis

Redis tem:

  • Sorted sets (leaderboards)
  • HyperLogLog (estimativas de contagem única)
  • Índices geoespaciais
  • Streams (pub/sub avançado)

Equivalentes do Postgres existem mas são mais complicados:

-- Leaderboard no Postgres (mais lento)
SELECT user_id, score
FROM leaderboard
ORDER BY score DESC
LIMIT 10;

-- vs Redis
ZREVRANGE leaderboard 0 9 WITHSCORES

3. Você Tem um Requisito de Camada de Cache Separada

Se sua arquitetura exige uma camada de cache separada (ex: microsserviços), mantenha Redis.


Estratégia de Migração

Não remova Redis da noite para o dia. Aqui está como fiz:

Fase 1: Lado a Lado (Semana 1)

// Escrever em ambos
await redis.set(key, value);
await pg.query('INSERT INTO cache ...');

// Ler do Redis (ainda primário)
let data = await redis.get(key);

Monitorar: Comparar taxas de acerto, latência.

Fase 2: Ler do Postgres (Semana 2)

// Tentar Postgres primeiro
let data = await pg.query('SELECT value FROM cache WHERE key = $1', [key]);

// Fallback para Redis
if (!data) {
  data = await redis.get(key);
}

Monitorar: Taxas de erro, performance.

Fase 3: Escrever Apenas no Postgres (Semana 3)

// Escrever apenas no Postgres
await pg.query('INSERT INTO cache ...');

Monitorar: Tudo ainda funciona?

Fase 4: Remover Redis (Semana 4)

# Desligar Redis
# Observar erros
# Nada quebrou? Sucesso!

Exemplos de Código: Implementação Completa

Módulo de Cache (PostgreSQL)

// cache.js
class PostgresCache {
  constructor(pool) {
    this.pool = pool;
  }

  async get(key) {
    const result = await this.pool.query(
      'SELECT value FROM cache WHERE key = $1 AND expires_at > NOW()',
      [key]
    );
    return result.rows[0]?.value;
  }

  async set(key, value, ttlSeconds = 3600) {
    await this.pool.query(
      `INSERT INTO cache (key, value, expires_at)
       VALUES ($1, $2, NOW() + INTERVAL '${ttlSeconds} seconds')
       ON CONFLICT (key) DO UPDATE
         SET value = EXCLUDED.value,
             expires_at = EXCLUDED.expires_at`,
      [key, value]
    );
  }

  async delete(key) {
    await this.pool.query('DELETE FROM cache WHERE key = $1', [key]);
  }

  async cleanup() {
    await this.pool.query('DELETE FROM cache WHERE expires_at < NOW()');
  }
}

module.exports = PostgresCache;

Módulo Pub/Sub

// pubsub.js
class PostgresPubSub {
  constructor(pool) {
    this.pool = pool;
    this.listeners = new Map();
  }

  async publish(channel, message) {
    const payload = JSON.stringify(message);
    await this.pool.query('SELECT pg_notify($1, $2)', [channel, payload]);
  }

  async subscribe(channel, callback) {
    const client = await this.pool.connect();
    
    await client.query(`LISTEN ${channel}`);
    
    client.on('notification', (msg) => {
      if (msg.channel === channel) {
        callback(JSON.parse(msg.payload));
      }
    });

    this.listeners.set(channel, client);
  }

  async unsubscribe(channel) {
    const client = this.listeners.get(channel);
    if (client) {
      await client.query(`UNLISTEN ${channel}`);
      client.release();
      this.listeners.delete(channel);
    }
  }
}

module.exports = PostgresPubSub;

Módulo de Fila de Trabalhos

// queue.js
class PostgresQueue {
  constructor(pool) {
    this.pool = pool;
  }

  async enqueue(queue, payload, scheduledAt = new Date()) {
    await this.pool.query(
      'INSERT INTO jobs (queue, payload, scheduled_at) VALUES ($1, $2, $3)',
      [queue, payload, scheduledAt]
    );
  }

  async dequeue(queue) {
    const result = await this.pool.query(
      `WITH next_job AS (
        SELECT id FROM jobs
        WHERE queue = $1
          AND attempts < max_attempts
          AND scheduled_at <= NOW()
        ORDER BY scheduled_at
        LIMIT 1
        FOR UPDATE SKIP LOCKED
      )
      UPDATE jobs
      SET attempts = attempts + 1
      FROM next_job
      WHERE jobs.id = next_job.id
      RETURNING jobs.*`,
      [queue]
    );
    
    return result.rows[0];
  }

  async complete(jobId) {
    await this.pool.query('DELETE FROM jobs WHERE id = $1', [jobId]);
  }

  async fail(jobId, error) {
    await this.pool.query(
      `UPDATE jobs
       SET attempts = max_attempts,
           payload = payload || jsonb_build_object('error', $2)
       WHERE id = $1`,
      [jobId, error.message]
    );
  }
}

module.exports = PostgresQueue;

Dicas de Otimização de Performance

1. Use Connection Pooling

const { Pool } = require('pg');

const pool = new Pool({
  max: 20,  // Máximo de conexões
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

2. Adicione Índices Apropriados

CREATE INDEX CONCURRENTLY idx_cache_key ON cache(key) WHERE expires_at > NOW();
CREATE INDEX CONCURRENTLY idx_jobs_pending ON jobs(queue, scheduled_at) 
  WHERE attempts < max_attempts;

3. Ajuste a Configuração do PostgreSQL

# postgresql.conf
shared_buffers = 2GB           # 25% da RAM
effective_cache_size = 6GB     # 75% da RAM
work_mem = 50MB                # Para queries complexas
maintenance_work_mem = 512MB   # Para VACUUM
```### 4. Manutenção Regular
```sql
-- Executar diariamente
VACUUM ANALYZE cache;
VACUUM ANALYZE jobs;

-- Ou ativar autovacuum (recomendado)
ALTER TABLE cache SET (autovacuum_vacuum_scale_factor = 0.1);

Os Resultados: 3 Meses Depois

O que economizei:

  • :white_check_mark: $100/mês (sem mais ElastiCache)
  • :white_check_mark: 50% de redução na complexidade de backup
  • :white_check_mark: Um serviço a menos para monitorar
  • :white_check_mark: Implantação mais simples (uma dependência a menos)

O que perdi:

  • :cross_mark: ~0.5ms de latência em operações de cache
  • :cross_mark: Estruturas de dados exóticas do Redis (não precisava delas)

Faria novamente? Sim, para este caso de uso.

Recomendaria universalmente? Não.


Matriz de Decisão

Substitua Redis por PostgreSQL se:

  • :white_check_mark: Você está usando Redis para cache/sessões simples
  • :white_check_mark: Taxa de acerto de cache é < 95% (muitas escritas)
  • :white_check_mark: Você quer consistência transacional
  • :white_check_mark: Você está ok com operações 0.1-1ms mais lentas
  • :white_check_mark: Você é um pequeno time com recursos de ops limitados

Mantenha Redis se:

  • :cross_mark: Você precisa de 100k+ ops/segundo
  • :cross_mark: Você usa estruturas de dados do Redis (sorted sets, etc.)
  • :cross_mark: Você tem um time de ops dedicado
  • :cross_mark: Latência sub-milissegundo é crítica
  • :cross_mark: Você está fazendo geo-replicação

Recursos

Recursos do PostgreSQL:

Ferramentas:

Soluções Alternativas:


TL;DR

Substitui Redis por PostgreSQL para:

  1. Cache → Tabelas UNLOGGED
  2. Pub/Sub → LISTEN/NOTIFY
  3. Filas de jobs → SKIP LOCKED
  4. Sessões → Tabelas JSONB

Resultados:

  • Economizei $100/mês
  • Reduzi complexidade operacional
  • Um pouco mais lento (0.1-1ms) mas aceitável
  • Consistência transacional garantida

Quando fazer isso:

  • Apps pequenas a médias
  • Necessidades simples de cache
  • Quer reduzir componentes

Quando NÃO fazer isso:

  • Requisitos de alta performance (100k+ ops/seg)
  • Usando recursos específicos do Redis
  • Tem um time de ops dedicado

Você já substituiu Redis por Postgres (ou vice-versa)? Qual foi sua experiência? Compartilhe seus benchmarks nos comentários! :backhand_index_pointing_down:

P.S. - Quer um follow-up sobre “Recursos Ocultos do PostgreSQL” ou “Quando Redis é Realmente Melhor”? Me avise!

originalmente postado por @polliog no dev.to

1 curtida