Проектування баз даних

Identity Map: Кешування сутностей у рамках сесії

Вирішення проблеми подвійного завантаження та розбіжності стану: реалізація Identity Map як кешу первинних ключів, інтеграція з JdbcRepository, дослідження областей видимості та проблем concurrency.

Identity Map: Кешування сутностей у рамках сесії

Вступ: Коли два об'єкти — це один і той самий автор

Розглянемо типовий сценарій у нашому репозиторії аудіоплатформи. Бізнес-логіка виконує дві операції в рамках однієї транзакції: отримати автора для побудови звіту і окремо — для перевірки прав доступу.

AuthorRepository repo = new JdbcAuthorRepository(cm);

UUID tarasId = taras.getId();

// Перший виклик — SELECT з БД
Author author1 = repo.findById(tarasId).orElseThrow();

// ... кілька рядків бізнес-логіки ...

// Другий виклик — знову SELECT з БД
Author author2 = repo.findById(tarasId).orElseThrow();

// Здається, це один автор — але Java каже інакше
System.out.println(author1 == author2);           // false — різні об'єкти в пам'яті
System.out.println(author1.equals(author2));       // true — якщо equals через id

// Оновлюємо через перший об'єкт
author1.setFirstName("Тарас");
repo.update(author1);

// Другий об'єкт — застарілий! Він досі зберігає старе ім'я.
System.out.println(author2.getFirstName()); // ← старе значення

Ця ситуація демонструє три окремих проблеми, що поглиблюють одна одну:

По-перше, надлишкові запити до бази даних. Кожен виклик findById() виконує SELECT-запит, навіть якщо ми вже завантажили цей об'єкт секунду тому. При складному сценарії — збереження аудіокниги (що завантажує автора), перевірка рекомендацій (що знову завантажує того самого автора), побудова відповіді API (ще один findById) — кількість зайвих запитів може вимірюватися десятками за одну HTTP-сесію.

По-друге, розбіжність стану. Коли два різних фрагменти коду тримають посилання на «однакову» сутність, зміна через одне посилання не відображається в іншому. Система має суперечливий стан у пам'яті.

По-третє, порушення семантики ідентичності. У реляційній базі даних сутність однозначно визначається своїм первинним ключем — один UUID відповідає одному рядку. У Java-програмі без додаткових механізмів той самий рядок може бути представлений скількома завгодно різними об'єктами одночасно. Це і є той самий Impedance Mismatch у питанні ідентичності, що ми описували у статті 09.

Рішення — Identity Map.


Концепція: Що таке Identity Map

Identity Map (Фаулер, Patterns of Enterprise Application Architecture, 2002):

«Ensures that each object gets loaded only once by keeping every loaded object in a map. Looks up objects using the map when referring to them.»

«Гарантує, що кожен об'єкт завантажується лише один раз, зберігаючи кожен завантажений об'єкт у Map. При зверненні до об'єктів шукає їх у цьому Map.»

Identity Map — це реєстр завантажених об'єктів, організований за первинним ключем. Перш ніж звертатися до бази даних, репозиторій перевіряє: «чи є цей об'єкт вже в пам'яті?». Якщо так — повертає його з кешу. Якщо ні — завантажує з БД і кладе у кеш для майбутніх звернень.

Loading diagram...
flowchart TD
    CL["Клієнт викликає<br/>findById(id)"]
    CHK{"Identity Map<br/>містить id?"}
    RET["Повернути<br/>кешований об'єкт<br/>(0 запитів до БД)"]
    DB["Виконати<br/>SELECT ... WHERE id=?"]
    CACHE["Зберегти об'єкт<br/>у Identity Map"]
    RESP["Повернути<br/>новий об'єкт"]

    CL --> CHK
    CHK -- "так (cache hit)" --> RET
    CHK -- "ні (cache miss)" --> DB --> CACHE --> RESP

    style RET fill:#22c55e,stroke:#15803d,color:#ffffff
    style DB fill:#f59e0b,stroke:#b45309,color:#000000

