Referencia Detallada de Caching a Nivel Aplicación
Implementar caches en memoria, distribuidos e hibridos en la capa de aplicacion. Cubre caches LRU, TTL, estrategias multi-tier, sizing, thread safety y patrones de produccion para Python, Java y Node.js.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Introducción
El caching a nivel aplicacion se ubica entre tu logica de negocio y tu base de datos. Almacena datos frecuentemente accedidos en memoria o en un store distribuido rapido, reduciendo la carga de base de datos y los tiempos de respuesta. A diferencia del caching CDN (que cachea respuestas HTTP) o el caching de base de datos (que cachea resultados de queries), el caching a nivel aplicacion te da control granular sobre que se cachea, por cuanto tiempo, y como se invalida. Esta guia recorre caches en memoria, caches distribuidos, estrategias hibridas multi-tier, y patrones de produccion.
Tipos de Cache en la Capa Aplicación
Tipo Ubicacion Velocidad Capacidad Compartido?
──────────────────────────────────────────────────────────────────────
En Memoria Memoria proceso ~0.01ms Limitada No (por instancia)
Distribuido Redis/Memcached ~0.5ms Grande Si (todas instancias)
Hibrido Memoria + Dist. ~0.01ms Grande Si (eventualmente)
Caches en Memoria
Los caches en memoria almacenan datos en el proceso de la aplicacion. Son la opcion mas rapida (acceso sub-microsegundo) pero estan limitados por la RAM disponible y no se comparten entre instancias.
Cache LRU en Python
from functools import lru_cache
@lru_cache(maxsize=1024)
def get_user(user_id: int) -> dict:
return db.users.find_by_id(user_id)
El decorador lru_cache cachea hasta 1024 resultados. Cuando el cache esta lleno, la entrada least recently used es evicted. Esto es simple pero tiene limitaciones: sin TTL, sin garantias de thread safety en todos los casos, y sin forma de inspeccionar o manejar el cache.
Cache LRU Custom con TTL
import time
from collections import OrderedDict
import threading
class LRUCache:
def __init__(self, maxsize: int = 1024, ttl: int = 3600):
self.maxsize = maxsize
self.ttl = ttl
self._cache: OrderedDict = OrderedDict()
self._lock = threading.RLock()
def get(self, key: str) -> object | None:
with self._lock:
if key not in self._cache:
return None
value, expires_at = self._cache[key]
if time.time() > expires_at:
del self._cache[key]
return None
self._cache.move_to_end(key)
return value
def set(self, key: str, value: object) -> None:
with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
self._cache[key] = (value, time.time() + self.ttl)
if len(self._cache) > self.maxsize:
self._cache.popitem(last=False)
def delete(self, key: str) -> None:
with self._lock:
self._cache.pop(key, None)
def clear(self) -> None:
with self._lock:
self._cache.clear()
cache = LRUCache(maxsize=1024, ttl=3600)
Cache en Memoria en Node.js
const { LRUCache } = require("lru-cache");
const cache = new LRUCache({
max: 1024,
ttl: 3600 * 1000, // 1 hora en ms
});
function getUser(userId) {
const cached = cache.get(`user:${userId}`);
if (cached) return cached;
const user = db.users.findById(userId);
if (user) cache.set(`user:${userId}`, user);
return user;
}
Cache en Memoria en Java (Caffeine)
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Cache;
import java.util.concurrent.TimeUnit;
Cache<String, User> userCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(1, TimeUnit.HOURS)
.recordStats()
.build();
public User getUser(Long userId) {
return userCache.get("user:" + userId, key -> {
return db.users.findById(userId);
});
}
Cuándo Usar Caches en Memoria
-
For alternatives, see Complete Guide to Cache Invalidation.
-
Datos que son lo suficientemente pequenos para caber en memoria de proceso
-
Datos que cambian infrecuentemente (configuraciones, datos de referencia)
-
Datos que son especificos de una sola instancia (no compartidos)
-
Escenarios donde se requiere acceso sub-milisegundo
Cuándo NO Usar Caches en Memoria
- Datos que deben ser consistentes across todas las instancias
- Datos que son demasiado grandes para la memoria de proceso
- Datos que deben sobrevivir restarts de proceso
- Deployments multi-instancia donde el cache warming es costoso
Caches Distribuidos
Los caches distribuidos almacenan datos en un proceso separado (Redis, Memcached) que todas las instancias de aplicacion comparten. Son mas lentos que los caches en memoria pero proporcionan consistencia y mayor capacidad.
Redis como Cache Distribuido
import redis
import json
r = redis.Redis(host="localhost", port=6379, db=0)
def get_user(user_id: int) -> dict | None:
cache_key = f"user:{user_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
user = db.users.find_by_id(user_id)
if user:
r.setex(cache_key, 3600, json.dumps(user))
return user
Memcached como Cache Distribuido
import memcache
mc = memcache.Client(["localhost:11211"], debug=0)
def get_user(user_id: int) -> dict | None:
cached = mc.get(f"user:{user_id}")
if cached:
return cached
user = db.users.find_by_id(user_id)
if user:
mc.set(f"user:{user_id}", user, time=3600)
return user
Redis vs Memcached
| Feature | Redis | Memcached |
|---|---|---|
| Estructuras de datos | Strings, hashes, lists, sets, sorted sets | Solo strings |
| Persistencia | RDB, AOF | Ninguna |
| Clustering | Built-in | Sharding client-side |
| Pub/Sub | Si | No |
| Eviction | LRU, LFU, TTL, random | Solo LRU |
| Multi-threaded | No (single-threaded) | Si |
| Tamano max valor | 512MB | 1MB |
Usa Redis cuando necesitas estructuras de datos, persistencia, o pub/sub. Usa Memcached para caching simple key-value de alto throughput.
Caching Hibrido Multi-Tier
Combina caches en memoria y distribuidos para lo mejor de ambos: velocidad en memoria para hot data, cache distribuido para datos compartidos.
Cache Two-Tier (L1: En Memoria, L2: Redis)
import redis
import json
import time
from collections import OrderedDict
import threading
class TwoTierCache:
def __init__(self, redis_client, l1_maxsize: int = 1024, l1_ttl: int = 60, l2_ttl: int = 3600):
self.redis = redis_client
self.l1_ttl = l1_ttl
self.l2_ttl = l2_ttl
self._l1: OrderedDict = OrderedDict()
self._l1_maxsize = l1_maxsize
self._lock = threading.RLock()
def get(self, key: str) -> object | None:
# L1: en memoria
with self._lock:
if key in self._l1:
value, expires_at = self._l1[key]
if time.time() <= expires_at:
self._l1.move_to_end(key)
return value
else:
del self._l1[key]
# L2: Redis
cached = self.redis.get(key)
if cached:
value = json.loads(cached)
self._set_l1(key, value)
return value
return None
def set(self, key: str, value: object) -> None:
self._set_l1(key, value)
self.redis.setex(key, self.l2_ttl, json.dumps(value))
def _set_l1(self, key: str, value: object) -> None:
with self._lock:
if key in self._l1:
self._l1.move_to_end(key)
self._l1[key] = (value, time.time() + self.l1_ttl)
if len(self._l1) > self._l1_maxsize:
self._l1.popitem(last=False)
def delete(self, key: str) -> None:
with self._lock:
self._l1.pop(key, None)
self.redis.delete(key)
cache = TwoTierCache(r, l1_maxsize=1024, l1_ttl=60, l2_ttl=3600)
Cómo Funciona el Caching Two-Tier
- Read: Checkea L1 (en memoria). Si hit, retorna. Si miss, checkea L2 (Redis). Si hit, puebla L1 y retorna. Si miss, fetchea de base de datos, puebla ambos L1 y L2.
- Write: Escribe a ambos L1 y L2.
- Delete: Elimina de ambos L1 y L2.
- L1 TTL es mas corto que L2 TTL: L1 expira mas rapido, asi que revalida contra L2. L2 expira mas lento, asi que revalida contra la base de datos.
Prevención de Cache Stampede en Multi-Tier
import threading
def get_with_stampede_protection(key: str, loader: callable) -> object:
# Checkear cache
cached = cache.get(key)
if cached is not None:
return cached
# Adquirir lock
lock_key = f"lock:{key}"
acquired = r.set(lock_key, "1", nx=True, ex=10)
if acquired:
try:
# Double-check despues del lock
cached = cache.get(key)
if cached is not None:
return cached
# Cargar de base de datos
value = loader()
cache.set(key, value)
return value
finally:
r.delete(lock_key)
else:
# Esperar y reintentar
time.sleep(0.05)
return get_with_stampede_protection(key, loader)
Sizing de Cache
Estimar el Tamaño del Cache
import sys
def estimate_cache_size(avg_value_bytes: int, num_entries: int) -> int:
overhead_per_entry = 64 # Overhead aproximado por entrada en Python dict
total_bytes = (avg_value_bytes + overhead_per_entry) * num_entries
return total_bytes
# Ejemplo: 10,000 usuarios, promedio 500 bytes cada uno
size = estimate_cache_size(500, 10_000)
print(f"Tamano estimado de cache: {size / 1024 / 1024:.1f} MB")
Sizing por Objetivo de Hit Rate
Tu cache hit rate depende del ratio de tamano de cache al working set. Una regla general:
- Cachear 20% del working set: ~50% hit rate
- Cachear 50% del working set: ~80% hit rate
- Cachear 80% del working set: ~95% hit rate
- Cachear 100% del working set: ~99% hit rate
Dimensiona tu cache para contener al menos 50% de tu working set para la mayoria de casos.
Thread Safety
Python: Usar RLock
import threading
class ThreadSafeCache:
def __init__(self):
self._cache = {}
self._lock = threading.RLock()
def get(self, key):
with self._lock:
return self._cache.get(key)
def set(self, key, value):
with self._lock:
self._cache[key] = value
Java: Usar ConcurrentHashMap
import java.util.concurrent.ConcurrentHashMap;
ConcurrentHashMap<String, User> cache = new ConcurrentHashMap<>();
public User getUser(String key) {
return cache.computeIfAbsent(key, k -> db.users.findById(k));
}
Node.js: Single-Threaded (Sin Locks)
Node.js es single-threaded, asi que las operaciones de cache en memoria son atomicas. No se necesitan locks para operaciones simples get/set.
const cache = new Map();
function getUser(userId) {
if (cache.has(userId)) return cache.get(userId);
const user = db.users.findById(userId);
if (user) cache.set(userId, user);
return user;
}
Cache Warming
Pre-puebla el cache en startup para evitar penalidades de cache frio.
def warm_cache():
popular_users = db.users.find_most_active(limit=1000)
for user in popular_users:
cache.set(f"user:{user.id}", user)
configurations = db.configs.find_all()
for config in configurations:
cache.set(f"config:{config.key}", config)
# Llamar en startup de aplicacion
warm_cache()
Monitoreo de Caches de Aplicación
Métricas Clave
- Hit rate: porcentaje de requests servidas desde cache
- Miss rate: porcentaje de requests que caen a base de datos
- Eviction rate: entradas evicted por segundo
- Tamano de cache: tamano actual vs tamano max
- Latencia: tiempo para get/set desde cache
- Uso de memoria: RAM consumida por cache
Medir Hit Rate
class MonitoredCache:
def __init__(self, cache):
self.cache = cache
self.hits = 0
self.misses = 0
def get(self, key):
value = self.cache.get(key)
if value is not None:
self.hits += 1
else:
self.misses += 1
return value
@property
def hit_rate(self) -> float:
total = self.hits + self.misses
return self.hits / total if total > 0 else 0.0
Pitfalls Comunes
Cachear Demasiado
Cachear todo consume memoria y aumenta la complejidad de invalidacion. Solo cachea datos que son costosos de computar y frecuentemente accedidos.
Cachear Muy Poco
Cachear solo unos pocos items da un hit rate bajo. Si tu hit rate es menor a 50%, aumenta el tamano del cache o reconsidera que cacheas.
Ignorar Invalidation del Cache
Datos stale en el cache son peores que no tener cache. Siempre ten una estrategia de invalidacion: TTL, event-driven, o versioned keys.
No Manejar Fallos de Cache
Si el cache no esta disponible, la aplicacion deberia fallar back a la base de datos, no crashear.
def get_user_resilient(user_id: int) -> dict | None:
try:
cached = cache.get(f"user:{user_id}")
if cached:
return cached
except Exception:
pass # Cache no disponible, fall back a DB
return db.users.find_by_id(user_id)
Preguntas Frecuentes
¿Debería usar caching en memoria o distribuido?
Empieza con caching en memoria para aplicaciones simples, de una sola instancia. Pasa a caching distribuido cuando tienes multiples instancias que necesitan compartir datos cacheados. Usa caching hibrido (two-tier) cuando necesitas tanto velocidad (en memoria) como sharing (distribuido).
¿Cómo elijo el TTL del cache?
Setea el TTL al maximo staleness que tu aplicacion puede tolerar. Para perfiles de usuario: 5 minutos. Para catalogos de productos: 1 hora. Para configuraciones: 24 horas. Para datos en tiempo real: 0 (sin cache). Anade jitter (10-20% aleatorio del TTL) para prevenir cache stampedes.
¿Qué es cache warming?
Cache warming es pre-poblar el cache con hot data conocida en startup de aplicacion. Esto evita el periodo de cache frio donde cada request es un miss. Calienta el cache con los datos mas frecuentemente accedidos (top usuarios, productos populares, todas las configuraciones).
¿Cómo testeo el comportamiento del cache?
Escribe integration tests que verifiquen: cache hits retornan datos cacheados, cache misses fetchean de base de datos y pueblan cache, escrituras invalidan cache, expiracion de TTL triggerea fetch de DB, fallo de cache falla back a base de datos gracefulmente.
¿Cuál es la diferencia entre LRU y LFU?
LRU (Least Recently Used) evicta la entrada que fue accedida hace mas tiempo. LFU (Least Frequently Used) evicta la entrada que fue accedida la menor cantidad de veces. LRU es mejor para workloads con temporal locality (datos accedidos recientemente son probables de ser accedidos de nuevo). LFU es mejor para workloads con patrones de acceso skewed (unos pocos items son accedidos muy frecuentemente).
¿Cómo manejo consistencia de cache en multi-tier?
Usa TTLs mas cortos para L1 (en memoria) que para L2 (distribuido). Cuando los datos cambian, invalida ambos tiers. Si se requiere consistencia estricta, usa pub/sub para notificar a todas las instancias que invaliden sus caches L1 cuando los datos cambian.
Recursos Relacionados
Complete Guide to Redis Caching Strategies
Master Redis caching with cache-aside, read-through, write-through, write-behind, and refresh-ahead patterns. Covers eviction policies, TTL tuning, serialization, and production operations.
GuideComplete Guide to CDN Caching Strategy
Design CDN caching for web applications and APIs. Covers edge caching, cache keys, cache headers, invalidation strategies, surrogate keys, and multi-CDN setups for global performance.
PatternCache-Aside Pattern
Load data into the cache on demand from the backing store. A caching pattern that gives the application full control over what and when to cache.
GuideComplete Guide to Cache Invalidation
Master cache invalidation strategies: TTL expiration, event-driven invalidation, versioned keys, tag-based purging, and write-through invalidation. Covers multi-tier invalidation, race conditions, and consistency patterns.