JavaRush /Курсы /Spring Data JPA /Persistence context:...

Persistence context: identity map и кэш

Spring Data JPA
20 уровень , 0 лекция
Открыта

1. Persistence context: идея и границы

Если вы впервые слышите фразу «внутри ORM есть память», легко представить себе что-то подозрительное: будто Hibernate тайком строит «мини-базу данных в оперативке». На самом деле всё куда прагматичнее. У нас есть транзакция как одна бизнес-операция, и внутри неё мы хотим, чтобы чтения и изменения данных были последовательными и предсказуемыми — не только на уровне SQL, но и на уровне Java-объектов.

Представьте простой сценарий из нашего mini-shop. Мы оформляем заказ. Внутри placeOrder() мы можем несколько раз встретить один и тот же товар: один раз когда проверяем остатки, второй раз когда строим позиции заказа, третий раз когда считаем сумму или логируем что-то для диагностики. Если каждый такой «контакт» создавал бы новый Java-объект Product, мы бы быстро получили:

- путаницу (какой из объектов “настоящий”?),
- несогласованность (в одном объекте поле уже поменяли, в другом — нет),
- дубли в памяти,
- сюрпризы в ассоциациях (объект из orderItem.getProduct() и объект из productRepository.findById() вдруг разные, хотя это одна строка в БД).

Поэтому ORM в пределах unit of work держит единый набор управляемых сущностей — чтобы одна строка таблицы с конкретным id соответствовала одной Java-ссылке в пределах текущей операции. Именно отсюда и вырастают два ключевых понятия нашей лекции: identity map и first-level cache.

Слово “контекст” звучит так, будто мы сейчас будем обсуждать психологию. Но здесь всё инженерно. Persistence context — это «рабочая область», в которой JPA/Hibernate держит сущности во время выполнения unit of work. В JPA-терминах он связан с EntityManager. В Hibernate-терминах вы иногда встретите слово Session. В нашем Spring Boot-приложении это всё красиво спрятано под капотом: репозитории используют EntityManager, а Spring аккуратно привязывает его жизнь к транзакции.

Чтобы не потеряться, держим в голове простую картину: когда сервисный метод входит в @Transactional, Spring открывает транзакцию и предоставляет связанный с ней контекст, в котором ORM ведёт учёт загруженных сущностей. В этот момент Product — уже не просто «объект», а объект, который находится под наблюдением ORM.

Наглядно это удобно представить так:

flowchart TD
    A["Service method @Transactional"] --> B["Transaction boundary / Unit of Work"]
    B --> C[EntityManager]
    C --> D[Persistence Context]
    D --> E[(Database)]

Важно: persistence context — это не «ещё один слой базы», а внутренняя структура ORM, которая помогает выполнить unit of work корректно. Он живёт ограниченное время: обычно ровно столько, сколько живёт транзакция. Поэтому на него нельзя смотреть как на «кэш приложения» или «хранилище состояния между запросами».

2. Identity map и кэш 1-го уровня

Identity map: один id — одна ссылка

Теперь к самой «вау-магии», которую на самом деле лучше воспринимать как строгую математику. Identity map — это правило: внутри одного persistence context для одного и того же id существует один и тот же экземпляр сущности. То есть если вы в одной транзакции дважды загрузили Product с id = 10, вы получите не две копии, а одну и ту же ссылку.

Давайте посмотрим на минимальный пример на нашем проекте. Мы предполагаем, что ProductRepository уже существует (он у нас появился ещё в модуле про репозитории), а сервис — обычный Spring @Service.

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) {
        // Первый вызов, как правило, сделает SELECT и положит сущность в persistence context
        Product p1 = productRepository.findById(id).orElseThrow();

        // Второй вызов в рамках той же транзакции обычно вернёт тот же объект из контекста
        Product p2 = productRepository.findById(id).orElseThrow();

        // Сравниваем ссылки: identity map гарантирует, что это один и тот же экземпляр в памяти
        System.out.println(p1 == p2); // true
    }
}

