---
name: redis-experto
description: >
  Redis: caching patterns (cache-aside, write-through), data structures, pub/sub
  vs streams, rate limiting, distributed locks con Redlock y pipelining. Cargar
  cuando se implemente caching, rate limiting o comunicación en tiempo real.
version: "1.0.0"
herramientasPermitidas: [Read, Write]
evolvable: true  # default para skill estandar
exclusiones:
  - "No cargar para mensajería async entre microservicios con garantías de durabilidad y reintentos — para colas duraderas con dead letter queue usar `kafka-experto` o SQS/Service Bus."
  - "No cargar para almacenamiento de sesiones distribuidas en aplicaciones Angular/Next.js — para manejo de sesiones en el frontend el estado vive en el cliente; Redis aplica al backend."
  - "No cargar para caché de assets estáticos o CDN — para caché de archivos estáticos usar CloudFront, Nginx o similar; Redis es para datos de aplicación."
  - "No cargar para búsqueda de texto completo — Redis tiene módulos de búsqueda (RediSearch) pero para full-text search general cargar `postgresql-experto` con `tsvector` o usar Elasticsearch."
---
# Redis Experto — Caching, Estructuras y Tiempo Real

## Cuándo NO cargar

- La tarea es cola de mensajes con garantías de durabilidad fuerte y DLQ: usar Kafka, SQS o Azure Service Bus.
- La tarea es caché de assets estáticos o CDN: usar CloudFront, Nginx o un CDN dedicado.
- La tarea es full-text search: cargar `postgresql-experto` (tsvector) o Elasticsearch.
- La tarea es base de datos primaria de documentos: Redis no reemplaza a MongoDB o PostgreSQL.

Redis es una estructura de datos en memoria con persistencia. Su velocidad lo
hace ideal para caching, rate limiting, colas ligeras y pub/sub. Este skill
cubre los patrones de caching, las estructuras de datos correctas para cada
caso, y las operaciones atómicas con Lua para garantizar consistencia.

## Cuándo cargar este skill

Invoca `Skill("redis-experto")` cuando:

- Se implemente caching con estrategia cache-aside o write-through
- Se diseñe rate limiting para endpoints o usuarios
- Se implemente distributed lock para evitar race conditions
- Se use pub/sub o Streams para mensajeria
- Se diseñan claves con TTL y namespacing
- Se configure connection pooling o Redis Cluster

## Conceptos clave

### Tres estrategias de caching

Cache-aside (lazy loading): la aplicación lee cache primero; si no existe, lee
la BD y llena el cache. Write-through: al escribir en BD, también se escribe
en cache. Write-behind: se escribe en cache primero, la BD se actualiza
asincrónamente. Cache-aside es el patrón más común y seguro.

### Atomicidad con Lua scripts

Redis es single-threaded para comandos. Un script Lua se ejecuta atómicamente:
ningún otro comando puede interferir entre las instrucciones del script. Usar
para operaciones que requieren "leer y escribir" sin race conditions.

### Pub/Sub vs Streams

Pub/Sub es fire-and-forget: si un consumidor está desconectado, pierde mensajes.
Streams (XADD/XREAD) persisten mensajes, soportan consumer groups y permiten
replay. Usar Streams para eventos importantes; Pub/Sub para notificaciones efímeras.

## Reglas obligatorias

### SIEMPRE definir TTL al crear claves — nunca claves permanentes en produccion

```typescript
// MAL — clave sin TTL que vive para siempre
await redis.set(`usuario:${id}`, JSON.stringify(usuario));

// BIEN — TTL explicito segun la naturaleza del dato
await redis.setex(`usuario:${id}`, 3600, JSON.stringify(usuario)); // 1 hora
// O con la API moderna:
await redis.set(`usuario:${id}`, JSON.stringify(usuario), { EX: 3600 });
```

**Por qué**: Claves sin TTL consumen memoria indefinidamente. En producción,
la memoria de Redis es limitada y cara.

### Namespacing obligatorio en todas las claves

```typescript
// MAL — colisión entre ambientes y módulos
await redis.set('usuario:123', data);
await redis.set('sesion:abc', token);

// BIEN — prefijo de ambiente y modulo
const PREFIJO = `${process.env.NODE_ENV}:miapp`;
await redis.set(`${PREFIJO}:usuario:123`, data, { EX: 3600 });
await redis.set(`${PREFIJO}:sesion:abc`, token, { EX: 86400 });
```

### NUNCA usar KEYS * en produccion

```bash
# MAL — bloquea Redis mientras escanea toda la keyspace
KEYS usuario:*

# BIEN — SCAN con cursor, no bloquea
SCAN 0 MATCH usuario:* COUNT 100
```

