Guia Practica de Cache con Redis - Diseno e Implementacion de Acceso Rapido a Datos

2025.12.02

Redis es un almacen de datos en memoria de alta velocidad, ampliamente utilizado para cache, gestion de sesiones, funcionalidades en tiempo real y mas. En este articulo, explicamos de manera practica estrategias efectivas de cache usando Redis.

Fundamentos de Redis

Configuracion de Conexion

// redis.ts
import Redis from 'ioredis';

// Instancia unica
const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD,
  db: 0,
  retryStrategy: (times) => {
    const delay = Math.min(times * 50, 2000);
    return delay;
  },
  maxRetriesPerRequest: 3,
  enableReadyCheck: true,
  lazyConnect: true,
});

// Configuracion de cluster
const cluster = new Redis.Cluster([
  { host: 'redis-node-1', port: 6379 },
  { host: 'redis-node-2', port: 6379 },
  { host: 'redis-node-3', port: 6379 },
], {
  redisOptions: {
    password: process.env.REDIS_PASSWORD,
  },
  scaleReads: 'slave', // Distribuir lecturas a esclavos
  maxRedirections: 16,
});

// Eventos de conexion
redis.on('connect', () => console.log('Redis connected'));
redis.on('error', (err) => console.error('Redis error:', err));
redis.on('close', () => console.log('Redis connection closed'));

export { redis, cluster };

Resumen de Tipos de Datos

Tipo de DatosUso
StringCache, contadores, sesiones
HashAlmacenamiento de objetos, perfiles de usuario
ListColas, timelines, logs
SetTags, valores unicos, relaciones
Sorted SetRankings, scoreboards, timelines
StreamEvent sourcing, mensajeria
JSONObjetos complejos (RedisJSON)

Patrones de Cache

Patron Cache-Aside

// cache-aside.ts
import { redis } from './redis';

interface CacheOptions {
  ttl?: number; // segundos
  prefix?: string;
}

class CacheAside<T> {
  private prefix: string;
  private defaultTtl: number;

  constructor(prefix: string, defaultTtl: number = 3600) {
    this.prefix = prefix;
    this.defaultTtl = defaultTtl;
  }

  private getKey(id: string): string {
    return `${this.prefix}:${id}`;
  }

  async get(id: string): Promise<T | null> {
    const cached = await redis.get(this.getKey(id));
    if (cached) {
      return JSON.parse(cached);
    }
    return null;
  }

  async set(id: string, data: T, ttl?: number): Promise<void> {
    const key = this.getKey(id);
    const serialized = JSON.stringify(data);
    await redis.setex(key, ttl || this.defaultTtl, serialized);
  }

  async getOrFetch(
    id: string,
    fetcher: () => Promise<T | null>,
    ttl?: number
  ): Promise<T | null> {
    // 1. Verificar cache
    const cached = await this.get(id);
    if (cached) {
      return cached;
    }

    // 2. Obtener de fuente de datos
    const data = await fetcher();
    if (data === null) {
      return null;
    }

    // 3. Guardar en cache
    await this.set(id, data, ttl);

    return data;
  }

  async invalidate(id: string): Promise<void> {
    await redis.del(this.getKey(id));
  }

  async invalidatePattern(pattern: string): Promise<void> {
    const keys = await redis.keys(`${this.prefix}:${pattern}`);
    if (keys.length > 0) {
      await redis.del(...keys);
    }
  }
}

// Ejemplo de uso
interface User {
  id: string;
  name: string;
  email: string;
}

const userCache = new CacheAside<User>('user', 3600);

async function getUser(id: string): Promise<User | null> {
  return userCache.getOrFetch(id, async () => {
    // Obtener de base de datos
    const user = await db.user.findUnique({ where: { id } });
    return user;
  });
}

async function updateUser(id: string, data: Partial<User>): Promise<User> {
  // Actualizar base de datos
  const user = await db.user.update({
    where: { id },
    data,
  });

  // Invalidar cache
  await userCache.invalidate(id);

  return user;
}

Patron Write-Through

// write-through.ts

class WriteThrough<T extends { id: string }> {
  private cache: CacheAside<T>;

  constructor(
    prefix: string,
    private repository: Repository<T>,
    ttl: number = 3600
  ) {
    this.cache = new CacheAside<T>(prefix, ttl);
  }

  async get(id: string): Promise<T | null> {
    return this.cache.getOrFetch(id, () => this.repository.findById(id));
  }

