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
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
| Lenguaje | Librería | Scope | TTL | LRU | Distribuido |
|---|---|---|---|---|---|
| Python | functools.lru_cache | Memoización | No | Sí | No |
| Python | cachetools | En memoria | Sí | Sí | No |
| Python | redis-py | Distribuido | Sí | No | Sí |
| JavaScript | lru-cache (npm) | En memoria | Sí | Sí | No |
| JavaScript | node-redis | Distribuido | Sí | No | Sí |
| Java | Caffeine | En memoria | Sí | Sí | No |
| Java | Spring Cache + Redis | Distribuido | Sí | Sí | Sí |
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
| Estrategia | Cuándo usar | Compromiso |
|---|---|---|
| TTL | Los datos cambian predeciblemente | Puede servir datos stale brevemente |
| Write-through | La consistencia es crítica | Writes más lentos, reads más simples |
| Write-behind | Alto throughput de escritura | Riesgo de pérdida de datos ante crash |
| Cache-aside | Flexibilidad, read-heavy | La aplicación maneja la lógica de caché |
| Evicción (LRU/LFU) | Restricciones de memoria | Puede 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.
Recursos Relacionados
Implementar el patron Cache-Aside con Redis
Usa el patron cache-aside para leer y escribir datos a traves de Redis, manejando cache misses, lecturas obsoletas e invalidacion write-through
RecipeImplementar una cache LRU en Node.js
Construye una cache least-recently-used en Node.js con operaciones get y set O(1) usando un Map y lista doblemente enlazada
RecipeConfigurar Caffeine Cache en Java con Politicas de Eviction
Configura Caffeine cache en una aplicacion Java con politicas de eviction por tamano, tiempo y peso para caching local de alto rendimiento.
RecipeCache de resultados de funciones con Redis y TTL en Python
Construye un decorador de Python que cachea resultados de funciones en Redis con TTL configurable, generacion de claves e invalidacion de cache
RecipeCache multi-nivel con L1 en memoria y L2 en Redis
Implementa una cache de dos niveles combinando L1 en memoria y L2 en Redis para lecturas de baja latencia con consistencia entre instancias
RecipeGeneración de UUID en Python, JavaScript y Java
Generá identificadores únicos universales (UUIDs) para claves de base de datos, tokens de sesión y nombrado de recursos en Python, JavaScript y Java.