1. «La caché funciona» — hipótesis
Con la elección de la política local para Redis se puede vivir de diferentes maneras: dejarlo desechable o conservar /data mediante un volumen con nombre. Pero esto no afecta al hecho mismo de que la caché funcione. Mientras Redis esté disponible y el perfil cache esté activado, la pregunta principal es: ¿la lectura repetida realmente ha dejado de ir al almacenamiento principal o solo nos lo parece?
En el almacenamiento en caché hay un truco psicológico astuto: cuando todo está configurado, se vuelve… invisible. Las solicitudes siguen devolviendo JSON como antes. El código del controlador sigue siendo simple como antes. Y es precisamente por eso que un principiante a menudo verifica la caché con la frase «bueno, parece que va más rápido» — y eso es más o menos como medir la temperatura del procesador con la palma de la mano. A veces funciona, pero es mejor no ponerlo en el informe.
En nuestra realidad, la caché debe confirmarse no con emociones, sino con señales observables. Además, las señales deben ser comprensibles en el entorno Docker/Compose, donde tenemos varios contenedores, y cualquier «aceleración» puede ser casual (por ejemplo, la JVM se ha calentado, Postgres ha almacenado en caché páginas, la red ha tenido un buen día). Por eso, hoy elegimos conscientemente una verificación que «hace clic» de forma binaria: o bien el método realmente fue al almacenamiento principal, o bien no fue en absoluto, porque la respuesta vino de Redis.
Y una aclaración importante. No medimos el rendimiento, no construimos benchmarks y no ajustamos Redis. Para un curso sobre Docker esto es innecesario. Construimos un hábito sostenible: cualquier capa de infraestructura debe poder verificarse a través de registros/señales, de la misma manera que verifica que un contenedor se haya iniciado.
2. Qué consideramos cache hit y miss
Para no ahogarnos en detalles, acordemos una definición simple que funciona bien en el proyecto educativo Container-Ready Catalog Service. Cache miss para nosotros es una situación en la que una solicitud de lectura obliga a la aplicación a realizar un «trabajo real»: ir al almacenamiento principal (en el modo postgres será PostgreSQL a través del repositorio JPA). Cache hit es una situación en la que la misma solicitud de lectura devuelve el mismo resultado sin volver a ir al almacenamiento principal, porque el valor ya está en Redis.
Es importante entender un matiz de Spring Cache que nos será útil para la diagnosis. La anotación @Cacheable está diseñada de tal manera que en caso de hit, Spring puede no llamar a su método en absoluto y simplemente devolver el resultado almacenado. Suena como magia, pero para la observabilidad es un regalo: si agregamos un registro exactamente en el cuerpo del método cacheado, entonces ese registro aparecerá solo en miss. En hit no aparecerá porque el método no se ejecutó. Eso es la señal «binaria» que necesita un principiante.
Para mayor claridad, es útil tener en mente esta imagen (muy simplificada, pero correcta en esencia):
sequenceDiagram
participant Client as "Cliente (curl/Postman)"
participant App as "App (Spring Boot)"
participant Redis as Redis
participant Pg as PostgreSQL
Note over Client,App: "1.ª solicitud (cache miss)"
Client->>App: GET /api/catalog/items/10
App->>Redis: "GET (clave)"
Redis-->>App: "(vacío)"
App->>Pg: SELECT ...
Pg-->>App: row
App->>Redis: "SET (clave=valor)"
App-->>Client: 200 OK + JSON
Note over Client,App: "2.ª solicitud (cache hit)"
Client->>App: GET /api/catalog/items/10
App->>Redis: "GET (clave)"
Redis-->>App: valor
App-->>Client: 200 OK + JSON
Una vez más: no estudiamos Redis como producto, lo estudiamos como una dependencia del Compose-stack. Por lo tanto, solo nos interesa un hecho: «la primera vez estaba vacío, la segunda vez lo encontramos».
3. Registro en @Cacheable: hacer visible el miss
El error más común de un principiante es tratar de ver la caché en el controlador. El controlador se ejecutará en cada solicitud HTTP, independientemente de la caché, y esto no probará nada. Necesitamos un registro donde no haya trabajo en caso de hit. Y el lugar ideal es el cuerpo del método marcado con @Cacheable.
Imaginemos (o recordemos de su proyecto) que tenemos un CatalogQueryService que el controlador utiliza para las lecturas. Almacenamos en caché 1–2 escenarios: lectura de un elemento por id y, posiblemente, lectura de una lista. Hacemos los registros lo más «escolares» posible para que a través de ellos se pueda aprender a leer el comportamiento del sistema, no adivinarlo.
Ejemplo para lectura por id:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.Cacheable;
public class CatalogQueryService {
private static final Logger log = LoggerFactory.getLogger(CatalogQueryService.class);
@Cacheable("catalog-item-by-id") // La clave de caché depende del argumento id (por defecto Spring Cache tiene en cuenta los parámetros del método)
public CatalogItem findById(Long id) {
// Este registro aparecerá solo en cache miss: en cache hit el método normalmente no se llama en absoluto
log.info("Cache MISS -> loading item {} from storage", id);
// "Trabajo real": acceso al almacenamiento principal (BD/repositorio/servicio externo)
return storage.findById(id);
}
}
El sentido de este fragmento de código es: si ve en los registros la línea Cache MISS -> ..., significa que Spring ejecutó el método y realmente fuimos al almacenamiento principal. Si hace una segunda solicitud idéntica y la línea no se repite, es una fuerte evidencia de que el método fue omitido y el resultado se tomó de la caché.
Para la lista, la idea es la misma. La lista no tiene argumentos, por lo que la clave «por defecto» será la misma (no se asuste; solo recuerde que findAll() es un escenario de lectura repetible):
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.Cacheable;
public class CatalogQueryService {
private static final Logger log = LoggerFactory.getLogger(CatalogQueryService.class);
@Cacheable("catalog-items") // Almacenamos en caché el resultado "de la lista completa" bajo una misma clave (sin argumentos)
public List<CatalogItem> findAll() {
// En una solicitud repetida este registro no aparecerá si se produce un cache hit
log.info("Cache MISS -> loading catalog items list from storage");
// Aquí mostramos explícitamente el punto de "acceso al almacenamiento"
return storage.findAll();
}
}
En ese momento suele aparecer la pregunta: «¿Y por qué no registrar directamente en el repositorio?» Se puede, pero entonces se pierde la ventaja clave: en hit su método no se ejecuta y se ve la ausencia del registro. Si se pone el registro «más abajo» (dentro del repositorio), en hit tampoco lo verá, pero en la lectura de la relación causa-efecto, al principiante le resulta más fácil aferrarse al método de servicio que él mismo marcó con @Cacheable.
Y otra observación pequeña pero importante sobre el entorno Docker. Los registros deben ir a stdout/stderr (ya tenemos esto como base), por lo que estos log.info(...) se leerán normalmente a través de docker compose logs. Es decir, no construimos una diagnosis «local» separada; hacemos la diagnosis amigable con contenedores desde el principio.
4. Verificación a través de HTTP y docker compose logs
La verificación de la caché debe ser como una buena prueba: repetible y aburrida. Si provoca emociones, es que algo se ha hecho mal, y probablemente no será la caché, sino un efecto placebo. Por eso elegimos un endpoint y una solicitud concreta que se pueda repetir dos veces seguidas.
Un escenario típico para un servicio educativo es la lectura de un elemento por id:
# Hacemos la MISMA solicitud dos veces seguidas: así capturamos la transición miss -> hit
curl -i http://localhost:8080/api/catalog/items/10
curl -i http://localhost:8080/api/catalog/items/10
Si usa un archivo .http o Postman, la lógica es la misma. Lo principal es que el id sea el mismo. De lo contrario, usted mismo está provocando un cache miss y luego se queja de Redis.
Al mismo tiempo, miramos los registros del contenedor de la aplicación. En Compose es conveniente hacerlo así (no hacemos de esto una «lección sobre comandos», solo mostramos la idea):
# Miramos stdout/stderr de la aplicación: allí debería aparecer el registro sobre MISS solo en la primera solicitud
docker compose logs -f app
Y debería ver una dinámica más o menos así: en la primera solicitud aparece la línea Cache MISS -> ..., en la segunda no. Si su registro se ve diferente, no es «malo», es solo una señal de que necesita entender qué está verificando realmente.
A veces es conveniente mirar también los registros de Redis para ver que el contenedor está vivo y no se cae al inicio:
# Verificamos que Redis como contenedor está vivo y escribe registros
docker compose logs -f redis
Pero no espere que Redis imprima «cache hit» por sí solo. Redis es simplemente un almacenamiento. «Hit» y «miss» son el significado que le damos a la lectura en el lado de la aplicación.
En el entorno de contenedores hay otra señal útil de que «el sistema está vivo»: el estado de salud (health status). Si ha configurado healthcheck (lo hicimos en la lección sobre Compose wiring), entonces el comando docker compose ps mostrará healthy/unhealthy (el formato depende de la versión de Docker, pero la idea es la misma). Esto ayuda a distinguir la situación «la caché no funciona» de «Redis ni siquiera se inició».
5. Verificamos Redis: exec, ping, monitor
Cuando agrega por primera vez una nueva dependencia a la pila Compose, desea tener un mínimo de herramientas para asegurarse rápidamente de que se trata realmente de Redis y no de un «contenedor que hace como que es». Aquí es útil una disciplina simple: primero verificamos la disponibilidad de la dependencia misma, luego la integración de la aplicación con ella, y solo después el efecto de negocio (hit/miss).
La verificación más simple de Redis dentro del contenedor es PING. En Compose se ve así:
# Verificamos que redis-cli ha llegado al servidor Redis dentro del contenedor
docker compose exec redis redis-cli ping
# PONG
Si en lugar de PONG ve un error, tiene un problema a nivel de «Redis no está listo/no funciona» o «estamos ejecutando exec en el lugar equivocado», no un problema a nivel de «Spring Cache».
A veces (solo para entender, sin convertir el día en un curso de Redis) es útil ver una vez que la aplicación realmente está ejecutando comandos de Redis. Para eso existe el «modo de escucha» MONITOR. Imprime todos los comandos que Redis recibe. Es ruidoso, pero en un escenario educativo de un par de solicitudes es muy ilustrativo:
# ATENCIÓN: el monitor es muy ruidoso, ejecútelo solo por un tiempo corto para diagnóstico
docker compose exec redis redis-cli monitor
Luego hace dos solicitudes HTTP idénticas en otra pestaña, y en el monitor verá que Redis recibe GET/SET (los comandos exactos y el formato dependen de la serialización y la implementación del cache manager, pero el significado es el mismo: en la primera solicitud habrá una escritura, en la segunda una lectura de una clave ya existente).
Aquí es importante no caer en la trampa típica del principiante: «No veo en MONITOR exactamente lo que esperaba, por lo tanto la caché no funciona». No se apresure. Spring Cache es una abstracción, puede generar claves de manera diferente a como usted las escribiría manualmente, y la serialización puede ser binaria. En esta lección nos interesa el hecho de que aparezca actividad en Redis en la primera solicitud y una lectura más «limpia» en la segunda, no el nombre exacto de la clave. El nombre exacto de la clave ya es otro tema, y fácilmente convierte un curso de Docker en un club de Redis.
Si desea la señal más «honesta» precisamente a nivel de aplicación, entonces el registro en el método @Cacheable (de la sección anterior) sigue siendo el mejor: es su código, usted confía en él, y está directamente vinculado a si el método se ejecutó o no.
6. Si no se ve el hit: analizamos las causas
En la práctica, el problema «la caché no funcionó» casi siempre se descompone en varias causas muy terrenales. Y cuanto antes aprenda a distinguirlas, menos tiempo pasará en el modo «estoy reescribiendo YAML con la esperanza de que el universo note mis esfuerzos».
A continuación, una tabla que ayuda a clasificar rápidamente la situación. No es una «lista de verificación para todas las ocasiones», sino una pista para nuestra pila app + postgres + redis, para que no confunda las capas.
| Observación en el comportamiento | A qué se parece | Qué señal verificar primero |
|---|---|---|
| El registro Cache MISS -> ... aparece tanto en la primera como en la segunda solicitud idéntica | La caché no funciona (la anotación no funciona o la llamada pasa por alto el proxy) | Asegurarse de que existe @EnableCaching, el método es public, y se llama desde fuera, no desde dentro de la misma clase |
| Error al iniciar la aplicación sobre Redis host/connection refused | Redis no está disponible en la red / host incorrecto | Verificar SPRING_DATA_REDIS_HOST=redis (no localhost), docker compose ps, healthcheck de Redis |
| La aplicación se inicia, pero en los registros hay mensajes de que Redis está desactivado/no configurado (o silencio total sobre la caché) | El perfil cache no está activado | Verificar SPRING_PROFILES_ACTIVE=postgres,cache y que realmente inició a través de Compose, no con un antiguo inicio manual |
| En redis-cli ping no hay PONG | El contenedor Redis no está vivo/no está listo | Primero tratamos Redis como servicio: registros, healthcheck, image/tag correctos, disponibilidad de redis-cli |
| Usted «espera un hit», pero cada vez solicita un id diferente | La verificación es incorrecta | Para un hit se necesita la misma solicitud: el mismo id, el mismo endpoint |
Tenga en cuenta: todas estas comprobaciones encajan en el enfoque del curso. No construimos una plataforma compleja de observabilidad, no activamos perfiles pesados, no profundizamos en los entresijos de la JVM. Simplemente miramos honestamente lo que sucede: perfiles, registros, readiness, nombres DNS en Compose.
Y otro matiz que a veces sorprende. Si ha activado la caché e inmediatamente hace una escritura (POST/PATCH), y luego lee, puede obtener «cosas extrañas»: la caché almacena el resultado de la lectura, pero la escritura ya ha cambiado los datos. Conscientemente no resolvemos este problema hoy (se trata de invalidación/actualización de la caché). Por lo tanto, el smoke-check debe ser lo más «limpio» posible: dos lecturas idénticas seguidas, sin cambios de datos entre ellas. No es porque «en el mundo real no sucede así», sino porque estamos aprendiendo a verificar el hecho mismo de la activación de la capa, no a diseñar todas las consecuencias.
7. Verificación correcta de «la caché no funciona»
A veces Redis está conectado perfectamente, las anotaciones están colocadas correctamente, Compose está sano, pero el estudiante aún dice: «No veo cache hit». Y luego resulta que la verificación fue más o menos así: «Llamé dos veces a GET /api/catalog/items/{id}, pero el id era diferente». O «Primero hice un PATCH, luego un GET, y esperaba que la caché lo adivinara automáticamente».
El almacenamiento en caché es algo estricto. No tiene intuición como un humano. Funciona por clave, y la clave generalmente depende de los argumentos del método. Si el argumento es diferente, entonces la clave es diferente, por lo tanto miss es absolutamente legítimo. Para un humano «estoy leyendo dos veces el mismo endpoint» suena como «lo mismo», pero para la caché id=10 y id=11 son dos mundos diferentes.
En nuestro escenario educativo, lo mejor es elegir un elemento de los datos semilla y trabajar solo con él. Así las solicitudes resultan lo más idénticas posible, no «introduce ruido», y el comportamiento miss → hit se ve tan claramente como la diferencia entre docker compose down y docker compose down -v.
Y aquí también vale la pena recordar un importante principio arquitectónico del día: el controlador no debe saber si Redis está activado o no. Si comienza a cambiar el controlador «para notar la caché», está rompiendo la idea de la extensión por perfiles. El controlador es el límite HTTP, se mantiene igual. Lo que cambia es el comportamiento dentro del servicio de lectura.
8. Errores típicos en la observación de cache hit/miss
Error n.º 1: el smoke-check se hace con diferentes solicitudes, pero se espera hit.
El escenario más popular: «Solicité el elemento dos veces, pero el id era diferente». En ese momento la caché tiene toda la razón al hacer miss cada vez, porque las claves son diferentes. Para verificar la caché en un día de aprendizaje, se necesita el mismo endpoint con el mismo parámetro. Si desea verificar la caché de una lista, entonces la lista también debe ser «la misma»: sin filtros aleatorios y sin cambios de datos entre solicitudes.
Error n.º 2: se olvidó de activar el perfil cache, y Redis se queda «simplemente como un contenedor al lado».
En Compose, Redis puede estar perfectamente iniciado, redis-cli ping puede responder PONG, pero si la aplicación se inició con el perfil postgres sin cache, entonces Spring Cache puede ni siquiera estar activado en la forma necesaria. Aquí ayuda un hábito simple: en los registros de la aplicación al inicio, debe ver los perfiles activos (ya lo hemos establecido en el módulo sobre registros). Si no aparece cache, entonces está verificando la caché en un modo donde no hay caché. No es un error, es un comportamiento correcto.
Error n.º 3: se especificó Redis host como localhost dentro de Compose.
Esto es «un clásico eterno» y no pasará de moda mientras exista localhost. Dentro de un contenedor, localhost es el propio contenedor. Redis vive en otro contenedor y se llama redis (nombre de servicio). Por lo tanto, SPRING_DATA_REDIS_HOST=redis no es estética, es física. Si ve connection refused, primero verifique esto, no las anotaciones en el código.
Error n.º 4: @Cacheable está presente, pero el método igual se ejecuta en cada solicitud, porque la llamada pasa por alto el proxy.
El almacenamiento en caché en Spring generalmente se implementa a través de un proxy. En lenguaje humano, esto significa que la anotación «se activa» cuando el método se llama como un método de bean desde fuera. Si dentro de la misma clase llama a su propio método @Cacheable (autollamada), el proxy no participa y la caché no se aplica. En un proyecto educativo, lo más fácil es mantener las lecturas cacheadas en un servicio separado (CatalogQueryService), que es llamado por el controlador. Entonces la llamada viene «desde fuera» y la anotación funciona como se espera.
Error n.º 5: se intenta demostrar hit mediante la aceleración de la respuesta, no mediante la desaparición de la consulta al almacenamiento principal.
«Se volvió más rápido» es un mal indicador. La JVM puede calentarse, Postgres puede almacenar en caché páginas, la red puede comportarse de manera diferente. Para nosotros, un buen indicador es la desaparición del «trabajo real»: el registro Cache MISS -> ... deja de aparecer en la solicitud repetida, o en Redis MONITOR se ve que la segunda solicitud es una lectura de un valor ya existente sin escritura.
GO TO FULL VERSION