Reemplacé Redis con PostgreSQL (¡Y es más rápido!)

Reemplacé Redis con PostgreSQL (Y Es Más Rápido)

Tenía un stack típico de aplicación web:

  • PostgreSQL para datos persistentes
  • Redis para caché, pub/sub y trabajos en segundo plano

Dos bases de datos. Dos cosas que gestionar. Dos puntos de fallo.

Luego me di cuenta: PostgreSQL puede hacer todo lo que Redis hace.

Eliminé Redis completamente. Esto es lo que pasó.


La Configuración: Para Qué Usaba Redis

Antes del cambio, Redis manejaba tres cosas:

1. Caché (70% del uso)

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

2. Pub/Sub (20% del uso)

// Notificaciones en tiempo real
redis.publish('notifications', JSON.stringify({ userId, message }));

3. Cola de Trabajos en Segundo Plano (10% del uso)

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

Los puntos problemáticos:

  • Dos bases de datos para hacer backup
  • Redis usa RAM (caro a escala)
  • La persistencia de Redis es… complicada
  • Salto de red entre Postgres y Redis

Por Qué Consideré Reemplazar Redis

Razón #1: Costo

Mi configuración de Redis:

  • AWS ElastiCache: $45/mes (2GB)
  • Crecer a 5GB costaría $110/mes

PostgreSQL:

  • Ya pagando por RDS: $50/mes (almacenamiento de 20GB)
  • Agregar 5GB de datos: $0.50/mes

Ahorros potenciales: ~$100/mes

Razón #2: Complejidad Operacional

Con Redis:

Backup de Postgres ✅
Backup de Redis ❓ (¿RDB? ¿AOF? ¿Ambos?)
Monitoreo de Postgres ✅
Monitoreo de Redis ❓
Failover de Postgres ✅
Redis Sentinel/Cluster ❓

Sin Redis:

Backup de Postgres ✅
Monitoreo de Postgres ✅
Failover de Postgres ✅

Una parte móvil menos.

Razón #3: Consistencia de Datos

El problema clásico:

// Actualizar base de datos
await db.query('UPDATE users SET name = $1 WHERE id = $2', [name, id]);

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

// ⚠️ ¿Y si Redis está caído?
// ⚠️ ¿Y si esto falla?
// Ahora el caché y la BD están desincronizados

Con todo en Postgres: las transacciones resuelven esto.


Característica de PostgreSQL #1: Caché con Tablas 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);

Insertar:

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;

Leer:

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

Limpieza (ejecutar periódicamente):

DELETE FROM cache WHERE expires_at < NOW();

¿Qué es UNLOGGED?

Tablas UNLOGGED:

  • Omiten el Write-Ahead Log (WAL)
  • Escrituras mucho más rápidas
  • No sobreviven a fallos (¡perfecto para caché!)

Rendimiento:

Redis SET: 0.05ms
Postgres UNLOGGED INSERT: 0.08ms

Lo suficientemente cercano para caché.


Característica de PostgreSQL #2: Pub/Sub con LISTEN/NOTIFY

Aquí es donde se pone interesante.

PostgreSQL tiene pub/sub nativo que la mayoría de desarrolladores no conocen.

Redis Pub/Sub

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

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

PostgreSQL Pub/Sub

-- Publicador
NOTIFY notifications, '{"userId": 123, "msg": "Hello"}';
// Suscriptor (Node.js con 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);
});

Comparación de rendimiento:

Latencia de pub/sub de Redis: 1-2ms
Latencia de NOTIFY de Postgres: 2-5ms

Ligeramente más lento, pero:

  • Sin infraestructura adicional
  • Se puede usar en transacciones
  • Se puede combinar con consultas

Ejemplo del Mundo Real: Live Tail

En mi aplicación de gestión de logs, necesitaba streaming de logs en tiempo real.

Con Redis:

// Cuando llega un nuevo log
await db.query('INSERT INTO logs ...');
await redis.publish('logs:new', JSON.stringify(log));

// El frontend escucha
redis.subscribe('logs:new');

Problema: Dos operaciones. ¿Y si la publicación falla?

Con 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();

Ahora es atómico. La inserción y la notificación ocurren juntas o no ocurren.

// Frontend (vía 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 en tiempo real sin Redis.


Característica de PostgreSQL #3: Colas de Trabajos con 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;

Encolar:

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

Worker (desencolar):

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 *;