Ключова гарантія Identity Map: для одного первинного ключа завжди існує не більше одного Java-об'єкта в пам'яті. Це вирішує всі три проблеми одночасно:

  • Зайвий SELECT не виконується при повторному findById
  • Обидва посилання (author1 і author2) вказують на один і той самий об'єкт у пам'яті
  • Семантика ідентичності Java (==) збігається з семантикою БД (первинний ключ)

Реалізація IdentityMap<ID, T>

Identity Map є самостійним компонентом з єдиною відповідальністю: зберігати і повертати об'єкти за їх ідентифікаторами. Він не знає ані про SQL, ані про конкретні сутності — це чистий кеш.

package com.example.audiobook.persistence;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

/**
 * Identity Map — реєстр завантажених об'єктів, індексований за первинним ключем.
 * <p>
 * Гарантує, що для кожного значення ключа {@code ID} у пам'яті існує
 * не більше одного екземпляра типу {@code T}. При повторному запиті
 * з тим самим ключем повертає той самий об'єкт (не копію), що:
 * <ul>
 *   <li>усуває зайві SELECT-запити до БД;</li>
 *   <li>підтримує узгодженість стану у рамках сесії;</li>
 *   <li>вирівнює семантику ідентичності Java та реляційної БД.</li>
 * </ul>
 *
 * <p><b>Область видимості:</b> один екземпляр Identity Map повинен
 * існувати в межах однієї логічної одиниці роботи (запит, транзакція
 * або сесія). Він <em>не є</em> глобальним або thread-safe кешем.
 *
 * @param <ID> тип первинного ключа (наприклад, {@code UUID})
 * @param <T>  тип доменної сутності (наприклад, {@code Author})
 */
public class IdentityMap<ID, T> {

    /**
     * Внутрішнє сховище: первинний ключ → екземпляр сутності.
     * <p>
     * Використовуємо {@link HashMap} (не {@link java.util.concurrent.ConcurrentHashMap}),
     * оскільки Identity Map призначений для однопотокового використання
     * у рамках однієї сесії/транзакції.
     */
    private final Map<ID, T> store = new HashMap<>();

    /**
     * Шукає об'єкт у кеші за первинним ключем.
     *
     * @param id первинний ключ
     * @return {@link Optional} з об'єктом, якщо він раніше був завантажений;
     *         {@link Optional#empty()} якщо cache miss
     */
    public Optional<T> get(ID id) {
        return Optional.ofNullable(store.get(id));
    }

    /**
     * Реєструє об'єкт у кеші після завантаження з БД.
     * <p>
     * Якщо об'єкт з таким {@code id} вже зареєстрований — він буде замінений.
     * Зазвичай це не має відбуватися у нормальному потоці роботи.
     *
     * @param id     первинний ключ
     * @param entity завантажений об'єкт
     */
    public void put(ID id, T entity) {
        store.put(id, entity);
    }

    /**
     * Видаляє об'єкт із кешу.
     * <p>
     * Викликається після успішного {@code DELETE} або при явній інвалідації
     * (наприклад, у {@link com.example.audiobook.repository.CachedJdbcRepository#deleteById}).
     *
     * @param id первинний ключ
     */
    public void remove(ID id) {
        store.remove(id);
    }

    /**
     * Перевіряє, чи об'єкт з даним ключем зареєстрований у кеші.
     *
     * @param id первинний ключ
     * @return {@code true} якщо cache hit
     */
    public boolean contains(ID id) {
        return store.containsKey(id);
    }

    /**
     * Очищає весь кеш.
     * <p>
     * Викликається в кінці сесії або при явному скиданні стану.
     * Після виклику наступний {@code findById} знову звернеться до БД.
     */
    public void clear() {
        store.clear();
    }

    /**
     * Повертає кількість об'єктів у кеші.
     * Корисно для діагностики та тестів.
     */
    public int size() {
        return store.size();
    }
}

Клас навмисно мінімалістичний: сім публічних методів, жодного SQL, жодної залежності від конкретних доменних типів. Параметризація <ID, T> дозволяє створювати окремий IdentityMap для кожної сутності без дублювання коду.


