StackPractices
beginner Por Mathias Paulenko

Patrón Cache-Aside

Carga datos en el caché bajo demanda desde el almacenamiento principal. Un patrón de caché que da a la aplicación control total sobre qué y cuándo cachear.

Temas: design

Resumen

Cada lectura repetida contra la base de datos cuesta latencia y capacidad de conexión en cada petición. El Patrón Cache-Aside pone a la aplicación a cargo de resolverlo: verifica el caché primero y, ante un miss, obtiene el dato de la base de datos, lo guarda en el caché y lo devuelve. La aplicación es dueña de la lógica de caché, la invalidación y la consistencia — no se delega al proveedor de caché.

Encaja en sistemas donde dominan las lecturas y unos segundos de datos obsoletos son aceptables. En cargas de trabajo con muchas escrituras, o cuando cada lectura exige consistencia fuerte, el caché write-through es mejor opción.

Cuándo usarlo

Usa el Patrón Cache-Aside cuando:

  • Cargas de trabajo de lectura intensiva donde los mismos datos se solicitan con frecuencia
  • La aplicación deba controlar qué se cachea y por cuánto tiempo
  • La invalidación de caché pueda manejarse explícitamente desde la capa de aplicación
  • Quieras una estrategia de caché simple y portable que funcione con cualquier proveedor (Redis, Memcached, en memoria)
  • Ejemplos: perfiles de usuario, catálogos de productos, datos de configuración, datos de referencia

Evítalo cuando dominan las escrituras — el caché rota y casi nunca acierta — o cuando una aplicación pequeña no necesita una capa de caché en absoluto.

Solución

Python

import time
from typing import Any, Callable

class CacheAside:
    def __init__(self, cache: dict, ttl_seconds: float = 60):
        self.cache = cache
        self.ttl = ttl_seconds
        self.timestamps = {}

    def get(self, key: str, loader: Callable[[], Any]) -> Any:
        now = time.time()
        if key in self.cache:
            if now - self.timestamps.get(key, 0) < self.ttl:
                print(f"Cache hit: {key}")
                return self.cache[key]
            else:
                del self.cache[key]

        print(f"Cache miss: {key}")
        value = loader()
        self.cache[key] = value
        self.timestamps[key] = now
        return value

    def invalidate(self, key: str):
        self.cache.pop(key, None)
        self.timestamps.pop(key, None)

# Uso
cache = {}
store = CacheAside(cache)

def load_user(user_id: int) -> dict:
    # Simula llamada a DB
    return {"id": user_id, "name": f"User {user_id}"}

user = store.get("user:1", lambda: load_user(1))
user = store.get("user:1", lambda: load_user(1))  # Cache hit
store.invalidate("user:1")

JavaScript

class CacheAside {
  constructor(cache, ttlMs = 60000) {
    this.cache = cache;
    this.ttl = ttlMs;
    this.timestamps = new Map();
  }

  get(key, loader) {
    const now = Date.now();
    if (this.cache.has(key)) {
      if (now - (this.timestamps.get(key) || 0) < this.ttl) {
        console.log(`Cache hit: ${key}`);
        return this.cache.get(key);
      }
      this.cache.delete(key);
    }

    console.log(`Cache miss: ${key}`);
    const value = loader();
    this.cache.set(key, value);
    this.timestamps.set(key, now);
    return value;
  }

  invalidate(key) {
    this.cache.delete(key);
    this.timestamps.delete(key);
  }
}

// Uso
const cache = new Map();
const store = new CacheAside(cache);

function loadUser(userId) {
  return { id: userId, name: `User ${userId}` };
}

let user = store.get("user:1", () => loadUser(1));
user = store.get("user:1", () => loadUser(1)); // Cache hit
store.invalidate("user:1");

Java

import java.util.*;
import java.util.function.Supplier;

public class CacheAside<K, V> {
    private final Map<K, V> cache;
    private final Map<K, Long> timestamps;
    private final long ttlMs;

    public CacheAside(Map<K, V> cache, long ttlMs) {
        this.cache = cache;
        this.timestamps = new HashMap<>();
        this.ttlMs = ttlMs;
    }

    public V get(K key, Supplier<V> loader) {
        long now = System.currentTimeMillis();
        if (cache.containsKey(key)) {
            if (now - timestamps.getOrDefault(key, 0L) < ttlMs) {
                System.out.println("Cache hit: " + key);
                return cache.get(key);
            }
            cache.remove(key);
        }

        System.out.println("Cache miss: " + key);
        V value = loader.get();
        cache.put(key, value);
        timestamps.put(key, now);
        return value;
    }

    public void invalidate(K key) {
        cache.remove(key);
        timestamps.remove(key);
    }
}