La magia: FOR UPDATE SKIP LOCKED

Esto hace que PostgreSQL sea una cola sin bloqueos:

  • Múltiples workers pueden obtener trabajos concurrentemente
  • Ningún trabajo se procesa dos veces
  • Si un worker falla, el trabajo vuelve a estar disponible

Rendimiento:

Redis BRPOP: 0.1ms
Postgres SKIP LOCKED: 0.3ms

Diferencia negligible para la mayoría de cargas de trabajo.


Característica de PostgreSQL #4: Limitación de Velocidad

Redis (limitador de velocidad clásico):

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  -- Reiniciar 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;

O más simple con una función de ventana:

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

-- Verificar límite de velocidad
SELECT COUNT(*) FROM api_requests
WHERE user_id = $1
  AND created_at > NOW() - INTERVAL '1 minute';

-- Si está bajo el límite, insertar
INSERT INTO api_requests (user_id) VALUES ($1);

-- Limpiar solicitudes antiguas periódicamente
DELETE FROM api_requests WHERE created_at < NOW() - INTERVAL '5 minutes';
```**Cuándo Postgres es mejor:**
- Necesitas limitar la velocidad basándote en lógica compleja (no solo conteos)
- Quieres que los datos de límite de velocidad estén en la misma transacción que la lógica empresarial

**Cuándo Redis es mejor:**
- Necesitas limitación de velocidad en sub-milisegundos
- Rendimiento extremadamente alto (millones de solicitudes/seg)

---

## Característica PostgreSQL #5: Sesiones con 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);

-- Insertar/Actualizar
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;

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

Bonus: Operadores JSONB

Puedes consultar dentro de la sesión:

-- Encontrar todas las sesiones de un usuario específico
SELECT * FROM sessions
WHERE data->>'userId' = '123';

-- Encontrar sesiones con un rol específico
SELECT * FROM sessions
WHERE data->'user'->>'role' = 'admin';

¡No puedes hacer esto con Redis!


Benchmarks del Mundo Real

Ejecuté benchmarks en mi conjunto de datos de producción:

Configuración de Prueba

  • Hardware: AWS RDS db.t3.medium (2 vCPU, 4GB RAM)
  • Conjunto de datos: 1 millón de entradas de caché, 10k sesiones
  • Herramienta: pgbench (scripts personalizados)

Resultados

Operación Redis PostgreSQL Diferencia
Cache SET 0.05ms 0.08ms +60% más lento
Cache GET 0.04ms 0.06ms +50% más lento
Pub/Sub 1.2ms 3.1ms +158% más lento
Queue push 0.08ms 0.15ms +87% más lento
Queue pop 0.12ms 0.31ms +158% más lento

PostgreSQL es más lento… pero:

  • Todas las operaciones siguen siendo menores a 1ms
  • Elimina el salto de red a Redis
  • Reduce la complejidad de la infraestructura

Operaciones Combinadas (La Victoria Real)

Escenario: Insertar datos + invalidar caché + notificar suscriptores

Con Redis:

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

Con PostgreSQL:

BEGIN;
INSERT INTO posts ...;                          -- 2ms
DELETE FROM cache WHERE key = 'posts:latest';  -- 0.1ms (misma conexión)
NOTIFY posts_new, '...';                        -- 0.1ms (misma conexión)
COMMIT;
-- Total: ~2.2ms

PostgreSQL es más rápido cuando las operaciones se combinan.


Cuándo Mantener Redis

No reemplaces Redis si:

1. Necesitas Rendimiento Extremo

Redis: 100,000+ ops/seg (instancia única)
Postgres: 10,000-50,000 ops/seg

Si estás haciendo millones de lecturas de caché/seg, mantén Redis.

2. Estás Usando Estructuras de Datos Específicas de Redis

Redis tiene:

  • Conjuntos ordenados (tablas de clasificación)
  • HyperLogLog (estimaciones de conteo único)
  • Índices geoespaciales
  • Streams (pub/sub avanzado)

Los equivalentes de Postgres existen pero son más complicados:

-- Tabla de clasificación en Postgres (más lento)
SELECT user_id, score
FROM leaderboard
ORDER BY score DESC
LIMIT 10;

-- vs Redis
ZREVRANGE leaderboard 0 9 WITHSCORES

3. Tienes un Requisito de Capa de Caché Separada

Si tu arquitectura requiere una capa de caché separada (p. ej., microservicios), mantén Redis.


Estrategia de Migración

No elimines Redis de la noche a la mañana. Así es como lo hice:

Fase 1: Lado a Lado (Semana 1)

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

// Leer de Redis (aún principal)
let data = await redis.get(key);

Monitorear: Comparar tasas de acierto, latencia.

Fase 2: Leer de Postgres (Semana 2)

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

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

Monitorear: Tasas de error, rendimiento.

Fase 3: Escribir Solo en Postgres (Semana 3)

// Solo escribir en Postgres
await pg.query('INSERT INTO cache ...');

Monitorear: ¿Sigue funcionando todo?

Fase 4: Eliminar Redis (Semana 4)

# Apagar Redis
# Vigilar errores
# ¿Nada se rompe? ¡Éxito!

Ejemplos de Código: Implementación Completa

Módulo de Caché (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 Cola de Trabajos

// 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;

Consejos de Ajuste de Rendimiento

1. Usar Agrupación de Conexiones

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

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

2. Agregar Índices Apropiados

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. Ajustar Configuración de PostgreSQL

# postgresql.conf
shared_buffers = 2GB           # 25% de RAM
effective_cache_size = 6GB     # 75% de RAM
work_mem = 50MB                # Para consultas complejas
maintenance_work_mem = 512MB   # Para VACUUM
```### 4. Mantenimiento Regular
```sql
-- Ejecutar diariamente
VACUUM ANALYZE cache;
VACUUM ANALYZE jobs;

-- O habilitar autovacuum (recomendado)
ALTER TABLE cache SET (autovacuum_vacuum_scale_factor = 0.1);

Los Resultados: 3 Meses Después

Lo que ahorré:

  • :white_check_mark: $100/mes (sin más ElastiCache)
  • :white_check_mark: 50% de reducción en complejidad de copias de seguridad
  • :white_check_mark: Un servicio menos para monitorear
  • :white_check_mark: Despliegue más simple (una dependencia menos)

Lo que perdí:

  • :cross_mark: ~0.5ms de latencia en operaciones de caché
  • :cross_mark: Estructuras de datos exóticas de Redis (no las necesitaba)

¿Lo volvería a hacer? Sí, para este caso de uso.

¿Lo recomendaría universalmente? No.


Matriz de Decisión

Reemplaza Redis con Postgres si:

  • :white_check_mark: Usas Redis para caché/sesiones simples
  • :white_check_mark: La tasa de acierto de caché es < 95% (muchas escrituras)
  • :white_check_mark: Quieres consistencia transaccional
  • :white_check_mark: Estás de acuerdo con operaciones 0.1-1ms más lentas
  • :white_check_mark: Eres un equipo pequeño con recursos de operaciones limitados

Mantén Redis si:

  • :cross_mark: Necesitas 100k+ operaciones/segundo
  • :cross_mark: Usas estructuras de datos de Redis (conjuntos ordenados, etc.)
  • :cross_mark: Tienes un equipo de operaciones dedicado
  • :cross_mark: La latencia submilisegundo es crítica
  • :cross_mark: Estás haciendo geo-replicación

Recursos

Características de PostgreSQL:

Herramientas:

Soluciones Alternativas:


TL;DR

Reemplacé Redis con PostgreSQL para:

  1. Caché → tablas UNLOGGED
  2. Pub/Sub → LISTEN/NOTIFY
  3. Colas de trabajos → SKIP LOCKED
  4. Sesiones → tablas JSONB

Resultados:

  • Ahorré $100/mes
  • Reducí la complejidad operacional
  • Ligeramente más lento (0.1-1ms) pero aceptable
  • Consistencia transaccional garantizada

Cuándo hacer esto:

  • Aplicaciones pequeñas a medianas
  • Necesidades de caché simples
  • Quieres reducir componentes móviles

Cuándo NO hacer esto:

  • Requisitos de alto rendimiento (100k+ operaciones/seg)
  • Usar características específicas de Redis
  • Tener un equipo de operaciones dedicado

¿Has reemplazado Redis con Postgres (o viceversa)? ¿Cuál fue tu experiencia? ¡Comparte tus benchmarks en los comentarios! :backhand_index_pointing_down:

P.S. - ¿Quieres un seguimiento sobre “Características Ocultas de PostgreSQL” o “Cuándo Redis es Realmente Mejor”? ¡Avísame!

publicado originalmente por @polliog en dev.to