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:
$100/mês (sem mais ElastiCache)
50% de redução na complexidade de backup
Um serviço a menos para monitorar
Implantação mais simples (uma dependência a menos)
O que perdi:
~0.5ms de latência em operações de cache
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:
Você está usando Redis para cache/sessões simples
Taxa de acerto de cache é < 95% (muitas escritas)
Você quer consistência transacional
Você está ok com operações 0.1-1ms mais lentas
Você é um pequeno time com recursos de ops limitados
Mantenha Redis se:
Você precisa de 100k+ ops/segundo
Você usa estruturas de dados do Redis (sorted sets, etc.)
Você tem um time de ops dedicado
Latência sub-milissegundo é crítica
Você está fazendo geo-replicação
Recursos
Recursos do PostgreSQL:
Ferramentas:
- pgBouncer - Connection pooling
- pg_stat_statements - Performance de queries
Soluções Alternativas:
- Graphile Worker - Fila de jobs baseada em Postgres
- pg-boss - Outra fila Postgres
TL;DR
Substitui Redis por PostgreSQL para:
- Cache → Tabelas UNLOGGED
- Pub/Sub → LISTEN/NOTIFY
- Filas de jobs → SKIP LOCKED
- 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! ![]()
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
