StackPractices
intermediate Por Mathias Paulenko

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

  1. Lectura de cacheget_or_load verifica Redis primero. En un hit, retorna el valor cacheado sin tocar la base de datos.
  2. Cache miss — la funcion loader obtiene desde la base de datos. El resultado se escribe a Redis con un TTL para que auto-expire.
  3. Invalidacion write-through — despues de cualquier escritura a la base de datos, invalidate elimina la entrada de cache para que la siguiente lectura cargue datos frescos.
  4. 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.
  5. Invalidacion por patroninvalidate_pattern usa SCAN para 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 KEYS en lugar de SCANKEYS bloquea Redis; SCAN es 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.