JavaRush /Курсы /Hibernate deep-dive /Read-only загрузка

Read-only загрузка

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

1. Цена dirty checking

Dirty checking удобен, но не бесплатен. Он выглядит как магия: «я меняю поля — Hibernate сам всё обновит». Но у любой магии есть бухгалтерия, и Hibernate — тот самый бухгалтер, который не улыбается, пока не сведёт дебет с кредитом. Чтобы понять, зачем нам read-only, нужно честно признать: dirty checking — это работа, память и время.

После accidental update вопрос возникает сам собой: можно ли пометить чистый read-сценарий так, чтобы Hibernate не тратил силы на dirty checking и не превращал случайный setter в запись? Да — для этого и нужен read-only режим.

Когда сущность становится managed, Hibernate не просто кладёт объект в persistence context. Он ещё сохраняет snapshot — набор значений полей, с которым потом будет сравнивать текущее состояние объекта в памяти. В конце unit of work, обычно при завершении транзакции, Hibernate проходит по managed-сущностям и ищет отличия. Если они есть, сущность считается dirty, и для неё готовится UPDATE. Если отличий нет, Hibernate ничего не обновляет, даже если вы сто раз читали поля или собирали красивую строку для вывода.

В небольших сценариях всё это почти незаметно. Но представьте чистое чтение для админки: загрузили 500 товаров, показали список, ничего не меняли. Если Hibernate будет хранить snapshot для всех 500 объектов и затем проверять их на изменения, мы платим за работу, которая не нужна по смыслу. Более того, мы сами создаём риск accidental update: где-то по пути кто-то случайно тронет поле — нормализует строку, подрежет пробелы, проставит дефолт, — и «чтение» станет «записью».

Вот здесь и появляется read-only как инженерный инструмент: мы не отключаем ORM и не «переходим на чистый SQL», мы просто говорим Hibernate: «в этом месте не нужно вести себя как редактор документа — достаточно режима просмотра».

2. Read-only в Hibernate: без snapshot

Слово «read-only» часто понимают слишком буквально: будто объект становится иммутабельным и Java запрещает вызывать setter. Увы — или к счастью — Java так не умеет. Hibernate read-only — это другое: объект может оставаться managed, жить в persistence context и участвовать в identity map, но при этом Hibernate не обязан поддерживать snapshot и выполнять dirty checking для этой сущности.

Полезно держать в голове простую схему. В обычном режиме Hibernate делает три вещи: хранит managed-объект, хранит snapshot и сравнивает одно с другим при синхронизации. В read-only режиме snapshot не нужен, сравнивать нечего — значит, и UPDATE автоматически не готовится.

flowchart TD
    A[Сущность загружена] --> B{Режим загрузки?}

    B -->|обычный| C["managed entity + snapshot"]
    C --> D[dirty checking]
    D -->|есть отличия| E[готовим UPDATE]
    D -->|нет отличий| F[UPDATE не нужен]

    B -->|read-only| G["managed entity без snapshot"]
    G --> H[dirty checking пропускается]
    H --> I[UPDATE не генерируется]

Важный нюанс: read-only — это не «глобальная настройка приложения», а локальный режим, который можно включать на разных уровнях. В нашем курсе нам нужны три основных варианта.

Первый вариант — read-only на уровне запроса: «результаты этого запроса загрузи как read-only». Второй вариант — read-only на уровне конкретной сущности: «вот этот объект сделай read-only, даже если он уже загружен». Третий вариант — default read-only на уровне Session: «всё, что я сейчас загружаю в этой сессии, считай read-only, потому что это транзакция чистого чтения».

3. Read-only в запросе

Read-only query — самый аккуратный вход в тему, потому что он хорошо выражает намерение: «этот запрос — только для чтения». В JPA это обычно делается через query hint — дополнительный параметр, который влияет на поведение запроса. Hibernate понимает hint org.hibernate.readOnly и загружает сущности как read-only.

Давайте покажем это на нашем проекте Commerce Persistence Lab. Пусть у нас есть Product, и мы хотим получить список товаров для «списка в админке» (это пока ещё не про оптимизацию fetching и не про projections — мы просто иллюстрируем read-only).

import java.util.List;

import jakarta.persistence.EntityManager;

import org.hibernate.jpa.HibernateHints;
import org.springframework.stereotype.Repository;

@Repository
public class ProductQueryRepository {

    private final EntityManager entityManager;

