Cachear 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.
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.
Descripcion general
El caching de resultados de consultas de base de datos almacena el resultado de consultas costosas en Redis para que peticiones subsecuentes salten la base de datos completamente. El patron cache-aside — verificar cache, obtener de DB en miss, poblar cache — es el enfoque mas comun. A continuacion: implementar cache-aside en Python con Redis, manejar serializacion, invalidacion en writes, prevencion de cache stampede y caching multi-query.
Cuando Usar Esto
-
For alternatives, see Complete Guide to Redis Caching Strategies.
-
Consultas costosas (agregaciones, joins, full-text search) que se ejecutan frecuentemente
-
Workloads de lectura intensa donde los datos cambian infrecuentemente
-
Reducir la carga de base de datos durante picos de trafico
-
Consultas de dashboard o reporting con staleness aceptable
Prerrequisitos
- Python 3.10+
- Servidor Redis
- Paquetes
redis(redis-py) ysqlalchemy
Solucion
1. Instalar Dependencias
pip install redis sqlalchemy
2. Patron Cache-Aside
import json
import redis
from sqlalchemy import create_engine, text
engine = create_engine("postgresql://user:pass@localhost/mydb")
redis_client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
def get_product(product_id: int) -> dict:
cache_key = f"product:{product_id}"
# 1. Verificar cache
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# 2. Obtener de base de datos en miss
with engine.connect() as conn:
result = conn.execute(
text("SELECT id, name, price FROM products WHERE id = :id"),
{"id": product_id}
)
row = result.fetchone()
if row is None:
return None
product = {"id": row.id, "name": row.name, "price": float(row.price)}
# 3. Poblar cache con TTL
redis_client.setex(cache_key, 300, json.dumps(product))
return product
3. Invalidacion de Cache en Writes
def update_product(product_id: int, name: str, price: float) -> dict:
with engine.begin() as conn:
conn.execute(
text("UPDATE products SET name = :name, price = :price WHERE id = :id"),
{"id": product_id, "name": name, "price": price}
)
# Invalidar cache — la siguiente lectura obtendra datos frescos
redis_client.delete(f"product:{product_id}")
return {"id": product_id, "name": name, "price": price}
def delete_product(product_id: int) -> None:
with engine.begin() as conn:
conn.execute(
text("DELETE FROM products WHERE id = :id"),
{"id": product_id}
)
redis_client.delete(f"product:{product_id}")
4. Cachear Consultas de Lista
def get_products_by_category(category_id: int, page: int = 1, per_page: int = 20) -> list:
cache_key = f"products:category:{category_id}:page:{page}:size:{per_page}"
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
offset = (page - 1) * per_page
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT id, name, price FROM products
WHERE category_id = :cat
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset
"""),
{"cat": category_id, "limit": per_page, "offset": offset}
)
products = [
{"id": row.id, "name": row.name, "price": float(row.price)}
for row in result
]
redis_client.setex(cache_key, 300, json.dumps(products))
return products
def invalidate_category_cache(category_id: int) -> None:
# Eliminar todas las entradas de cache paginadas para esta categoria
pattern = f"products:category:{category_id}:*"
keys = list(redis_client.scan_iter(match=pattern, count=100))
if keys:
redis_client.delete(*keys)
5. Prevencion de Cache Stampede con Lock
import time
import uuid
def get_product_with_lock(product_id: int) -> dict:
cache_key = f"product:{product_id}"
lock_key = f"lock:{cache_key}"
# Verificar cache
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# Intentar adquirir lock
lock_token = str(uuid.uuid4())
lock_acquired = redis_client.set(lock_key, lock_token, nx=True, ex=10)
if lock_acquired:
try:
# Obtener de DB
product = fetch_product_from_db(product_id)
if product:
redis_client.setex(cache_key, 300, json.dumps(product))
return product
finally:
# Liberar lock (solo si aun lo poseemos)
lua_script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
redis_client.eval(lua_script, 1, lock_key, lock_token)
else:
# Esperar y reintentar
time.sleep(0.1)
return get_product_with_lock(product_id)
6. Cachear Consultas de Agregacion
def get_sales_summary(start_date: str, end_date: str) -> dict:
cache_key = f"sales:summary:{start_date}:{end_date}"
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
COUNT(*) as total_orders,
SUM(total) as revenue,
AVG(total) as avg_order_value
FROM orders
WHERE created_at BETWEEN :start AND :end
"""),
{"start": start_date, "end": end_date}
)
row = result.fetchone()
summary = {
"total_orders": row.total_orders,
"revenue": float(row.revenue) if row.revenue else 0,
"avg_order_value": float(row.avg_order_value) if row.avg_order_value else 0,
}
# Cachear por 5 minutos — las agregaciones no necesitan frescura en tiempo real
redis_client.setex(cache_key, 300, json.dumps(summary))
return summary
7. Write-Through Cache
def create_product_write_through(name: str, price: float, category_id: int) -> dict:
with engine.begin() as conn:
result = conn.execute(
text("""
INSERT INTO products (name, price, category_id)
VALUES (:name, :price, :cat)
RETURNING id, name, price
"""),
{"name": name, "price": price, "cat": category_id}
)
row = result.fetchone()
product = {"id": row.id, "name": row.name, "price": float(row.price)}
# Poblar cache inmediatamente — sin ventana stale
redis_client.setex(f"product:{row.id}", 300, json.dumps(product))
# Invalidar caches de lista que incluirian este producto
invalidate_category_cache(category_id)
return product
8. Carga Masiva de Cache
def get_products_batch(product_ids: list) -> dict:
# Usar MGET para lookup masivo de cache
cache_keys = [f"product:{pid}" for pid in product_ids]
cached_values = redis_client.mget(cache_keys)
results = {}
missing_ids = []
for pid, cached in zip(product_ids, cached_values):
if cached:
results[pid] = json.loads(cached)
else:
missing_ids.append(pid)
# Obtener faltantes de DB en una sola consulta
if missing_ids:
with engine.connect() as conn:
placeholders = ",".join(f":id{i}" for i in range(len(missing_ids)))
params = {f"id{i}": pid for i, pid in enumerate(missing_ids)}
result = conn.execute(
text(f"SELECT id, name, price FROM products WHERE id IN ({placeholders})"),
params
)
for row in result:
product = {"id": row.id, "name": row.name, "price": float(row.price)}
results[row.id] = product
redis_client.setex(f"product:{row.id}", 300, json.dumps(product))
return results
Como Funciona
- Cache-aside: La aplicacion verifica el cache antes de consultar la base de datos. En un cache hit, el valor cacheado se retorna. En un miss, se consulta la base de datos y el resultado se almacena en el cache con un TTL.
- Invalidacion: Cuando los datos cambian (INSERT, UPDATE, DELETE), la aplicacion elimina explicitamente la clave de cache correspondiente. La siguiente lectura obtiene datos frescos de la base de datos y repuebla el cache.
- Cache stampede: Cuando una entrada de cache popular expira, muchas peticiones concurrentes hit la base de datos simultaneamente. Un lock de Redis (
SET NX EX) asegura que solo una peticion obtiene de la base de datos mientras otras esperan. - Carga masiva:
MGETobtiene multiples entradas de cache en un solo comando Redis. Las entradas faltantes se obtienen de la base de datos en una sola consultaWHERE id IN (...). - TTL como red de seguridad: Incluso con invalidacion explicita, un TTL asegura que los datos stale se auto-reparen si se pierde una invalidacion (ej., por un bug o excepcion).
Variantes
Cache con TTL Jitter
import random
def set_with_jitter(key: str, value: str, base_ttl: int = 300):
jitter = random.randint(0, 60)
redis_client.setex(key, base_ttl + jitter, value)
Read-Through Cache (Transparente)
class ReadThroughCache:
def __init__(self, redis_client, db_fetch_fn, ttl=300):
self.redis = redis_client
self.fetch_fn = db_fetch_fn
self.ttl = ttl
def get(self, key: str, *args, **kwargs):
cached = self.redis.get(key)
if cached:
return json.loads(cached)
value = self.fetch_fn(*args, **kwargs)
if value is not None:
self.redis.setex(key, self.ttl, json.dumps(value))
return value
# Uso
product_cache = ReadThroughCache(
redis_client,
lambda pid: fetch_product_from_db(pid),
ttl=300
)
product = product_cache.get(f"product:42", 42)
Cache Multi-Nivel (L1 Memoria + L2 Redis)
from functools import lru_cache
@lru_cache(maxsize=1000)
def get_product_l1(product_id: int) -> dict:
# L1: cache en memoria (por-proceso)
cached = redis_client.get(f"product:{product_id}")
if cached:
return json.loads(cached)
product = fetch_product_from_db(product_id)
if product:
redis_client.setex(f"product:{product_id}", 300, json.dumps(product))
return product
# L1 hit: instantaneo. L1 miss -> L2 (Redis) check -> L2 miss -> DB
Cache con Stale-While-Revalidate
def get_product_swr(product_id: int) -> dict:
cache_key = f"product:{product_id}"
stale_key = f"stale:{cache_key}"
cached = redis_client.get(cache_key)
if cached:
# Verificar si es stale (pasado el TTL pero aun disponible)
is_stale = redis_client.exists(stale_key)
if is_stale:
# Retornar datos stale y disparar refresco en background
# En produccion, usa una cola de tareas (Celery, RQ) para el refresco
pass
return json.loads(cached)
# Cache miss — obtener de DB
product = fetch_product_from_db(product_id)
if product:
redis_client.setex(cache_key, 300, json.dumps(product))
redis_client.setex(stale_key, 600, "1") # Ventana stale: 5 minutos extra
return product
Mejores Practicas
- Cachear en la granularidad correcta: Cachea resultados completos de consultas, no filas individuales. Una clave de cache por query + parametros.
- Establecer TTL incluso con invalidacion explicita: El TTL es una red de seguridad. Si la invalidacion falla, los datos stale se auto-reparan.
- Usar
SETEXen lugar deSET+EXPIRE:SETEXes atomico — la clave y el TTL se establecen en una operacion. - Invalidar caches de lista en writes: Cuando se crea o elimina un producto, invalida las claves de cache a nivel categoria, no solo la clave individual del producto.
- Usar
MGETpara lecturas masivas: Obtener 100 productos uno por uno son 100 round-trips a Redis.MGETlo hace en uno. - Monitorear hit rate del cache: Menos de 50% significa que el cache esta mal configurado o el workload no es cacheable.
Errores Comunes
- Cachear sin TTL: Si la invalidacion falla, los datos stale persisten para siempre. Siempre establece un TTL.
- Invalidar demasiado ampliamente: Eliminar
product:*cuando un producto cambia limpia todo el cache. Elimina claves especificas. - No manejar resultados
None: Si la base de datos retornaNone, cachearlo previene repetidos DB hits para claves inexistentes. Usa un valor centinela o TTL corto para negative caching. - Colisiones de claves de cache: Usa claves descriptivas con namespaces (
product:42, no solo42). Diferentes queries con el mismo ID colisionaran. - Olvidar invalidar despues de operaciones masivas:
UPDATE products SET price = price * 1.1cambia todos los productos pero no dispara invalidacion por clave. Limpia el patron o incrementa una version.
FAQ
Cache-aside vs read-through — cual es la diferencia?
En cache-aside, el codigo de la aplicacion verifica explicitamente el cache y obtiene de la base de datos. En read-through, una capa de cache obtiene transparentemente de la base de datos en miss. Cache-aside da mas control; read-through simplifica el codigo de aplicacion.
Como cacheo consultas paginadas?
Incluye numero de pagina y tamano de pagina en la clave de cache: products:category:1:page:3:size:20. Cuando un producto en la categoria cambia, invalida todas las paginas con un patron: products:category:1:*.
Deberia cachear JOINs?
Si, si el JOIN es costoso y el resultado se consume frecuentemente. Cachea el resultado denormalizado. Invalida cuando cualquiera de las tablas joined cambie.
Que es negative caching?
Cachear el hecho de que una clave no existe (ej., product:999 retorna None). Esto previene repetidas consultas a la base de datos para claves inexistentes. Usa un TTL corto (30-60 segundos) para evitar cachear la no-existencia por demasiado tiempo.
Como mido la efectividad del cache?
Rastrea cache hits y misses. En Redis, usa el comando INFO stats para ver keyspace_hits y keyspace_misses. Calcula el hit rate: hits / (hits + misses). Un buen hit rate es superior al 80%.
¿Esta solución está lista para producción?
Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.
¿Cuáles son las características de rendimiento?
El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.
¿Cómo depuro problemas con este enfoque?
Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.
Recursos Relacionados
Cache Function Results with Redis and TTL in Python
Build a Python decorator that caches function return values in Redis with configurable TTL, key generation, and cache invalidation
RecipeCache Database Queries with Django Cache Framework
Use Django's built-in cache framework with per-view caching, template fragment caching, and low-level cache API for database query optimization.
GuideComplete Guide to API Versioning Strategies
Version REST and GraphQL APIs with URI, header, query param, and content negotiation strategies. Covers deprecation, sunset, and migration patterns.
GuideComplete Guide to React Performance Optimization
Optimize React apps for speed. Covers memoization, virtualization, code splitting, bundle analysis, React Profiler, concurrent features, and Core Web Vitals.
RecipeCache HTTP Responses with httpx and CacheControl in Python
Cache HTTP responses in Python using httpx with CacheControl for HTTP-compliant caching, ETag handling, and conditional requests.
RecipeDistribute Background Tasks with Python Celery and Redis
Set up Celery with Redis broker for distributed task processing including task chaining, groups, chords, retry strategies, scheduled tasks with Celery Beat, and result backends.