CachedJdbcRepository: Інтеграція Identity Map з Repository

Маючи готовий IdentityMap<ID, T>, наступний крок — інтегрувати його з існуючою архітектурою репозиторіїв зі статті 14. Ми могли б додати кеш прямо в AbstractJdbcRepository, але це порушило б принцип єдиної відповідальності: абстрактний репозиторій відповідає за JDBC-операції, а не за кешування.

Кращий підхід — Decorator Pattern: створити проміжний клас CachedJdbcRepository<T, ID>, що обгортає будь-який Repository<T, ID> і додає кешування, не змінюючи оригінального коду.

Loading diagram...
classDiagram
    direction TB

    class Repository {
        <<interface>>
        +findById(id) Optional~T~
        +findAll() List~T~
        +save(entity) void
        +update(entity) void
        +deleteById(id) bool
        +count() long
        +existsById(id) bool
    }

    class AbstractJdbcRepository {
        <<abstract>>
        #connectionManager
        +findById(id) Optional~T~
        +findAll() List~T~
        +save(entity) void
        +update(entity) void
        +deleteById(id) bool
    }

    class JdbcAuthorRepository {
        #mapRow(rs) Author
        #getInsertSql() String
        #setInsertParams(stmt, entity)
    }

    class CachedJdbcRepository {
        -delegate: Repository~T,ID~
        -identityMap: IdentityMap~ID,T~
        +findById(id) Optional~T~
        +findAll() List~T~
        +save(entity) void
        +update(entity) void
        +deleteById(id) bool
    }

    Repository <|.. AbstractJdbcRepository
    AbstractJdbcRepository <|-- JdbcAuthorRepository
    Repository <|.. CachedJdbcRepository
    CachedJdbcRepository o-- Repository : delegates to
    CachedJdbcRepository o-- IdentityMap
package com.example.audiobook.repository;

import com.example.audiobook.persistence.IdentityMap;

import java.util.List;
import java.util.Optional;

/**
 * Декоратор для будь-якого {@link Repository}, що додає кешування
 * через {@link IdentityMap}.
 * <p>
 * Реалізує патерн Decorator (GoF): делегує всі операції wrapped-репозиторію,
 * перехоплюючи {@code findById} і {@code save/update/delete} для підтримки
 * кешу в актуальному стані.
 * <p>
 * <b>Контракт:</b>
 * <ul>
 *   <li>{@code findById} — спочатку кеш, потім БД;</li>
 *   <li>{@code findAll} — завжди БД (наповнює кеш результатами);</li>
 *   <li>{@code save} — зберігає в БД і додає у кеш;</li>
 *   <li>{@code update} — оновлює в БД і інвалідує (оновлює) кеш;</li>
 *   <li>{@code deleteById} — видаляє з БД і з кешу.</li>
 * </ul>
 *
 * @param <T>  тип доменної сутності
 * @param <ID> тип первинного ключа
 */
public class CachedJdbcRepository<T, ID> implements Repository<T, ID> {

    /** Обгорнутий репозиторій: виконує реальні SQL-операції. */
    private final Repository<T, ID> delegate;

    /** Кеш завантажених об'єктів у межах поточної сесії. */
    private final IdentityMap<ID, T> identityMap;

    /** Функція отримання ID з сутності — потрібна для put/remove у кеш. */
    private final java.util.function.Function<T, ID> idExtractor;

    /**
     * @param delegate    реальний репозиторій (наприклад, JdbcAuthorRepository)
     * @param idExtractor функція для отримання ID з сутності (Author::getId)
     */
    public CachedJdbcRepository(
            Repository<T, ID> delegate,
            java.util.function.Function<T, ID> idExtractor) {
        this.delegate = delegate;
        this.idExtractor = idExtractor;
        this.identityMap = new IdentityMap<>();
    }

