Skip to content

🎯 Meta: hacer tu backend rápido (caché) y escalable (colas). Cuando una API crece, estos dos patrones son la diferencia entre una app que vuela y una que se arrastra. Redis es la herramienta estrella para ambos.

Versión: Redis 7.x / Valkey 8 (fork open source de Redis).


B.1 · ¿Qué es Redis?

Redis es una base de datos en memoria (RAM): rapidísima (microsegundos) pero pensada para datos temporales o de acceso frecuente. No reemplaza a PostgreSQL; lo complementa.

PostgreSQL                          Redis
├─ Datos permanentes                ├─ Datos temporales / de acceso rápido
├─ En disco (duradero)              ├─ En memoria (volátil, opcional persistencia)
├─ Milisegundos                     ├─ Microsegundos
└─ La "verdad" de tu sistema        └─ Caché, sesiones, colas, contadores, rate limit

🧠 Analogía: PostgreSQL es tu archivador (todo guardado, ordenado, seguro pero lento de abrir). Redis es tu mesa de trabajo (lo que usas ahora mismo, al alcance de la mano, pero se limpia al final del día). Usas ambos.

📝 Nota 2026: tras el cambio de licencia de Redis, muchos usan Valkey (fork idéntico, mantenido por la Linux Foundation). El código y comandos son los mismos; si ves "Valkey", trátalo como Redis.

Levántalo con Docker (cap. 12):

bash
docker run --name redis -p 6379:6379 -d redis:7-alpine
docker exec -it redis redis-cli      # cliente interactivo

Comandos básicos:

SET clave "valor"           guardar
GET clave                   leer
SET clave "valor" EX 60     guardar con expiración (60 segundos)
DEL clave                   borrar
INCR contador               incrementar (atómico)
EXPIRE clave 30             ponerle caducidad
TTL clave                   ¿cuánto le queda?

B.2 · Caché: no repitas trabajo caro

El patrón más común: cache-aside. Antes de consultar la BD (lento), miras si el resultado ya está en Redis (rápido):

1. ¿Está en Redis?  ── sí ──►  devuélvelo (¡microsegundos!)

        no

2. Consulta PostgreSQL (lento)
3. Guarda el resultado en Redis (con expiración)
4. Devuélvelo

Ejemplo en FastAPI (Python):

python
import redis.asyncio as redis
import json

r = redis.from_url("redis://localhost:6379")

async def obtener_producto(id: int, session):
    clave = f"producto:{id}"

    # 1. Intentar desde caché
    cacheado = await r.get(clave)
    if cacheado:
        return json.loads(cacheado)          # HIT: ultrarrápido

    # 2. MISS: ir a la BD
    producto = await session.get(Producto, id)
    datos = producto.model_dump()

    # 3. Guardar en caché 5 minutos
    await r.set(clave, json.dumps(datos), ex=300)
    return datos

Mismo patrón en cualquier stack:

  • Laravel: Cache::remember('producto:'.$id, 300, fn() => Producto::find($id)); (una línea).
  • NestJS: @nestjs/cache-manager con CacheInterceptor.
  • Go: cliente go-redis.

⚠️ El problema difícil de la caché: la invalidación. Si cacheas un producto y luego cambia su precio, la caché queda "vieja" (stale). Estrategias:

  1. TTL corto (expiración): aceptas datos algo desactualizados unos segundos. Simple y suele bastar.
  2. Invalidar al escribir: cuando actualizas el producto, borras su clave de caché (DEL producto:42). Preciso pero hay que acordarse en cada escritura.

"Solo hay dos cosas difíciles en informática: invalidar cachés y nombrar cosas." — es un chiste clásico porque es verdad.

💡 Qué cachear: lo que se lee mucho y cambia poco (catálogos, configuración, perfiles públicos). No caches lo que cambia a cada segundo ni datos sensibles/personalizados sin cuidado.

Cache-aside no es la única estrategia

