Caché
En resumen: Una caché guarda una copia de los datos que se leen a menudo más cerca del código; con cache-aside el programa mira primero en la caché, y cuando los datos cambian hay que descartar la copia o mostrará datos viejos.
Problema
La página que le pregunta lo mismo a la base de datos
La tienda lanza una campaña de café. La página del producto pregunta a la base de datos unas 40 veces: el producto, el precio, el stock, las reseñas, los productos relacionados. Si la página se abre 2000 veces por minuto (un ejemplo, no una medición), son 80 000 consultas por minuto. Y las respuestas son las mismas, porque el precio del café no cambia cada segundo.
La base de datos está al 100 % de CPU, la página tarda 4 segundos en cargar y los compradores se van. Aquí está lo mismo en pequeño, con una base de datos que cuenta sus consultas:
def product_page(products, product_id): p = products.find(product_id) return f"{p.name}: {p.price:.2f} €"db = Database(CATALOG)popular = [7, 9, 12]for i in range(1000): product_page(db, popular[i % 3])print(f"aperturas: 1000, consultas a la base de datos: {db.queries}")aperturas: 1000, consultas a la base de datos: 1000Mil aperturas de tres productos: mil consultas para tres respuestas distintas.
La idea
Una copia delante de la base de datos
Si la respuesta no cambia a menudo, guárdala. El programa mira primero en la caché. Si hay una copia, la devuelve (un acierto). Si no (un fallo), pregunta a la base de datos, guarda una copia en la caché y devuelve la respuesta. La copia tiene una caducidad, el TTL: pasado ese tiempo la caché olvida la copia y la siguiente lectura va otra vez a la base de datos.
Los comandos de Redis para esto, TTL y DEL, los viste en la lección Clave-valor y la caché. Aquí la pregunta es otra: dónde vive la caché en el código, para que el resto del código no sepa de ella.
La respuesta es un envoltorio alrededor del repositorio. CachedRepository tiene la misma interfaz que la base de datos (Repository) y envuelve la base de datos como un Decorator. El servicio y la página reciben un repositorio y no saben si es una caché. Lo decide el código que ensambla los objetos.
Esto se llama cache-aside: la caché está al lado de la base de datos, y el código la llena cuando hay un fallo.
Míralo
La caché en código
El producto, el repositorio y la base de datos que cuenta sus consultas:
@dataclass(frozen=True)class Product: id: int name: str price: float# la base de datos: cuenta sus consultas para que las veamosclass Database: def __init__(self, products): self.rows = {p.id: p for p in products} self.queries = 0
def find(self, product_id): self.queries += 1 return self.rows[product_id]
def save(self, product): self.queries += 1 self.rows[product.id] = productinterface: Database y CachedRepository simplemente tienen los mismos métodos find y save (duck typing). Si quieres que mypy lo compruebe, los describes con typing.Protocol.El tiempo es un contador de ticks. Así el TTL vence igual en cada ejecución:
# el tiempo es un contador de ticks, no un relojclass Clock: def __init__(self): self.now = 0La caché: primero la copia, la base de datos si hay un fallo, y save escribe en la base de datos y descarta la copia.
@dataclassclass Entry: product: Product expires: int
class CachedRepository: def __init__(self, db, clock, ttl): self.db = db self.clock = clock self.ttl = ttl self.cache = {} self.hits = 0 self.misses = 0
def find(self, product_id): entry = self.cache.get(product_id) if entry and self.clock.now < entry.expires: self.hits += 1 return entry.product # una copia, no la verdad self.misses += 1 product = self.db.find(product_id) expires = self.clock.now + self.ttl self.cache[product_id] = Entry(product, expires) return product
def save(self, product): self.db.save(product) self.cache.pop(product.id, None) # descartar la copiaLas mil aperturas, pero a través de la caché, con un TTL de 100 ticks:
clock = Clock()db = Database(CATALOG)products = CachedRepository(db, clock, 100)popular = [7, 9, 12]for i in range(1000): clock.now = i # cada apertura es un tick product_page(products, popular[i % 3])print(f"consultas a la base de datos: {db.queries}")print(f"aciertos: {products.hits}, fallos: {products.misses}")consultas a la base de datos: 30aciertos: 970, fallos: 30Treinta consultas en vez de mil. Cada uno de los tres productos se lee una vez cada 100 ticks, y la copia responde las otras 970 aperturas.
Pruébalo
Predice la salida
La base de datos y el reloj son como en el programa de arriba, el TTL es de 100 ticks y el reloj se queda en 0. ¿Qué imprime?
products = CachedRepository(db, clock, 100)products.find(7)products.find(7)products.find(9)products.save(Product(7, "café", 12.90))products.find(7)products.find(7)print(products.hits, products.misses, db.queries)El primer find(7) es un fallo (consulta 1), el segundo es un acierto. find(9) es un fallo (consulta 2). save escribe en la base de datos (consulta 3) y descarta la copia de 7, así que el siguiente find(7) vuelve a ser un fallo (consulta 4), y el último es un acierto. Dos aciertos, tres fallos, cuatro consultas.
La idea
El precio viejo
El café baja de 14,90 € a 12,90 €. La importación nocturna de precios escribe directo en la base de datos, sin pasar por CachedRepository:
clock = Clock()db = Database(CATALOG)products = CachedRepository(db, clock, 5)print("tick 0:", product_page(products, 7))# la importación nocturna de precios escribe directo en la base de datosdb.save(Product(7, "café", 12.90))for t in range(1, 7): clock.now = t print(f"tick {t}:", product_page(products, 7))tick 0: café: 14.90 €tick 1: café: 14.90 €tick 2: café: 14.90 €tick 3: café: 14.90 €tick 4: café: 14.90 €tick 5: café: 12.90 €tick 6: café: 12.90 €La base de datos dice 12,90 €, y la página muestra 14,90 € hasta el tick 5. La caché es una copia, no la verdad. Nadie le avisó de que la verdad cambió, así que responde con el valor viejo hasta que vence el TTL. En una tienda real el TTL es de minutos, y el comprador ve un precio en la página y otro en el carrito.
Por eso todo el que cambia los datos descarta también la copia. Esto se llama invalidación de la caché. El mismo cambio, pero con save:
clock = Clock()db = Database(CATALOG)products = CachedRepository(db, clock, 5)print("tick 0:", product_page(products, 7))# el mismo cambio, pero con saveproducts.save(Product(7, "café", 12.90))clock.now = 1print("tick 1:", product_page(products, 7))print(f"consultas a la base de datos: {db.queries}")tick 0: café: 14.90 €tick 1: café: 12.90 €consultas a la base de datos: 3El precio nuevo se ve de inmediato. Las tres consultas son la lectura en el tick 0, la escritura y la nueva lectura tras la copia descartada.
El TTL se queda como red de seguridad: si algo alguna vez escribe sin pasar por la caché, la mentira vive un TTL como máximo.
Míralo
La copia, el precio viejo y la invalidación
def find(self, product_id): entry = self.cache.get(product_id) if entry and self.clock.now < entry.expires: self.hits += 1 return entry.product # una copia, no la verdad self.misses += 1 product = self.db.find(product_id) expires = self.clock.now + self.ttl self.cache[product_id] = Entry(product, expires) return product
def save(self, product): self.db.save(product) self.cache.pop(product.id, None) # descartar la copiaPaso a paso, en palabras
- Tick 0: la página pide el café (producto 7). La caché no tiene copia: un fallo.
- En un fallo la caché pregunta a la base de datos. Es la única consulta hasta ahora.
- La caché guarda una copia hasta el tick 5 (TTL de 5 ticks) y la página muestra 14.90 €.
- Tick 1: un acierto. Responde la copia y la base de datos no se entera.
- La importación de precios escribe 12.90 € directo en la base de datos. En el tick 2 la copia aún dice 14.90 €: el precio viejo.
- Nadie se lo dijo a la caché. La copia vieja vive hasta que su TTL vence en el tick 5.
- La corrección: el cambio pasa por save. Escribe en la base de datos y descarta la copia de inmediato.
- La siguiente apertura no encuentra copia, pregunta a la base de datos y obtiene 12.90 €.
- La caché es una copia. La verdad está en la base de datos, y quien la cambia descarta la copia.
save no escribe el precio nuevo en la caché, solo descarta la copia. La siguiente lectura toma la verdad de la base de datos. Por qué esto es más seguro está en el test del final.
Pruébalo
Completa
¿Qué método descarta la copia cuando cambia el precio?
def save(self, product):
self.db.save(product)
self.cache.(product.id, None)self.cache.pop(product.id, None) descarta la copia. None es lo que devuelve pop si no hay copia. Sin él, pop lanza un KeyError. El siguiente find es un fallo y toma el precio nuevo de la base de datos.
La idea
Cuándo una caché hace daño
Una caché es rápida porque miente un poco. Aquí está cuándo la mentira cuesta más que la velocidad:
- Valores que deben ser exactos. El precio y el stock en el pago se leen de la base de datos, no de la caché. Si no, vendes dos veces el último artículo o cobras el precio viejo.
- Cambios que no pasan por la caché. Una importación, otro programa o un
UPDATEmanual en la base de datos no descartan la copia. Cuantos más caminos escriben, más fácil es que uno se olvide. - Una carrera entre una lectura y una escritura. Una lectura con fallo toma 14,90 € de la base de datos. Antes de guardar la copia,
saveescribe 12,90 € y descarta la clave (todavía vacía). Entonces la lectura guarda sus 14,90 €, y viven un TTL entero. Es una condición de carrera entre dos solicitudes. Por eso el TTL nunca es infinito. - Todo vence a la vez. Si miles de copias vencen en el mismo momento, o la caché se reinicia vacía, todas las solicitudes van a la base de datos a la vez. El vencimiento se reparte con un poco de aleatoriedad, y los datos más leídos se cargan por adelantado.
- Cada servidor con su propia caché. Una caché en la memoria del proceso es rápida, pero con tres copias del servidor (en la lección Escalado) cada copia tiene su propio precio viejo. Por eso la caché compartida es aparte (Redis).
- Una caché que nadie midió. Una caché agrega código, memoria y errores. La agregas cuando un contador o una medición muestran que la base de datos es el cuello de botella, no por si acaso.
Pruébalo
Encuentra el error
Un colega guarda en la caché toda la página terminada del producto. Las pruebas pasan. Al día siguiente, los compradores que hablan inglés ven la página en español. ¿En qué línea está el error?
Elige la línea con el error.
La clave es solo el número del producto, pero la página también depende del idioma. Quien abre primero el producto elige el idioma para todos hasta que la copia vence. La clave tiene que contener todo de lo que depende la respuesta: f"page:{product_id}:{lang}". Lo mismo vale para la moneda, los permisos y cualquier otra cosa que haga distintas dos respuestas.
Más precisamente, para curiosos
Otras formas de usar una caché. Con read-through, la caché pregunta ella misma a la base de datos cuando hay un fallo (el código habla solo con la caché). Con write-through, la escritura pasa por la caché y la caché escribe en la base de datos. Con write-behind, la caché escribe en la base de datos más tarde, por lotes: es rápido, pero una caída pierde escrituras. Cache-aside es la más común, porque la caché puede caerse sin detener la tienda: entonces todo va a la base de datos.
Invalidación con un evento. Si el precio cambia en un servicio y se guarda en caché en otro, el primero no puede descartar una clave que no es suya. Entonces el cambio publica un evento «el precio de 7 cambió», y todo el que guarda en caché descarta su copia. Eso es la lección Eventos y colas.
Las cachés reales: Redis y Valkey (una caché compartida para todos los servidores), Memcached. Dentro del proceso: Caffeine en Java, IMemoryCache y HybridCache en ASP.NET Core, functools.lru_cache en Python (sin TTL), el paquete lru-cache en Node. Los frameworks también traen un envoltorio como el de aquí: @Cacheable y @CacheEvict en Spring, la caché de Django.
Cachés por el camino. El navegador y el CDN también guardan respuestas según la cabecera Cache-Control. Configurar un CDN y proxies es infraestructura y se enseña en el curso de DevOps ZeroToOps.
Phil Karlton dijo que en programación hay dos cosas difíciles: invalidar cachés y ponerles nombre a las cosas. Ahora sabes por qué la primera está en la lista.
Ejercicio
El precio de envío
Resuélvelo en tu editor.
El carrito le pregunta a un proveedor externo cuánto cuesta el envío a la ciudad del comprador. El proveedor responde despacio y cuenta las llamadas. Los precios: Sofía 4,90 €, Plovdiv 5,90 €, Varna 6,90 €. Escribe CachedQuotes: guarda el precio de cada ciudad durante 10 ticks y pregunta al proveedor solo cuando hay un fallo. Ejecuta las solicitudes en los ticks 0, 1, 2, 5, 11, 12 y 13 para las ciudades Sofía, Plovdiv, Sofía, Sofía, Sofía, Plovdiv y Varna.
tick 0, Sofía: 4.90 €tick 1, Plovdiv: 5.90 €tick 2, Sofía: 4.90 €tick 5, Sofía: 4.90 €tick 11, Sofía: 4.90 €tick 12, Plovdiv: 5.90 €tick 13, Varna: 6.90 €llamadas al proveedor: 5Cinco llamadas en vez de siete. Sofía en el tick 11 es un fallo: la copia del tick 0 vivió hasta el tick 10.
Más tareas
- Cuenta los aciertos. Agrega un contador de aciertos y fallos e imprímelos al final.
- El proveedor cambia un precio. Agrega un método con el que el proveedor diga «el precio de Varna cambió», y descarta solo esa copia.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Decorator · Repository · Condición de carrera · Capas
- Continúa en: Escalado · ADR: registros de decisiones de arquitectura
- Ejercicios sobre el tema: Arquitectura de software