Если вы включите SQL-логи, вы обычно увидите, что первый findById() делает SELECT, а второй уже берёт объект из контекста. Но даже если вы логи не смотрите, p1 == p2 даёт очень важное наблюдение: это действительно один объект.

Почему это ценно? Потому что Java-ссылка — это и есть «личность» объекта в памяти. Когда в одном месте вы изменили объект (просто поменяли поле), во всех остальных местах, где у вас на него ссылка, вы увидите это изменение. И это не какая-то «магия сеттеров», это просто обычная Java: один объект — одна память — одно состояние.

Identity map в сервисе

На бумаге сравнение ссылок выглядит как трюк для вечеринки: можно показать друзьям и сказать «смотрите, Java!». Но в реальном сервисе это проявляется куда полезнее: в рамках одной unit of work повторные чтения по id не должны разрушать вашу модель и плодить копии.

Представим такой сценарий: вы загрузили товар, изменили ему имя в памяти (пока без разговора о том, отправится ли это в БД), а затем ещё раз “нашли” его по id. Вы ожидаете, что увидите уже изменённое имя — потому что это тот же объект.

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) {
        // Загружаем сущность: с этого момента она managed (внутри persistence context)
        Product product = productRepository.findById(id).orElseThrow();

        // Меняем состояние объекта в памяти (пока не обсуждаем flush/UPDATE)
        product.setName("Green Tea");

        // Повторно читаем по id в той же транзакции: вернётся тот же объект
        Product again = productRepository.findById(id).orElseThrow();

        // Поэтому мы увидим уже изменённое значение (это не «перечитали из БД», это тот же экземпляр)
        System.out.println(again.getName()); // Green Tea
    }
}

Обратите внимание: мы здесь пока не обсуждаем “улетит ли UPDATE в базу” и “нужен ли save()”. Это отдельная важная тема, и она будет позже. Здесь мы фиксируем только одно: внутри контекста вы не получаете «вторую копию из базы», вы получаете тот же объект из памяти unit of work.

First-level cache: что хранится

Термин first-level cache часто вводит новичков в заблуждение словом “cache”. Кажется, будто это что-то вроде Redis, только внутри Hibernate, и сейчас мы научимся «ускорять приложение одной аннотацией». На самом деле first-level cache — это просто следствие identity map: раз контекст обязан держать “один id → один объект”, ему естественным образом приходится хранить уже загруженные сущности и возвращать их без повторного чтения из БД.

Важная деталь: этот «кэш» всегда локальный. Он живёт внутри persistence context, то есть обычно внутри транзакции. Он не переживает перезапуск приложения, не делится между потоками, не доступен «другим запросам» и не предназначен как инструмент кэширования на уровне системы. Это именно рабочая память unit of work, а не “оптимизация ради оптимизации”.

Иногда полезно буквально представить это как внутреннюю мапу:

flowchart TD
    PC[Persistence Context] --> M[Identity Map]
    M --> K1["key: Product#10"]
    K1 --> V1["value: (same Java object ref)"]
    M --> K2["key: Category#3"]
    K2 --> V2["value: (same Java object ref)"]

Такое представление помогает сразу избавиться от двух мифов. Первый миф: «ORM каждый раз ходит в базу, когда я делаю find()». Не обязательно, если сущность уже в контексте. Второй миф: «раз есть cache — значит можно не думать о запросах». Нет, потому что контекст ограничен и работает только в пределах unit of work.

Репозиторий и EntityManager: один контекст

Иногда кажется, что репозиторий — это какая-то магическая самостоятельная штука. На деле productRepository.findById() внутри себя использует JPA-инфраструктуру, а она опирается на EntityManager. И самое приятное, что это всё работает в одном и том же persistence context.