// Uso
CacheAside<String, Map<String, Object>> store =
    new CacheAside<>(new HashMap<>(), 60000);

Map<String, Object> user = store.get("user:1", () ->
    Map.of("id", 1, "name", "User 1")
);

Explicación

El Patrón Cache-Aside sigue este flujo:

  1. Lectura: la aplicación verifica el caché — si hay hit, devuelve; si hay miss, continúa.
  2. Carga: obtiene el dato desde el almacenamiento principal (base de datos, API).
  3. Almacenamiento: escribe el resultado en el caché con un TTL.
  4. Invalidación: en escrituras y actualizaciones, elimina la entrada del caché para que la próxima lectura la refresque.
flowchart diagram: Petición de lectura

La aplicación es el único punto de control — decide cuándo leer del caché, cuándo recurrir al almacenamiento y cuándo invalidar. El coste es que tu código es dueño de la expiración, la invalidación y el manejo de fallos. Dos escrituras muy próximas también pueden dejar una entrada obsoleta hasta la próxima lectura o hasta que el TTL expire, por eso la invalidación en escritura importa más que el propio TTL.

Variantes

VarianteDescripciónCaso de uso
Lazy LoadingEl cache miss dispara la cargaLa más común; evita llenados innecesarios del caché
Write-ThroughLas escrituras actualizan caché y DB a la vezCuando se requiere consistencia fuerte
Refresh-AheadRefresca de forma proactiva antes de que expire el TTLPatrones de acceso predecibles
Multi-NivelL1 (en memoria) + L2 (Redis) + L3 (DB)Aplicaciones de alta escala

Lo que funciona

  • Siempre define un TTL — los datos obsoletos son peores que un cache miss
  • Invalida en las escrituras — elimina la clave del caché tras actualizar la DB para mantener la consistencia
  • Usa un circuit breaker alrededor de las fallas de caché — si Redis está caído, ve directo a la DB
  • Serializa objetos complejos antes de guardarlos (JSON, protobuf) y versiona tus claves para que un cambio de esquema produzca misses en vez de errores
  • Monitorea el cache hit ratio — apunta a >90% en cargas de lectura intensiva
  • Precalienta el caché al arrancar para los datos de referencia críticos

Errores comunes

  • Olvidar invalidar el caché tras escrituras en la DB, sirviendo datos obsoletos
  • Definir un TTL demasiado largo y servir información desactualizada
  • No manejar las fallas del proveedor de caché sin romper (ej. conexión a Redis perdida)
  • Almacenar demasiados datos en caché, causando presión de memoria o expulsión de claves calientes
  • Cache stampede: muchas peticiones golpean un caché frío a la vez y sobrecargan la DB

Solución de problemas

  • Datos obsoletos justo tras una escritura: la actualización llegó a la base de datos pero la entrada vieja del caché sigue viva. Elimina la clave en el mismo camino de código que escribe, y trata el TTL como red de seguridad, no como estrategia.
  • Pico de tráfico tras un deploy o vaciado de caché: un caché frío convierte N misses concurrentes en N consultas idénticas a la base de datos. Usa un lock por clave o single-flight para que solo una petición cargue mientras el resto espera.
  • Redis inalcanzable: cada búsqueda se convierte en un camino lento hacia la base de datos. Envuelve las llamadas al caché con un timeout corto, trata los errores como misses y deja que un circuit breaker deje de golpear un Redis muerto.
  • Hit ratio bajo y estancado: las claves expiran demasiado rápido, el conjunto de trabajo no cabe en memoria o se está cacheando el dato equivocado. Revisa las estadísticas de expulsión antes de añadir más capacidad.
  • Desajuste de serialización tras un deploy: cambió la forma almacenada y la deserialización falla. Versiona las claves de caché (user:v2:1) para que las entradas viejas hagan miss en vez de romperse.

Puntos clave

  • Cache-aside mantiene la lectura simple: consulta el caché, ante un miss lee del almacén y llena el caché.
  • Combínalo con un TTL y con invalidación en escrituras — el TTL solo deja una ventana de datos obsoletos, y la invalidación sola se rompe la primera vez que se pierde un evento.
  • Su punto débil es el stampede de caché frío; planifica un lock o single-flight antes de necesitarlo.
  • Prefiérelo sobre write-through cuando dominan las lecturas y el caché debe seguir siendo opcional — si el caché cae, la aplicación sigue funcionando, solo que más lenta.

Temas avanzados

Escenario: Cache-Aside para una API de productos

// Cache-Aside: leer del caché, ante un miss leer de la DB y llenar el caché
class ProductCache {
  constructor(private redis: RedisClient, private db: Pool, private ttlSec: number = 300) {}