EstrategiaCómo funcionaCuándo usarla
Cache-aside (arriba)La app consulta Redis; si falla, va a la BD y rellena RedisEl default: control total, simple, lo que ya viste
Write-throughCada escritura va a Redis Y a la BD a la vez, en la misma operaciónCuando no toleras ni un instante de dato "viejo" tras escribir
Write-behindLa escritura va a Redis primero; un proceso la persiste en la BD después, en batchEscrituras muy frecuentes (contadores de vistas) donde perder el último valor en un crash es aceptable
python
# Write-through: la mutación actualiza los dos sitios en el mismo flujo
async def actualizar_precio(id: int, nuevo_precio: float):
    await session.execute(update(Producto).where(Producto.id == id).values(precio=nuevo_precio))
    await session.commit()
    await r.set(f"producto:{id}", json.dumps({"precio": nuevo_precio}), ex=300)  # sin esperar al próximo MISS

⚠️ Write-behind es el más arriesgado de los tres: si Redis se reinicia antes de persistir el batch pendiente, ese dato se pierde de verdad — solo acéptalo para datos donde perder los últimos segundos no importa (contadores de "vistas", no saldos ni pedidos). Para el 95% de los casos de este libro, cache-aside sigue siendo la elección correcta.


B.3 · Sesiones y rate limiting con Redis

Sesiones: en apps con varios servidores, la sesión no puede vivir en la memoria de uno solo (el siguiente request podría ir a otro servidor). Se guardan en Redis, compartido por todos:

python
await r.set(f"sesion:{token}", usuario_id, ex=3600)   # sesión de 1 hora

Rate limiting (limitar peticiones por usuario/IP) — Redis lo hace trivial con INCR + EXPIRE:

python
async def permitir_peticion(ip: str) -> bool:
    clave = f"rate:{ip}"
    actual = await r.incr(clave)
    if actual == 1:
        await r.expire(clave, 60)      # ventana de 60 segundos
    return actual <= 100               # máx 100 peticiones/minuto

🔗 Esto complementa el rate limiting de Nginx (cap. 13): Nginx frena a nivel de red; Redis te permite límites por usuario/lógica de negocio. Defensa en capas.

El INCR+EXPIRE de arriba es fixed window: su punto débil

El contador de arriba reinicia en un instante fijo del reloj (minuto 0, minuto 1…). Eso permite un doble de peticiones justo en el borde de la ventana: 100 peticiones a las 23:59:59 + 100 más a las 00:00:00 = 200 en 2 segundos, aunque el límite sea "100/minuto".

Fixed window:        |████████████ 100|████████████ 100|   ← 200 en el borde central
Sliding window log:   ventana de 60s que se desliza con CADA petición, sin bordes fijos
Token bucket:         un "cubo" con capacidad N que se rellena a ritmo constante
python
# Sliding window con un Sorted Set: cada petición es un miembro con su timestamp como score
async def permitir_sliding(ip: str, limite: int = 100, ventana_seg: int = 60) -> bool:
    clave = f"rate:sliding:{ip}"
    ahora = time.time()
    async with r.pipeline() as pipe:
        pipe.zremrangebyscore(clave, 0, ahora - ventana_seg)   # descarta lo que ya expiró
        pipe.zadd(clave, {str(ahora): ahora})                  # registra esta petición
        pipe.zcard(clave)                                      # cuenta las que quedan en ventana
        pipe.expire(clave, ventana_seg)
        _, _, total, _ = await pipe.execute()
    return total <= limite

🧠 Cuándo cada uno: fixed window (lo simple de arriba) basta para el 90% de los casos — proteger un endpoint de abuso burdo. Sliding window elimina el problema del borde a cambio de algo más de memoria (un ZSET por cliente). Token bucket es el estándar cuando quieres permitir ráfagas cortas (el usuario puede gastar de golpe su cuota acumulada) pero limitar el ritmo sostenido — es el algoritmo que usan la mayoría de APIs públicas (Stripe, GitHub) y el que implementan librerías como elysia-rate-limit (cap. 07) o Flask-Limiter (cap. 04) por debajo.


B.4 · Colas de tareas: no hagas esperar al usuario

