StackPractices
intermediate Por Mathias Paulenko

Caché y Memoización en Python, JavaScript y Java

Cómo cachear computaciones costosas y respuestas de API usando caches en memoria, LRU, TTL y distribuidos en Python, JavaScript y Java.

Overview

flowchart diagram: Request

El caching es una de las formas más baratas de acelerar trabajo repetido. Calculás algo una vez, guardás el resultado y servís el siguiente request sin volver a hacer el cálculo. La memoización es simplemente caching aplicado a los valores de retorno de una función, indexados por los argumentos que recibió. El catch es más complejidad: datos stale, invalidación, dolores de cabeza de consistencia — el tipo de cosa que te muerde en sistemas distribuidos.

Recurrí a caching en proyectos donde una sola query de base de datos tardaba 800ms y se ejecutaba 200 veces por segundo. Ponerle un cache de 60 segundos de TTL adelante bajó el response time promedio a 2ms. Ese es el tipo de win que te da el caching — pero solo si estás encima de la invalidación. Para un deep dive del cache-aside pattern con Redis, mirá nuestra recipe de Redis cache-aside. Si estás viendo setups multi-level, nuestra recipe de multi-level cache L1/L2 recorre la arquitectura en detalle.

Cuándo Usar

  • Una query de base de datos o llamada a API costosa se repite una y otra vez.
  • Una función hace cálculos matemáticos o estadísticos pesados que no querés repetir.
  • Los datos cambian poco, como configuración o datos de referencia.
  • La latencia es un problema y tu sistema hace muchos más reads que writes.
  • Querés aliviar la carga de un servicio downstream.

Cuándo NO Usar

  • Los datos subyacentes cambian más rápido de lo que podés invalidar la caché.
  • Se requiere consistencia fuerte y no se toleran ni lecturas stale breves.
  • El working set es más grande que la memoria de caché que tenés, y no hay política de evicción.
  • No mediste el cuello de botella. Cacheá solo después de perfilar.

Solución

Python

from functools import lru_cache
from cachetools import TTLCache

# Memoización LRU built-in
@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))  # Instantáneo, cacheado

# Caché TTL con expiración
api_cache = TTLCache(maxsize=100, ttl=300)  # 5 minutos

def fetch_user(user_id):
    if user_id in api_cache:
        return api_cache[user_id]
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    api_cache[user_id] = user
    return user

JavaScript

// Memoización simple
function memoize(fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

const fib = memoize((n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)));
console.log(fib(100)); // Instantáneo

// Caché LRU con límite de tamaño
class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.cache = new Map();
  }
  get(key) {
    if (!this.cache.has(key)) return undefined;
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }
  set(key, value) {
    if (this.cache.has(key)) this.cache.delete(key);
    else if (this.cache.size >= this.capacity) {
      const first = this.cache.keys().next().value;
      this.cache.delete(first);
    }
    this.cache.set(key, value);
  }
}

Java con Caffeine

import com.github.benmanes.caffeine.cache.*;

Cache<String, User> userCache = Caffeine.newBuilder()
    .maximumSize(100)
    .expireAfterWrite(Duration.ofMinutes(5))
    .build();

// Obtener o computar
User user = userCache.get(userId, id -> db.findById(id));

// Put manual
userCache.put(userId, updatedUser);

// Invalidar
userCache.invalidate(userId);

Redis cache-aside

import redis
import json

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

def get_user(user_id):
    cached = r.get(f"user:{user_id}")
    if cached:
        return json.loads(cached)
    user = db.find(user_id)
    r.setex(f"user:{user_id}", 300, json.dumps(user))
    return user

Prevenir cache stampede

Un cache stampede ocurre cuando un montón de requests golpean una clave ausente o expirada al mismo tiempo y todos hacen fetch desde la source. El fix: dejá que solo un request haga fetch, y que los demás esperen.

Python con per-key lock:

import threading
import time

_locks = {}
_locks_guard = threading.Lock()

def get_with_lock(key, fetch_fn, ttl=300):
    cached = r.get(key)
    if cached:
        return json.loads(cached)
    with _locks_guard:
        if key not in _locks:
            _locks[key] = threading.Lock()
    with _locks[key]:
        # Double-check después de adquirir el lock
        cached = r.get(key)
        if cached:
            return json.loads(cached)
        value = fetch_fn()
        r.setex(key, ttl, json.dumps(value))
        return value

JavaScript con single-flight basado en promises:

const inflight = new Map();

async function getWithSingleFlight(key, fetchFn, ttlSeconds = 300) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  if (inflight.has(key)) return inflight.get(key);

  const promise = fetchFn().then((value) => {
    redis.setex(key, ttlSeconds, JSON.stringify(value));
    inflight.delete(key);
    return value;
  });
  inflight.set(key, promise);
  return promise;
}

Vi stampedes tirar abajo una base de datos durante un pico de tráfico. Single-flight o per-key locking es un seguro barato — agregalo desde el inicio si esperás concurrencia.

Multi-level cache (L1 en memoria + L2 Redis)

Un multi-level cache mantiene un cache chico en memoria (L1) adelante de un cache Redis compartido (L2). L1 sirve las claves más calientes en menos de un milisegundo; L2 levanta todo lo demás y se mantiene consistente across instancias.

from cachetools import TTLCache