    /**
     * Знаходить сутність за ID.
     * <p>
     * <b>Логіка:</b>
     * <ol>
     *   <li>Перевірити Identity Map — cache hit? Повернути об'єкт.</li>
     *   <li>Cache miss → делегувати у wrapped repository (SQL SELECT).</li>
     *   <li>Зареєструвати знайдений об'єкт у Identity Map.</li>
     * </ol>
     */
    @Override
    public Optional<T> findById(ID id) {
        // Крок 1: перевірка кешу
        Optional<T> cached = identityMap.get(id);
        if (cached.isPresent()) {
            return cached;  // cache hit — SELECT не виконується
        }

        // Крок 2: cache miss — завантажити з БД
        Optional<T> fromDb = delegate.findById(id);

        // Крок 3: зареєструвати у кеші (якщо знайдено)
        fromDb.ifPresent(entity -> identityMap.put(id, entity));

        return fromDb;
    }

    /**
     * Повертає всі сутності.
     * <p>
     * <b>Стратегія:</b> завжди звертається до БД (не можна знати, чи кеш
     * є повним). Заповнює кеш усіма завантаженими об'єктами — наступні
     * {@code findById} будуть cache hit.
     */
    @Override
    public List<T> findAll() {
        // Завжди звертаємось до БД: не знаємо, чи всі записи вже у кеші
        List<T> all = delegate.findAll();

        // Наповнюємо кеш: наступний findById вже не піде в БД
        all.forEach(entity -> identityMap.put(idExtractor.apply(entity), entity));

        return all;
    }

    /**
     * Зберігає нову сутність.
     * <p>
     * Делегує в БД, потім реєструє сутність у кеші.
     */
    @Override
    public void save(T entity) {
        delegate.save(entity);
        // Після успішного INSERT реєструємо у кеші
        identityMap.put(idExtractor.apply(entity), entity);
    }

    /**
     * Оновлює існуючу сутність.
     * <p>
     * Делегує в БД, потім оновлює кеш — замінює старий об'єкт новим.
     * Це гарантує, що наступний {@code findById} поверне актуальний стан.
     */
    @Override
    public void update(T entity) {
        delegate.update(entity);
        // Інвалідуємо і оновлюємо кеш: кладемо новий об'єкт
        identityMap.put(idExtractor.apply(entity), entity);
    }

    /**
     * Видаляє сутність за ID.
     * <p>
     * Делегує в БД, потім видаляє з кешу.
     * Без цього кроку {@code findById} після видалення знайшов би
     * об'єкт у кеші і повернув би його — навіть якщо рядку вже немає у БД.
     */
    @Override
    public boolean deleteById(ID id) {
        boolean deleted = delegate.deleteById(id);
        if (deleted) {
            identityMap.remove(id);  // обов'язкова інвалідація
        }
        return deleted;
    }

    @Override
    public long count() {
        return delegate.count();  // делегуємо без кешування
    }

    @Override
    public boolean existsById(ID id) {
        // Якщо є у кеші — точно існує; якщо немає — запитуємо БД
        return identityMap.contains(id) || delegate.existsById(id);
    }

    /**
     * Повертає Identity Map для доступу до метаданих кешу.
     * Корисно для тестів та діагностики.
     */
    public IdentityMap<ID, T> getIdentityMap() {
        return identityMap;
    }
}

Анатомія методу findById() (рядки 61–75)

Метод є серцем Identity Map і демонструє класичний Cache-Aside патерн у трьох рядках:

  1. Рядок 63identityMap.get(id): перевірити кеш. Якщо знайдено — повернути негайно. Жодного SQL-запиту.
  2. Рядок 68delegate.findById(id): кеш порожній — делегувати у реальний репозиторій. Виконується SELECT ... WHERE id = ?.
  3. Рядок 71identityMap.put(id, entity): зареєструвати результат у кеші. Наступний findById для того самого id буде cache hit.

Чому findAll() завжди йде в БД (рядки 83–90)

Цей вибір є свідомим архітектурним рішенням. Кеш не є «повним» — він містить лише ті об'єкти, що були раніше завантажені через findById або save. Якби ми повернули identityMap.values() замість запиту до БД, ми пропустили б нові записи, додані іншими транзакціями.