  async create(data: Omit<T, 'id'>): Promise<T> {
    // 1. Escribir en base de datos
    const entity = await this.repository.create(data);

    // 2. Escribir en cache simultaneamente
    await this.cache.set(entity.id, entity);

    return entity;
  }

  async update(id: string, data: Partial<T>): Promise<T> {
    // 1. Actualizar base de datos
    const entity = await this.repository.update(id, data);

    // 2. Actualizar cache tambien
    await this.cache.set(id, entity);

    return entity;
  }

  async delete(id: string): Promise<void> {
    // 1. Eliminar de base de datos
    await this.repository.delete(id);

    // 2. Eliminar de cache tambien
    await this.cache.invalidate(id);
  }
}

Patron Write-Behind

// write-behind.ts
import { redis } from './redis';

class WriteBehind<T extends { id: string }> {
  private pendingWrites: Map<string, T> = new Map();
  private flushInterval: NodeJS.Timeout;

  constructor(
    private prefix: string,
    private repository: Repository<T>,
    private batchSize: number = 100,
    private flushIntervalMs: number = 5000
  ) {
    // Escritura por lotes periodica
    this.flushInterval = setInterval(
      () => this.flush(),
      flushIntervalMs
    );
  }

  async write(entity: T): Promise<void> {
    // 1. Escribir en cache inmediatamente
    await redis.setex(
      `${this.prefix}:${entity.id}`,
      3600,
      JSON.stringify(entity)
    );

    // 2. Agregar a cola pendiente
    this.pendingWrites.set(entity.id, entity);

    // Flush si alcanza el tamano de lote
    if (this.pendingWrites.size >= this.batchSize) {
      await this.flush();
    }
  }

  async get(id: string): Promise<T | null> {
    // Si esta en pendiente, retornar eso
    if (this.pendingWrites.has(id)) {
      return this.pendingWrites.get(id)!;
    }

    // Verificar cache
    const cached = await redis.get(`${this.prefix}:${id}`);
    if (cached) {
      return JSON.parse(cached);
    }

    // Obtener de base de datos
    return this.repository.findById(id);
  }

  private async flush(): Promise<void> {
    if (this.pendingWrites.size === 0) return;

    const writes = Array.from(this.pendingWrites.values());
    this.pendingWrites.clear();

    try {
      // Escribir en base de datos por lotes
      await this.repository.upsertMany(writes);
      console.log(`Flushed ${writes.length} writes to database`);
    } catch (error) {
      // En caso de fallo, devolver a pendiente
      for (const entity of writes) {
        this.pendingWrites.set(entity.id, entity);
      }
      console.error('Flush failed:', error);
    }
  }

  async stop(): Promise<void> {
    clearInterval(this.flushInterval);
    await this.flush();
  }
}

Estrategias de TTL

TTL Adaptativo

// adaptive-ttl.ts

interface CacheMetrics {
  hits: number;
  misses: number;
  lastAccess: number;
}

class AdaptiveTTLCache<T> {
  private metrics: Map<string, CacheMetrics> = new Map();
  private readonly minTtl: number;
  private readonly maxTtl: number;
  private readonly baseTtl: number;

  constructor(
    private prefix: string,
    options: { minTtl?: number; maxTtl?: number; baseTtl?: number } = {}
  ) {
    this.minTtl = options.minTtl || 60;      // 1 minuto
    this.maxTtl = options.maxTtl || 86400;   // 24 horas
    this.baseTtl = options.baseTtl || 3600;  // 1 hora
  }

  private calculateTtl(key: string): number {
    const metric = this.metrics.get(key);

    if (!metric) {
      return this.baseTtl;
    }

    const hitRate = metric.hits / (metric.hits + metric.misses + 1);
    const timeSinceLastAccess = Date.now() - metric.lastAccess;

    // Mayor tasa de aciertos = TTL mas largo
    let ttl = this.baseTtl * (1 + hitRate);

    // Mayor frecuencia de acceso = TTL mas largo
    if (timeSinceLastAccess < 60000) { // Dentro de 1 minuto
      ttl *= 1.5;
    }

    return Math.min(Math.max(ttl, this.minTtl), this.maxTtl);
  }

  async get(key: string): Promise<T | null> {
    const fullKey = `${this.prefix}:${key}`;
    const cached = await redis.get(fullKey);

    const metric = this.metrics.get(key) || { hits: 0, misses: 0, lastAccess: 0 };

    if (cached) {
      metric.hits++;
      metric.lastAccess = Date.now();
      this.metrics.set(key, metric);
      return JSON.parse(cached);
    }

    metric.misses++;
    this.metrics.set(key, metric);
    return null;
  }