En código:
```typescript
// Usar scan iterativo, nunca keys()
for await (const key of redis.scanIterator({ MATCH: 'prod:usuario:*', COUNT: 100 })) {
    // procesar key
}
```

### Distributed Lock con TTL de seguridad — NUNCA lock sin expiracion

```typescript
// MAL — lock sin TTL, si el proceso muere queda bloqueado para siempre
await redis.set('lock:proceso-critico', '1');

// BIEN — lock con TTL (NX = solo si no existe, EX = TTL en segundos)
const adquirido = await redis.set(
    'lock:proceso-critico',
    tokenUnico,            // valor único para verificar propiedad al liberar
    { NX: true, EX: 30 }  // TTL de 30s como safety net
);
if (!adquirido) throw new Error('No se pudo adquirir el lock');
```

## Patrones recomendados

### Cache-aside con helper tipado

```typescript
class CacheService {
    constructor(private readonly redis: Redis) {}

    async obtenerOCargar<T>(
        clave: string,
        ttlSegundos: number,
        cargador: () => Promise<T>,
    ): Promise<T> {
        const cached = await this.redis.get(clave);
        if (cached) return JSON.parse(cached) as T;

        const valor = await cargador();
        await this.redis.setex(clave, ttlSegundos, JSON.stringify(valor));
        return valor;
    }

    async invalidar(patron: string): Promise<void> {
        for await (const clave of this.redis.scanIterator({ MATCH: patron })) {
            await this.redis.del(clave);
        }
    }
}

// Uso
const producto = await cache.obtenerOCargar(
    `prod:producto:${id}`,
    3600,
    () => db.producto.findUnique({ where: { id } }),
);
```

### Rate limiting con Sorted Sets (sliding window)

```typescript
async function verificarRateLimit(
    usuarioId: string,
    limite: number,
    ventanaMs: number,
): Promise<{ permitido: boolean; restantes: number }> {
    const clave = `prod:ratelimit:${usuarioId}`;
    const ahora = Date.now();
    const ventanaInicio = ahora - ventanaMs;

    const [, , conteo] = await redis
        .multi()
        .zremrangebyscore(clave, 0, ventanaInicio)  // limpiar entradas viejas
        .zadd(clave, { score: ahora, value: `${ahora}-${Math.random()}` })
        .zcard(clave)
        .expire(clave, Math.ceil(ventanaMs / 1000))
        .exec();

    const permitido = (conteo as number) <= limite;
    return { permitido, restantes: Math.max(0, limite - (conteo as number)) };
}
```

### Lua script para operación atómica

```typescript
// Decrementar stock solo si hay suficiente — atomico
const luaDecrementarStock = `
    local stock = tonumber(redis.call('GET', KEYS[1]))
    if stock == nil then return -1 end
    if stock < tonumber(ARGV[1]) then return -2 end
    return redis.call('DECRBY', KEYS[1], ARGV[1])
`;

const resultado = await redis.eval(luaDecrementarStock, {
    keys: [`prod:stock:${productoId}`],
    arguments: [cantidad.toString()],
});
// -1: clave no existe, -2: stock insuficiente, >=0: nuevo valor
```

### Streams para cola de eventos con consumer groups

```typescript
// Productor: agregar evento al stream
await redis.xadd('prod:eventos:pedidos', '*', {
    pedidoId: pedido.id,
    tipo:     'CREADO',
    datos:    JSON.stringify(pedido),
});

// Consumidor con consumer group (tolerante a fallos)
await redis.xgroupCreate('prod:eventos:pedidos', 'procesadores', '0', { MKSTREAM: true });

while (true) {
    const mensajes = await redis.xreadgroup(
        'procesadores', 'worker-1',
        [{ key: 'prod:eventos:pedidos', id: '>' }],
        { COUNT: 10, BLOCK: 2000 }
    );
    for (const { id, message } of mensajes?.[0]?.messages ?? []) {
        await procesarEvento(message);
        await redis.xack('prod:eventos:pedidos', 'procesadores', id);
    }
}
```

### Pipelining para operaciones masivas

```typescript
// MAL — round trip por cada operación
for (const id of ids) {
    await redis.get(`prod:usuario:${id}`);
}

// BIEN — pipeline envia todo de una vez
const pipeline = redis.multi();
for (const id of ids) {
    pipeline.get(`prod:usuario:${id}`);
}
const resultados = await pipeline.exec();
```

## Anti-patrones conocidos

### Single Point of Failure — usar Redis Sentinel o Cluster en producción

Un único nodo Redis que falla detiene la aplicación si no hay fallback.
Configurar Redis Sentinel para alta disponibilidad o Redis Cluster para
escalabilidad horizontal.