    public ProductQueryRepository(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    public List<Product> findAllReadOnly() {
        // Загружаем сущности как обычно, но просим Hibernate НЕ делать snapshot и dirty checking
        return entityManager.createQuery("select p from Product p", Product.class)
                // Важно: это именно Hibernate-hint для read-only загрузки результатов запроса
                .setHint(HibernateHints.HINT_READ_ONLY, true)
                .getResultList();
    }
}

Здесь важны три момента. Во-первых, это по-прежнему загрузка сущностей, а не DTO. Во-вторых, мы ничего не делаем с detach(): объекты остаются в persistence context, identity map работает, повторное чтение по id вернёт тот же объект. В-третьих, Hibernate получает явный сигнал: snapshot хранить не надо и потом сравнивать эти сущности на dirty тоже не надо.

На практике эффект read-only query мы замечаем не по расплывчатому ощущению «стало быстрее», а по наблюдаемым признакам. Самый простой из них: вы можете случайно поменять поле в объекте в памяти, и UPDATE не появится в SQL-логе. Мы специально используем read-only как защиту от accidental update.

import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogInspectService {

    private final ProductQueryRepository productQueryRepository;

    public CatalogInspectService(ProductQueryRepository productQueryRepository) {
        this.productQueryRepository = productQueryRepository;
    }

    @Transactional
    public void inspectFirstProductName() {
        // Этот список загружается read-only (через hint внутри репозитория)
        List<Product> products = productQueryRepository.findAllReadOnly();

        Product first = products.get(0);
        // Меняем объект в памяти, но Hibernate не должен генерировать UPDATE
        first.setName("Temporary name"); // в SQL-логе UPDATE по product не ожидаем
    }
}

Если вы сейчас подумали: «Подождите, но ведь я реально изменил имя, почему Hibernate молчит?» — это хороший вопрос. Hibernate «молчит», потому что read-only означает: «не синхронизируй изменения этого объекта в БД автоматически». Ваш объект в памяти изменится — Java-то ничего не запрещала, — но в базу это не уйдёт. И в этом одновременно сила и ловушка read-only, к которой мы ещё вернёмся.

Отдельно приятно, что read-only query можно сделать и на уровне Spring Data репозитория через @QueryHints. Это бывает удобно, когда вы не хотите руками писать EntityManager.createQuery(...) для простых кейсов.

import java.util.List;

import jakarta.persistence.QueryHint;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.QueryHints;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Hibernate-hint: результаты этого метода будут загружены в read-only режиме
    @QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true"))
    List<Product> findByStatus(ProductStatus status);
}

Обратите внимание на деталь: в аннотации value = "true" — строка. Это нормально для @QueryHint: там всё приходит как строковое значение.

4. Read-only для сущности

Иногда read-only нужен не для всего запроса. Бывает, мы уже загрузили сущность обычным способом — например, через find() — и хотим после этого сделать её read-only, чтобы защититься от accidental update. Особенно это полезно в «инспекторских» сценариях: открыли карточку товара или заказа, что-то посчитали, что-то показали и точно ничего сохранять не собираетесь.

Для entity-level read-only нам нужен Hibernate API, то есть Session. В JPA-коде мы работаем с EntityManager, но Hibernate живёт под ним. Поэтому используем unwrap(Session.class) — это буквально «достань мне настоящий Hibernate Session из EntityManager».

import jakarta.persistence.EntityManager;

import org.hibernate.Session;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductReadOnlyGuardService {

    private final EntityManager entityManager;

    public ProductReadOnlyGuardService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional
    public void inspectProduct(Long id) {
        // Обычная загрузка: сущность managed и по умолчанию с snapshot
        Product product = entityManager.find(Product.class, id);

        // Достаём Hibernate Session из EntityManager, чтобы включить read-only точечно
        Session session = entityManager.unwrap(Session.class);
        // Делает конкретный объект read-only: Hibernate перестаёт отслеживать его изменения
        session.setReadOnly(product, true);

        // Меняем объект в памяти, но UPDATE не должен появиться
        product.setName("Temporary name"); // UPDATE не ожидаем
    }
}

Чем это отличается от detach(), который мы проходили раньше? Важная мысль: detach() вообще выводит объект из persistence context. А read-only оставляет его внутри, но говорит Hibernate: «не отслеживай изменения и не обновляй автоматически». То есть identity map продолжает работать, и вы не ломаете модель «внутри транзакции всё согласованно». Позже мы увидим, почему detach может создавать другие проблемы, особенно когда начнутся ленивые ассоциации. Пока достаточно запомнить разницу по смыслу: read-only — это «managed, но не обновляемый автоматически».

Тут есть ещё один педагогически полезный момент: entity-level read-only заставляет явно проговорить намерение. Код читается так: «нашли товар — поставили предохранитель — делаем что-то для чтения». Это сильно снижает шанс, что кто-то через полгода добавит случайный setter «по пути» и получит загадочный UPDATE.

5. Default read-only в Session

Иногда весь use case в рамках транзакции является чтением. Например, вы строите отчёт в памяти, генерируете CSV, считаете агрегаты на основе сущностей или просто обходитесь без любых изменений данных. В таком случае удобнее не размечать каждую сущность вручную, а сказать сессии: «всё, что ты сейчас будешь грузить, грузи в read-only режиме по умолчанию».