El problema: cuando un usuario se registra, quieres enviarle un email. Enviar el email tarda 2-3 segundos. Si lo haces dentro de la petición, el usuario espera 3 segundos mirando una pantalla congelada.

La solución: una cola. La petición encola la tarea (instantáneo) y responde ya; un worker (proceso aparte) procesa la tarea en segundo plano.

Petición: POST /registro
   → guarda el usuario (rápido)
   → encola "enviar email de bienvenida"   ← instantáneo
   → responde 201 al usuario  ✅ (no espera al email)

Worker (proceso aparte, en bucle):
   → saca tareas de la cola
   → envía el email (tarda lo que tarde, da igual)

🧠 Regla de oro: todo lo lento o que puede fallar y reintentarse va a una cola: enviar emails, procesar imágenes/vídeos, generar PDFs/reportes, llamar a APIs externas, notificaciones. La petición HTTP debe responder rápido; el trabajo pesado, en segundo plano.

Colas por stack

StackHerramientaBackend
LaravelQueues (nativo)Redis / database
NestJSBullMQ (@nestjs/bullmq)Redis
Elysia/BunBullMQRedis
FastAPI/FlaskCelery o ARQ / DramatiqRedis / RabbitMQ
GoAsynq / RiverRedis / PostgreSQL

Ejemplo Laravel (lo más limpio):

php
// Encolar (en el controlador) — instantáneo:
dispatch(new EnviarBienvenida($usuario));

// El Job (la tarea):
class EnviarBienvenida implements ShouldQueue
{
    public function __construct(public Usuario $usuario) {}

    public function handle(): void
    {
        Mail::to($this->usuario->email)->send(new BienvenidaMail());
    }
}
bash
php artisan queue:work        # arranca el worker que procesa la cola

Ejemplo NestJS con BullMQ:

typescript
// Encolar
await this.colaEmail.add('bienvenida', { usuarioId: usuario.id });

// Procesar (worker)
@Processor('email')
export class EmailProcessor extends WorkerHost {
  async process(job: Job) {
    await this.mailer.enviarBienvenida(job.data.usuarioId);
  }
}

💡 Ventaja extra de las colas: reintentos automáticos. Si el envío de email falla (el servidor SMTP estaba caído), la cola lo reintenta solo, con espera creciente. En una petición HTTP normal, ese fallo sería un error para el usuario. Las colas dan resiliencia.


B.5 · Trabajos programados (cron / scheduled)

Tareas que corren a una hora fija: limpiar datos viejos, enviar resúmenes diarios, generar reportes. No es una cola, es un scheduler:

php
// Laravel — routes/console.php
Schedule::command('reportes:diario')->dailyAt('06:00');
Schedule::call(fn() => limpiarSesionesViejas())->hourly();
python
# FastAPI — con APScheduler o Celery Beat
@scheduler.scheduled_job("cron", hour=6)
async def reporte_diario():
    ...

En el servidor, esto se apoya en cron de Linux (cap. 14) o en el scheduler del framework.


B.6 · Pub/Sub y tiempo real (bonus)

Redis también hace publicación/suscripción: un proceso publica un mensaje y todos los suscritos lo reciben. Base de chats, notificaciones en vivo y sincronización entre servidores:

python
# Publicar
await r.publish("notificaciones", json.dumps({"usuario": 42, "msg": "Nuevo pedido"}))

# Suscribirse (en otro proceso)
async for mensaje in pubsub.listen():
    # reenviar por WebSocket al navegador del usuario...

🔗 Esto conecta con el apéndice D (WebSockets): Redis Pub/Sub es cómo varios servidores WebSocket comparten mensajes para que un usuario conectado al servidor A reciba lo que pasa en el servidor B.


B.7 · Estructuras de datos avanzadas: más allá de strings

Redis no es solo SET/GET. Estas estructuras resuelven problemas concretos con una sola estructura nativa, sin lógica extra en tu app:

