CodeGym /Cursos /Spring Data JPA /Persistence context: identity map y caché

Persistence context: identity map y caché

Spring Data JPA
Nivel 20, Lección 0
Disponible

1. Persistence context: idea y límites

Si escuchas por primera vez la frase «dentro de ORM hay memoria», es fácil imaginar algo sospechoso: como si Hibernate construyera en secreto una «mini base de datos en RAM». En realidad, todo es mucho más pragmático. Tenemos una transacción como una operación de negocio, y dentro de ella queremos que las lecturas y modificaciones de datos sean secuenciales y predecibles — no solo a nivel de SQL, sino también a nivel de objetos Java.

Imagina un escenario simple de nuestro mini-shop. Estamos procesando un pedido. Dentro de placeOrder() podemos encontrarnos con el mismo producto varias veces: una vez al verificar el stock, otra vez al construir las líneas del pedido, una tercera vez al calcular el total o registrar algo para diagnóstico. Si cada uno de esos «contactos» creara un nuevo objeto Java Product, rápidamente obtendríamos:

- confusión (¿cuál de los objetos es el «real»?),
- inconsistencia (en un objeto ya se cambió un campo, en otro no),
- duplicados en memoria,
- sorpresas en asociaciones (el objeto de orderItem.getProduct() y el objeto de productRepository.findById() de repente son diferentes, aunque es la misma fila en la BD).

Por lo tanto, dentro del unit of work, la ORM mantiene un conjunto único de entidades gestionadas — de modo que una fila de tabla con un id específico corresponda a una única referencia Java dentro de la operación actual. De aquí surgen los dos conceptos clave de nuestra lección: identity map y first-level cache.

La palabra «contexto» suena como si estuviéramos a punto de hablar de psicología. Pero aquí todo es ingeniería. Persistence context es el «área de trabajo» en la que JPA/Hibernate mantiene las entidades durante la ejecución del unit of work. En términos de JPA, está asociado con EntityManager. En términos de Hibernate, a veces verás la palabra Session. En nuestra aplicación Spring Boot, todo esto está elegantemente oculto bajo el capó: los repositorios usan EntityManager, y Spring vincula cuidadosamente su vida a la transacción.

Para no perdernos, mantengamos una imagen simple: cuando un método de servicio entra en @Transactional, Spring abre una transacción y proporciona el contexto asociado, en el cual la ORM lleva un registro de las entidades cargadas. En ese momento, el Product ya no es simplemente un «objeto», sino un objeto que está bajo la supervisión de la ORM.

Visualmente, es útil representarlo así:

flowchart TD
    A["Método de servicio @Transactional"] --> B["Límite de transacción / Unidad de Trabajo"]
    B --> C[EntityManager]
    C --> D[Contexto de Persistencia]
    D --> E[(Base de datos)]

Importante: el persistence context no es «otra capa de base de datos», sino una estructura interna de la ORM que ayuda a ejecutar el unit of work correctamente. Vive un tiempo limitado: normalmente tanto como dura la transacción. Por lo tanto, no se debe ver como un «caché de aplicación» o un «almacén de estado entre peticiones».

2. Identity map y caché de primer nivel

Identity map: un id — una referencia

Ahora a la «magia wow», que en realidad es mejor entender como matemática estricta. Identity map es la regla: dentro de un mismo persistence context para un mismo id existe la misma instancia de entidad. Es decir, si en una transacción cargas dos veces un Product con id = 10, no obtendrás dos copias, sino la misma referencia.

Veamos un ejemplo mínimo en nuestro proyecto. Suponemos que ProductRepository ya existe (apareció en el módulo sobre repositorios), y el servicio es un @Service normal de Spring.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogDebugService {

    private final ProductRepository productRepository;

    public CatalogDebugService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void identityMapDemo(Long id) {
        // Primera llamada, por lo general, hará un SELECT y colocará la entidad en el persistence context
        Product p1 = productRepository.findById(id).orElseThrow();

        // Segunda llamada dentro de la misma transacción normalmente devolverá el mismo objeto del contexto
        Product p2 = productRepository.findById(id).orElseThrow();

        // Comparamos referencias: identity map garantiza que es la misma instancia en memoria
        System.out.println(p1 == p2); // true
    }
}

Si habilitas los logs de SQL, normalmente verás que el primer findById() hace un SELECT, mientras que el segundo ya toma el objeto del contexto. Pero incluso si no miras los logs, p1 == p2 proporciona una observación muy importante: realmente es un solo objeto.