Натомість ми використовуємо findAll як можливість наповнити кеш: після його виконання всі завантажені об'єкти реєструються в identityMap, і наступні findById стають cache hit.


Демонстрація: Ефект Identity Map

package com.example.audiobook;

import com.example.audiobook.db.ConnectionManager;
import com.example.audiobook.domain.Author;
import com.example.audiobook.repository.AuthorRepository;
import com.example.audiobook.repository.CachedJdbcRepository;
import com.example.audiobook.repository.jdbc.JdbcAuthorRepository;

import java.util.UUID;

public class Main {

    public static void main(String[] args) {

        ConnectionManager cm = ConnectionManager.forH2("./data/audiobook_db");

        // Підклад: реальний JDBC-репозиторій
        JdbcAuthorRepository jdbcRepo = new JdbcAuthorRepository(cm);

        // Обгортка: той самий AuthorRepository, але з Identity Map
        // Author::getId — функція вилучення ID для put/remove у кеш
        AuthorRepository repo = new CachedJdbcRepository<>(jdbcRepo, Author::getId);

        // Зберегти автора
        Author shevchenko = new Author("Тарас", "Шевченко");
        repo.save(shevchenko);
        UUID id = shevchenko.getId();

        System.out.println("=== Перший findById ===");
        Author a1 = repo.findById(id).orElseThrow();
        // → cache miss: виконується SELECT
        // → об'єкт кладеться у Identity Map

        System.out.println("=== Другий findById (той самий id) ===");
        Author a2 = repo.findById(id).orElseThrow();
        // → cache hit: SELECT НЕ виконується

        // Перевірка семантики ідентичності
        System.out.println("a1 == a2: " + (a1 == a2));   // true — ОДИН і той самий об'єкт
        System.out.println("a1 id:    " + System.identityHashCode(a1));
        System.out.println("a2 id:    " + System.identityHashCode(a2));
        // Обидва System.identityHashCode однакові — це буквально той самий об'єкт

        System.out.println("=== Зміна через a1, читання через a2 ===");
        a1.setFirstName("Тарасик");   // змінюємо через перше посилання
        System.out.println("a2.getFirstName() = " + a2.getFirstName());
        // "Тарасик" — бо a1 і a2 — це той самий об'єкт у пам'яті!

        System.out.println("=== Оновлення в БД та інвалідація кешу ===");
        a1.setFirstName("Тарас");      // повернули правильне ім'я
        repo.update(a1);               // UPDATE в БД + оновлення кешу

        System.out.println("=== Видалення та перевірка кешу ===");
        boolean deleted = repo.deleteById(id);
        System.out.println("Видалено: " + deleted);

        // Після deleteById об'єкт вилучений з кешу
        // Наступний findById знову піде в БД — і не знайде запис
        Author a3 = repo.findById(id).orElse(null);
        System.out.println("Знайдено після видалення: " + a3);  // null

        cm.close();
    }
}

Ключовий момент у рядках 38–42: a1 == a2 повертає true. Це означає, що Java-оператор == (порівняння посилань, а не значень) дає true для двох об'єктів, отриманих двома окремими викликами findById. Identity Map зрівняв семантику Java-ідентичності (==) та реляційної ідентичності (первинний ключ).

java Main
$ java -cp . com.example.audiobook.Main
[Pool] Ініціалізовано: 2 з'єднань готові
=== Перший findById ===
[SQL] SELECT id, first_name, last_name, bio, image_path FROM authors WHERE id = ?
Завантажено з БД, кешовано
=== Другий findById (той самий id) ===
Cache hit — SQL не виконується
a1 == a2: true
a1 id: 1923847562
a2 id: 1923847562
=== Зміна через a1, читання через a2 ===
a2.getFirstName() = Тарасик
=== Видалення та перевірка кешу ===
[SQL] DELETE FROM authors WHERE id = ?
Видалено: true
[SQL] SELECT id, first_name, last_name FROM authors WHERE id = ?
Знайдено після видалення: null
[Pool] Закрито. Закрито 2 з'єднань

