1. Limitaciones de L1 y el papel del caché L2
Si ha desactivado honestamente OSIV (spring.jpa.open-in-view=false) y mantiene las transacciones a nivel de servicio, entonces cada nueva llamada de negocio suele crear un nuevo persistence context. Esto es bueno para la arquitectura y la previsibilidad, pero tiene un efecto secundario: incluso si lee cinco veces seguidas la misma Category «en diferentes solicitudes», Hibernate comenzará desde cero cada vez. Es en ese momento cuando al desarrollador le surge la tentación de decir: «¿Podemos hacer algo para no tener que ir a la base de datos una y otra vez?». El second-level cache (L2 cache) es precisamente un intento de darle a Hibernate una “memoria” entre diferentes sesiones, pero hacerlo de forma cuidadosa y controlada.
Imagine que en Commerce Persistence Lab tiene un catálogo de categorías (Category) que cambia una vez por semana y se lee cientos de veces al día. Dentro de una misma transacción, el first-level cache solo ayudará si vuelve a acceder a la misma entidad por id. Pero si tiene dos llamadas de servicio diferentes (dos transacciones distintas), el first-level cache ya no participa, porque vive exactamente el tiempo que vive el EntityManager/Session.
El second-level cache resuelve precisamente este problema: vive «por encima» de las sesiones, a nivel de SessionFactory (aproximadamente, a nivel de aplicación), y puede proporcionar datos de entidades entre diferentes unit of work. Esto no significa que «Hibernate dejará de ir a la base de datos». Significa que, en algunos escenarios de lectura, podrá responder sin SQL si los datos ya están en L2 y se consideran actualizados según las reglas del caché.
2. Dónde vive el caché L2
Con el second-level cache, el problema más frecuente no suele estar en la API, sino en la imagen mental. Si el modelo mental es incorrecto, el caché se convierte en un «botón mágico de aceleración» o en una fuente de errores inexplicables («¿por qué actualicé la fila pero la aplicación sigue leyendo el valor antiguo?»). Necesitamos una visión tranquila y de ingeniería: el caché L2 es una capa entre Hibernate y la base de datos que puede participar en la lectura y, a veces, en la escritura, pero no reemplaza ni las transacciones, ni el persistence context, ni el SQL normal.
Dibujemos una cadena simplificada. No pretende abarcar el 100% de todos los detalles, pero fija bien lo principal:
flowchart TD
A["Service method (@Transactional)"] --> B["EntityManager / Session"]
B --> C{"L1 cache (persistence context) hay instancia managed?"}
C -->|Sí| D["Devolver el mismo objeto Java (sin SQL)"]
C -->|No| E{"L2 cache (shared) existe estado de la entidad?"}
E -->|Sí| F["Construir entidad managed a partir de los datos del caché"]
E -->|No| G["SQL a BD SELECT ..."]
G --> H["Guardar en L1 (y posiblemente en L2)"]
F --> H
H --> D
El punto clave aquí: incluso si los datos provienen de L2, Hibernate sigue creando una instancia managed y la coloca en L1 (persistence context). Es decir, L2 no es «devolver el mismo objeto que se creó ayer». Es más bien «usar el estado almacenado para no ejecutar SQL, pero seguimos trabajando dentro de la sesión actual».
Esta diferencia es importante porque el first-level cache se encarga del mapa de identidad y del ciclo de vida de los objetos managed dentro de una transacción, mientras que el second-level cache se encarga de la reutilización de datos entre transacciones. Son tareas diferentes, y si las mezcla, obtendrá algo así como intentar almacenar leche en la unidad del sistema: técnicamente posible, pero el olor será desaprobado por la ingeniería.
3. ¿Qué almacena en caché el L2?
Para que el caché L2 no se convierta en una fantasía del estilo «lo activamos y todo será rápido», debemos entender claramente sus límites. En la forma básica y más común, el second-level cache funciona como un caché de entidades por clave primaria: Hibernate almacena en caché el estado de la entidad para que, al cargar esa entidad por id en otra sesión, no sea necesario ejecutar SQL.
Aquí es útil una comparación entre first-level y second-level cache en forma de tabla. Es breve, pero muy aterrizadora:
| Propiedad | First-level cache (L1) | Second-level cache (L2) |
|---|---|---|
| Dónde vive | dentro de Session / EntityManager | a nivel de SessionFactory (condicionalmente «para toda la aplicación») |
| Límite de vida | una unit of work (generalmente una transacción) | muchas unit of work (muchas transacciones) |
| Objetivo principal | mapa de identidad + base para dirty checking | reutilización de datos entre sesiones |
| Qué almacena en caché | objetos managed en memoria (y sus snapshots) | estado de la entidad (datos), no el objeto Java como tal |
| «¿Funciona solo?» | sí, siempre | no, es una decisión consciente y requiere infraestructura |
Ahora hablemos de lo que L2 no está obligado a hacer. No promete «almacenar en caché cualquier consulta JPQL». Si hace una consulta «lista de productos por estado», el SQL igualmente puede ejecutarse, porque no es una «carga por id», sino una consulta completa. Sin mecanismos especiales, Hibernate no debe adivinar que «el resultado de esta consulta se puede tomar del caché». Solo puede usar L2 como fuente de datos para entidades que se cargan por clave.
Esta es otra razón por la que en el curso hablamos tanto sobre read-model y projections. Muchas de las lecturas «pesadas» reales no son find-por-id, sino listas, tablas, informes, where + order by + paginación. El caché L2 en su forma pura ayuda a muchos escenarios de lectura, pero no a todos.
En cambio, puede ser útil para catálogos, entidades que cambian raramente y lecturas repetitivas de findById. Un candidato típico en nuestro dominio es Category (si la lee por id con frecuencia y la modifica rara vez). Un candidato más discutible es Product, porque un producto puede cambiar más a menudo (precio, estado, soft delete), y el coste del almacenamiento en caché puede resultar mayor que el beneficio, especialmente si hay mucha invalidación.
4. Frescura e invalidación del L2
Cuando un desarrollador escucha por primera vez «caché entre transacciones», suele alegrarse. Cuando por primera vez se topa con datos obsoletos, empieza a entristecerse. Y aquí es importante una conversación honesta: cualquier caché compartido (y L2 es precisamente un caché compartido) paga la velocidad a costa de problemas de frescura, invalidación y complejidad operativa. Por eso, en la práctica de ingeniería, el second-level cache no es un «default ON», sino una herramienta que se activa para un perfil de carga concreto y requisitos específicos de actualidad de datos.
Hibernate sabe invalidar (limpiar) las entradas del caché cuando él mismo ejecuta UPDATE/DELETE para la entidad correspondiente. Esto parece lógico: si Hibernate actualizó una Category, puede descartar o actualizar su entrada en L2. Pero hay un matiz que la documentación de Hibernate subraya constantemente (y que realmente aparece en producción): si los datos cambian no a través de Hibernate (por ejemplo, otro servicio, otro lenguaje, SQL manual en el panel de administración, migraciones «en caliente»), entonces el caché no se entera automáticamente.
Y aquí comienza el «divertido desafío» con la pregunta: «¿Hasta qué punto me conviene almacenar en caché estos datos?». Si tiene un solo servicio y una sola BD, y todos los cambios se realizan a través del mismo Hibernate, la situación es más sencilla. Si tiene varias aplicaciones que escriben en el mismo esquema, el caché L2 se vuelve más peligroso: puede devolver un estado ligeramente desactualizado, y eso ya no es una optimización de rendimiento, sino un generador de errores.
En nuestro proyecto educativo no construimos un entorno distribuido, pero incluso en un monolito existen causas típicas de desactualización. Por ejemplo, si hizo una actualización masiva (el tema de ayer) o realizó un cambio directo mediante SQL en el laboratorio. Una actualización masiva puede eludir las entidades managed y provocar desincronización no solo en el first-level cache (stale persistence context), sino también en las capas «compartidas». Por lo tanto, la disciplina general es la siguiente: cuanto más se aleje del modelo orientado a entidades (bulk, SQL nativo, escrituras externas), más cuidado debe tener con cualquier forma de almacenamiento en caché.
Y es precisamente por eso que en el curso repetimos constantemente el mantra: primero arregle el fetching, la forma de las consultas y el SQL, y luego considere el caché como un potenciador. El caché no es un sustituto de un diseño de lectura correcto, sino un mecanismo para reutilizar una lectura ya bien diseñada.
5. CacheMode en la sesión de Hibernate
Ahora, la parte práctica que es útil conocer incluso si el caché L2 aún no está activado en el proyecto. Hibernate permite controlar cómo la Session actual se relaciona con el caché. Esto se hace a través de CacheMode. Y esta es una idea muy importante: el almacenamiento en caché en Hibernate no es algo que «se activa una vez y se olvida». Puede decir para un caso de uso concreto: «lee del caché si es posible», o «ignora el caché», o «vuelve a leer de la base de datos y actualiza el caché».
Lo mejor es que se puede mostrar con pequeños fragmentos de código comprensibles, sin necesidad de configurar una infraestructura enorme. En Commerce Persistence Lab solemos trabajar a través de EntityManager, pero la API específica de Hibernate es accesible mediante unwrap(Session.class).
A continuación se muestra un ejemplo mínimo donde activamos el modo «GET». Significa que se puede leer del caché si hay algo allí. Este modo por sí mismo no garantiza un acierto de caché: si no hay caché o está vacío, Hibernate igualmente irá a la BD.
import jakarta.persistence.EntityManager;
import org.hibernate.CacheMode;
import org.hibernate.Session;
// Obtenemos la sesión de Hibernate desde el EntityManager de JPA para gestionar el caché a nivel de Hibernate
Session session = entityManager.unwrap(Session.class);
// Permitimos la lectura desde L2 (si hay datos), pero no obligamos a Hibernate a "solo desde el caché"
session.setCacheMode(CacheMode.GET);
// En caso de hit de caché puede que no se necesite SQL, pero de todas formas se creará una instancia gestionada en la sesión actual (L1)
Product product = session.find(Product.class, 1L);
Una idea útil: CacheMode.GET es un buen «modo mental por defecto» para lecturas donde no se opone al caché, pero tampoco quiere gestionarlo agresivamente. No hace que el caché sea «obligatorio», lo hace «permitido».
Ahora, un ejemplo del modo opuesto: IGNORE. Es útil cuando se realiza un escenario de diagnóstico y se desea ver SQL de forma predecible, o cuando se sospecha que el caché puede devolver datos obsoletos y se quiere evitar deliberadamente.
import jakarta.persistence.EntityManager;
import org.hibernate.CacheMode;
import org.hibernate.Session;
// Se necesita la sesión de Hibernate para establecer el CacheMode para una unidad de trabajo específica
Session session = entityManager.unwrap(Session.class);
// Ignoramos completamente el caché L2: Hibernate funcionará como si el caché compartido no existiera
session.setCacheMode(CacheMode.IGNORE);
// Útil para diagnóstico: la lectura es más "honesta" y predecible (lo más probable es que haya SQL)
PurchaseOrder order = session.find(PurchaseOrder.class, 10L);
Y finalmente, el modo que a menudo se percibe como «anti-magia»: REFRESH. La idea es simple: «no quiero leer del caché, quiero leer de la base de datos y actualizar el caché con datos frescos». Esto puede ser necesario en escenarios de administración, después de actualizaciones sospechosas, o en pruebas/laboratorios cuando se desea sincronizar explícitamente el estado en caché con la BD real.
import jakarta.persistence.EntityManager;
import org.hibernate.CacheMode;
import org.hibernate.Session;
// Gestionamos el comportamiento del caché solo dentro de la sesión/transacción actual
Session session = entityManager.unwrap(Session.class);
// Forzamos la lectura desde la BD y la actualización del estado en el caché (si L2 está habilitado y configurado)
session.setCacheMode(CacheMode.REFRESH);
// Útil cuando se necesita frescura garantizada: leemos desde la BD y actualizamos el estado en caché
Product product = session.find(Product.class, 1L);
Para que todo esto no se convierta en un lío, conviene tener una pequeña tabla (no para memorizar, sino para tener a mano):
| CacheMode | Cómo pensar en lenguaje humano | Cuándo es apropiado |
|---|---|---|
| GET | «Permito usar el caché» | casos de uso de lectura habituales donde es posible y no peligroso un acierto de caché |
| IGNORE | «No tocamos el caché en absoluto» | diagnóstico, sospecha de datos obsoletos, deseo de SQL predecible |
| REFRESH | «Leemos de la BD, actualizamos el caché» | escenarios que requieren frescura, operaciones administrativas, sincronización controlada |
Una aclaración importante: incluso cuando los datos provienen de L2, siguen siendo managed dentro de la sesión actual y participan en sus reglas. Es decir, CacheMode no anula L1, afecta la relación con L2.
6. Candidatos al caché: @Cacheable y region
La pregunta que surge de inmediato: «De acuerdo, ¿cómo sabe Hibernate qué entidades se pueden mantener en el segundo nivel?». La respuesta es mediante la combinación de dos decisiones. La primera decisión es que usted marca la entidad como candidata a cacheable. La segunda es que usted (o su equipo) elige la estrategia de concurrencia y en qué región se almacena en caché. Es importante señalar que sin un proveedor (implementación externa del caché) esto no funcionará realmente, pero como «lectura de código» y «comprensión de la intención», es necesario conocerlo ya.
Para empezar, el nivel más simple de JPA: @Cacheable. Indica: «esta entidad se puede considerar para el caché compartido». Por defecto, muchos proyectos siguen el enfoque de «almacenamiento en caché selectivo» para no almacenar en caché todo sin querer.
Aquí hay un mini-fragmento de cómo podría verse en la entidad Category en nuestro proyecto:
import jakarta.persistence.Cacheable;
import jakarta.persistence.Entity;
@Entity
@Cacheable // Señal de JPA: la entidad puede ser considerada candidata para el caché de segundo nivel (L2)
public class Category {
// ...
}
Ahora, el nivel específico de Hibernate: la anotación @Cache, donde normalmente se especifican la estrategia y la región. La estrategia trata sobre cómo se comporta el caché ante cambios concurrentes. La región es simplemente el nombre de la «caja» donde se colocan las entradas de este tipo. La región es importante para que el caché sea gestionable: pueda decir «limpiar el caché de categorías» sin afectar al resto.
Ejemplo (breve e ilustrativo):
import jakarta.persistence.Cacheable;
import jakarta.persistence.Entity;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Cacheable // Permitimos el caché compartido a nivel de JPA
@Cache(
usage = CacheConcurrencyStrategy.READ_WRITE, // Cómo se sincroniza el caché ante cambios concurrentes
region = "catalog.category" // Nombre explícito de la región para poder gestionar/limpiar selectivamente después
)
public class Category {
// ...
}
¿Por qué READ_WRITE? En la vida real, esta decisión depende de cuán modificable es la entidad y de cuán importante es la consistencia. Para catálogos, a menudo es adecuado READ_ONLY o enfoques similares (si los datos son realmente inmutables), pero ahora no estamos haciendo de esta lección un curso sobre estrategias de proveedores. Nuestro objetivo es ver que el almacenamiento en caché no es «activar una bandera», sino una expresión explícita de intención en el código: «este tipo de datos se puede reutilizar entre transacciones».
Y otro matiz importante que suelen confundir los principiantes. Si marca una entidad como cacheable, eso no significa que cualquier consulta a esa tabla deje de ir a la BD. El caché suele ayudar principalmente en la carga por id. Por lo tanto, si su escenario es una lista de productos a través de una proyección o un JPQL complejo, el caché L2 puede que apenas participe. Y eso es normal: el caché no está obligado a salvar cada escenario, ayuda allí donde coinciden las condiciones de uso.
7. Limpieza del caché: evict y disciplina
Un caché sin posibilidad de limpiar no es un caché, es una trampa. Incluso si lo ha diseñado todo a la perfección, de vez en cuando necesitará poder decir: «descartamos esta entrada, que Hibernate la vuelva a leer de la base de datos». En JPA, para ello existe la API Cache a nivel de EntityManagerFactory. Hibernate también tiene sus propias formas, pero la variante JPA es suficientemente clara y útil como base.
En una aplicación Spring Boot, es conveniente mantener el EntityManagerFactory (o SessionFactory) y realizar operaciones de eviction puntuales. Por ejemplo, podemos agregar un pequeño componente de servicio en com.example.commerce.common.jpa (o cerca de donde tenga este tipo de cosas de infraestructura) y poder limpiar un Product específico por id.
Mini-ejemplo:
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceUnit;
@PersistenceUnit
private EntityManagerFactory entityManagerFactory; // Inyectamos la fábrica para obtener acceso al caché de JPA (L2)
public void evictProduct(long productId) {
// Eliminamos exactamente una entrada: en la siguiente lectura, Hibernate/proveedor de caché se verán obligados a releer desde la BD
entityManagerFactory.getCache().evict(Product.class, productId);
}
¿Qué aporta esto en la práctica? En un laboratorio educativo, es una herramienta muy útil para «eliminar la magia y ver la realidad». En una aplicación real, ofrece la posibilidad de forzar la limpieza del caché después de operaciones administrativas, migraciones o actualizaciones masivas sospechosas. Es importante no convertir evict() en un «parche diario para todo»: si está limpiando constantemente el caché, significa que el caché no es necesario, o que está almacenando en caché lo que no debe, o que sus escenarios de actualidad de datos están mal definidos.
Otra opción es la limpieza completa del caché. Es realmente un botón de emergencia que conviene usar con poca frecuencia, porque puede anular el beneficio del caché y aumentar temporalmente la carga en la BD (después de la limpieza, todo empezará a calentarse de nuevo).
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceUnit;
@PersistenceUnit
private EntityManagerFactory entityManagerFactory; // Acceso al caché compartido a nivel de aplicación
public void evictAll() {
// Limpieza de emergencia: eliminamos todo el caché L2 (luego habrá "calentamiento" y más SQL durante un tiempo)
entityManagerFactory.getCache().evictAll();
}
En resumen: evict no es una «muleta», es un elemento de la disciplina de operación del caché. Cualquier caché compartido vive en el mundo real, donde existen correcciones manuales, escenarios inusuales y la necesidad de restaurar rápidamente la consistencia sin reiniciar toda la aplicación.
8. Errores típicos al trabajar con second-level cache
Error nº1: confundir second-level cache con first-level cache y esperar que conserve la identidad del objeto.
Una ilusión muy común suena así: «Si el objeto estuvo en el caché ayer, entonces hoy obtendré la misma instancia de Java». No, no funciona así. L2 almacena datos (estado), no su referencia al objeto. En cada nueva sesión, Hibernate igualmente creará una instancia managed, solo que puede rellenarla no desde SQL, sino desde el caché. Si construye lógica basada en == entre diferentes transacciones, la está construyendo sobre arena.
Error nº2: activar L2 cache como «medicina para una consulta lenta».
Si tiene problemas de rendimiento en una lista de productos debido a N+1, un SELECT amplio o un JOIN pesado, activar el segundo nivel normalmente no hace que esa consulta sea «saludable». A menudo, seguirá yendo a la BD. El caché es un potenciador de lecturas repetitivas, no un sustituto de un diseño de consultas adecuado. Primero corrija el fetching y el perfil SQL, y solo después considere el uso de caché compartido.
Error nº3: almacenar en caché entidades que cambian con frecuencia y sorprenderse por la invalidación constante.
Si una entidad se actualiza constantemente, el caché se limpiará o reconstruirá continuamente. Al final, pagará la complejidad y apenas obtendrá beneficio. Un síntoma típico: activó con orgullo el caché para Product, y luego tiene cambios de precio masivos, cambios frecuentes de estado, soft delete, auditoría... y el caché la mayor parte del tiempo está vacío o se invalida constantemente. No es que «Hibernate sea malo», es que el candidato no es el adecuado.
Error nº4: olvidarse de los cambios externos a los datos y obtener un estado desactualizado.
Si otras aplicaciones escriben en la base de datos, scripts SQL manuales o algún mecanismo «diferente», el caché puede devolver valores antiguos porque no es telépata. Hibernate invalida el caché cuando él mismo modifica los datos, pero no puede saber de cambios «externos» sin una infraestructura adicional. Por lo tanto, antes de almacenar en caché, debe responder honestamente: «¿Nos sirve este modelo de consistencia?».
Error nº5: usar CacheMode como un interruptor mágico sin comprender el escenario.
CacheMode.IGNORE es útil para diagnóstico, pero si lo establece globalmente «por si acaso», estará desactivando el beneficio de L2. CacheMode.REFRESH es útil para forzar frescura, pero si comienza a usarlo sin motivo, estará constantemente golpeando la BD y actualizando el caché, convirtiendo el caché en trabajo extra. CacheMode debe ser la respuesta a una pregunta concreta del use case, no a la ansiedad del desarrollador.
GO TO FULL VERSION