¿Por qué es valioso? Porque la referencia Java es la «identidad» del objeto en memoria. Cuando en un lugar cambiaste el objeto (simplemente modificaste un campo), en todos los demás lugares donde tienes una referencia a él verás ese cambio. Y no es una «magia de setters», es simplemente Java normal: un objeto, una memoria, un estado.

Identity map en el servicio

En papel, la comparación de referencias parece un truco de fiesta: puedes mostrarlo a tus amigos y decir «¡mira, Java!». Pero en un servicio real, esto se manifiesta de manera mucho más útil: dentro de una misma unit of work, las lecturas repetidas por id no deben destruir tu modelo ni crear copias.

Imaginemos este escenario: cargaste un producto, cambiaste su nombre en memoria (sin hablar aún de si se enviará a la BD), y luego lo «buscaste» nuevamente por id. Esperas ver el nombre ya modificado — porque es el mismo objeto.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductRenameDemoService {

    private final ProductRepository productRepository;

    public ProductRenameDemoService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void renameAndReadAgain(Long id) {
        // Cargamos la entidad: desde este momento está managed (dentro del persistence context)
        Product product = productRepository.findById(id).orElseThrow();

        // Cambiamos el estado del objeto en memoria (sin discutir aún flush/UPDATE)
        product.setName("Green Tea");

        // Leemos nuevamente por id en la misma transacción: devolverá el mismo objeto
        Product again = productRepository.findById(id).orElseThrow();

        // Por lo tanto, veremos el valor ya modificado (no es «releído de la BD», es la misma instancia)
        System.out.println(again.getName()); // Green Tea
    }
}

Observa: aquí todavía no discutimos «si el UPDATE volará a la base de datos» ni «si se necesita un save()». Ese es un tema importante aparte, y vendrá más adelante. Aquí fijamos solo una cosa: dentro del contexto no obtienes una «segunda copia de la base de datos», obtienes el mismo objeto de la memoria del unit of work.

First-level cache: qué almacena

El término first-level cache a menudo induce a error a los principiantes con la palabra «cache». Parece que es algo como Redis, pero dentro de Hibernate, y que ahora aprenderemos a «acelerar la aplicación con una anotación». En realidad, first-level cache es simplemente una consecuencia de identity map: dado que el contexto debe mantener «un id → un objeto», naturalmente tiene que almacenar las entidades ya cargadas y devolverlas sin volver a leer de la BD.

Detalle importante: esta «caché» siempre es local. Vive dentro del persistence context, es decir, normalmente dentro de la transacción. No sobrevive al reinicio de la aplicación, no se comparte entre hilos, no está disponible para «otras solicitudes» y no está diseñado como una herramienta de caché a nivel de sistema. Es precisamente la memoria de trabajo del unit of work, no una «optimización por optimizar».

A veces es útil imaginarlo literalmente como un mapa interno:

flowchart TD
    PC[Contexto de Persistencia] --> M[Mapa de Identidad]
    M --> K1["clave: Product#10"]
    K1 --> V1["valor: (misma referencia de objeto Java)"]
    M --> K2["clave: Category#3"]
    K2 --> V2["valor: (misma referencia de objeto Java)"]

Esta representación ayuda a eliminar dos mitos. Primer mito: «la ORM va a la base de datos cada vez que hago un find()». No necesariamente, si la entidad ya está en el contexto. Segundo mito: «como hay caché, entonces no hay que preocuparse por las consultas». No, porque el contexto es limitado y solo funciona dentro de unit of work.

Repositorio y EntityManager: un mismo contexto

A veces parece que el repositorio es algo mágico e independiente. En realidad, productRepository.findById() internamente usa la infraestructura JPA, y esta se apoya en EntityManager. Y lo mejor es que todo funciona en el mismo persistence context.

Hagamos un ejemplo donde comparamos un objeto obtenido a través del repositorio y un objeto obtenido directamente mediante EntityManager.find(). Dentro de una misma transacción, debería ser la misma instancia.

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class EntityManagerDemoService {

    private final ProductRepository productRepository;
    private final EntityManager entityManager;

    public EntityManagerDemoService(ProductRepository productRepository, EntityManager entityManager) {
        this.productRepository = productRepository;
        this.entityManager = entityManager;
    }

    @Transactional
    public void repositoryAndEntityManagerSeeSameObject(Long id) {
        // Leemos a través del repositorio (bajo el capó se usa el EntityManager del mismo contexto)
        Product p1 = productRepository.findById(id).orElseThrow();

        // Leemos directamente mediante EntityManager en la misma transacción
        Product p2 = entityManager.find(Product.class, id);

        // Es la misma entidad managed dentro del persistence context
        System.out.println(p1 == p2); // true
    }
}