### Cache sin invalidacion al mutar datos

```typescript
// MAL — actualiza la BD pero no invalida el cache
await db.producto.update({ where: { id }, data: { precio } });
// El cache sigue devolviendo el precio viejo

// BIEN — invalidar el cache inmediatamente después de la mutación
await db.producto.update({ where: { id }, data: { precio } });
await redis.del(`prod:producto:${id}`);
```

### Hash grandes con HGETALL completo

```typescript
// MAL — carga todo el hash aunque solo se necesite un campo
const datos = await redis.hgetall(`prod:usuario:${id}`);
const nombre = datos.nombre;

// BIEN — obtener solo el campo necesario
const nombre = await redis.hget(`prod:usuario:${id}`, 'nombre');
```

## Checklist de verificación

- [ ] Todas las claves tienen TTL definido explícitamente
- [ ] Namespacing con prefijo de ambiente en todas las claves
- [ ] Sin KEYS * — usar SCAN con cursor
- [ ] Distributed locks con TTL de seguridad y valor único para verificar propiedad
- [ ] Operaciones "leer-modificar-escribir" con Lua script o transacciones MULTI/EXEC
- [ ] Pipelining para operaciones masivas
- [ ] Redis Sentinel o Cluster configurado en producción

## Referencias

- [Redis Commands](https://redis.io/docs/latest/commands/)
- [Redis Patterns](https://redis.io/docs/latest/develop/use/patterns/)
- [node-redis](https://github.com/redis/node-redis)

---

## Gotchas / Errores comunes no obvios

**`redis.del(key)` en Node.js `node-redis` v4 no lanza error si la clave no existe — devuelve `0` en lugar de fallar, causando que la invalidación de caché silencie errores de lógica**: un servicio que llama `await redis.del("prod:usuario:123")` después de una mutación asume que la clave existía, pero si la clave ya había expirado o nunca se creó (por un bug en write-through), el `0` pasa desapercibido y el código continúa sin invalidar nada en realidad. Causa: `DEL` en Redis devuelve el número de claves eliminadas; la API de node-redis expone ese número, no un error. Fix: cuando la invalidación es crítica, usar `EXISTS` antes del `DEL` o verificar que el retorno sea `> 0`. Para caché convencional cache-aside, el `0` es aceptable (la clave ya expiró), pero documentar explícitamente la decisión.

**El patrón Redlock con 3 nodos falla en escenarios de split-brain cuando los nodos Redis están en diferentes zonas de disponibilidad con latencia asimétrica**: si el cliente adquiere el lock en 2 de 3 nodos (mayoría), pero el tercer nodo reaparece antes de que expire el TTL del lock, otro cliente puede adquirir el lock en ese nodo tardío y los dos primeros, creando dos titulares del lock simultáneamente. Causa: Redlock asume relojes sincronizados y latencia simétrica entre nodos; en producción cloud con múltiples AZs ambas suposiciones son aproximadas. Fix: usar TTLs de lock generosos (10-30s para operaciones rápidas) y diseñar el trabajo protegido por el lock para ser idempotente — si dos workers ejecutan simultáneamente, el sistema debe tolerarlo o detectarlo y revertir.

**`redis.xreadgroup` con `BLOCK: 2000` bloquea la conexión del pool durante 2 segundos y agota el pool si hay más consumidores concurrentes que conexiones disponibles**: con un pool de 10 conexiones y 8 workers de Streams que bloquean durante 2s esperando mensajes, los requests de la API que llegan durante ese tiempo no encuentran conexiones disponibles y fallan. Causa: las operaciones bloqueantes de Redis (BLPOP, BRPOP, XREAD con BLOCK) mantienen una conexión del pool ocupada durante el período de bloqueo. Fix: usar un pool de conexiones separado exclusivamente para los consumers de Streams, independiente del pool de la API. Configurar el pool de Streams con `max: N_workers + buffer`, donde buffer = 20% para tolerancia.

**`KEYS *` en producción con 1M+ claves congela Redis durante varios segundos porque es una operación O(N) que bloquea el event loop de Redis**: un script de diagnóstico que ejecuta `await redis.keys("prod:session:*")` para contar sesiones activas hace que todas las operaciones del servidor queden en cola durante 3-8 segundos. Causa: Redis es single-threaded para comandos; `KEYS` escanea todo el keyspace sin pausas. Fix: usar siempre `SCAN` con cursor: `for await (const key of redis.scanIterator({ MATCH: "prod:session:*", COUNT: 100 }))` — procesa lotes de 100 claves, cediendo el event loop entre lotes.

*Skill creado con swl:crear-skill el 2026-03-31. Versión 1.0.0.*