Область видимості Identity Map

Одне з найважливіших архітектурних рішень при впровадженні Identity Map — визначити його область видимості: скільки часу і для якого контексту він існує. Фаулер виділяє три основних рівні.

Loading diagram...
graph LR
    subgraph REQ["Per-Request (HTTP Request)"]
        direction TB
        R1["IM створюється<br/>на початку запиту"]
        R2["Використовується<br/>у handler/service"]
        R3["Знищується<br/>після відповіді"]
        R1 --> R2 --> R3
    end

    subgraph TX["Per-Transaction"]
        direction TB
        T1["IM створюється<br/>perед BEGIN"]
        T2["Дані узгоджені<br/>у межах транзакції"]
        T3["Знищується<br/>після COMMIT/ROLLBACK"]
        T1 --> T2 --> T3
    end

    subgraph SS["Per-Session (довгоживучий)"]
        direction TB
        S1["IM існує весь<br/>час сесії"]
        S2["Потрібна<br/>активна інвалідація"]
        S3["Ризик stale data"]
        S1 --> S2 --> S3
    end

    style REQ fill:#1e3a5f,stroke:#3b82f6,color:#ffffff
    style TX fill:#1e3a2f,stroke:#22c55e,color:#ffffff
    style SS fill:#3a1e1e,stroke:#ef4444,color:#ffffff

Per-Request: Один кеш на один HTTP-запит

Найбезпечніший і найпоширеніший підхід у веб-застосунках. Identity Map живе рівно стільки, скільки виконується обробка одного запиту: створюється на початку та знищується разом із відповіддю.

// Псевдокод HTTP-обробника (Spring-стиль без Spring)
public Response handleGetAudiobook(UUID audiobookId) {
    // Новий CachedJdbcRepository = новий IdentityMap для цього запиту
    AuthorRepository authorRepo =
        new CachedJdbcRepository<>(new JdbcAuthorRepository(cm), Author::getId);

    // Навіть якщо логіка тричі завантажує автора — SELECT виконається один раз
    Audiobook book = buildAudiobookResponse(audiobookId, authorRepo);
    return Response.ok(book);
    // CachedJdbcRepository виходить зі scope → Identity Map знищується
}

Переваги: Жодних проблем зі stale data між запитами. Жодних проблем із конкурентністю — кожен запит ізольований. Недоліки: Кеш не переживає між запитами — якщо той самий автор потрібен у двох різних HTTP-запитах, SELECT виконається двічі.

Per-Transaction: Один кеш на одну JDBC-транзакцію

Identity Map є частиною транзакційного контексту. Це природна межа для JDBC-систем: усередині транзакції дані повинні бути узгодженими, тому кеш гарантовано актуальний.

// Усі операції в межах однієї транзакції використовують один IM
try (Connection conn = cm.getConnection()) {
    conn.setAutoCommit(false);

    CachedJdbcRepository<Author, UUID> authorRepo =
        new CachedJdbcRepository<>(new JdbcAuthorRepository(cm), Author::getId);

    Author author = authorRepo.findById(id1).orElseThrow(); // SELECT
    Author same   = authorRepo.findById(id1).orElseThrow(); // cache hit!

    author.setBio("Оновлена біографія");
    authorRepo.update(author);

    conn.commit();
    // IdentityMap виходить зі scope разом із транзакцією
}

Per-Session: Довгоживучий кеш

Identity Map живе протягом всієї сесії користувача (або application-context). Це те, що робить Hibernate SessionFactory з L2 Cache та JPA EntityManager.

Per-session Identity Map вимагає активної інвалідації: при оновленні або видаленні запису необхідно явно видалити його з кешу, інакше інший потік або наступний запит того самого користувача прочитає застарілі дані. Без правильної інвалідації система стає джерелом важковловимих багів.

Проблеми Identity Map

Незважаючи на очевидні переваги, Identity Map вносить кілька нетривіальних проблем, що необхідно розуміти перед впровадженням.

