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
Cache-aside (tambien llamado lazy loading) es el patron de caching mas comun. La aplicacion verifica la cache primero; en un miss, carga desde la base de datos, escribe el resultado en la cache y lo retorna. En escrituras, la aplicacion actualiza la base de datos e invalida la entrada de cache. A continuacion se implementa cache-aside en Python con Redis, incluyendo proteccion contra thundering herd e invalidacion write-through.
Cuando Usar Esto
-
For alternatives, see Complete Guide to Redis Caching Strategies.
-
Workloads de lectura intensiva donde los mismos datos se acceden repetidamente
-
Datos que cambian infrecuentemente pero son costosos de obtener
-
Quieres que la cache sea opcional — el sistema funciona sin ella
Requisitos Previos
- Python 3.10+
- Paquete
redis(pip install redis) - Un cliente de base de datos (SQLAlchemy, Psycopg o similar)
Solucion
1. Instalar dependencias
pip install redis
2. Implementar lectura cache-aside
import json
import logging
from redis import Redis
logger = logging.getLogger(__name__)
class CacheAside:
def __init__(self, redis_client: Redis, default_ttl: int = 300):
self.redis = redis_client
self.default_ttl = default_ttl
def get_or_load(
self,
key: str,
loader: callable,
ttl: int | None = None,
) -> dict | None:
"""Read from cache, or load from source and cache the result.
Args:
key: Cache key.
loader: Function that loads data from the source on cache miss.
ttl: Override default TTL in seconds.
Returns:
Cached or freshly loaded data.
"""
try:
cached = self.redis.get(key)
if cached is not None:
return json.loads(cached)
except Exception as e:
logger.warning("Cache read failed for %s: %s", key, e)
result = loader()
if result is None:
return None
try:
self.redis.setex(key, ttl or self.default_ttl, json.dumps(result, default=str))
except Exception as e:
logger.warning("Cache write failed for %s: %s", key, e)
return result
def invalidate(self, key: str) -> None:
"""Remove a key from the cache."""
try:
self.redis.delete(key)
except Exception as e:
logger.warning("Cache invalidation failed for %s: %s", key, e)
def invalidate_pattern(self, pattern: str) -> int:
"""Delete all keys matching a pattern (e.g., 'user:*')."""
count = 0
try:
for key in self.redis.scan_iter(match=pattern, count=100):
self.redis.delete(key)
count += 1
except Exception as e:
logger.warning("Pattern invalidation failed for %s: %s", pattern, e)
return count
3. Implementar invalidacion write-through
from myapp.database import db
cache = CacheAside(redis_client, default_ttl=300)
def get_user(user_id: str) -> dict | None:
return cache.get_or_load(
key=f"user:{user_id}",
loader=lambda: db.users.find_by_id(user_id),
ttl=120,
)
def update_user(user_id: str, data: dict) -> dict:
user = db.users.update(user_id, data)
cache.invalidate(f"user:{user_id}")
return user
def delete_user(user_id: str) -> None:
db.users.delete(user_id)
cache.invalidate(f"user:{user_id}")
cache.invalidate_pattern(f"user_posts:{user_id}:*")
4. Proteccion contra thundering herd
Cuando una entrada de cache expira, multiples peticiones pueden disparar el loader simultaneamente, causando un pico en la base de datos. Un lock previene esto:
import time
import uuid
class CacheAsideWithLock(CacheAside):
def get_or_load(
self,
key: str,
loader: callable,
ttl: int | None = None,
lock_timeout: int = 10,
) -> dict | None:
# Intentar cache primero
try:
cached = self.redis.get(key)
if cached is not None:
return json.loads(cached)
except Exception:
pass
# Adquirir lock para prevenir thundering herd
lock_key = f"lock:{key}"
lock_token = str(uuid.uuid4())
acquired = self.redis.set(lock_key, lock_token, nx=True, ex=lock_timeout)
if acquired:
try:
result = loader()
if result is not None:
self.redis.setex(key, ttl or self.default_ttl, json.dumps(result, default=str))
return result
finally:
# Liberar lock solo si todavia lo poseemos
self._release_lock(lock_key, lock_token)
else:
# Esperar y reintentar
for _ in range(5):
time.sleep(0.1)
cached = self.redis.get(key)
if cached is not None:
return json.loads(cached)
# Lock expiro — cargar de todas formas como fallback
return loader()
def _release_lock(self, lock_key: str, token: str) -> None:
"""Release lock using Lua script to prevent releasing someone else's lock."""
script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
self.redis.eval(script, 1, lock_key, token)
5. Calentamiento de cache
Pre-poblar la cache antes de que llegue el trafico:
def warm_user_cache(user_ids: list[str]) -> None:
users = db.users.find_many({"id": {"in": user_ids}})
for user in users:
cache_key = f"user:{user['id']}"
redis_client.setex(cache_key, 300, json.dumps(user, default=str))
logger.info("Warmed cache for %d users", len(users))
Como Funciona
- Lectura de cache —
get_or_loadverifica Redis primero. En un hit, retorna el valor cacheado sin tocar la base de datos. - Cache miss — la funcion
loaderobtiene desde la base de datos. El resultado se escribe a Redis con un TTL para que auto-expire. - Invalidacion write-through — despues de cualquier escritura a la base de datos,
invalidateelimina la entrada de cache para que la siguiente lectura cargue datos frescos. - Lock de thundering herd — en un cache miss, solo una peticion adquiere el lock y carga desde la base de datos. Las demas esperan y reintentan la lectura de cache.
- Invalidacion por patron —
invalidate_patternusaSCANpara encontrar y eliminar claves que coinciden con un patron glob, util para invalidar todas las entradas de un usuario.
Variantes
Read-Through con Refresh-Ahead
Refresca la cache antes de que expire:
def get_or_refresh(key: str, loader: callable, ttl: int = 300) -> dict | None:
cached = redis_client.get(key)
if cached:
result = json.loads(cached)
# Verificar si la entrada esta cerca de expirar (dentro del 10% del TTL)
ttl_remaining = redis_client.ttl(key)
if ttl_remaining < ttl * 0.1:
# Refrescar en background
threading.Thread(target=lambda: cache.get_or_load(key, loader, ttl), daemon=True).start()
return result
return cache.get_or_load(key, loader, ttl)
Write-Behind (Write-Back)
Escribe en la cache primero, luego persiste asincronamente a la base de datos:
def write_behind_update(user_id: str, data: dict) -> dict:
cache_key = f"user:{user_id}"
redis_client.setex(cache_key, 300, json.dumps(data, default=str))
redis_client.lpush("write_queue:user", json.dumps({"id": user_id, "data": data}))
return data
# Worker en background procesa la cola
def process_write_queue():
while True:
item = redis_client.brpop("write_queue:user", timeout=10)
if item:
payload = json.loads(item[1])
db.users.update(payload["id"], payload["data"])
Mejores Practicas
- Establece un TTL en cada entrada de cache — previene que datos obsoletos persistan indefinidamente
- Invalida al escribir — actualiza la base de datos primero, luego elimina la entrada de cache (no al reves)
- Maneja fallos de Redis elegantemente — la cache es una optimizacion; el loader debe seguir funcionando sin ella
- Usa TTLs cortos para datos mutables — 30-120 segundos para datos de usuario, mas largo para datos de referencia
Errores Comunes
- Actualizar la cache antes que la base de datos — si la escritura a la base de datos falla, la cache tiene datos obsoletos
- No manejar cache misses — si el loader retorna
None, no lo caches (o cachealo con un TTL corto para prevenir misses repetidos) - Usar
KEYSen lugar deSCAN—KEYSbloquea Redis;SCANes non-blocking y seguro para produccion - Cachear demasiado — cachea solo datos calientes; datos frios desperdician memoria y aumentan la overhead de invalidacion
Troubleshooting
- Cache and database are out of sync: define a TTL or invalidation policy.
- Hit rate dropped after a deployment: check cache key generation and serialization changes.
- Cold cache causes thundering herd: use cache warming, request coalescing, or single-flight patterns for hot keys.
- Memory usage grows uncontrollably: set max memory policies, eviction thresholds, and key expiration. Audit large values.
- Stale data served to users: implement cache invalidation on write and cache-bust URLs for static assets.
Puntos Clave
- Aplica implementar el patron cache-aside con redis cuando necesites una solución práctica para tu caso de uso.
- Monitorea el rendimiento después de implementar; mide latencia, errores y uso de recursos antes y después.
- Revisa la sección de Troubleshooting ante errores comunes; la mayoría tienen causa raíz documentada con solución.
- Mantén dependencias actualizadas y ejecuta tests en CI para prevenir regresiones en producción.
Errores Comunes en Producción
- Copiar el ejemplo sin adaptarlo a volúmenes y modos de fallo reales.
- Saltar tests de carga e inyección de errores antes del primer despliegue productivo.
- Codificar valores fijos que deberían ser configurables por entorno.
- Olvidar agregar logging y monitoreo en cada paso.
- Desplegar sin plan de rollback ni estrategia de backup probada.
- Asumir que el ejemplo mínimo escalará sin agregar caché o procesamiento por lotes.
- No documentar la versión y configuración usadas en producción.
- Dejar la receta sin cambios cuando evolucionan las dependencias o la escala.
Preguntas frecuentes
Cache-aside vs. read-through — cual es la diferencia?
En cache-aside, la aplicacion gestiona lecturas y escrituras de cache. En read-through, una libreria de cache lo maneja transparentemente. Cache-aside da mas control; read-through es mas simple.
Debo actualizar la cache o invalidarla al escribir?
Invalida (elimina). La siguiente lectura cargara datos frescos. Actualizar la cache arriesga inconsistencia si la escritura a la base de datos falla.
Como manejo cache stampede?
Usa el patron de lock mostrado arriba, o coalescing de peticiones — solo una peticion carga datos mientras las demas esperan.
Que TTL debo usar?
Empieza con 300 segundos y ajusta. Monitorea el hit rate de cache — por debajo del 80% significa que el TTL es demasiado corto o los datos cambian muy rapido.
Recursos Relacionados
Caching con Redis
Cómo implementar caching de aplicaciones usando Redis para rendimiento y escalabilidad.
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
PatternPatrón Cache-Aside
Carga datos en el caché bajo demanda desde el almacenamiento principal. Un patrón de caché que da a la aplicación control total sobre qué y cuándo cachear.
RecipeEstrategias y patrones de invalidacion de cache CDN
Implementa invalidacion de cache CDN usando purge APIs, surrogate keys, invalidacion por tags y URLs versionadas para mantener contenido fresco
RecipeConfigurar headers HTTP Cache-Control para APIs y assets
Establece headers Cache-Control, ETag y Last-Modified para controlar el caching de navegadores y CDN para respuestas API y assets estaticos
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