Давайте сделаем пример, где мы сравниваем объект, полученный через репозиторий, и объект, полученный напрямую через EntityManager.find(). Внутри одной транзакции это должен быть тот же экземпляр.

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) {
        // Читаем через репозиторий (под капотом используется EntityManager того же контекста)
        Product p1 = productRepository.findById(id).orElseThrow();

        // Читаем напрямую через EntityManager в той же транзакции
        Product p2 = entityManager.find(Product.class, id);

        // Это одна и та же managed-сущность в рамках persistence context
        System.out.println(p1 == p2); // true
    }
}

Этот пример полезен не тем, что вы будете каждый день писать entityManager.find(). Полезен он тем, что помогает «разобрать по слоям» происходящее: репозитории — это удобная декларативная оболочка, но под ней всё равно работает JPA-механика с тем же самым persistence context.

Граница контекста: другая транзакция

Вот тут самое важное место для здравого смысла. Правило identity map работает внутри одного persistence context. Как только контекст закончился (чаще всего вместе с транзакцией), правило «тот же id → та же ссылка» перестаёт быть обязано выполняться.

То есть в другой транзакции вы снова прочитаете Product с id = 10 — и это будет новый Java-объект, просто с теми же данными. И это нормально: JVM не может (и не должна) гарантировать, что объект из вчерашней транзакции «продолжит быть тем же самым объектом» сегодня.

Показать это детерминированно проще всего через TransactionTemplate: он позволяет открыть две разные транзакции прямо в одном методе и сравнить ссылки. Это выглядит чуть более «инфраструктурно», но пример короткий и хорошо демонстрирует границу.

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) {
        // Первая транзакция: свой persistence context, своя managed-сущность
        Product p1 = tx.execute(status -> productRepository.findById(id).orElseThrow());

        // Вторая транзакция: новый persistence context, будет создан/загружен другой объект
        Product p2 = tx.execute(status -> productRepository.findById(id).orElseThrow());

        // Ссылки, как правило, разные, потому что контексты разные
        System.out.println(p1 == p2); // false (разные persistence context)
    }
}

Заметьте, мы не говорим «всегда false» как абсолютный закон физики. Но с точки зрения модели JPA это именно так и должно быть: два разных контекста — два разных объекта. Главное, что вы должны вынести: identity map — это локальное правило внутри unit of work, а не обещание «навсегда».

Mini-shop: консистентность ссылок

Давайте вернёмся к прикладному смыслу. В нашем проекте есть связи: Category -> Product, CustomerOrder -> OrderItem, OrderItem -> Product. В рамках транзакции, когда вы строите заказ, вы можете получить Product как минимум двумя путями: напрямую через ProductRepository и косвенно как ссылку из OrderItem.

Если бы ORM не делал identity map, вы могли бы столкнуться с ситуацией «у меня в заказе один Product, а отдельно в сервисе я загрузил вроде бы тот же товар, но это другой объект». Тогда любые сравнения ссылок, любые коллекции, любые проверки на «уже добавлено» превращались бы в минное поле.

Можно показать это на маленьком примере: внутри транзакции мы берём товар из позиции заказа и отдельно загружаем этот же товар по id — и ожидаем, что это будет одна и та же сущность в рамках контекста.

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) {
        // Загружаем заказ в рамках транзакции: всё, что будет прочитано дальше, попадёт в один контекст
        CustomerOrder order = orderRepository.findById(orderId).orElseThrow();

        // Берём Product через ассоциацию (как именно загрузится — зависит от fetch-настроек)
        Product fromItem = order.getItems().get(0).getProduct();

        // Загружаем тот же Product по id напрямую через репозиторий
        Product byId = productRepository.findById(fromItem.getId()).orElseThrow();

        // Identity map обеспечивает консистентность: это одна и та же managed-сущность в рамках контекста
        System.out.println(fromItem == byId); // true (в рамках одного контекста)
    }
}