Проблема 1: Stale Data (застарілі дані)

Найнебезпечніший сценарій: два паралельних потоки або два HTTP-запити одночасно завантажують один і той самий об'єкт. Перший оновлює його у БД і у своєму кеші. Другий — досі тримає старий об'єкт у своєму кеші.

Потік A:  findById(42) → cache miss → SELECT → author{name="Тарас"} → кеш
Потік B:  findById(42) → cache miss → SELECT → author{name="Тарас"} → кеш

Потік A:  author.setName("Тарас Григорович") → update() → UPDATE у БД → кеш A оновлено
Потік B:  findById(42) → cache hit → повертає author{name="Тарас"} ← ЗАСТАРІЛІ ДАНІ!

Вирішення для per-request: кеш ізольований між потоками — кожен запит має власний CachedJdbcRepository. Stale data неможливі в межах одного запиту.

Проблема 2: Витік пам'яті при per-session кеші

Якщо Identity Map живе довго, він накопичує об'єкти без звільнення. При роботі з великими колекціями (завантаження всіх аудіокниг через findAll) кеш може рости необмежено.

Рішення: обмежена місткість кешу з витісненням по LRU (Least Recently Used). Або стратегія WeakHashMap — Java автоматично видаляє записи, коли на об'єкт більше немає сильних посилань.

// Варіант з WeakHashMap — автоматичне звільнення при GC
private final Map<ID, T> store = new java.util.WeakHashMap<>();
// Увага: не підходить якщо є тільки посилання через кеш — об'єкт може зникнути!

Проблема 3: Відсутність thread-safety

HashMap, що використовується у нашій реалізації IdentityMap, не є потокобезпечним. При спільному доступі з кількох потоків необхідно або:

  • Використовувати ConcurrentHashMap (але тоді треба carefully handle get+put atomicity)
  • Або обмежити область видимості Identity Map одним потоком (per-request у thread-per-request моделі)
У сучасних Java-фреймворках (Spring, Quarkus) кожен HTTP-запит обробляється в одному потоці протягом усієї обробки (virtual threads у Project Loom або bounded thread pool). Per-request Identity Map із HashMap є безпечним у цьому контексті — жодна зовнішня синхронізація не потрібна.

Проблема 4: Неповнота кешу для findAll

Наша реалізація findAll() наповнює кеш, але не позначає кеш як «повний». Після виклику findAll() клієнтський код міг би теоретично пропустити SELECT і повернути identityMap.values(). Але ми цього не робимо — і правильно: між findAll() і наступним зверненням інші операції могли додати нові рядки.

// Неправильна оптимізація (не робимо):
@Override
public List<T> findAll() {
    if (allLoaded) {
        return new ArrayList<>(identityMap.values()); // небезпечно!
    }
    // ...
}
// Чому небезпечно: інша транзакція могла додати запис після нашого findAll.
// Флаг allLoaded = true → ми пропустили новий запис.

Правильне рішення — ніколи не вважати кеш «повним» без явного механізму блокування нових вставок (що виходить за рамки Identity Map).


Підсумок

Що вирішує Identity Map

  • Усуває зайві SELECT-запити при повторних findById
  • Гарантує єдиний екземпляр об'єкта на ключ у рамках сесії
  • Вирівнює семантику == Java з PK-ідентичністю реляційної моделі
  • Реалізується через Decorator — без зміни існуючого коду

Обмеження та застереження

  • Per-session кеш потребує активної інвалідації
  • findAll() завжди звертається до БД
  • HashMap не є thread-safe для спільного доступу
  • Не усуває N+1 (це завдання Lazy Loading зі статті 18)

Identity Map є першим компонентом того, що Фаулер назвав Session State: механізмів підтримки узгодженого стану об'єктів протягом однієї одиниці роботи. У наступній статті ми розглянемо Unit of Work — компонент, що доповнює Identity Map: він не лише кешує завантажені об'єкти, а й відстежує їх зміни та координує збереження у правильному порядку.


Завдання