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, но не синхронизацию с внешней реальностью по каждому чиху.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