«1. Обзор

При использовании отложенной загрузки в Hibernate мы можем столкнуться с исключениями, говорящими об отсутствии сеанса.

В этом уроке мы обсудим, как решить эти проблемы с ленивой загрузкой. Для этого мы будем использовать Spring Boot для изучения примера.

2. Проблемы с ленивой загрузкой

Целью ленивой загрузки является экономия ресурсов за счет того, что связанные объекты не загружаются в память при загрузке основного объекта. Вместо этого мы откладываем инициализацию ленивых сущностей до момента, когда они понадобятся. Hibernate использует прокси-серверы и обертки коллекций для реализации ленивой загрузки.

При получении лениво загружаемых данных процесс состоит из двух шагов. Во-первых, это заполнение основного объекта, а во-вторых, извлечение данных из его прокси. Для загрузки данных всегда требуется открытая сессия в Hibernate.

Проблема возникает, когда второй шаг происходит после закрытия транзакции, что приводит к исключению LazyInitializationException.

Рекомендуемый подход заключается в разработке нашего приложения таким образом, чтобы извлечение данных происходило за одну транзакцию. Но иногда это может быть сложно при использовании ленивого объекта в другой части кода, который не может определить, что было загружено, а что нет.

У Hibernate есть обходной путь, свойство enable_lazy_load_no_trans. Включение этого параметра означает, что каждая выборка отложенного объекта будет открывать временный сеанс и выполняться внутри отдельной транзакции.

3. Пример отложенной загрузки

Давайте посмотрим на поведение отложенной загрузки в нескольких сценариях.

3.1 Настройка объектов и служб

Предположим, у нас есть два объекта: Пользователь и Документ. У одного пользователя может быть много документов, и мы будем использовать @OneToMany для описания этих отношений. Кроме того, мы будем использовать @Fetch(FetchMode.SUBSELECT) для повышения эффективности.

Следует отметить, что по умолчанию @OneToMany имеет тип отложенной выборки.

Давайте теперь определим нашу сущность User:

@Entity
public class User {

    // other fields are omitted for brevity

    @OneToMany(mappedBy = "userId")
    @Fetch(FetchMode.SUBSELECT)
    private List<Document> docs = new ArrayList<>();
}

Далее нам нужен сервисный уровень с двумя методами, чтобы проиллюстрировать различные варианты. Один из них помечен как @Transactional. Здесь оба метода выполняют одну и ту же логику, подсчитывая все документы от всех пользователей:

@Service
public class ServiceLayer {

    @Autowired
    private UserRepository userRepository;

    @Transactional(readOnly = true)
    public long countAllDocsTransactional() {
        return countAllDocs();
    }

    public long countAllDocsNonTransactional() {
        return countAllDocs();
    }

    private long countAllDocs() {
        return userRepository.findAll()
            .stream()
            .map(User::getDocs)
            .mapToLong(Collection::size)
            .sum();
    }
}

Теперь давайте подробнее рассмотрим следующие три примера. Мы также будем использовать SQLStatementCountValidator, чтобы понять эффективность решения, подсчитав количество выполненных запросов.

3.2. Отложенная загрузка с окружающей транзакцией

Прежде всего, давайте воспользуемся отложенной загрузкой рекомендуемым способом. Итак, мы вызовем наш метод @Transactional на сервисном уровне:

@Test
public void whenCallTransactionalMethodWithPropertyOff_thenTestPass() {
    SQLStatementCountValidator.reset();

    long docsCount = serviceLayer.countAllDocsTransactional();

    assertEquals(EXPECTED_DOCS_COLLECTION_SIZE, docsCount);
    SQLStatementCountValidator.assertSelectCount(2);
}

Как мы видим, это работает и приводит к двум обращениям к базе данных. Первый цикл выбирает пользователей, а второй выбирает их документы.

3.3. Ленивая загрузка вне транзакции

Теперь давайте вызовем нетранзакционный метод, чтобы смоделировать ошибку, которую мы получаем без окружающей транзакции:

@Test(expected = LazyInitializationException.class)
public void whenCallNonTransactionalMethodWithPropertyOff_thenThrowException() {
    serviceLayer.countAllDocsNonTransactional();
}

Как и предполагалось, это приводит к ошибке, поскольку функция getDocs пользователя используется вне транзакции.

3.4. Отложенная загрузка с автоматической транзакцией

Чтобы исправить это, мы можем включить свойство:

spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true

Когда свойство включено, мы больше не получаем исключение LazyInitializationException.

Однако подсчет запросов показывает, что к базе данных было выполнено шесть циклических обращений. Здесь один круговой обход выбирает пользователей, а пять круговых обходов выбирают документы для каждого из пяти пользователей:

@Test
public void whenCallNonTransactionalMethodWithPropertyOn_thenGetNplusOne() {
    SQLStatementCountValidator.reset();
    
    long docsCount = serviceLayer.countAllDocsNonTransactional();
    
    assertEquals(EXPECTED_DOCS_COLLECTION_SIZE, docsCount);
    SQLStatementCountValidator.assertSelectCount(EXPECTED_USERS_COUNT + 1);
}

Мы столкнулись с пресловутой проблемой N + 1, несмотря на то, что мы установили стратегию выборки, чтобы избежать ее!

4. Сравнение подходов

Кратко обсудим плюсы и минусы.

С включенным свойством нам не нужно беспокоиться о транзакциях и их границах. Hibernate управляет этим за нас.

Однако решение работает медленно, потому что Hibernate запускает для нас транзакцию при каждой выборке.

Он отлично работает для демонстраций и когда нас не волнуют проблемы с производительностью. Это может быть нормально, если используется для выборки коллекции, содержащей только один элемент, или один связанный объект в отношении один к одному.

Без свойства у нас есть детальный контроль над транзакциями, и мы больше не сталкиваемся с проблемами производительности.

«В целом, это не готовая функция, и документация по Hibernate предупреждает нас:

Although enabling this configuration can make LazyInitializationException go away, it’s better to use a fetch plan that guarantees that all properties are properly initialized before the Session is closed.

5. Заключение

В этом руководстве мы рассмотрели работу с отложенной загрузкой.

Мы попробовали свойство Hibernate, чтобы помочь преодолеть LazyInitializationException. Мы также увидели, как это снижает эффективность и может быть жизнеспособным решением только для ограниченного числа вариантов использования.

Как всегда, все примеры кода доступны на GitHub.