Cache 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
Cachear resultados costosos de funciones en Redis reduce latencia y carga de base de datos. Un enfoque con decorador permite agregar caching a cualquier funcion con una sola anotacion @cached, manteniendo la logica de negocio limpia. Esta receta construye un decorador de cache respaldado por Redis con generacion automatica de claves, serializacion JSON, TTL configurable e invalidacion manual.
Cuando Usar Esto
-
For alternatives, see Complete Guide to Redis Caching Strategies.
-
Funciones que retornan el mismo resultado para los mismos argumentos (funciones puras o casi puras)
-
Consultas a base de datos, llamadas a API o computaciones costosas que raramente cambian
-
Resultados seguros de servir obsoletos durante la duracion del TTL
Requisitos Previos
- Python 3.10+
- Paquete
redis(pip install redis) - Una instancia de Redis corriendo
Solucion
1. Instalar dependencias
pip install redis
2. Construir el decorador de cache
import functools
import hashlib
import json
import logging
from typing import Any, Callable, TypeVar
from redis import Redis
logger = logging.getLogger(__name__)
T = TypeVar("T")
def cached(
redis_client: Redis,
ttl: int = 300,
prefix: str = "cache",
skip_args: tuple[str, ...] = (),
) -> Callable:
"""Cache function results in Redis with a TTL.
Args:
redis_client: Redis client instance.
ttl: Time-to-live in seconds.
prefix: Cache key prefix.
skip_args: Argument names to exclude from the cache key.
Returns:
Decorator that wraps the function with Redis caching.
"""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
@functools.wraps(func)
def wrapper(*args, **kwargs) -> T:
key = _make_key(func, args, kwargs, prefix, skip_args)
try:
cached_value = redis_client.get(key)
if cached_value is not None:
return json.loads(cached_value)
except (json.JSONDecodeError, ConnectionError) as e:
logger.warning("Cache read failed for %s: %s", key, e)
result = func(*args, **kwargs)
try:
redis_client.setex(key, ttl, json.dumps(result, default=str))
except (TypeError, ConnectionError) as e:
logger.warning("Cache write failed for %s: %s", key, e)
return result
wrapper.cache_invalidate = _make_invalidator(redis_client, func, prefix, skip_args) # type: ignore
return wrapper
return decorator
def _make_key(
func: Callable,
args: tuple,
kwargs: dict,
prefix: str,
skip_args: tuple[str, ...],
) -> str:
"""Generate a deterministic cache key from function name and arguments."""
import inspect
sig = inspect.signature(func)
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
filtered = {
k: v for k, v in bound.arguments.items()
if k not in skip_args and k != "self"
}
arg_hash = hashlib.sha256(
json.dumps(filtered, sort_keys=True, default=str).encode()
).hexdigest()[:16]
return f"{prefix}:{func.__module__}:{func.__name__}:{arg_hash}"
def _make_invalidator(
redis_client: Redis,
func: Callable,
prefix: str,
skip_args: tuple[str, ...],
) -> Callable:
"""Create a cache invalidation function for a specific decorated function."""
def invalidate(*args, **kwargs) -> int:
key = _make_key(func, args, kwargs, prefix, skip_args)
return redis_client.delete(key)
return invalidate
3. Usar el decorador
import redis
from myapp.database import get_user_by_id
r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
@cached(r, ttl=60, prefix="user")
def get_user(user_id: str, include_posts: bool = False) -> dict:
user = get_user_by_id(user_id)
if include_posts:
user["posts"] = fetch_user_posts(user_id)
return user
# Primera llamada — toca la base de datos, cachea el resultado
user = get_user("123", include_posts=True)
# Segunda llamada — retorna desde cache
user = get_user("123", include_posts=True)
# Invalidar la cache para argumentos especificos
get_user.cache_invalidate("123", include_posts=True)
4. Version asincrona
import asyncio
from redis.asyncio import Redis as AsyncRedis
def async_cached(
redis_client: AsyncRedis,
ttl: int = 300,
prefix: str = "cache",
) -> Callable:
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(*args, **kwargs):
key = _make_key(func, args, kwargs, prefix, ())
try:
cached = await redis_client.get(key)
if cached is not None:
return json.loads(cached)
except Exception as e:
logger.warning("Async cache read failed: %s", e)
result = await func(*args, **kwargs)
try:
await redis_client.setex(key, ttl, json.dumps(result, default=str))
except Exception as e:
logger.warning("Async cache write failed: %s", e)
return result
return wrapper
return decorator
# Uso
r = AsyncRedis(host="localhost", port=6379, decode_responses=True)
@async_cached(r, ttl=120, prefix="api")
async def fetch_weather(city: str) -> dict:
async with httpx.AsyncClient() as client:
resp = await client.get(f"https://api.weather.example/{city}")
return resp.json()
Como Funciona
- Generacion de claves usa el modulo y nombre de la funcion mas un hash SHA-256 de sus argumentos para crear una clave determinista y resistente a colisiones.
skip_argsexcluye argumentos comoselfu objetos de peticion de la clave, para que los metodos de instancia funcionen correctamente.setexestablece el valor con expiracion en un comando atomico de Redis, para que la entrada expire automaticamente despues del TTL.cache_invalidatese adjunta a la funcion wrapper, permitiendo a los llamadores eliminar una entrada especifica pasando los mismos argumentos.- Degradacion elegante — si Redis cae, la funcion sigue ejecutandose; el decorador registra el error y retorna el resultado sin cache.
Variantes
Cache-Aside con refresh manual
@cached(r, ttl=300, prefix="product")
def get_product(product_id: str) -> dict:
return db.products.find_by_id(product_id)
def refresh_product(product_id: str) -> dict:
get_product.cache_invalidate(product_id)
return get_product(product_id)
Invalidacion basada en tags
Invalida todas las entradas de cache para un tag (ej. todas las caches relacionadas con usuarios):
def invalidate_tag(redis_client: Redis, tag: str) -> int:
keys = redis_client.smembers(f"tag:{tag}")
if keys:
redis_client.delete(*keys)
redis_client.delete(f"tag:{tag}")
return len(keys)
# Al cachear, agrega la clave a un set de tag
redis_client.sadd("tag:users", key)
Compresion para valores grandes
import zlib
@cached(r, ttl=600, prefix="report")
def generate_report(date: str) -> dict:
data = heavy_computation(date)
return data # Podria ser grande
# En el decorador, comprimir antes de almacenar:
redis_client.setex(key, ttl, zlib.compress(json.dumps(result).encode()))
Mejores Practicas
- Usa TTLs cortos para datos que cambian frecuentemente — 30-60 segundos para feeds de usuario, 5-10 minutos para datos de referencia
- Cachea solo resultados serializables — el decorador usa
json.dumps; objetos con tipos personalizados necesitan un serializadordefault - Maneja fallos de Redis elegantemente — la cache es una optimizacion, no la fuente de verdad; la funcion debe seguir funcionando sin ella
- Invalida al escribir — llama
cache_invalidatedespues de cualquier mutacion que cambiaria el resultado cacheado
Errores Comunes
- Cachear funciones con efectos secundarios — si la funcion escribe a la base de datos, cachear omite la escritura en cache hits
- Usar argumentos por defecto mutables — el hash de la clave cambia si una lista o dict por defecto se modifica entre llamadas
- No establecer un TTL — sin
setex, las entradas persisten indefinidamente y consumen memoria de Redis - Incluir
selfen la clave — elid()del objeto cambia entre peticiones, causando cache misses
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.
Preguntas frecuentes
Debo usar Redis o cache en memoria?
Usa Redis cuando multiples procesos o servidores necesitan compartir la cache. Usa memoria (functools.lru_cache) para caches de un solo proceso y corta duracion.
Como manejo valores de retorno no serializables?
Pasa una funcion default a json.dumps que convierta tipos personalizados a dicts, o usa pickle (con precauciones de seguridad).
Que TTL debo usar?
Empieza con 300 segundos (5 minutos) y ajusta segun tolerancia a obsolescencia y frecuencia de escritura. TTLs mas cortos reducen obsolescencia pero aumentan cache misses.
Puedo cachear funciones que retornan None?
Si, pero distingue entre "None cacheado" y "cache miss" verificando la existencia de la clave con redis_client.exists(key) en lugar de redis_client.get(key).
Recursos Relacionados
Caching con Redis
Cómo implementar caching de aplicaciones usando Redis para rendimiento y escalabilidad.
RecipePatrones de Cache de Redis para Aplicaciones de Alto
Como implementar patrones cache-aside, write-through y write-behind con Redis para reducir carga de base de datos y mejorar tiempos de respuesta
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.
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
RecipeCachear Resultados de Consultas de Base de Datos con
Cachear resultados de consultas costosas en Redis con patron cache-aside, gestion de TTL e invalidacion en writes para aplicaciones Python.
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.