  async set(key: string, data: T): Promise<void> {
    const fullKey = `${this.prefix}:${key}`;
    const ttl = Math.round(this.calculateTtl(key));

    await redis.setex(fullKey, ttl, JSON.stringify(data));
  }
}

Cache por Niveles

// tiered-cache.ts

class TieredCache<T> {
  private l1: Map<string, { data: T; expiry: number }> = new Map();
  private l1MaxSize: number;
  private l1Ttl: number;

  constructor(
    private prefix: string,
    private l2Ttl: number = 3600,
    l1Options: { maxSize?: number; ttl?: number } = {}
  ) {
    this.l1MaxSize = l1Options.maxSize || 1000;
    this.l1Ttl = l1Options.ttl || 60;
  }

  async get(key: string): Promise<T | null> {
    // Verificar L1 (en memoria)
    const l1Entry = this.l1.get(key);
    if (l1Entry && l1Entry.expiry > Date.now()) {
      return l1Entry.data;
    }

    // Verificar L2 (Redis)
    const l2Data = await redis.get(`${this.prefix}:${key}`);
    if (l2Data) {
      const data = JSON.parse(l2Data);
      // Promover a L1
      this.setL1(key, data);
      return data;
    }

    return null;
  }

  async set(key: string, data: T): Promise<void> {
    // Guardar en L1
    this.setL1(key, data);

    // Guardar en L2
    await redis.setex(
      `${this.prefix}:${key}`,
      this.l2Ttl,
      JSON.stringify(data)
    );
  }

  private setL1(key: string, data: T): void {
    // Eviccion LRU
    if (this.l1.size >= this.l1MaxSize) {
      const oldestKey = this.l1.keys().next().value;
      this.l1.delete(oldestKey);
    }

    this.l1.set(key, {
      data,
      expiry: Date.now() + this.l1Ttl * 1000,
    });
  }

  async invalidate(key: string): Promise<void> {
    this.l1.delete(key);
    await redis.del(`${this.prefix}:${key}`);
  }
}

Bloqueos Distribuidos

Implementacion de Redlock

// distributed-lock.ts
import Redlock from 'redlock';

const redlock = new Redlock([redis], {
  driftFactor: 0.01,
  retryCount: 10,
  retryDelay: 200,
  retryJitter: 200,
  automaticExtensionThreshold: 500,
});

// Ejemplo de uso
async function processOrderWithLock(orderId: string) {
  let lock;

  try {
    // Adquirir bloqueo
    lock = await redlock.acquire([`lock:order:${orderId}`], 30000);

    // Seccion critica
    const order = await db.order.findUnique({ where: { id: orderId } });

    if (order?.status !== 'pending') {
      throw new Error('Order already processed');
    }

    await db.order.update({
      where: { id: orderId },
      data: { status: 'processing' },
    });

    // Ejecutar procesamiento...

    await db.order.update({
      where: { id: orderId },
      data: { status: 'completed' },
    });
  } catch (error) {
    if (error instanceof Redlock.LockError) {
      console.log('Could not acquire lock, order is being processed');
    }
    throw error;
  } finally {
    // Liberar bloqueo
    if (lock) {
      await lock.release();
    }
  }
}

// Implementacion simple de bloqueo
class SimpleLock {
  async acquire(key: string, ttl: number = 30000): Promise<string | null> {
    const token = crypto.randomUUID();
    const result = await redis.set(
      `lock:${key}`,
      token,
      'PX',
      ttl,
      'NX'
    );

    return result === 'OK' ? token : null;
  }