Для этого в Hibernate есть Session#setDefaultReadOnly(true). Важно сделать это до загрузки сущностей, иначе те, что уже загружены, останутся в прежнем режиме, и вы можете получить смешанный «зоопарк» сущностей: часть read-only, часть обычные. Смешанный режим иногда уместен, но обычно он просто усложняет жизнь, особенно новичкам.

import java.util.List;

import jakarta.persistence.EntityManager;

import org.hibernate.Session;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogReadOnlySessionService {

    private final EntityManager entityManager;

    public CatalogReadOnlySessionService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional
    public List<Product> loadCatalogReadOnly() {
        // Включаем default read-only ДО любых загрузок в рамках текущей сессии
        Session session = entityManager.unwrap(Session.class);
        session.setDefaultReadOnly(true);

        // Все сущности, загруженные этим запросом, будут read-only
        return entityManager.createQuery("select p from Product p", Product.class)
                .getResultList();
    }
}

В нашем учебном проекте такой приём полезен как «режим безопасности» для сервисов, которые по смыслу должны быть строго read-only. Он помогает не только с производительностью dirty checking, но и с дисциплиной: если вы случайно меняете поле у одного из загруженных Product, это не превратится в SQL-обновление.

И да, Session#setDefaultReadOnly(true) часто вспоминают рядом с @Transactional(readOnly = true). Здесь нам достаточно одного факта: на уровне Hibernate это управляет тем, как текущая сессия отслеживает загружаемые сущности.

Если свести три режима к одному правилу выбора, получается так:

Что нужно защитить от лишнего dirty checking Что выбрать
Один конкретный read-запрос query hint org.hibernate.readOnly
Один уже загруженный объект session.setReadOnly(entity, true)
Весь read-only use case / текущую сессию до загрузок session.setDefaultReadOnly(true)
Сценарий потенциально пишет read-only не включать

6. Ограничения read-only

Read-only режим иногда даёт ложное чувство спокойствия: «ну раз оно read-only, значит, я не могу ничего испортить». На самом деле read-only скорее похож на табличку «Не сохранять изменения» в текстовом редакторе. Вы можете сколько угодно печатать текст, но при закрытии документа вам скажут: «Изменения не будут сохранены». Если в этот момент вы удивляетесь — значит, режим был понят неправильно.

Самая распространённая ловушка звучит так: «я поменял поле у read-only сущности, а потом в этой же транзакции ещё раз прочитал её из базы через find() и увидел новое значение — значит, изменения сохранились». Нет, не сохранились. Вы просто снова получили тот же объект из identity map (first-level cache), а он у вас уже изменён в памяти. База тут ни при чём, и если вы откроете новую транзакцию, то увидите старое значение из БД.

Поэтому правило для мозга такое: read-only отключает автоматическую синхронизацию с БД, но не запрещает мутировать объект в памяти. А значит, read-only защищает базу от случайной записи, но не защищает вашу бизнес-логику от самообмана, если вы продолжите использовать изменённый объект как источник истины внутри той же транзакции.

Если вы случайно поменяли read-only сущность и хотите вернуться к реальному состоянию базы, есть два понятных способа. Первый — вообще не менять её в памяти (звучит банально, но работает стабильно). Второй — если изменения уже внесены, принудительно перечитать состояние из БД через refresh() (эту операцию мы уже знаем).

import jakarta.persistence.EntityManager;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductRefreshService {

    private final EntityManager entityManager;

    public ProductRefreshService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional
    public void changeAndRollbackInMemory(Long id) {
        // Загружаем managed-сущность (обычный режим)
        Product product = entityManager.find(Product.class, id);

        product.setName("Temporary");     // меняем в памяти
        // Принудительно перечитываем состояние из БД и перезаписываем объект в persistence context
        entityManager.refresh(product);   // возвращаем состояние из БД

        System.out.println(product.getName()); // снова "как в базе"
    }
}

refresh() — это как кнопка «Вернуть последнюю сохранённую версию». Она не «отменяет грязное состояние в Hibernate», а просто перечитывает данные из базы и переписывает объект в persistence context.

7. Read-only в Commerce Persistence Lab

В нашем проекте «каталог + заказы + остатки» read-only почти сразу находит себе место. Даже если вы пока не строите красивые списки и не думаете про оптимизацию, у вас уже есть классические read-сценарии: посмотреть товары, посмотреть заказ, собрать «человеческий» заголовок, вывести статус. И именно в таких местах accidental update чаще всего появляется «из ничего».