l1 = TTLCache(maxsize=100, ttl=30)  # 30s, por instancia
l2 = redis.Redis(host="localhost", port=6379, decode_responses=True)

def get_user(user_id):
    # L1 hit
    if user_id in l1:
        return l1[user_id]
    # L2 hit
    cached = l2.get(f"user:{user_id}")
    if cached:
        user = json.loads(cached)
        l1[user_id] = user
        return user
    # Miss — fetch desde DB
    user = db.find(user_id)
    l2.setex(f"user:{user_id}", 300, json.dumps(user))
    l1[user_id] = user
    return user

El trade-off es complejidad: ahora tenés dos caches para invalidar. Usá pub/sub para propagar invalidaciones de L1 across instancias, o mantené los TTLs de L1 cortos enough para que el staleness esté acotado. Para la arquitectura completa, mirá nuestra recipe de multi-level cache L1/L2.

Comparación de librerías

LenguajeLibreríaScopeTTLLRUDistribuido
Pythonfunctools.lru_cacheMemoizaciónNoNo
PythoncachetoolsEn memoriaNo
Pythonredis-pyDistribuidoNo
JavaScriptlru-cache (npm)En memoriaNo
JavaScriptnode-redisDistribuidoNo
JavaCaffeineEn memoriaNo
JavaSpring Cache + RedisDistribuido

Recurro a functools.lru_cache para memoización pura en Python, cachetools cuando necesito TTL, y Redis en cuanto necesito compartir estado de cache across procesos. En JavaScript, lru-cache (npm) es lo que uso — es rápido, bien testeado y maneja tanto TTL como LRU. En Java, Caffeine es la mejor opción in-memory que probé; Spring Cache abstrae el provider así podés swap Caffeine por Redis sin tocar el código de negocio.

Explicación

Una caché se ubica entre el llamador y la fuente de datos costosa. En un hit, devuelve el valor guardado. En un miss, obtiene, almacena y devuelve el valor. El TTL limita la antigüedad, el tamaño máximo dispara la evicción y la invalidación elimina entradas cuando los datos subyacentes cambian.

Variantes

EstrategiaCuándo usarCompromiso
TTLLos datos cambian predeciblementePuede servir datos stale brevemente
Write-throughLa consistencia es críticaWrites más lentos, reads más simples
Write-behindAlto throughput de escrituraRiesgo de pérdida de datos ante crash
Cache-asideFlexibilidad, read-heavyLa aplicación maneja la lógica de caché
Evicción (LRU/LFU)Restricciones de memoriaPuede evictar datos calientes prematuramente

Buenas Prácticas

  • Cacheá los datos más costosos y los más frecuentes, no todos los valores.
  • Definí TTLs con criterio. Muy cortos hacen inútil la caché; muy largos sirven datos stale.
  • Monitoreá hit rates. Una caché con menos del 80% suele no valer la pena.
  • Manejá fallos con elegancia. Si Redis cae, caé en la base de datos.
  • Versioná las claves o incluí la versión de la app para evitar datos stale tras deploys.
  • Invalidá proactivamente cuando los datos subyacentes cambian, en lugar de esperar al TTL.

Errores Comunes

  • Cachear datos que cambian muy frecuentemente o raramente se piden.
  • No manejar cache stampede cuando expira una clave popular.
  • Guardar caches sin límite que crecen hasta comerse toda la memoria.
  • Ignorar la consistencia de caché en sistemas distribuidos.
  • Olvidar invalidar la caché después de escrituras.

See Also

  • Documentación de Redis — docs oficiales de Redis, llenos de estructuras de datos, comandos y patrones de caching.
  • Docs de Caffeine — wiki de Caffeine con configuración, políticas de evicción y benchmarks de performance.
  • Docs de cachetools — la referencia de la librería de caching de Python, con TTLCache, LRUCache y LFUCache.
  • lru-cache (npm) — cache LRU para JavaScript con soporte de TTL, la opción standard para caching in-memory en Node.js.
  • Recipe de Java Caffeine — nuestro deep dive sobre configuración de Caffeine para apps Java.
  • Recipe de Node.js in-memory LRU — nuestra guía de caching LRU en Node.js con el package lru-cache.

Preguntas frecuentes

¿Qué es el cache stampede y qué lo frena?

Ocurre cuando muchos requests golpean una clave ausente al mismo tiempo. Usá locking, semáforos por clave o expiración temprana probabilística para reducir la carga en la fuente.

¿Cuándo uso Redis en vez de una caché en memoria?

Usá Redis cuando necesitás una caché compartida entre instancias, persistencia o estructuras de datos fancy. Las cachés en memoria son más rápidas, pero viven en un solo proceso — en cuanto escalás, necesitás Redis.

¿Debería cachear respuestas de API?

Sí, si los datos son cacheables y el endpoint es read-heavy. Usá el header Cache-Control para decirles a clientes y CDNs que la respuesta se puede cachear.

¿Cuándo conviene LRU sobre LFU para evicción?

LRU elimina el menos recientemente usado y funciona bien cuando hay localidad temporal. LFU elimina el menos frecuentemente usado y funciona mejor cuando un pequeño set de claves se accede intensivamente.

¿Cómo mantengo la caché consistente entre servicios?

Usá TTLs cortos, pub/sub de invalidación o write-through. Si necesitás consistencia fuerte, la caché probablemente sea la herramienta equivocada.