💾 Розділ 16 · Питання #9

Що таке кеш першого рівня в Hibernate

Кеш першого рівня (First-Level Cache, L1) — це вбудований механізм кешування в оперативній пам'яті JVM, жорстко прив'язаний до життєвого циклу поточного сеансу взаємодії з базою...

Кеш першого рівня (First-Level Cache, L1) — це вбудований механізм кешування в оперативній пам’яті JVM, жорстко прив’язаний до життєвого циклу поточного сеансу взаємодії з базою даних (EntityManager у JPA або Session у Hibernate). Він є реалізацією патерна Identity Map (Карта Ідентичності) і є фундаментальним ядром ORM, що забезпечує узгодженість даних, відстеження змін (Dirty Checking) та мінімізацію надлишкових звернень до БД.


🟢 Junior Level

Що таке L1 Cache і навіщо він потрібен

Кеш першого рівня — це локальне сховище об’єктів усередині EntityManager / Session. Коли застосунок запитує сутність за її первинним ключем (find()), Hibernate спочатку заглядає в кеш L1, і лише якщо об’єкта там немає, виконує реальний SQL-запит до СУБД.

// Перший запит: об'єкт відсутній у кеші L1 -> виконується фізичний SQL SELECT
User user1 = entityManager.find(User.class, 1L);
// Hibernate генерує: SELECT * FROM users WHERE id = 1;

// Другий запит із тим самим ID у тій самій сесії:
User user2 = entityManager.find(User.class, 1L);
// Фізичний SQL-запит НЕ виконується! Об'єкт миттєво повертається з пам'яті L1.

// Перевірка ідентичності посилань:
assert user1 == user2; // TRUE! Це строго один і той самий екземпляр у Heap!