Здесь есть тонкость: как именно загрузятся items и product зависит от ваших настроек fetch и от того, что именно вы читали до этого. Но сама идея верная: persistence context делает модель связной в памяти в пределах unit of work, и это фундамент нормального поведения ORM.

3. Что persistence context не делает

Очень легко перепутать границы и начать ожидать от persistence context того, чего он не обещал. Он не сделает ваше приложение «быстрым автоматически», не заменит нормальные запросы, не обеспечит глобальную кэш-стратегию и не станет “хранилищем истины” между запросами. Его цель гораздо более приземлённая: обеспечить корректность и предсказуемость работы с сущностями в рамках unit of work.

Удобно закрепить это маленькой таблицей сравнения (без углубления в будущие темы). Она нужна не для того, чтобы вы сейчас выучили всё наизусть, а чтобы не строили неверных ожиданий:

Механизм Где живёт Время жизни Главная идея
База данных PostgreSQL Долго Истина и долговременное состояние
Persistence context / first-level cache Внутри EntityManager Обычно одна транзакция Одна сущность на один id + рабочая память unit of work
«Кэш приложения» (в широком смысле) В отдельном слое (например, in-memory/Redis) Долго/по политике Ускорять повторяющиеся чтения между запросами

Если держать эту таблицу в голове, вы перестаёте ожидать от persistence context поведения Redis, и перестаёте ругать Hibernate за то, что он «не кэширует всё на свете». Он и не должен.

4. Типичные ошибки при работе с persistence context

Ошибка №1: воспринимать persistence context как “глобальный кэш приложения”.
Это самая частая и самая коварная ошибка, потому что она порождает ложные ожидания: «я же уже читал этот товар, почему снова SELECT?». Ответ почти всегда скучный: потому что это другая транзакция, другой контекст, другой момент времени. Persistence context — локальная память unit of work, а не общее хранилище для всех запросов и пользователей.

Ошибка №2: считать, что повторное чтение всегда обязано идти в базу.
После JDBC-опыта новичок часто думает: “я вызвал findById() — значит точно был запрос”. Но в JPA это не так: если сущность уже в контексте, её вернут оттуда. И это нормально. Пугает обычно то, что “я не видел SQL — значит, я не контролирую ситуацию”. Контроль здесь в другом: в понимании границы транзакции и жизни контекста.

Ошибка №3: переносить правило “один id → один объект” на весь runtime приложения.
В пределах одной транзакции p1 == p2 может быть true. Но это не означает, что завтра, в другой транзакции, вы получите ту же ссылку. Попытка «держаться за объект» между границами unit of work почти всегда приводит к разочарованию. ORM работает с данными, а не обещает вам вечную идентичность объектов в JVM.

Ошибка №4: игнорировать связь контекста с транзакцией и писать сервисный код так, будто транзакций нет.
Если вы делаете много чтений/изменений, но не даёте сервису транзакционной границы, вы сами себе ломаете фундамент: каждый вызов репозитория может жить в своём маленьком контексте. В результате identity map перестаёт “чувствоваться”, а поведение становится рваным. Обычно это лечится не “ещё одной настройкой”, а нормальной границей unit of work на сервисе.

Ошибка №5: ожидать, что persistence context будет “обновлять объект сам”, если база изменилась в другом месте.
Сущность, лежащая в контексте, — это объект в памяти. Он не телепат. Если где-то в другом сценарии данные изменились, ваш объект не обязан внезапно “подтянуть свежие значения”. Persistence context обеспечивает консистентность внутри unit of work, но не синхронизацию с внешней реальностью по каждому чиху.

1
Задача
Spring Data JPA, 20 уровень, 0 лекция
Недоступна
Два чтения одного товара в одной транзакции
Два чтения одного товара в одной транзакции
1
Задача
Spring Data JPA, 20 уровень, 0 лекция
Недоступна
Один объект через репозиторий и EntityManager
Один объект через репозиторий и EntityManager
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