Sorted Set (ZSET)  →  ranking/leaderboard, cola con prioridad, "más recientes primero"
Stream (XADD)      →  log de eventos con consumer groups (Kafka ligero, ap. S)
HyperLogLog (PF*)  →  contar ÚNICOS (visitantes, IPs) con memoria constante (~12 KB)
bash
# Sorted Set: leaderboard de una tienda gamificada — puntos por usuario, ordenado solo
ZADD ranking 1500 "ana" 2200 "beto" 900 "caro"
ZREVRANGE ranking 0 2 WITHSCORES     # top 3, mayor a menor
ZRANK ranking "ana"                  # posición de "ana" en el ranking

# Stream: log de eventos con grupos de consumidores (varios workers, sin duplicar trabajo)
XADD eventos '*' tipo "pedido.creado" pedido_id 42
XREADGROUP GROUP workers consumidor-1 COUNT 10 STREAMS eventos '>'

# HyperLogLog: "¿cuántos visitantes ÚNICOS tuvo hoy la API?" sin guardar cada IP
PFADD visitantes:hoy "203.0.113.5"
PFADD visitantes:hoy "203.0.113.5"    # repetido: no cuenta dos veces
PFCOUNT visitantes:hoy                # estimación (~0,8% de error), memoria fija

🧠 Cuándo usar cada una: un ZSET gana cuando necesitas orden (ranking, "los 10 más recientes"); un Stream gana cuando necesitas varios consumidores repartiéndose eventos sin perder ninguno (como una cola con historial); HyperLogLog gana cuando solo te importa el conteo de únicos, no la lista exacta — contar 1 millón de IPs únicas con un SET normal gastaría megabytes; con HLL, ~12 KB siempre.

💡 Streams vs BullMQ/colas (B.4): para "quiero que UN worker procese cada tarea una vez", una cola (BullMQ, Celery) sigue siendo más simple. Streams brillan cuando varios servicios distintos necesitan leer el mismo evento (ej. "pedido.creado" lo consumen facturación, inventario y notificaciones a la vez) — el mismo problema que resuelve Kafka (apéndice S), con mucha menos infraestructura para volúmenes moderados.


B.8 · Locks distribuidos: coordinar entre varios servidores

Con varias instancias de tu API corriendo, a veces necesitas que solo una ejecute algo a la vez (ej. "solo un servidor debe correr este cron de reportes, no los 3 a la vez"). Redis lo resuelve con un lock atómico:

python
# Adquirir un lock: SET ... NX (solo si no existe) EX (con expiración, por si el proceso muere)
adquirido = await r.set("lock:reporte-diario", "servidor-1", nx=True, ex=30)
if adquirido:
    try:
        generar_reporte()
    finally:
        await r.delete("lock:reporte-diario")   # libera el lock al terminar

⚠️ Este patrón simple basta para la mayoría de casos (evitar tareas duplicadas, cron en varios servidores). Para lo que de verdad depende de exclusión mutua bajo fallos de red (dinero, stock crítico), el algoritmo Redlock (varios nodos Redis independientes, mayoría-gana) da garantías más fuertes — pero añade complejidad operativa real. Empieza con el SET NX EX simple; sube a Redlock solo si un incidente concreto lo justifica.

🧠 El TTL del lock es tu red de seguridad: si el proceso que adquirió el lock muere sin liberarlo (crash, OOM), el EX lo libera solo pasados los segundos configurados. Sin TTL, un lock "atascado" bloquearía la tarea para siempre.


B.9 · Buenas prácticas

  1. Redis complementa, no reemplaza a tu BD. La "verdad" vive en PostgreSQL.
  2. Todo en Redis debe poder reconstruirse desde la BD (es caché, puede perderse).
  3. Pon expiración (TTL) a casi todo en caché: evita que crezca sin control.
  4. Nombra las claves con prefijos: producto:42, sesion:abc, rate:ip. Ordenado y fácil de depurar.
  5. Colas para todo lo lento (emails, imágenes, PDFs, APIs externas). La petición responde ya.
  6. Aprovecha los reintentos de las colas para tareas que pueden fallar.
  7. Monitoriza la cola: si se acumulan tareas más rápido de lo que se procesan, añade workers.