Представьте, что вы пишете сервис, который возвращает красивое название товара для отчёта. Новичок очень легко может сделать «нормализацию» через setter — подрезал пробелы, заменил два пробела на один, — потому что так быстрее. И внезапно чтение становится записью. Если сервис читает товары в read-only, вы выигрываете сразу в двух направлениях: не платите за dirty checking и не рискуете сделать запись там, где её не ждали.

Один из аккуратных стилей в проекте — выделять сервисы чтения и делать их явными. Например, CatalogTitleService, который собирает строки, может читать товары в read-only.

import jakarta.persistence.EntityManager;

import org.hibernate.Session;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogTitleService {

    private final EntityManager entityManager;

    public CatalogTitleService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional
    public String buildProductTitle(Long productId) {
        // В этом use case мы читаем и собираем строку, поэтому включаем режим "не сохранять изменения"
        Session session = entityManager.unwrap(Session.class);
        session.setDefaultReadOnly(true);

        // Повторные чтения по id в рамках транзакции всё ещё будут возвращать тот же объект (identity map)
        Product p = entityManager.find(Product.class, productId);
        return p.getSku() + " — " + p.getName();
    }
}

Да, здесь можно спорить, нужен ли default read-only ради одного find(). Это учебный пример, но он хорошо показывает мысль: read-only — не «оптимизация на последнем этапе», а способ зафиксировать намерение. В реальном коде вы могли бы сделать read-only на уровне query или на уровне конкретной сущности, но цель одна: отделить сценарии чтения от сценариев записи так, чтобы Hibernate не делал лишнюю работу и не удивлял вас UPDATE там, где вы его не ждёте.

Ещё один практический мотив: read-only очень помогает в лабораториях. Когда вы изучаете dirty checking, полезно иметь контрольные кейсы «здесь UPDATE должен быть» и «здесь UPDATE быть не должно». Read-only — отличный способ сделать второй кейс предсказуемым, даже если вы случайно потрогали поле в отладке.

8. Типичные ошибки при работе с read-only

Read-only режим часто выглядит как «кнопка сделать хорошо», поэтому люди нажимают её в самых неожиданных местах. В результате получается либо «ничего не сохраняется, хотя я хотел», либо «я думал, это ускорит запрос, а он всё равно медленный», либо классическое «оно работало, пока я не добавил одну строчку». Давайте заранее проговорим самые частые грабли — чтобы вы наступали на них только в учебных тестах, а не в проде.

Ошибка №1: считать read-only аналогом «запретить setter-ы».
Read-only не запрещает изменения в памяти. Он запрещает автоматическую синхронизацию с БД. Если вы в read-only сценарии меняете поле, объект действительно меняется в Java, и ваш код дальше может начать опираться на это новое значение. Но после завершения транзакции база останется прежней. Это порождает очень хитрые баги, когда «внутри метода всё красиво», а на следующем запросе данные «вдруг откатились».

Ошибка №2: включить read-only и ожидать, что это «ускорит SQL».
Read-only в первую очередь экономит работу Hibernate вокруг snapshot и dirty checking. Он не обязан менять форму SELECT и не обязан уменьшать количество запросов. Если проблема была в N+1 или в слишком широком JOIN, read-only это не вылечит. Он лечит другой класс расходов: «мы загрузили много сущностей, но ничего не меняем — зачем тратить силы на проверку изменений».

Ошибка №3: забыть, что query hint действует только на результаты конкретного запроса.
Когда вы пишете setHint("org.hibernate.readOnly", true), это касается только результата этого query. Следующий find() или другой query будет обычным, если вы снова не указали hint или не включили default read-only на session. Это частая причина «странной непоследовательности»: одна часть метода защищена, другая — нет.

Ошибка №4: включить session.setDefaultReadOnly(true) слишком поздно.
Default read-only работает как «режим по умолчанию для будущих загрузок». Если вы уже успели загрузить сущности, они останутся в прежнем режиме. В итоге получаете транзакцию, где часть сущностей read-only, часть — обычные. Смешанный режим иногда возможен, но для новичка это чаще ловушка: потом сложно понять, почему один объект обновился, а другой — нет.

Ошибка №5: пытаться сделать write-сценарий поверх read-only загрузки.
Самый типовой сценарий: вы взяли метод «для чтения», включили там read-only, а через неделю добавили «маленькое обновление статуса» или «подправить имя». И внезапно обновление перестало происходить, но без явной ошибки. Поэтому дисциплина простая: read-only используем там, где по смыслу запись невозможна. Если запись нужна — это другой use case, другой метод, другое намерение.

1
Задача
Hibernate deep-dive, 3 уровень, 3 лекция
Недоступна
Read-only query для одного товара
Read-only query для одного товара
1
Задача
Hibernate deep-dive, 3 уровень, 3 лекция
Недоступна
Entity-level read-only для `PurchaseOrder`
Entity-level read-only для `PurchaseOrder`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