Skip to content
StackPractices
advanced Por Mathias Paulenko

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

FeatureRedisMemcached
Estructuras de datosStrings, hashes, lists, sets, sorted setsSolo strings
PersistenciaRDB, AOFNinguna
ClusteringBuilt-inSharding client-side
Pub/SubSiNo
EvictionLRU, LFU, TTL, randomSolo LRU
Multi-threadedNo (single-threaded)Si
Tamano max valor512MB1MB

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

  1. 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.
  2. Write: Escribe a ambos L1 y L2.
  3. Delete: Elimina de ambos L1 y L2.
  4. 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.