¿Cómo funciona el cacheo de resultados de consultas en un ORM o en la capa de base de datos, y qué lo invalida?
El cacheo de resultados de consultas promete evitar viajes costosos a la base de datos, hasta que un simple UPDATE deja a la mitad de tus usuarios mirando datos de ayer sin que nadie se entere.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo funciona el cacheo de resultados de consultas en un ORM o en la capa de base de datos, y qué lo invalida?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Dónde vive el cacheo de resultados de consultas
"Cachear resultados de consultas" significa guardar la salida de una consulta —no una fila cruda de una tabla, sino el resultado ya formado de ejecutar una sentencia SQL (o una consulta de ORM) concreta con parámetros concretos— de modo que una repetición de esa misma consulta pueda responderse sin tocar la base de datos en absoluto. Esto aparece en tres capas distintas, y se comportan de forma muy diferente:
- Cacheo a nivel de base de datos. Algunos motores solían cachear resultados de consultas internamente (el query cache de MySQL, eliminado por completo en MySQL 8.0 porque generaba más contención de la que ahorraba). Las bases de datos modernas casi nunca hacen esto ya; cachean páginas y planes de ejecución, no conjuntos de resultados, porque invalidar cachés a nivel de resultado de forma correcta resultó demasiado costoso para hacerlo de manera transparente.
- Cacheo a nivel de ORM. Frameworks como Hibernate (Java/Kotlin) incluyen una "caché de segundo nivel" que se sitúa entre el ORM y la base de datos, indexada por entidad o por consulta, respaldada por un proveedor de caché en memoria o distribuido (Ehcache, Infinispan, Redis).
- Cacheo a nivel de aplicación. El patrón más habitual en la práctica: una función de repositorio o de servicio envuelve una consulta, revisa una caché externa (normalmente Redis o Memcached) antes de golpear la base de datos, y escribe el resultado de vuelta tras un fallo. Esto es cacheo "cache-aside" (o "look-aside"), y es lo que la mayoría de los equipos terminan construyendo a mano.
Las tres comparten la misma forma: una clave de caché derivada de la consulta y sus parámetros, un valor cacheado (el resultado serializado), y una regla de TTL o invalidación que decide cuánto tiempo puede vivir ese valor.
#Un ejemplo concreto
Aquí tienes un camino de lectura cache-aside para "dame las órdenes activas de un cliente", usando Redis como caché delante de una base de datos relacional:
import hashlibimport jsonimport rediscache = redis.Redis(host="localhost", port=6379, db=0)def cache_key(sql: str, params: tuple) -> str:fingerprint = hashlib.sha256(f"{sql}|{params}".encode()).hexdigest()return f"qcache:v1:{fingerprint}"def get_active_orders(db, customer_id: int, ttl_seconds: int = 30):sql = "SELECT id, total, status FROM orders WHERE customer_id = %s AND status = 'active'"key = cache_key(sql, (customer_id,))cached = cache.get(key)if cached is not None:return json.loads(cached)rows = db.execute(sql, (customer_id,)).fetchall()result = [dict(row) for row in rows]cache.setex(key, ttl_seconds, json.dumps(result))cache.sadd("qcache:tags:orders", key) # recordamos: esta clave depende de "orders"return result
Nada raro hasta aquí: se revisa la caché, si falla se cae a la base de datos, y se guarda el resultado con un TTL. La línea cache.sadd está haciendo algo que vale la pena notar: registra, en un conjunto, cada clave de caché construida a partir de una consulta que tocó la tabla orders. Ese registro es lo que hace posible la invalidación.
#El problema de la invalidación
Aquí está por qué cachear resultados de consultas es más difícil que cachear una sola fila por su clave primaria. Una fila cacheada (user:42) tiene un dueño claro: la fila con id 42. Cuando esa fila cambia, borras user:42 y listo — invalidar es una simple búsqueda.
Un resultado de consulta cacheado no tiene un único dueño. SELECT * FROM orders WHERE customer_id = 42 AND status = 'active' depende de cualquier fila que pudiera coincidir con ese predicado, algo que no se puede saber sin volver a ejecutar la consulta. Un UPDATE orders SET status = 'cancelled' WHERE id = 501 podría invalidar ese resultado cacheado (si la orden 501 pertenecía al cliente 42 y estaba activa) o podría no hacerlo (si pertenecía a otra persona) — y la caché no tiene forma de saber cuál de los dos casos aplica, porque solo guardó la salida, no las entradas que la determinaron.
Esta es la razón por la que la invalidación de caché aparece en cualquier lista de chistes sobre "los problemas más difíciles de la informática". Cada estrategia práctica es un compromiso entre corrección y tasa de aciertos:
| Estrategia | Cómo funciona | Compromiso |
|---|---|---|
| Solo TTL | La entrada simplemente expira tras N segundos, pase lo que pase | Trivial de implementar, pero garantiza datos obsoletos durante hasta N segundos tras cada escritura |
| Invalidación de tabla completa | Cualquier escritura en una tabla borra todas las consultas cacheadas que la tocaron | Siempre correcta, pero hunde la tasa de aciertos en tablas que se escriben con frecuencia |
| Invalidación por etiquetas/filas | Las entradas de caché se etiquetan con las filas o predicados concretos de los que dependen; una escritura solo borra las etiquetas que coinciden | Mucha mejor tasa de aciertos, pero la lógica de etiquetado crece en complejidad con cada nueva forma de consulta |
| Dirigida por eventos (CDC) | Un flujo de change-data-capture (por ejemplo, Debezium leyendo el write-ahead log de la base de datos) publica cada cambio de fila; un consumidor mapea esos cambios a claves de caché y las elimina | Captura escrituras de cualquier origen, incluidas las que evitan tu aplicación, pero añade todo un pipeline que operar |
| Claves versionadas | Un número de versión, incrementado en cambios de esquema o cargas masivas, se incrusta en cada clave, de modo que la generación anterior simplemente deja de pedirse | Excelente para eventos de "invalidar todo" como despliegues, inútil para actualizaciones fila por fila |
El ejemplo anterior usa invalidación de tabla completa, que es el default honesto para la mayoría de los equipos:
def update_order_status(db, order_id: int, new_status: str):db.execute("UPDATE orders SET status = %s WHERE id = %s",(new_status, order_id),)db.commit()stale_keys = cache.smembers("qcache:tags:orders")if stale_keys:cache.delete(*stale_keys)cache.delete("qcache:tags:orders")
Cada consulta cacheada que alguna vez tocó orders se descarta en el momento en que cambia cualquier fila de orders — incluidas las consultas de clientes completamente ajenos al cambio. Es un desperdicio, pero es correcto, y correcto-pero-desperdiciado le gana a rápido-pero-equivocado cada vez que un bug de caché llega a producción.
🧠 Check your understanding
0/2 · 0/2 answered1. ¿Por qué invalidar un resultado de consulta cacheado es más difícil que invalidar una sola fila cacheada por su clave primaria?
2. En el ejemplo de invalidación de tabla completa, un UPDATE a una orden borra todas las consultas cacheadas etiquetadas 'orders', incluidas las de clientes sin relación. ¿Qué está sacrificando este enfoque?