Patrón Caché Distribuido

7 min read

En el post del Patrón Singleton vimos cómo cachear una lista de empleados dentro de una sola instancia de aplicación. Funciona perfecto — hasta que tu aplicación crece y ya no corre en una sola instancia, sino en diez, detrás de un balanceador de carga.

Ahora cada instancia tiene su propio caché en memoria, desincronizado del resto. La instancia #3 puede tener datos de hace cinco minutos mientras la instancia #7 los tiene actualizados.

Ahí es donde entra el caché distribuido: un caché compartido entre todas las instancias, típicamente implementado con Redis.

Resuelve el problema de consistencia entre instancias, pero introduce sus propios desafíos: invalidación, expiración y el temido cache stampede.


El patrón Cache-Aside

El patrón más común para usar un caché distribuido se llama Cache-Aside (Caché al costado). La aplicación es responsable de consultar el caché y de poblarlo — Redis no sabe nada de tu base de datos, solo almacena lo que vos le pedís que guarde.

El flujo es simple:

  1. La aplicación consulta el caché primero.
  2. Si el dato está (cache hit, acierto de caché), lo retorna inmediatamente.
  3. Si el dato no está (cache miss, fallo de caché), consulta la base de datos, guarda el resultado en el caché con un TTL (Time To Live — Tiempo de Vida), y lo retorna.

El problema del Cache Stampede

Imaginá un producto muy popular en tu e-commerce, consultado miles de veces por segundo, cacheado con un TTL de 60 segundos.

Cuando ese TTL expira, en el peor de los casos, miles de requests simultáneos detectan el miss al mismo tiempo y todos golpean la base de datos de forma simultánea para recalcular el mismo dato.

Esto se llama cache stampede (estampida), y puede tumbar una base de datos que normalmente maneja el tráfico sin problema.

La solución: un lock distribuido. Cuando ocurre un miss, solo una instancia obtiene el lock y consulta la base de datos; las demás esperan brevemente y reintentan leer, que para entonces ya fue poblado.


El diagrama


Guía paso a paso

Paso 1 — Definir qué se cachea y por cuánto tiempo
No todo merece caché. Los buenos candidatos son datos de lectura frecuente y escritura poco frecuente: catálogos, perfiles de usuario, configuración. El TTL depende de qué tan “fresco” necesita estar el dato.

Paso 2 — Implementar el patrón cache-aside
Consultar caché → si falla, consultar base de datos → guardar en caché → retornar.

Paso 3 — Proteger contra cache stampede
Usá un lock distribuido (con SETNX en Redis, por ejemplo) para que solo una instancia recalcule el valor cuando expira, mientras las demás esperan.

Paso 4 — Definir la estrategia de invalidación
Cuando el dato cambia en la base de datos, ¿quién borra o actualiza el caché? Las dos estrategias comunes son invalidar explícitamente en el mismo código que hace el UPDATE, o simplemente dejar que el TTL expire naturalmente (aceptando datos ligeramente desactualizados por un tiempo).

Paso 5 — Monitorear el hit ratio
La proporción de hits vs. misses te dice si el caché realmente está ayudando. Un hit ratio bajo puede significar TTL muy corto, o que estás cacheando datos que casi nunca se repiten.


El código

Implementamos Cache-Aside con protección contra cache stampede usando un lock simple en memoria (en producción, este lock sería distribuido vía Redis).


import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;

// --- Entidad de negocio ---

@Getter
@AllArgsConstructor
class Product {
    private final String id;
    private final String name;
    private final double price;
}

// --- Simulación de Redis: caché con TTL ---

class CacheEntry {
    final Product value;
    final long expiresAt;

    CacheEntry(Product value, long ttlMs) {
        this.value = value;
        this.expiresAt = System.currentTimeMillis() + ttlMs;
    }

    boolean isExpired() {
        return System.currentTimeMillis() > expiresAt;
    }
}

class DistributedCache {
    private final ConcurrentHashMap<String, CacheEntry> store = new ConcurrentHashMap<>();

    public Product get(String key) {
        CacheEntry entry = store.get(key);
        if (entry == null || entry.isExpired()) {
            return null;
        }
        return entry.value;
    }

    public void set(String key, Product value, long ttlMs) {
        store.put(key, new CacheEntry(value, ttlMs));
        System.out.println("[Redis] SET " + key + " (TTL " + ttlMs + "ms)");
    }
}

// --- Simulación de la base de datos (consulta costosa) ---

