Saltar al contenido

Capítulo 4 de 13

Una caché que puedes invalidar y explicar

Construir cache-aside, representar resultados ausentes y reproducir la carrera que puede devolver datos antiguos.

#La promesa debe ser concreta

Una caché evita repetir una lectura costosa cuando muchas peticiones necesitan el mismo resultado. Antes de escribir código, define qué datos admite, durante cuánto tiempo pueden estar obsoletos y cómo se recuperan si Redis falla. “Que sea rápido” no responde a ninguna de esas preguntas.

Para una ficha de catálogo aceptamos una copia de treinta segundos; para decidir si se puede consumir un cupón usamos el origen con su control de concurrencia. Incluso dentro de una misma pantalla, datos distintos pueden tener tolerancias distintas. No caches decisiones de autorización sin pensar en cuánto tardaría en aplicarse una revocación.

#Cache-aside, paso a paso

Cache-aside significa que la aplicación consulta la caché y, si no encuentra una entrada, consulta el origen y guarda una copia. Redis no conoce automáticamente tu base de datos ni recibe sus cambios: la aplicación debe decidir cuándo leer, poblar e invalidar.

En un acierto, la aplicación responde con la copia; en un fallo consulta el origen, escribe una copia con TTL y responde.Ampliar diagrama ↗

El acierto se llama hit y la ausencia miss. Un miss es normal, no un incidente. Distinto es que Redis no responda: quizá también puedas consultar el origen, pero perder toda la caché puede multiplicar la carga. El siguiente capítulo limita esa degradación.

#Un ejemplo completo, con un origen pequeño visible

Guarda este bloque como cache.py. El diccionario ORIGEN simula una base para ver las lecturas; no prueba durabilidad ni coordinación entre procesos. En una aplicación real, leer_origen ejecutaría una consulta parametrizada y acotada sobre la base de datos.

import json
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry

r = redis.Redis(host="127.0.0.1", port=6380, decode_responses=True,
                socket_connect_timeout=0.3, socket_timeout=0.5,
                retry=Retry(NoBackoff(), 0))
ORIGEN = {42: {"nombre": "Lámpara", "precio_centimos": 3900}}

def clave(producto_id):
    if type(producto_id) is not int or producto_id <= 0:
        raise ValueError("Identificador inválido")
    return f"curso:producto:v1:{producto_id}"

def leer_origen(producto_id):
    print("Lectura del origen", producto_id)
    producto = ORIGEN.get(producto_id)
    return dict(producto) if producto is not None else None

def consultar(producto_id):
    key = clave(producto_id)
    try:
        raw = r.get(key)
    except (redis.ConnectionError, redis.TimeoutError):
        raw = None
    if raw is not None:
        return json.loads(raw)["dato"]
    dato = leer_origen(producto_id)
    entrada = json.dumps({"dato": dato}, ensure_ascii=False)
    try:
        r.set(key, entrada, ex=30 if dato is not None else 5)
    except (redis.ConnectionError, redis.TimeoutError):
        pass  # La respuesta correcta ya procede del origen.
    return dato

if __name__ == "__main__":
    r.delete(clave(42), clave(999))
    print(consultar(42))
    print(consultar(42))
    print(consultar(999))
    print(consultar(999))
    r.close()

La primera consulta de cada identificador imprime “Lectura del origen”; la segunda no. Para el inexistente, la entrada JSON contiene {"dato": null}. Eso es una entrada negativa: durante cinco segundos evita repetir la búsqueda de algo que no existe. Una entrada negativa demasiado larga retrasaría la aparición de un producto recién creado.

El sobre {"dato": ...} diferencia una respuesta válida vacía de la ausencia de la clave. La versión v1 del nombre permite cambiar el contrato a v2 sin que código nuevo intente interpretar entradas antiguas. El ejemplo confía en valores escritos por esta aplicación; en una caché compartida debes validar el esquema y contabilizar entradas inválidas, no dejar que un JSON inesperado tumbe cada petición.

