«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.