class ProductDatabase {
    public Product findById(String id) throws InterruptedException {
        System.out.println("[DB] Consultando producto " + id + "...");
        Thread.sleep(100); // simula una consulta costosa (~100ms)
        return new Product(id, "Audífonos inalámbricos", 89.99);
    }
}

// --- Servicio con Cache-Aside y protección contra Cache Stampede ---

@AllArgsConstructor
class ProductService {
    private final DistributedCache cache;
    private final ProductDatabase database;
    private final ConcurrentHashMap<String, ReentrantLock> locks = new ConcurrentHashMap<>();

    public Product getProduct(String productId) throws InterruptedException {
        String cacheKey = "product:" + productId;

        // 1. Intentar leer del caché
        Product cached = cache.get(cacheKey);
        if (cached != null) {
            System.out.println("[Cache HIT] " + cacheKey);
            return cached;
        }

        System.out.println("[Cache MISS] " + cacheKey);

        // 2. Cache stampede protection: solo una instancia consulta la BD
        ReentrantLock lock = locks.computeIfAbsent(cacheKey, k -> new ReentrantLock());
        lock.lock();
        try {
            // Doble verificación: quizás otro thread ya llenó el caché mientras esperábamos el lock
            cached = cache.get(cacheKey);
            if (cached != null) {
                System.out.println("[Cache HIT tras esperar el lock] " + cacheKey);
                return cached;
            }

            // 3. Consultar la base de datos
            Product product = database.findById(productId);

            // 4. Guardar en caché con TTL
            cache.set(cacheKey, product, 60_000);

            return product;
        } finally {
            lock.unlock();
        }
    }
}

// --- Main ---

public class DistributedCacheDemo {

    public static void main(String[] args) throws InterruptedException {
        DistributedCache cache = new DistributedCache();
        ProductDatabase database = new ProductDatabase();
        ProductService productService = new ProductService(cache, database);

        System.out.println("=== Primera consulta: cache miss ===");
        productService.getProduct("PROD-42");

        System.out.println("\n=== Segunda consulta: cache hit ===");
        productService.getProduct("PROD-42");

        System.out.println("\n=== Tercera consulta: sigue siendo hit ===");
        productService.getProduct("PROD-42");
    }
}

Output esperado:


=== Primera consulta: cache miss ===
[Cache MISS] product:PROD-42
[DB] Consultando producto PROD-42...
[Redis] SET product:PROD-42 (TTL 60000ms)

=== Segunda consulta: cache hit ===
[Cache HIT] product:PROD-42

=== Tercera consulta: sigue siendo hit ===
[Cache HIT] product:PROD-42

Para tener en cuenta en producción

  • Usá un cliente Redis real, no un mapa en memoria: en producción, Redisson o Lettuce para Java ofrecen locks distribuidos reales (con SETNX y expiración automática) que funcionan entre múltiples instancias, a diferencia del ReentrantLock del ejemplo, que solo sirve dentro de una JVM.
  • TTL con jitter para evitar expiraciones simultáneas: si cacheás mil productos con el mismo TTL exacto, todos expiran al mismo momento y generás mil misses simultáneos. Agregar una variación aleatoria pequeña al TTL de cada entrada distribuye las expiraciones en el tiempo.
  • Considerá el patrón write-through para datos críticos: en lugar de esperar a que expire el TTL, algunos sistemas actualizan el caché en el mismo momento que escriben en la base de datos, garantizando que el caché nunca esté desactualizado (a costo de mayor complejidad).
  • El caché no es tu fuente de verdad: si Redis se cae, tu aplicación debería seguir funcionando (más lenta, consultando directo a la base de datos), no fallar por completo. Diseñá el caché como una optimización, nunca como una dependencia obligatoria.

Conclusión

El caché distribuido resuelve el problema de consistencia entre instancias que tiene el caché local del patrón Singleton, pero trae su propio set de decisiones: TTL, invalidación, y protección contra cache stampede.

Ninguna de ellas es opcional una vez que tu tráfico crece lo suficiente como para necesitar Redis en primer lugar.

La regla general que me ha funcionado: empezá simple con cache-aside y un TTL razonable, y solo agregá el lock distribuido cuando tengas evidencia real de que un endpoint específico sufre cache stampede.

No todo necesita la complejidad completa desde el día uno.

Recordá suscribirte aquí para recibir los próximos posts directamente en tu correo.


Referencias:
Designing Data-Intensive Applications — Martin Kleppmann
Redis Documentation — https://redis.io/docs/latest/develop/use/patterns/

Leave a Reply

Your email address will not be published. Required fields are marked *