✅ Ejercicio del apéndice

Sobre una de tus APIs del blog:

1. Añade Redis al compose.yaml (cap. 12).
2. Cachea el listado de artículos con cache-aside y TTL de 60s. Invalida la
   caché cuando se crea/edita un artículo.
3. Implementa rate limiting por IP con INCR + EXPIRE (ej. 30 req/min).
4. Al publicar un artículo, encola una tarea "notificar suscriptores" en vez
   de hacerlo en la petición. Arranca un worker y compruébalo.
5. Programa una tarea diaria que borre borradores de más de 30 días.
6. Mide: ¿cuánto tarda el listado con caché (HIT) vs sin caché (MISS)?
7. Reemplaza el rate limiting fixed-window por uno sliding-window con ZSET y
   demuestra con una prueba el "doble en el borde" que el fixed-window no evita.
💡 Pistas
  • La invalidación del punto 2: borra la clave (DEL articulos:lista) al crear/editar, no intentes "actualizarla" a mano — es más fácil de razonar y menos propenso a bugs.
  • Para medir el punto 6, añade un log con el tiempo transcurrido antes y después de la llamada a Redis — la diferencia entre HIT y MISS suele ser de un orden de magnitud.
  • El worker del punto 4 es un proceso aparte del servidor web (otro comando en tu docker-compose.yml, cap. 12) — no lo mezcles con el proceso que atiende peticiones HTTP.

🧠 Autoevaluación

Redis vive en memoria: es rapidísimo pero volátil — todo lo que guardes ahí debe poder reconstruirse desde la base de datos si se pierde. PostgreSQL es la fuente de verdad duradera; Redis acelera el acceso a datos que ya viven, de verdad, en otro sitio.

TTL corto (acepta unos segundos de desfase, es simple) o invalidar la clave explícitamente en el mismo código que actualiza el producto (preciso, pero hay que acordarse en cada punto de escritura que toque ese dato).

El usuario espera lo que tarde el proveedor de email (2-3 segundos) mirando una pantalla congelada, y si el SMTP falla, la petición entera falla con él. Una cola desacopla: la petición solo encola la tarea (instantáneo) y un worker aparte la procesa, con reintentos automáticos si falla.

Nginx limita a nivel de red/conexión, sin conocer tu lógica de negocio. Redis te permite límites por usuario autenticado, por endpoint específico, o por reglas que dependan de datos de tu aplicación — son capas complementarias, no alternativas.

Un SET guarda cada valor único de verdad, así que su memoria crece con el número de elementos (millones de IPs = megabytes). HyperLogLog da una estimación con ~0,8% de error usando memoria prácticamente constante (~12 KB), sin importar si cuentas mil o mil millones de elementos — el trade-off correcto cuando no necesitas la lista exacta, solo el conteo.

Un lock distribuido con SET clave valor NX EX segundos: solo la primera instancia que llega consigue el SET (por el NX), las otras lo ven ocupado y no ejecutan. El EX es la red de seguridad — si el proceso que tiene el lock muere sin liberarlo, expira solo y no bloquea la tarea para siempre.

El fixed window reinicia el contador en un instante fijo del reloj, lo que permite que un cliente mande el límite completo justo antes de que la ventana expire y otro límite completo justo después — el doble de peticiones permitidas en un margen de segundos. El sliding window (con un ZSET que descarta entradas viejas por timestamp) elimina ese borde: siempre cuenta las peticiones de los últimos N segundos exactos, sin importar cuándo empezó a contar.

Porque la escritura se confirma en Redis antes de persistirse en la base de datos: si Redis se reinicia o pierde datos antes de que el proceso de persistencia en batch corra, ese cambio desaparece sin haber llegado nunca a la fuente de verdad. Solo es aceptable para datos donde perder los últimos segundos no tiene consecuencias reales (contadores, no pedidos ni saldos).


Volver al: README.md · Relacionado: 12-docker.md, 13-nginx-apache.md