  async release(key: string, token: string): Promise<boolean> {
    const script = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
      else
        return 0
      end
    `;

    const result = await redis.eval(script, 1, `lock:${key}`, token);
    return result === 1;
  }

  async withLock<T>(
    key: string,
    fn: () => Promise<T>,
    ttl: number = 30000
  ): Promise<T> {
    const token = await this.acquire(key, ttl);

    if (!token) {
      throw new Error(`Could not acquire lock for ${key}`);
    }

    try {
      return await fn();
    } finally {
      await this.release(key, token);
    }
  }
}

Gestion de Sesiones

// session.ts
import { randomBytes } from 'crypto';

interface Session {
  userId: string;
  email: string;
  role: string;
  createdAt: number;
  lastAccessedAt: number;
}

class RedisSessionStore {
  private readonly prefix = 'session';
  private readonly ttl = 24 * 60 * 60; // 24 horas

  async create(userId: string, userData: Partial<Session>): Promise<string> {
    const sessionId = randomBytes(32).toString('hex');
    const session: Session = {
      userId,
      email: userData.email || '',
      role: userData.role || 'user',
      createdAt: Date.now(),
      lastAccessedAt: Date.now(),
    };

    const key = `${this.prefix}:${sessionId}`;
    await redis.setex(key, this.ttl, JSON.stringify(session));

    // Gestionar lista de sesiones del usuario
    await redis.sadd(`user_sessions:${userId}`, sessionId);
    await redis.expire(`user_sessions:${userId}`, this.ttl);

    return sessionId;
  }

  async get(sessionId: string): Promise<Session | null> {
    const key = `${this.prefix}:${sessionId}`;
    const data = await redis.get(key);

    if (!data) return null;

    const session: Session = JSON.parse(data);

    // Ventana deslizante: extender TTL en acceso
    session.lastAccessedAt = Date.now();
    await redis.setex(key, this.ttl, JSON.stringify(session));

    return session;
  }

  async destroy(sessionId: string): Promise<void> {
    const session = await this.get(sessionId);
    if (session) {
      await redis.srem(`user_sessions:${session.userId}`, sessionId);
    }
    await redis.del(`${this.prefix}:${sessionId}`);
  }

  async destroyAllUserSessions(userId: string): Promise<void> {
    const sessionIds = await redis.smembers(`user_sessions:${userId}`);

    if (sessionIds.length > 0) {
      const keys = sessionIds.map(id => `${this.prefix}:${id}`);
      await redis.del(...keys);
    }

    await redis.del(`user_sessions:${userId}`);
  }

  async getUserSessionCount(userId: string): Promise<number> {
    return redis.scard(`user_sessions:${userId}`);
  }
}

const sessionStore = new RedisSessionStore();
export { sessionStore };

Limitacion de Tasa

Ventana Fija

// rate-limiter.ts

class FixedWindowRateLimiter {
  constructor(
    private windowSizeSeconds: number,
    private maxRequests: number
  ) {}

  async isAllowed(key: string): Promise<{ allowed: boolean; remaining: number }> {
    const windowKey = `ratelimit:${key}:${Math.floor(Date.now() / 1000 / this.windowSizeSeconds)}`;

    const current = await redis.incr(windowKey);

    if (current === 1) {
      await redis.expire(windowKey, this.windowSizeSeconds);
    }

    const allowed = current <= this.maxRequests;
    const remaining = Math.max(0, this.maxRequests - current);

    return { allowed, remaining };
  }
}

Log de Ventana Deslizante

class SlidingWindowLogRateLimiter {
  constructor(
    private windowSizeMs: number,
    private maxRequests: number
  ) {}

  async isAllowed(key: string): Promise<{ allowed: boolean; remaining: number; retryAfter?: number }> {
    const now = Date.now();
    const windowStart = now - this.windowSizeMs;
    const redisKey = `ratelimit:sliding:${key}`;

    // Ejecutar en transaccion
    const multi = redis.multi();

    // Eliminar entradas antiguas
    multi.zremrangebyscore(redisKey, 0, windowStart);

    // Contar solicitudes en ventana actual
    multi.zcard(redisKey);

    // Obtener resultados
    const results = await multi.exec();
    const currentCount = results![1][1] as number;

    if (currentCount >= this.maxRequests) {
      // Obtener tiempo de solicitud mas antigua para calcular tiempo de reintento
      const oldestRequest = await redis.zrange(redisKey, 0, 0, 'WITHSCORES');
      const retryAfter = oldestRequest.length > 0
        ? Math.ceil((parseInt(oldestRequest[1]) + this.windowSizeMs - now) / 1000)
        : 1;

      return {
        allowed: false,
        remaining: 0,
        retryAfter,
      };
    }

    // Agregar nueva solicitud
    await redis.zadd(redisKey, now, `${now}:${Math.random()}`);
    await redis.expire(redisKey, Math.ceil(this.windowSizeMs / 1000));

    return {
      allowed: true,
      remaining: this.maxRequests - currentCount - 1,
    };
  }
}

Token Bucket

class TokenBucketRateLimiter {
  constructor(
    private bucketSize: number,      // Capacidad maxima del bucket
    private refillRate: number,       // Tokens de recarga por segundo
    private refillIntervalMs: number = 1000
  ) {}

  async isAllowed(key: string, tokensRequired: number = 1): Promise<{
    allowed: boolean;
    tokens: number;
    retryAfter?: number;
  }> {
    const redisKey = `ratelimit:bucket:${key}`;

    // Procesar atomicamente con script Lua
    const script = `
      local bucket_key = KEYS[1]
      local bucket_size = tonumber(ARGV[1])
      local refill_rate = tonumber(ARGV[2])
      local refill_interval = tonumber(ARGV[3])
      local tokens_required = tonumber(ARGV[4])
      local now = tonumber(ARGV[5])

      local bucket = redis.call('HMGET', bucket_key, 'tokens', 'last_refill')
      local tokens = tonumber(bucket[1]) or bucket_size
      local last_refill = tonumber(bucket[2]) or now

      -- Recargar tokens
      local time_passed = now - last_refill
      local refills = math.floor(time_passed / refill_interval)
      tokens = math.min(bucket_size, tokens + refills * refill_rate)
      last_refill = last_refill + refills * refill_interval

      if tokens >= tokens_required then
        tokens = tokens - tokens_required
        redis.call('HMSET', bucket_key, 'tokens', tokens, 'last_refill', last_refill)
        redis.call('EXPIRE', bucket_key, 3600)
        return {1, tokens}
      else
        redis.call('HMSET', bucket_key, 'tokens', tokens, 'last_refill', last_refill)
        redis.call('EXPIRE', bucket_key, 3600)
        local tokens_needed = tokens_required - tokens
        local wait_time = math.ceil(tokens_needed / refill_rate * refill_interval)
        return {0, tokens, wait_time}
      end
    `;

    const result = await redis.eval(
      script,
      1,
      redisKey,
      this.bucketSize,
      this.refillRate,
      this.refillIntervalMs,
      tokensRequired,
      Date.now()
    ) as [number, number, number?];

    return {
      allowed: result[0] === 1,
      tokens: result[1],
      retryAfter: result[2] ? Math.ceil(result[2] / 1000) : undefined,
    };
  }
}

Monitoreo y Metricas

// redis-metrics.ts

class RedisMetrics {
  async getInfo(): Promise<Record<string, string>> {
    const info = await redis.info();
    const lines = info.split('\r\n');
    const result: Record<string, string> = {};

    for (const line of lines) {
      if (line.includes(':')) {
        const [key, value] = line.split(':');
        result[key] = value;
      }
    }

    return result;
  }

  async getKeyCount(): Promise<number> {
    const info = await this.getInfo();
    return parseInt(info['db0']?.split(',')[0]?.split('=')[1] || '0');
  }

  async getMemoryUsage(): Promise<{
    used: number;
    peak: number;
    fragmentation: number;
  }> {
    const info = await this.getInfo();
    return {
      used: parseInt(info['used_memory'] || '0'),
      peak: parseInt(info['used_memory_peak'] || '0'),
      fragmentation: parseFloat(info['mem_fragmentation_ratio'] || '1'),
    };
  }

  async getHitRate(): Promise<number> {
    const info = await this.getInfo();
    const hits = parseInt(info['keyspace_hits'] || '0');
    const misses = parseInt(info['keyspace_misses'] || '0');
    const total = hits + misses;

    return total > 0 ? hits / total : 0;
  }

  async getSlowLogs(count: number = 10): Promise<any[]> {
    return redis.slowlog('GET', count);
  }
}

const redisMetrics = new RedisMetrics();
export { redisMetrics };

Resumen

Redis es una herramienta poderosa para lograr acceso rapido a datos.

Seleccion de Estrategia de Cache

PatronCaso de Uso
Cache-AsideMuchas lecturas, pocas actualizaciones
Write-ThroughPrioridad en consistencia
Write-BehindPrioridad en rendimiento de escritura

Mejores Practicas

  1. Diseno apropiado de TTL: Configuracion segun caracteristicas de datos
  2. Diseno de claves: Espacios de nombres y versionado
  3. Pool de conexiones: Reutilizacion de conexiones
  4. Monitoreo: Tasa de aciertos y uso de memoria
  5. Failover: Redis Cluster/Sentinel

Utilizando Redis apropiadamente, puedes mejorar significativamente el rendimiento de tu aplicacion.

Enlaces de Referencia

← Volver a la lista