Redis y el Patrón Cache-Aside
Tres llamadas a proveedores por valuación son caras de repetir, tolerables de servir un poco desactualizadas, y exactamente lo que el patrón cache-aside de Redis fue construido para absorber.
Cada valuación que este servicio calcula cuesta tres llamadas salientes — Black Book, Carfax y S&P VIS — cada una con su propia latencia, su propio modo de falla, y en algunos contratos, su propia tarifa por llamada. Si un concesionario busca el mismo VIN dos veces en una tarde, o tres concesionarios distintos cotizan el mismo vehículo de trade-in que acaba de salir de un leasing, recalcular esa valuación desde cero cada vez es puro desperdicio: el vehículo no ha cambiado, el mercado no se ha movido de forma significativa en los últimos treinta minutos, y es muy probable que los tres proveedores devuelvan los mismos números que devolvieron la primera vez. Esta es precisamente la forma de problema que resuelve cache-aside.
Cache-aside (a veces llamado lazy loading) es un patrón, no una función específica de Redis — describe cómo la aplicación, no la caché, orquesta las lecturas y escrituras. El camino de lectura es una pequeña máquina de estados: revisar primero la caché; en un hit, devolver de inmediato y saltarse por completo la fuente de verdad; en un miss, caer al camino costoso (aquí, llamar a los tres proveedores y correr el motor de reglas), y luego escribir ese resultado en la caché antes de devolverlo, para que el siguiente lector obtenga un hit. La caché nunca inicia nada por sí sola — es puramente una tabla de búsqueda pasiva que la aplicación decide consultar o poblar.
La razón por la que esto encaja tan bien con las búsquedas de valuación es la misma razón por la que encaja con casi cualquier problema del tipo 'costoso de calcular, barato de almacenar, tolerante a algo de obsolescencia': la asimetría de costos es enorme. Un hit de caché es un viaje de ida y vuelta a Redis medido en milisegundos de un solo dígito; un miss de caché son tres llamadas HTTP a terceros que cada una puede tardar un segundo o más y cada una puede fallar de forma independiente. Y la obsolescencia aquí es genuinamente tolerable — una valuación de quince minutos de antigüedad es, para un concesionario decidiendo si hacer una oferta, indistinguible en términos prácticos de una calculada hace un segundo. Esa tolerancia es justamente lo que hace que cachear sea siquiera una opción válida; sería el patrón equivocado por completo para, digamos, el saldo de una cuenta.
Una caché sin expiración no es una caché — es un mapa descontrolado y en crecimiento perpetuo que eventualmente agotará la memoria de Redis, porque cada VIN que este servicio haya valuado alguna vez viviría en ella para siempre. Configurar un TTL (`Duration.ofMinutes(30)` en el bosquejo de la lección anterior) hace dos trabajos a la vez: acota la memoria al garantizar que las llaves que nadie ha pedido recientemente terminan expulsándose solas, y limita la obsolescencia al garantizar que una valuación nunca se sirva más vieja que la ventana que el negocio decidió que era aceptable. Elegir esa ventana es una disyuntiva real — TTLs más cortos significan datos más frescos y más llamadas a proveedores; TTLs más largos significan una operación más barata y un radio de impacto mayor si el precio de un proveedor estuvo brevemente equivocado justo cuando se cacheó.
Sin embargo, hay un modo de falla más agudo escondido dentro de este patrón, y vale la pena nombrarlo incluso en una lección introductoria: la estampida de caché (cache stampede, también llamada thundering herd). Imagina que la entrada de caché de un VIN popular expira justo en el momento en que llegan cincuenta solicitudes concurrentes por él. Cada una revisa la caché, cada una falla, y las cincuenta proceden a bombardear simultáneamente a Black Book, Carfax y S&P VIS por un valor que está a punto de ser idéntico cincuenta veces — exactamente el desperdicio que la caché existía para prevenir, concentrado en un solo pico doloroso.
Existen dos mitigaciones estándar, y este servicio se inclina hacia la primera: aplicar jitter al TTL para que las llaves no expiren todas al unísono — en lugar de que cada VIN cacheado a las 9:00am expire exactamente a las 9:30am, agregar un pequeño desplazamiento aleatorio (digamos, más o menos dos minutos) para que las expiraciones se dispersen en el tiempo en lugar de agruparse. La segunda es un bloqueo de corta duración (a veces implementado con el propio `SETNX` de Redis): la primera solicitud que falla en la caché adquiere un bloqueo y hace el trabajo costoso, mientras las otras cuarenta y nueve esperan brevemente a que ese resultado llegue a la caché, o recurren a un valor ligeramente obsoleto en lugar de recalcular todas de forma independiente.
Nótese lo que ninguna de las dos mitigaciones requiere: ninguna cambia la forma fundamental de cache-aside del primer párrafo. El TTL con jitter y los bloqueos anti-estampida son refinamientos añadidos sobre el mismo bucle de revisar-fallar-poblar, lo cual vale la pena recordar — cache-aside es deliberadamente simple, y la mayor parte de su endurecimiento en producción trata de suavizar sus casos límite, no de reemplazar su idea central.
suspend fun getValuation(vin: String): ValuationResult {val cacheKey = "valuation:$vin"// 1. Check the cachevaluationCache.opsForValue().get(cacheKey).awaitSingleOrNull()?.let { cached ->return cached // hit — skip the providers entirely}// 2. Miss — fall through to the source of truth (three provider calls + rules engine)val fresh = computeValuationFromProviders(vin)// 3. Populate the cache with a jittered TTL to avoid synchronized expirationsval jitter = Random.nextLong(-120, 120) // secondsval ttl = Duration.ofMinutes(30).plusSeconds(jitter)valuationCache.opsForValue().set(cacheKey, fresh, ttl).awaitSingle()// 4. Return to callerreturn fresh}
This is illustrative only (runnable: false) — the cache-aside read path as its own function: check, miss, read source of truth, populate, return. Requires a live Redis connection, so it isn't runnable in-browser.
suspend fun getValuationWithStampedeGuard(vin: String): ValuationResult {val cacheKey = "valuation:$vin"val lockKey = "lock:valuation:$vin"valuationCache.opsForValue().get(cacheKey).awaitSingleOrNull()?.let { return it }// SETNX-style lock: only one caller wins the right to recomputeval acquiredLock = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5)).awaitSingle()if (!acquiredLock) {delay(150) // brief wait for the lock holder to populate the cachereturn valuationCache.opsForValue().get(cacheKey).awaitSingleOrNull()?: computeValuationFromProviders(vin) // fallback if the holder is still working}val fresh = computeValuationFromProviders(vin)valuationCache.opsForValue().set(cacheKey, fresh, Duration.ofMinutes(30)).awaitSingle()redisTemplate.delete(lockKey).awaitSingle()return fresh}
This is illustrative only (runnable: false) — a short-lived lock guarding against cache stampede: only the first miss recomputes, while concurrent misses on the same key wait briefly instead of all calling the providers.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. A popular VIN's cached valuation expires, and 200 requests for that same VIN arrive within the same second. Under a plain cache-aside implementation with no stampede protection, what actually happens?