Este ejemplo no es útil porque vayas a escribir entityManager.find() todos los días. Es útil porque ayuda a «descomponer en capas» lo que sucede: los repositorios son una envoltura declarativa conveniente, pero debajo igualmente funciona la mecánica de JPA con el mismo persistence context.

Límite del contexto: otra transacción

Aquí está el punto más importante para el sentido común. La regla de identity map funciona dentro de un mismo persistence context. En cuanto el contexto termina (generalmente con la transacción), la regla «mismo id → misma referencia» deja de ser obligatoria.

Es decir, en otra transacción volverás a leer un Product con id = 10 — y será un nuevo objeto Java, solo que con los mismos datos. Y eso es normal: la JVM no puede (ni debe) garantizar que el objeto de la transacción de ayer «siga siendo el mismo objeto» hoy.

Mostrar esto de manera determinista es más fácil con TransactionTemplate: permite abrir dos transacciones diferentes directamente en un método y comparar referencias. Parece un poco más «infraestructural», pero el ejemplo es corto y muestra bien el límite.

import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;

@Service
public class TwoTransactionsDemoService {

    private final ProductRepository productRepository;
    private final TransactionTemplate tx;

    public TwoTransactionsDemoService(ProductRepository productRepository, TransactionTemplate tx) {
        this.productRepository = productRepository;
        this.tx = tx;
    }

    public void compareObjectsAcrossTransactions(Long id) {
        // Primera transacción: su propio persistence context, su propia entidad managed
        Product p1 = tx.execute(status -> productRepository.findById(id).orElseThrow());

        // Segunda transacción: nuevo persistence context, se creará/cargará otro objeto
        Product p2 = tx.execute(status -> productRepository.findById(id).orElseThrow());

        // Las referencias, por lo general, son diferentes porque los contextos son diferentes
        System.out.println(p1 == p2); // false (diferentes persistence context)
    }
}

Observa que no decimos «siempre false» como una ley física absoluta. Pero desde el punto de vista del modelo JPA, así debe ser: dos contextos diferentes, dos objetos diferentes. Lo principal que debes llevarte: identity map es una regla local dentro del unit of work, no una promesa de «para siempre».

Mini-shop: consistencia de referencias

Volvamos al sentido práctico. En nuestro proyecto hay relaciones: Category -> Product, CustomerOrder -> OrderItem, OrderItem -> Product. Dentro de una transacción, cuando construyes un pedido, puedes obtener un Product de al menos dos formas: directamente a través de ProductRepository e indirectamente como una referencia desde OrderItem.

Si la ORM no hiciera identity map, podrías encontrarte con la situación de «en mi pedido hay un Product, pero aparte en el servicio cargué aparentemente el mismo producto, pero es otro objeto». Entonces cualquier comparación de referencias, cualquier colección, cualquier verificación de «ya agregado» se convertiría en un campo minado.

Se puede mostrar con un pequeño ejemplo: dentro de una transacción, tomamos un producto de una línea de pedido y por separado cargamos ese mismo producto por id — y esperamos que sea la misma entidad dentro del contexto.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderReadConsistencyDemoService {

    private final CustomerOrderRepository orderRepository;
    private final ProductRepository productRepository;

    public OrderReadConsistencyDemoService(CustomerOrderRepository orderRepository,
                                          ProductRepository productRepository) {
        this.orderRepository = orderRepository;
        this.productRepository = productRepository;
    }

    @Transactional
    public void compareProductReferencesInsideTransaction(Long orderId) {
        // Cargamos el pedido dentro de la transacción: todo lo que se lea a continuación caerá en el mismo contexto
        CustomerOrder order = orderRepository.findById(orderId).orElseThrow();

        // Tomamos el Product a través de la asociación (cómo se cargará exactamente depende de la configuración de fetch)
        Product fromItem = order.getItems().get(0).getProduct();

        // Cargamos el mismo Product por id directamente a través del repositorio
        Product byId = productRepository.findById(fromItem.getId()).orElseThrow();

        // Identity map garantiza consistencia: es la misma entidad managed dentro del contexto
        System.out.println(fromItem == byId); // true (dentro del mismo contexto)
    }
}