#Actualizar el origen antes de invalidar

Cuando cambia un producto, primero confirma la modificación en su fuente de verdad; después elimina su copia. Si borras antes, otro lector puede recuperar el valor antiguo del origen y volverlo a cachear mientras la actualización sigue pendiente.

# Añadir a cache.py; este origen de demostración es un diccionario.
def cambiar_precio(producto_id, centimos):
    if type(centimos) is not int or centimos < 0:
        raise ValueError("Precio inválido")
    ORIGEN[producto_id]["precio_centimos"] = centimos
    r.delete(clave(producto_id))

En una base relacional, “primero” significa después del commit, la confirmación de la transacción. Si el cambio se revierte, no quieres publicar un estado que nunca existió. Si el commit se confirma y DEL falla, el origen cambió y la caché puede seguir vieja hasta caducar. Registrar y reintentar invalidaciones ayuda; un TTL limita su supervivencia. Las dos escrituras no forman una transacción por estar juntas en Python.

Para cambios importantes, guarda una tarea de invalidación en una tabla de salida (outbox) dentro de la misma transacción del cambio. Otro proceso la entrega con reintentos. Eso conserva la intención de invalidar, pero sigue habiendo un intervalo antes de que se entregue. Si no toleras ese intervalo, lee el origen para esa decisión.

#La carrera que DEL después del commit tampoco elimina

Hay una segunda carrera: un lector puede haber empezado antes del cambio y terminar después de la invalidación. Esta secuencia la reproduce de manera determinista, sin depender de acertar tiempos con hilos. Ejecútala tras importar r, ORIGEN, clave y leer_origen de cache.py:

import json
from cache import r, ORIGEN, clave, leer_origen

key = clave(42)
r.delete(key)
ORIGEN[42]["precio_centimos"] = 3900
copia_lenta = leer_origen(42)               # Lector A empieza.
ORIGEN[42]["precio_centimos"] = 4100        # Escritor B confirma.
r.delete(key)                              # B invalida.
r.set(key, json.dumps({"dato": copia_lenta}), ex=30)  # A termina tarde.
print(ORIGEN[42]["precio_centimos"])        # 4100
print(json.loads(r.get(key))["dato"]["precio_centimos"])  # 3900

Ningún comando individual falló. El problema es el orden entre sistemas. Un TTL de treinta segundos empieza cuando A escribe, no cuando el origen cambió. Si una consulta tarda mucho, la antigüedad total puede superar esos treinta segundos. Por eso también acotas duración de lecturas y defines qué tolerancia estás prometiendo.

#Elegir una mejora según la garantía necesaria

Necesidad Diseño razonable Coste o límite
Ficha tolerante a pequeños retrasos Cache-aside + TTL + invalidación tras commit Puede observar datos antiguos
Evitar que lectores viejos repueblen después de invalidar Generación y escritura condicional con WATCH La invalidación aún debe llegar desde el origen
Datos inmutables por revisión Clave que incluye la revisión del origen Hay que conocer la revisión vigente
Decisión que exige el último estado confirmado Consultar origen transaccional Renuncias a esa caché en esa decisión

Una generación es un número o token que cambia al invalidar. El lector recuerda cuál vio y solo publica si no cambió. El capítulo de operaciones compuestas implementa la comprobación; leer y comparar en Python sin proteger la escritura repetiría otra carrera.

La caché debe tener además un presupuesto: tamaño máximo de entrada, caducidad, cardinalidad prevista y métricas. No guardes respuestas arbitrarias de búsqueda con miles de combinaciones sin medir cuántas claves producen. Antes de optimizar, comprueba que tu consulta original tiene índices y límites adecuados: Redis no corrige una consulta mal planteada.

    ↑ ↓ para recorrer · Intro para abrir · Ctrl K para buscar