1. Два одинаковых товара в памяти
First-level cache в Hibernate нужен не только для скорости. Его главная роль — обеспечить объектную идентичность внутри persistence context. Если об этом забыть, ORM быстро превращается в странный набор совпадений. Слово «кэш» у новичка почти автоматически вызывает две мысли: «ускорение» и «всё стало сложнее». Но в Hibernate first-level cache появился не как турбокнопка, а как решение очень практической проблемы.
После лекций про find() и getReference() возникает совершенно прикладной вопрос: может ли один и тот же Product#1 внутри одного persistence context превратиться в несколько конкурирующих копий? Если да, дальше уже невозможно нормально рассуждать ни о связях, ни об изменениях.
Представьте, что в базе есть строка товара product(id=1, sku='SKU-001', name='Keyboard'). Если Hibernate внутри одной операции, то есть одного контекста, создаст два разных Java-объекта, которые оба «про одну и ту же строку», вы очень быстро попадёте в странную реальность. Вы поменяли имя у первого объекта, а дальше в коде вам попался второй — и он «старый». Или вы сравнили их, положили в коллекцию, а потом внезапно получили дубли. Или вы обновили связь на одном, а второй об этом не знает.
Вот именно чтобы такого не происходило, Hibernate держит правило: в рамках одного persistence context одна строка БД должна соответствовать одному managed-объекту в памяти. И это правило в живом виде реализуется через first-level cache и паттерн identity map.
First-level cache как часть persistence context
Когда говорят «first-level cache», новички часто представляют себе что-то вроде отдельного компонента: «включили кэш», «выключили кэш», «почистили кэш». На самом деле first-level cache — это внутреннее содержимое persistence context. То есть это не «добавка к Hibernate», а один из его органов — примерно как печень: отдельно не живёт, но очень влияет на качество жизни.
Если сказать совсем по‑простому, persistence context — это рабочая область, где Hibernate держит управляемые сущности и решает две ключевые задачи. Во‑первых, он должен знать, какие объекты сейчас managed. Во‑вторых, он должен обеспечивать уникальность объекта для каждой сущности по ключу (тип + id). И вот эта «карта соответствий» и есть first-level cache.
Важно сразу зафиксировать границу: first-level cache не является кэшем “на всё приложение”. Обычно в Spring-приложении с нормальными транзакционными границами он живёт примерно столько же, сколько живёт транзакция — специально упрощаем, чтобы не уходить в дебри. Поэтому он идеально подходит для сценария «я читаю и работаю с данными внутри одного unit of work», но совершенно не обязан помогать вам «помнить товары между разными HTTP-запросами».
3. Identity map: одна строка ↔ один объект
Слово “identity” здесь не про паспорт и не про кризис самоопределения, хотя в продакшене у сущностей бывает и такое. В данном случае identity — это идентичность сущности: конкретный тип и конкретный id. Паттерн identity map означает, что у нас есть структура данных, которая говорит: «если ты спрашиваешь Product#1, вот его единственная managed-версия».
Практически это выглядит так: у Hibernate внутри контекста есть что-то вроде карты, очень упрощённо: ключ ("Product", 1) → значение productObjectInstance. Если вы снова просите Product с id=1, Hibernate сначала смотрит в эту карту. Если объект там уже есть, вам возвращают тот же экземпляр. Если его там нет, Hibernate идёт в БД, читает строку, создаёт объект, кладёт его в карту и возвращает.
Это правило делает сразу две вещи. Оно снижает количество повторных чтений — да, это ускорение, — но ещё важнее предотвращает ситуацию «две конкурирующие версии одной сущности в одной операции». ORM тогда становится не лотереей, а системой, где можно рассуждать так: «внутри этой транзакции вот этот Product — это вот этот объект, и он один».
4. Как find() использует first-level cache
После лекции про find() легко остаться с ощущением: «find() — это всегда SELECT». Это нормальная интуиция, если думать как SQL-разработчик. Но Hibernate думает как менеджер объектов: «find() — это дай мне managed-представление сущности с таким id, а уже потом решим, надо ли идти в базу».
Чтобы увидеть это инженерно, полезно держать в голове простую последовательность действий. Сначала EntityManager, точнее Hibernate под капотом, проверяет, есть ли сущность с таким ключом в текущем persistence context. Если есть — возвращает её сразу. Если нет — выполняет SELECT, материализует объект и регистрирует его в контексте.
Ниже — упрощённая блок-схема. Она не отражает всех нюансов Hibernate 7.2, но отлично показывает главную мысль: first-level cache — это «первая остановка» в маршруте find().
flowchart TD
A["entityManager.find(Product.class, 1L)"] --> B{"Product#1 уже в persistence context?"}
B -- "Да" --> C["Вернуть тот же managed-объект (без нового SELECT)"]
B -- "Нет" --> D["SELECT ... FROM product WHERE id=1"]
D --> E["Создать Java-объект Product"]
E --> F["Положить Product#1 в first-level cache (identity map)"]
F --> G["Вернуть managed-объект"]
И вот тут важно понять одну простую вещь: вы не обязаны угадывать поведение. Можно открыть SQL trace и увидеть, что второй find() не породил новый запрос. Это не «Hibernate решил сэкономить», а «Hibernate выполняет свою базовую обязанность».
5. Повторный find() в одной транзакции
Сейчас мы сделаем маленький эксперимент на материале нашего учебного проекта Commerce Persistence Lab. Идея простая: в рамках одной транзакции дважды запросить один и тот же Product по одному и тому же id и проверить две вещи. Первая — сколько SQL реально ушло. Вторая — один ли это объект в памяти.
Для примера возьмём сервис, который явно делает два find(). Обратите внимание: здесь важна одна транзакция, иначе у вас будет два разных persistence context, и вы не увидите identity map в действии.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogIdentityMapDemoService {
private final EntityManager entityManager;
public CatalogIdentityMapDemoService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional // Важно: один persistence context на весь метод
public void demoRepeatedFind() {
// Первый find(): если Product#1 ещё не managed, Hibernate сделает SELECT и положит объект в L1
var p1 = entityManager.find(Product.class, 1L);
// Второй find() в том же контексте: Hibernate вернёт тот же экземпляр из identity map
var p2 = entityManager.find(Product.class, 1L);
// Проверяем именно ссылочную идентичность: это должен быть один и тот же объект в памяти
System.out.println(p1 == p2); // true
}
}
Проверка p1 == p2 здесь намеренно максимально простая. Мы сейчас не обсуждаем equals() — это отдельная большая история. Здесь нас интересует именно ссылочная идентичность: является ли p2 тем же объектом, что и p1. В нормальном поведении Hibernate внутри одного контекста — да.
В SQL trace вы, как правило, увидите один SELECT. Второй find() вернёт объект из first-level cache. И это не «оптимизация», а фундамент ORM-модели: Hibernate не должен плодить копии одной и той же сущности внутри одной unit of work.
Если хочется быстро проверить, что объект действительно живёт в текущем context, достаточно contains().
import jakarta.persistence.EntityManager;
// Если объект managed, значит он находится в текущем persistence context (и, соответственно, в L1)
var p = entityManager.find(Product.class, 1L);
System.out.println(entityManager.contains(p)); // true
Этого уже хватает, чтобы не путать first-level cache с любыми объектами, созданными через new.
6. getReference() и find(): одна identity
В прошлой лекции мы увидели, что getReference() часто возвращает proxy — «ссылку-заместитель», которая знает id, но не обязана сразу иметь загруженные поля. Теперь добавим важное уточнение: proxy тоже живёт внутри identity map. То есть если вы сначала получили proxy, а потом вызвали find(), Hibernate обычно не создаст второй объект «на ту же строку», а постарается использовать тот же экземпляр.
Посмотрим на короткий пример. В нём есть аккуратная ловушка: мы создаём ссылку через getReference(), потом сравниваем, а затем обращаемся к полю, чтобы увидеть, что чтение может случиться в момент доступа, а не в момент получения ссылки.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional // В одном persistence context proxy и entity "сойдутся" в одну identity
public void demoReferenceAndFind(EntityManager entityManager) {
// getReference(): обычно не делает SELECT, но регистрирует proxy как managed-представление сущности
var ref = entityManager.getReference(Product.class, 1L);
// find(): если в контексте уже есть managed-репрезентация (включая proxy), новый объект не создаётся
var ent = entityManager.find(Product.class, 1L);
// В рамках одного контекста это обычно один и тот же экземпляр (proxy или уже инициализированная сущность)
System.out.println(ref == ent); // true
// Доступ к полю может инициировать SELECT: это уже ленивая инициализация proxy
System.out.println(ref.getName());
}
Здесь важно не запутаться. getReference() может не делать SELECT, но возвращённый объект всё равно становится managed в том смысле, что он «в контексте» и известен Hibernate. Если затем вы делаете find() для того же ключа, Hibernate старается не создавать конкурирующую копию. Он возвращает ту же managed-репрезентацию.
А вот когда вы делаете ref.getName(), proxy понимает, что ему нужны данные, и может инициировать чтение. Это уже следствие proxy-модели, но identity map здесь играет роль «одного паспорта на одну сущность»: какой бы дорогой вы ни пришли — через find или getReference — внутри контекста идентичность остаётся одной.
7. Границы first-level cache: не кэш приложения и не кэш запросов
Очень хочется, особенно после пары удачных демо, начать относиться к first-level cache как к универсальному ускорителю: «Hibernate же кэширует — значит, база нам почти не нужна». Увы, база всё ещё нужна, и кофе тоже. First-level cache решает вполне конкретную задачу: уникальность managed-объекта по identity в рамках контекста. Всё остальное — побочные бонусы или ограничения.
Чтобы не перепутать разные уровни «кэширования», полезно зафиксировать маленькую табличку. Другие уровни мы упоминаем здесь только как ориентир, без углубления: наша цель сегодня не производительность «вообще», а корректная модель того, как это работает.
| “Кэш” | Где живёт | Сколько живёт | Что хранит | Главная цель |
|---|---|---|---|---|
| First-level cache | внутри persistence context (EntityManager / Session) | обычно в пределах транзакции | managed-объекты по ключу (тип + id) | identity map и консистентность работы с объектами |
| Second-level cache | на уровне фабрики сессий (грубо: “на приложение”) | между транзакциями | данные сущностей/коллекций (в зависимости от настройки) | повторные чтения между разными unit of work |
| Query cache | отдельный механизм | между транзакциями | результаты запросов | ускорение повторяющихся одинаковых запросов |
Ключевой вывод: first-level cache не предназначен для «переиспользования данных между запросами пользователя». Он также не является «кэшем по любым условиям запроса». Если вы выполняете JPQL-запрос «найти все товары со статусом ACTIVE», Hibernate не обязан кэшировать сам результат списка на уровне L1. Но если в результате этого запроса он загрузил Product#1, а потом вы снова попросили find(Product.class, 1), то Product#1 уже будет в контексте — и это снова identity map в действии.
И ещё одна граница, которая особенно часто сбивает новичков: если вы ожидаете, что повторный find() «прочитает свежие данные из базы», то first-level cache как раз работает наоборот. Он говорит: «у нас уже есть managed-версия, работаем с ней». Поэтому рядом с identity map всегда нужны и явные операции управления context: иногда объект надо отцепить, иногда контекст — очистить, а иногда данные — перечитать заново.
8. Кейс: Customer и PurchaseOrder
Абстрактные примеры с find() по одному id хороши, но настоящая ценность identity map раскрывается, когда у вас появляется граф объектов. В нашем проекте это происходит сразу: PurchaseOrder ссылается на Customer. В реляционном мире это внешний ключ purchase_order.customer_id. В объектном — ссылка order.getCustomer().
С точки зрения Hibernate, «customer заказа» — это та же сущность Customer#10, условно. И в рамках одного контекста правило identity map означает: если Customer#10 уже managed, то ссылка в PurchaseOrder должна указывать на ту же managed-репрезентацию, а не на «второго такого же клиента».
Посмотрим на простой сценарий: сначала читаем клиента, потом читаем заказ, а затем сравниваем объект клиента и то, что лежит в ссылке заказа. В учебном проекте это очень удобный способ почувствовать, что identity map — это не только про «меньше SQL», но и про «нормальную объектную модель».
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional // Оба find() выполняются в одном persistence context
public void demoOrderCustomerIdentity(EntityManager entityManager) {
// Сначала загружаем Customer и делаем его managed
var customer = entityManager.find(Customer.class, 10L);
// Затем загружаем заказ; ссылка order.getCustomer() должна указывать на ту же identity
var order = entityManager.find(PurchaseOrder.class, 100L);
// Проверяем, что Hibernate не создал "второго Customer#10" в рамках одного контекста
System.out.println(order.getCustomer() == customer); // обычно true
}
Даже если order.getCustomer() изначально представлен proxy-объектом из-за lazy-стратегии, Hibernate всё равно обязан держать единую identity внутри контекста. Это помогает избежать ситуаций, когда у вас «два разных Customer с одним id» в рамках одной операции. Если бы такие дубли появлялись, вам было бы невозможно уверенно рассуждать о связях и изменениях графа: код превратился бы в болото.
И вот здесь можно сделать маленькое наблюдение по SQL trace: в зависимости от того, как именно загружается Customer — сразу или через proxy, — количество запросов может отличаться, но принцип identity map сохраняется. То есть вы можете экспериментировать с порядком операций и видеть: объекты остаются едиными в рамках контекста.
9. Типичные ошибки при работе с first-level cache и identity map
Ошибки вокруг first-level cache обычно не выглядят как «программа упала». Они опаснее: программа работает, но ведёт себя «странно», и разработчик начинает подозревать заговор ORM. На самом деле это почти всегда конфликт между ожиданиями — «каждый find читает базу заново» — и реальной моделью Hibernate — «в рамках контекста сущность едина и уже известна». Давайте аккуратно проговорим самые частые грабли.
Ошибка №1: воспринимать first-level cache как глобальный кэш приложения.
Очень типичный сценарий мышления: «раз find() второй раз не делает SELECT, значит Hibernate всё запомнил, и в следующем запросе пользователя тоже не будет SELECT». Но следующий запрос почти всегда означает новый persistence context, а значит, и новый first-level cache. В итоге человек удивляется: «почему снова читает базу?» Потому что L1 — это память конкретной операции, а не память приложения.
Ошибка №2: ожидать, что повторный find() обновит данные “как в SQL”.
Если вы прочитали Product#1, а потом где-то в другой транзакции или в другой сессии его обновили, повторный find() в том же контексте не обязан «подхватить изменения». Он вернёт тот же managed-объект. Это логично для unit of work, но неожиданно для мышления «я же заново прочитал». Для таких ситуаций существуют явные операции синхронизации, например refresh, и очистки контекста, например clear; без них повторный find() не обязан вести себя как «сходи в базу ещё раз».
Ошибка №3: пытаться “увидеть identity map” без единого контекста.
Если вы делаете два чтения в разных транзакциях или в разных методах, где фактически разные EntityManager/Session, вы получите два объекта, и это будет нормально. Новички иногда проверяют p1 == p2 в двух разных местах и расстраиваются, что видят false. Identity map работает только в рамках одного persistence context, поэтому и для демонстраций, и для реальных unit of work важна правильная граница операции.
Ошибка №4: путать “кэширование сущности” и “кэширование запроса”.
First-level cache не означает, что «любой запрос теперь бесплатный». Он гарантирует уникальность объекта по id. Если вы делаете запросы, которые возвращают много разных сущностей, Hibernate всё равно будет ходить в базу. Более того, если запрос возвращает тысячу строк, в контексте появится тысяча managed-объектов — и это уже вопрос дисциплины работы с контекстом. Мы пока не уходим в эту сторону, но важно не строить ожидания в духе «Hibernate всё закэшировал».
Ошибка №5: делать выводы о поведении ORM “по ощущениям”, игнорируя SQL trace.
Самая практическая ошибка: «мне кажется, Hibernate сходил в базу» или «мне кажется, он не сходил». Не нужно гадать. В нашем курсе SQL trace включён именно для того, чтобы вы могли открыть лог и увидеть: был ли SELECT, был ли он один и где именно он произошёл. First-level cache — не магия, а механизм, который отлично наблюдается через логирование.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