Aquí hay un detalle: cómo se carguen exactamente items y product depende de tu configuración de fetch y de lo que hayas leído antes. Pero la idea en sí es correcta: el persistence context hace que el modelo sea coherente en memoria dentro del unit of work, y esto es el fundamento del comportamiento normal de la ORM.

3. Lo que persistence context no hace

Es muy fácil confundir los límites y empezar a esperar del persistence context lo que no prometió. No hará que tu aplicación sea «rápida automáticamente», no reemplazará las consultas normales, no proporcionará una estrategia global de caché ni se convertirá en el «almacén de la verdad» entre solicitudes. Su objetivo es mucho más terrenal: garantizar la corrección y previsibilidad al trabajar con entidades dentro del unit of work.

Conviene fijar esto con una pequeña tabla comparativa (sin profundizar en temas futuros). No es para que aprendas todo de memoria ahora, sino para que no crees expectativas incorrectas:

Mecanismo Dónde vive Tiempo de vida Idea principal
Base de datos PostgreSQL Largo Verdad y estado persistente
Contexto de persistencia / caché de primer nivel Dentro de EntityManager Normalmente una transacción Una entidad por id + memoria de trabajo del unit of work
«Caché de aplicación» (en sentido amplio) En una capa separada (por ejemplo, in-memory/Redis) Largo/según política Acelerar lecturas repetidas entre solicitudes

Si mantienes esta tabla en la cabeza, dejas de esperar del persistence context el comportamiento de Redis, y dejas de culpar a Hibernate por «no cachear todo». Y no debe hacerlo.

4. Errores típicos al trabajar con persistence context

Error nº1: considerar el persistence context como un «caché global de aplicación».
Es el error más frecuente y más engañoso, porque genera expectativas falsas: «ya había leído este producto, ¿por qué otro SELECT?». La respuesta casi siempre es aburrida: porque es otra transacción, otro contexto, otro momento. Persistence context es la memoria local del unit of work, no un almacén compartido para todas las solicitudes y usuarios.

Error nº2: pensar que la lectura repetida siempre debe ir a la base de datos.
Después de la experiencia con JDBC, el principiante suele pensar: «llamé a findById(), luego seguro que hubo una consulta». Pero en JPA no es así: si la entidad ya está en el contexto, se devuelve desde allí. Y es normal. Lo que suele asustar es: «no vi SQL, entonces no controlo la situación». El control está en otro lado: en entender el límite de la transacción y la vida del contexto.

Error nº3: extender la regla «un id → un objeto» a todo el runtime de la aplicación.
Dentro de una transacción, p1 == p2 puede ser true. Pero eso no significa que mañana, en otra transacción, obtengas la misma referencia. Intentar «aferrarse al objeto» entre fronteras de unit of work casi siempre lleva a la decepción. La ORM trabaja con datos, no te promete identidad eterna de objetos en la JVM.

Error nº4: ignorar la relación del contexto con la transacción y escribir código de servicio como si no hubiera transacciones.
Si haces muchas lecturas/cambios pero no le das al servicio un límite transaccional, te estás rompiendo la base tú mismo: cada llamada al repositorio puede vivir en su propio pequeño contexto. Como resultado, el identity map deja de «sentirse» y el comportamiento se vuelve inconsistente. Normalmente esto se soluciona no con «un ajuste más», sino con un límite adecuado de unit of work en el servicio.

Error nº5: esperar que el persistence context «actualice el objeto por sí solo» si la base de datos cambió en otro lugar.
La entidad que está en el contexto es un objeto en memoria. No es telépata. Si en otro escenario los datos cambiaron, tu objeto no tiene por qué «traer de repente valores actualizados». El persistence context garantiza consistencia dentro del unit of work, pero no sincronización con la realidad externa a cada rato.

1
Tarea
Spring Data JPA, nivel 20, lección 0
Bloqueada
Dos lecturas de un mismo producto en una misma transacción
Dos lecturas de un mismo producto en una misma transacción
1
Tarea
Spring Data JPA, nivel 20, lección 0
Bloqueada
Un objeto a través del repositorio y EntityManager
Un objeto a través del repositorio y EntityManager
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION