JavaRush /Курсы /Hibernate deep-dive /First-level cache и identity map

First-level cache и identity map

Hibernate deep-dive
2 уровень , 3 лекция
Открыта

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 — не магия, а механизм, который отлично наблюдается через логирование.

1
Задача
Hibernate deep-dive, 2 уровень, 3 лекция
Недоступна
Два `find()` по одному `id` внутри одного context
Два `find()` по одному `id` внутри одного context
1
Задача
Hibernate deep-dive, 2 уровень, 3 лекция
Недоступна
`getReference()` и `find()` делят одну identity
`getReference()` и `find()` делят одну identity
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