Ключові характеристики кешу першого рівня

  1. Увімкнений завжди і безальтернативно: Кеш L1 не можна вимкнути налаштуваннями конфігурації, оскільки без нього неможливе функціонування механізмів Dirty Checking та каскадів.
  2. Область видимості (Scope) — одна транзакція/сесія: Кеш створюється в момент відкриття EntityManager і знищується при його закритті. Інші потоки та інші транзакції не мають доступу до чужого кешу L1.
  3. Гарантія ідентичності (Identity Guarantee): У межах однієї сесії одному рядку в таблиці БД із заданим первинним ключем відповідає рівно один Java-об’єкт в оперативній пам’яті. Порівняння через == двох результатів вибірки поверне true.
                  АРХІТЕКТУРА КЕШУ ПЕРШОГО РІВНЯ (L1)
                  
 [ Потік застосунку ]
          │
          ▼
   [ EntityManager / Session ]
   ┌───────────────────────────────────────────────┐
   │             PersistenceContext (L1)           │
   │                                               │
   │   entitiesByKey: Map<EntityKey, Object>       │
   │   ┌───────────────────────────────────────┐   │
   │   │  EntityKey(User, 1L) ──> User@0x4f12  │   │
   │   │  EntityKey(User, 2L) ──> User@0x7a31  │   │
   │   └───────────────────────────────────────┘   │
   │                                               │
   │   entityEntries: Map<Object, EntityEntry>     │
   │   ┌───────────────────────────────────────┐   │
   │   │  User@0x4f12 ──> [loadedState: Snapshot]  │
   │   └───────────────────────────────────────┘   │
   └──────────────────────┬────────────────────────┘
                          │ (Якщо об'єкт не знайдено в L1)
                          ▼
                 [ База даних (SQL) ]

🟡 Middle Level

Поведінка L1 Cache при JPQL / Criteria API запитах

Часта помилка новачків: «Якщо об’єкт з ID=1 лежить у кеші L1, то виклик JPQL SELECT u FROM User u WHERE u.id = 1 не піде до бази даних».

Це не так:

  1. JPQL/HQL/Criteria запити завжди виконують фізичний SQL SELECT до бази даних, оминаючи перевірку L1 кешу.
  2. Однак, коли результат SQL повертається в Hibernate, рушій перевіряє первинний ключ кожного рядка в entitiesByKey.
  3. Якщо сутність із таким ID вже завантажена в контекст, Hibernate відкидає щойно отримані з БД сирі дані і повертає існуюче кероване посилання з кешу L1!
  4. Навіщо це потрібно: Щоб не перезатерти незбережені локальні зміни, які розробник встиг зробити в пам’яті над керованою сутністю.
User user = entityManager.find(User.class, 1L);
user.setName("Нове ім'я в пам'яті");

// Виконуємо JPQL:
User userFromJpql = entityManager.createQuery("SELECT u FROM User u WHERE u.id = :id", User.class)
        .setParameter("id", 1L)
        .getSingleResult();

// SQL SELECT фізично виконається в БД!
// Але userFromJpql.getName() поверне "Нове ім'я в пам'яті"!
assert user == userFromJpql; // true

Програмне керування кешем L1

Метод EntityManager Дія над кешем L1 Практичне застосування
detach(entity) / evict(entity) Видаляє один конкретний об’єкт з L1. Сутність стає Detached. Виключення «важкого» об’єкта з транзакції для економії RAM.
clear() Повністю очищує весь L1 кеш поточної сесії. Обов’язковий при пакетній обробці (Batch processing) для запобігання OOM.
refresh(entity) Перезавантажує стан об’єкта напряму з БД, перезаписуючи кеш. Синхронізація з БД після виконання тригерів або зовнішніх змін.
contains(entity) Перевіряє, чи знаходиться екземпляр у поточному L1 кеші. Перевірка статусу Managed перед операціями.

Небезпека переповнення пам’яті (Memory Leak) у Batch операціях

Оскільки L1 кеш зберігає жорсткі посилання на всі сутності, завантажені або створені в сесії, пакетна вставка або обробка призводить до java.lang.OutOfMemoryError:

// ❌ НЕБЕЗПЕЧНО: Кеш L1 утримає в пам'яті всі 100 000 об'єктів зі знімками!
@Transactional
public void badBatchInsert() {
    for (int i = 0; i < 100_000; i++) {
        User user = new User("User_" + i);
        entityManager.persist(user); // Об'єкт і його знімок навічно осідають у L1
    }
}

// ✅ ПРАВИЛЬНО: Пакетне скидання та очищення контексту
@Transactional
public void correctBatchInsert() {
    int batchSize = 50;
    for (int i = 0; i < 100_000; i++) {
        User user = new User("User_" + i);
        entityManager.persist(user);

        if (i > 0 && i % batchSize == 0) {
            entityManager.flush(); // Відправляємо накопичений батч INSERT у БД
            entityManager.clear(); // Очищуємо L1 кеш, звільняючи купу JVM
        }
    }
}

🔴 Senior Level

Внутрішня реалізація: StatefulPersistenceContext

У Hibernate сесія (SessionImpl) делегує керування кешем об’єкту StatefulPersistenceContext.

Всередині нього працюють дві ключові структури даних:

public class StatefulPersistenceContext implements PersistenceContext {
    // 1. Швидкий пошук сутностей за ID та Типом
    private Map<EntityKey, Object> entitiesByKey = new HashMap<>();

    // 2. Метадані та знімки вихідного стану
    private IdentityMap<Object, EntityEntry> entityEntries = IdentityMap.instantiateSequentially();
    
    // 3. Відстеження колекцій (PersistentBag, PersistentSet)
    private IdentityMap<PersistentCollection<?>, CollectionEntry> collectionEntries;
}

Що зберігається в EntityEntry:

  • Status status: поточний статус (MANAGED, READ_ONLY, DELETED, SAVING).
  • Object[] loadedState: масив значень усіх полів сутності на момент її читання з БД або початкового збереження. Саме з цим масивом механізм Dirty Checking побайтово порівнює поточні поля об’єкта під час flush().
  • Object id та Object version: первинний ключ та номер версії (для @Version).

Read-Only оптимізація для кешу першого рівня

Якщо транзакція виконує лише читання великого обсягу аналітичних даних, підтримка знімків loadedState для Dirty Checking — марна трата CPU та RAM.

Hibernate надає механізми відключення знімків L1:

// Варіант 1: JPA Query Hint
List<User> users = entityManager.createQuery("SELECT u FROM User u", User.class)
    .setHint(org.hibernate.jpa.HibernateHints.HINT_READ_ONLY, true)
    .getResultList();

// Варіант 2: Для конкретної сутності через Session
Session session = entityManager.unwrap(Session.class);
User user = session.get(User.class, 1L);
session.setReadOnly(user, true);

Внутрішній ефект: Для READ_ONLY сутностей масив loadedState в EntityEntry або очищується (null), або ігнорується при flush(). Це економить до 50% пам’яті на сутність і знижує час скидання контексту до нуля.

Альтернатива: StatelessSession

Для масивних міграцій даних або генерації звітів Hibernate пропонує інтерфейс StatelessSession:

  • У ній повністю відсутній L1 кеш (PersistenceContext == null).
  • Не працює Dirty Checking (усі оновлення вимагають явного update()).
  • Не працюють каскади (CascadeType) та відкладене завантаження (Lazy Loading).
  • Запити виконуються напряму через JDBC без накладних витрат на створення знімків об’єктів.

4 Tricky Questions

1. Ми завантажили User(id=1), потім напряму через чистий jdbcTemplate.update("UPDATE users SET name = 'DB' WHERE id = 1") змінили рядок у БД. Що поверне подальший виклик entityManager.find(User.class, 1L) у межах тієї ж транзакції?

Відповідь:

  1. entityManager.find(User.class, 1L) поверне старе значення, яке знаходиться в Java-об’єкті всередині кешу L1!
  2. Жодного запиту до БД виконано не буде, оскільки find() перевіряє наявність ключа EntityKey(User, 1L) у карті entitiesByKey.
  3. Щоб підтягнути актуальне значення, записане через JDBC в обхід Hibernate, необхідно явно викликати entityManager.refresh(user).

2. Якщо в межах однієї сесії викликати entityManager.getReference(User.class, 1L), а потім entityManager.find(User.class, 1L), чи поверне find() реальний клас або Proxy?

Відповідь:

  1. entityManager.find() поверне той самий ByteBuddy-проксі, який був створений методом getReference(), а не реальний екземпляр User!
  2. Причина: правило Identity Guarantee у кеші L1. В entitiesByKey вже зареєстровано проксі-об’єкт. Hibernate не може замінити посилання на інший об’єкт у Heap, оскільки це порушило б посилальну цілісність (userRef == userFind).
  3. При зверненні до будь-якого бізнес-методу (наприклад, userFind.getName()) проксі всередині себе виконає SQL SELECT та проініціалізує цільовий об’єкт.

3. Чому погана реалізація equals() та hashCode() у складеного ключа (@EmbeddedId / @IdClass) ламає кеш першого рівня?

Відповідь:

  1. Ключем у карті L1 кешу entitiesByKey є об’єкт EntityKey, який обчислює свій hashCode і порівнює рівність на основі переданого первинного ключа: int hashCode = 37 * entityName.hashCode() + id.hashCode().
  2. Якщо клас складеного ключа не перевизначає або некоректно реалізує equals() та hashCode():
    • Два пошуки за логічно однаковим ключем не знайдуть існуючий об’єкт в entitiesByKey.
    • Hibernate спробує повторно завантажити рядок і створити дублюючий об’єкт, що призведе або до NonUniqueObjectException, або до порушення узгодженості Identity Map.

4. Чи створює entityManager.find() знімок стану loadedState, якщо сутність позначена анотацією @org.hibernate.annotations.Immutable?

Відповідь:

  1. Для сутностей з анотацією @Immutable Hibernate реєструє об’єкт у кеші entitiesByKey (щоб працював швидкий find() за ID).
  2. Однак Hibernate не створює і не зберігає знімок loadedState в EntityEntry, а статус сутності встановлюється в Status.READ_ONLY.
  3. Під час виконання flush() фаза Dirty Checking повністю пропускає такі сутності, що виключає генерацію будь-яких SQL UPDATE та суттєво економить пам’ять JVM.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«Кеш першого рівня (L1) у Hibernate/JPA — це вбудований, невідключаємий кеш рівня сесії (EntityManager), що реалізує патерн Identity Map. Він вирішує три ключові завдання: гарантує посилальну ідентичність об’єктів (один ID в БД = один екземпляр у купі JVM), зберігає початкові знімки колонок (loadedState) для автоматичного Dirty Checking та усуває дублюючі SELECT при повторному виклику find(). Важливо пам’ятати: JPQL-запити завжди виконують реальний SQL-запит у базу, але замінюють отримані рядки на вже керовані об’єкти з L1. При обробці великих масивів даних L1 необхідно періодично очищати через flush() та clear(), або використовувати StatelessSession, щоб уникнути OutOfMemoryError».

Ключові метрики для Production-моніторингу

  • Hibernate Session Entity Count (session.getStatistics().getEntityCount()): різке зростання кількості керованих об’єктів сигналізує про витік пам’яті в L1.
  • Тривалість Flush Time: велика кількість об’єктів у L1 лінійно збільшує час роботи dirty checking при кожному коміті транзакції.

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «Я вимкнув кеш першого рівня через властивості в application.properties» — Кеш L1 невідключаємий, це архітектурна основа JPA.
  • ❌ «Кеш L1 розділяється між кількома паралельними запитами користувачів» — Кеш L1 суворо ізольований у межах одного потоку/транзакції (EntityManager).
  • ❌ «JPQL-запит не звертається до БД, якщо дані вже є в кеші L1» — JPQL завжди звертається до БД через фізичний SQL SELECT.
  • ❌ «Для звільнення пам’яті L1 достатньо викликати System.gc()» — Посилання в StatefulPersistenceContext жорсткі (Strong References), об’єкти не будуть видалені збирачем сміття до виклику clear() або закриття сесії.

Пов’язані теми