  async getProduct(id: string): Promise<Product | null> {
    // 1. Intentar caché
    const cached = await this.redis.get(`product:${id}`);
    if (cached) return JSON.parse(cached);

    // 2. Cache miss: leer de la DB
    const res = await this.db.query("SELECT * FROM products WHERE id = $1", [id]);
    if (res.rows.length === 0) return null;
    const product = res.rows[0];

    // 3. Llenar el caché
    await this.redis.setex(`product:${id}`, this.ttlSec, JSON.stringify(product));
    return product;
  }

  async updateProduct(id: string, data: Partial<Product>): Promise<void> {
    // 1. Actualizar la DB
    await this.db.query("UPDATE products SET name=$1, price=$2 WHERE id=$3", [data.name, data.price, id]);
    // 2. Invalidar el caché
    await this.redis.del(`product:${id}`);
  }

  async deleteProduct(id: string): Promise<void> {
    await this.db.query("DELETE FROM products WHERE id = $1", [id]);
    await this.redis.del(`product:${id}`);
  }
}

Estrategias de invalidación comparadas:

EstrategiaDescripciónProsContras
TTLExpira tras N segundosSimpleDatos obsoletos hasta expirar
Write-throughEscribe en caché al escribir en DBSiempre consistenteLatencia en escrituras
Write-aroundEscribe solo en DB, carga el caché al leerEscrituras rápidasPrimera lectura lenta
Write-backEscribe en caché, asíncrono a DBEscrituras rápidasRiesgo de pérdida de datos
ExplícitaInvalida en update/deleteControl totalRequiere código manual

El flujo en un paso: las lecturas van caché → miss → base de datos → llenar caché; las escrituras actualizan la base de datos y eliminan la clave del caché en vez de actualizarla. El TTL queda como red de seguridad para los eventos de invalidación que se escapen. Para una implementación completa con Redis y protección anti-stampede, mira la receta de cache-aside con Redis.

Errores comunes en producción

  • Cachear sin TTL confiando solo en la invalidación — un evento perdido sirve datos obsoletos indefinidamente.
  • Actualizar el caché en las escrituras en vez de eliminar la clave — quedan dos caminos de escritura que mantener sincronizados.
  • Tratar el caché como infraestructura obligatoria: si la app lanza error cuando Redis cae, cache-aside se vuelve un lastre en vez de una optimización.
  • Serializar objetos con claves sin versión y luego desplegar un cambio de esquema que rompe la deserialización.
  • Cachear datos por usuario o por tenant bajo claves compartidas — una fuente clásica de fugas de datos.
  • Ignorar el caso del stampede hasta que el primer pico de tráfico tras un deploy tumba la base de datos.
  • Llenar el caché con valores que nadie vuelve a leer — un hit ratio bajo significa que el caché añade latencia en vez de quitarla.

Preguntas frecuentes

¿Cuál es la diferencia entre Cache-Aside y Read-Through?

En Cache-Aside, la aplicación controla la lógica de caché: verificar, cargar, llenar, invalidar. En Read-Through, el proveedor de caché obtiene los datos de la base de datos de forma transparente mediante un loader. Cache-Aside es más explícito y portable; Read-Through oculta la fontanería detrás de la capa de caché.

¿Cómo prevengo los cache stampedes?

Usa un lock por clave para que solo una petición llegue a la base de datos mientras las demás esperan — un lock distribuido en Redis funciona, o reutiliza la promesa en curso en JavaScript (single-flight). La expiración temprana probabilística también ayuda: refresca las claves calientes un poco antes de que acabe el TTL para que los misses nunca lleguen todos a la vez.

¿Debería cachear escrituras (Write-Through) o invalidarlas (Cache-Aside)?

Invalidar en la escritura es más simple y seguro — el caché se autocorrige en la próxima lectura. Write-Through actualiza caché y base de datos a la vez, lo que mantiene las lecturas consistentes pero acopla cada escritura al caché. Úsalo solo cuando las lecturas obsoletas sean inaceptables.

¿Cuándo debería evitar Cache-Aside?

En cargas con muchas escrituras donde las entradas rotan antes de ser releídas, cuando se exige consistencia fuerte en cada lectura, o en aplicaciones pequeñas donde la capa extra añade fontanería sin beneficio medible.

¿Qué TTL debería usar?

Parte de cuánta obsolescencia tolera el dato: segundos para precios o inventario, minutos para perfiles de usuario, horas para datos de referencia. No hay un valor universal — un TTL corto combinado con invalidación en escrituras gana a un TTL largo a secas.

¿Qué pasa si el caché se cae?

Diseña para ello: trata los errores de caché como misses con un timeout corto y deja que la base de datos absorba la carga. Es la principal ventaja frente a Read-Through — la aplicación degrada en vez de fallar. Solo asegúrate de que la base de datos soporte todo el tráfico de lectura sin el caché absorbiéndolo.