Patrones efectivos para cachés concurrentes en Java: computeIfAbsent, placeholder futures y Caffeine

java Patrones efectivos para cachés concurrentes en Java: computeIfAbsent, placeholder futures y Caffeine

Patrones efectivos para cachés concurrentes en Java

En aplicaciones concurrentes es común necesitar una caché local que evite cargas duplicadas y sea segura bajo concurrencia. Aquí veremos patrones prácticos (desde lo más básico hasta soluciones de producción) con ejemplos de código, ventajas/inconvenientes y recomendaciones operativas.

El problema típico

Si varios hilos piden el mismo recurso simultáneamente, queremos que solo uno cargue el dato y el resto espere o reutilice el resultado. Implementaciones ingenuas causan contención, cargas duplicadas o inconsistencias.

1) Implementación ingenua con synchronizedMap (mala idea)

private final Map cache = Collections.synchronizedMap(new HashMap<>());

public Value get(String key) {
    Value v = cache.get(key);
    if (v == null) {
        v = loadFromDb(key); // llamada lenta
        cache.put(key, v);
    }
    return v;
}

Problemas: si loadFromDb es lento, varios hilos pueden entrar en la carga concurrentemente entre la comprobación y la inserción. Incluso si sincronizas todo, pierdes concurrencia y escalabilidad.

2) ConcurrentHashMap + computeIfAbsent (sencillo y correcto en muchos casos)

private final ConcurrentHashMap cache = new ConcurrentHashMap<>();

public Value get(String key) {
    return cache.computeIfAbsent(key, k -> loadFromDb(k));
}

Ventajas: evita cargas duplicadas por clave y es lock-free a nivel global. Inconvenientes prácticos:

  • La función de mapeo se ejecuta con una estructura interna que bloquea la "bin" de la tabla: si la función bloquea mucho, otros hilos que intenten la misma clave quedan bloqueados.
  • Si la función lanza una excepción, no se registra el valor y el comportamiento debe manejarse explícitamente.
  • No hay políticas de expiración/evicción integradas.

3) Placeholder pattern con CompletableFuture — evita bloqueos duplicados y maneja fallos

En vez de almacenar el valor final, almacena un placeholder (CompletableFuture). Así solo el primer hilo dispara la carga y el resto espera el resultado sin volver a cargar.

private final ConcurrentHashMap> cache = new ConcurrentHashMap<>();

public Value get(String key) {
    while (true) {
        CompletableFuture future = cache.get(key);
        if (future == null) {
            CompletableFuture newFuture = new CompletableFuture<>();
            future = cache.putIfAbsent(key, newFuture);
            if (future == null) { // yo soy el que debe cargar
                try {
                    Value value = loadFromDb(key);
                    newFuture.complete(value);
                    return value;
                } catch (Throwable t) {
                    // importante: limpiar la entrada para evitar cache poisoning
                    cache.remove(key, newFuture);
                    newFuture.completeExceptionally(t);
                    throw t;
                }
            }
        }
        try {
            return future.get(); // espera el resultado cargado por otro hilo
        } catch (ExecutionException ee) {
            // si el futuro completó excepcionalmente, borrar para permitir nuevos intentos
            cache.remove(key, future);
            throw new RuntimeException(ee.getCause());
        } catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(ie);
        }
    }
}

Variantes más simples usan computeIfAbsent con CompletableFuture.supplyAsync, pero cuida el manejo de excepciones y el executor.

4) Evitar bloquear dentro de computeIfAbsent

No pongas llamadas largas o bloqueantes en la función de mapeo. Si necesitas IO, usa el patrón de placeholder (futures) o un cache asíncrono. Ejemplo de peligro:

// PELIGRO: si loadFromDb bloquea, podrás afectar concurrencia
cache.computeIfAbsent(key, k -> loadFromDb(k));

5) Usa Caffeine para producción

Caffeine ofrece LoadingCache y AsyncLoadingCache, expiración, límite de tamaño y estadísticas. Es la alternativa recomendada antes de inventar tu propia caché.

// Sync loading cache
LoadingCache cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(10))
    .recordStats()
    .build(k -> loadFromDb(k));

Value v = cache.get(key);

// Async cache (mejor si loadFromDb es IO-bound)
AsyncLoadingCache async = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(10))
    .buildAsync((k, executor) -> CompletableFuture.supplyAsync(() -> loadFromDb(k), executor));

Value v2 = async.get(key).get();

Ventajas: gestión de memoria, TTL, políticas de eviction, métricas y refresh asíncrono. Usa refreshAfterWrite cuando puedas servir datos ligeramente obsoletos y refrescar en segundo plano.

6) Pitfalls y recomendaciones operativas

  • No almacenes datos sensibles sin cifrado o controles apropiados (evita cache de secretos crudos).
  • Define maximumSize y políticas de expiración; los caches sin límites provocan OOM.
  • Mide: activa recordStats() en Caffeine y monitorea hit/miss/evictions.
  • Gestiona fallos: borra entradas que completan excepcionalmente para evitar cache poisoning.
  • Si necesitas coherencia entre instancias, combina una caché local (L1) con un respaldo distribuido (Redis, Hazelcast) y una estrategia de invalidación o short TTL.

7) Microbenchmark y pruebas de carga

Usa JMH para microbenchmark de latencia bajo concurrencia. En pruebas de integración simula fallos en el loader para verificar que las entradas fallidas no queden permanentemente en la caché.

Código mínimo de ejemplo (placeholder + limpieza en fallo)

private final ConcurrentHashMap> cache = new ConcurrentHashMap<>();

public CompletableFuture getAsync(String key) {
    return cache.computeIfAbsent(key, k ->
        CompletableFuture.supplyAsync(() -> loadFromDb(k))
                 .whenComplete((v, t) -> {
                     if (t != null) {
                         // limpiar solo si la entrada sigue siendo este future
                         cache.remove(k);
                     }
                 })
    );
}

Este patrón mantiene una sola carga concurrente, expone API asíncrona y asegura que fallos no contaminen la caché.

Siguiente paso práctico: si tu aplicación es distribuida y necesitas coherencia, integra una capa L1 con Caffeine y un backing store (Redis) con invalidaciones via pub/sub o TTLs cortos; para cargas IO-bound opta por AsyncLoadingCache y mide el comportamiento en producción.

Consejo de seguridad: nunca caches tokens o credenciales sin cifrado y controles de acceso; valida el cumplimiento cuando la caché reside en nodos multitenant.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión