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é:
$100/mes (sin más ElastiCache)
50% de reducción en complejidad de copias de seguridad
Un servicio menos para monitorear
Despliegue más simple (una dependencia menos)
Lo que perdí:
~0.5ms de latencia en operaciones de caché
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:
Usas Redis para caché/sesiones simples
La tasa de acierto de caché es < 95% (muchas escrituras)
Quieres consistencia transaccional
Estás de acuerdo con operaciones 0.1-1ms más lentas
Eres un equipo pequeño con recursos de operaciones limitados
Mantén Redis si:
Necesitas 100k+ operaciones/segundo
Usas estructuras de datos de Redis (conjuntos ordenados, etc.)
Tienes un equipo de operaciones dedicado
La latencia submilisegundo es crítica
Estás haciendo geo-replicación
Recursos
Características de PostgreSQL:
Herramientas:
- pgBouncer - Agrupación de conexiones
- pg_stat_statements - Rendimiento de consultas
Soluciones Alternativas:
- Graphile Worker - Cola de trabajos basada en Postgres
- pg-boss - Otra cola de Postgres
TL;DR
Reemplacé Redis con PostgreSQL para:
- Caché → tablas UNLOGGED
- Pub/Sub → LISTEN/NOTIFY
- Colas de trabajos → SKIP LOCKED
- 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! ![]()
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
