JavaRush /Курсы /Spring Data JPA /Lazy loading и первый доступ

Lazy loading и первый доступ

Spring Data JPA
21 уровень , 0 лекция
Открыта

1. Проблема: «загрузить всё сразу»

Если вы впервые сталкиваетесь с ORM, очень хочется, чтобы он вёл себя как добрый официант: «Вы заказали заказ (order) — вот вам сразу и позиции, и товары, и категории, и остатки на складе, и, на всякий случай, рецепт борща нашего шефа». Проблема в том, что база данных не знает, насколько вы любопытны сегодня, а Hibernate не телепат (и это, кстати, его лучшая черта). В реальности «загрузить всё сразу» почти всегда означает загрузить слишком много, потратить лишнее время, память, сеть — и сделать чтение дорогим даже там, где нужно было всего две колонки.

Представьте, что у нас есть привычный для проекта кусок графа:

flowchart LR
  O[CustomerOrder] --> I[OrderItem]
  I --> P[Product]
  P --> C[Category]
  P --> S[StockItem]

Теперь зададим два сценария чтения:

Первый — «показать список последних заказов: номер, статус, сумма». Это типичная админская табличка или «история заказов». Здесь нам не нужны items, не нужны product, не нужна category, и тем более не нужны stockItem. Нам нужны буквально три-четыре поля корня.

Второй — «показать детали заказа: позиции, товары, цены». Тут уже нужны items, и, скорее всего, product. Но даже здесь обычно не нужна вся карточка товара и не нужны все связи продукта.

Если ORM будет грузить всё всегда, он превратит первый сценарий (лёгкий) во второй (дорогой) даже тогда, когда вы этого не просили. А если у вас список из 50 заказов, и в каждом по 10 позиций, то «всё сразу» внезапно превращается в огромный объём данных. И да, «огромный» наступает раньше, чем кажется: достаточно одного неудачного экрана в админке.

Отсюда рождается базовая идея: по умолчанию читаем корень, а «ветки графа» подтягиваем только тогда, когда код реально в них полез.

2. Lazy loading: что откладывается и что он обещает

Lazy loading — это не «данных нет», и не «Hibernate решил вас потроллить». Это договорённость: связанные данные не загружаются при чтении корневой сущности, а загружаются позже, при первом реальном обращении к связи. То есть сначала вы получаете объект, который умеет дать вам связь, но не обязан иметь её данные прямо сейчас.

В JPA это выглядит максимально невинно: вы просто пишете fetch = FetchType.LAZY и вроде бы всё. Но за этим стоит важное различие: «сущность загружена» и «все связи сущности загружены» — это разные факты.

Давайте напомним на примере OrderItem -> Product, который у нас очень естественный для mini-shop:

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;

@Entity // Сущность JPA: отображается на таблицу
public class OrderItem {

    @Id // Первичный ключ строки позиции заказа
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY) // LAZY: Product подтянется при первом реальном обращении
    private Product product;
}

И на примере CustomerOrder -> items:

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

@Entity // Сущность заказа
public class CustomerOrder {

    @Id // Идентификатор заказа
    private Long id;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) // LAZY: позиции не грузим при чтении заказа
    private List<OrderItem> items = new ArrayList<>(); // Важно: это поле может быть Hibernate-обёрткой
}

В обоих случаях ORM говорит вам примерно следующее: «Я могу дать тебе order.getItems() и item.getProduct(), но я не обещал, что эти данные уже лежат в памяти. Если они понадобятся — я схожу в базу и подгружу. Но только если у меня есть на это право и возможность».

Ключевое слово — подгружу. Lazy loading — это не про “не загружать никогда”, а про “не загружать раньше времени”.

3. Корень и связи: два шага чтения

Когда вы делаете findById или любой другой запрос, сначала выполняется начальный SELECT, который выбирает колонки корневой сущности. Если у сущности есть @ManyToOne, в строке таблицы обычно есть FK (например, product_id), и это позволяет ORM запомнить «куда ведёт связь», не загружая сам объект Product.

Идея выглядит так:

sequenceDiagram
    participant S as OrderService
    participant R as Repository
    participant DB as PostgreSQL

    S->>R: findById(orderId)
    R->>DB: "SELECT ... FROM customer_order WHERE id = ?"
    DB-->>R: "row with basic columns"
    R-->>S: "CustomerOrder (items = lazy wrapper)"
    S->>S: order.getItems().size()
    S->>DB: "SELECT ... FROM order_item WHERE order_id = ?"
    DB-->>S: rows for items

Это не «обман» и не «костыль». Это способ сделать чтение управляемым. Сначала получить то, что точно нужно (корень), и только потом, по требованию, получить то, что может понадобиться (связи).

Важный психологический момент: вызов геттера на связь не всегда равен загрузке данных. Иногда это просто «дать вам объект-обёртку» (для коллекции) или «дать ссылку-прокси» (для to-one). А вот дальше начинается самое интересное: какие действия заставляют ORM сказать «ладно, придётся идти в базу».

4. Первый доступ к to-one связям

У to-one связей (например, OrderItem -> Product, Product -> Category, Product -> StockItem) lazy loading обычно устроен так: вместо настоящего объекта Hibernate может подставить прокси-объект. Он похож на Product, ведёт себя как Product, но внутри держит «пока что» только идентификатор и механизм, который умеет подгрузить остальные поля при необходимости.

Вот маленькая демонстрация в стиле «посмотрим, кто ты такой на самом деле»:

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

@Service
public class OrderItemDebugService {

    private final OrderItemRepository orderItemRepository;

    public OrderItemDebugService(OrderItemRepository orderItemRepository) {
        this.orderItemRepository = orderItemRepository;
    }

    @Transactional(readOnly = true) // Важно: транзакция держит Persistence Context, прокси смогут догрузиться
    public void printProductClass(Long itemId) {
        // Загружаем позицию заказа (Product при этом остаётся ленивым)
        OrderItem item = orderItemRepository.findById(itemId).orElseThrow();

        // Смотрим реальный класс поля: это часто будет HibernateProxy, а не Product
        System.out.println(item.getProduct().getClass().getName());
        // например: com.example...Product$HibernateProxy$...
    }
}

Обратите внимание: мы вызвали getProduct, но в этом примере мы не просили ни getName, ни getPrice. Мы просто посмотрели класс. Такой «контакт глазами» обычно не требует дополнительных данных.

А вот «первый реальный доступ» — это когда вы делаете что-то, что невозможно выполнить без данных связанной сущности. Например:

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

@Service
public class OrderItemDebugService {

    private final OrderItemRepository orderItemRepository;

    public OrderItemDebugService(OrderItemRepository orderItemRepository) {
        this.orderItemRepository = orderItemRepository;
    }

    @Transactional(readOnly = true) // Без транзакции (и открытого контекста) здесь легко словить проблемы с lazy
    public String loadProductName(Long itemId) {
        // На этом шаге Product ещё может быть прокси, без полей
        OrderItem item = orderItemRepository.findById(itemId).orElseThrow();

        // Вот здесь ORM уже нужны данные Product: произойдёт SQL-запрос на подгрузку
        return item.getProduct().getName();
    }
}

Именно строка getName (или любой другой «настоящий» доступ к полям продукта) чаще всего и является тем самым первым доступом, после которого улетит SQL в базу.

Чтобы не превращать это в набор «магических правил», можно мыслить проще: первый доступ — это любое действие, которому реально нужен объект, а не просто ссылка на него. Как только вы требуете значение поля — ORM должен его добыть.

Ниже — табличка, которая помогает не путаться (и не спорить с коллегами на уровне «мне кажется»):

Код Обычно запускает SQL для lazy to-one? Почему
item.getProduct() чаще нет вы получили ссылку/прокси
item.getProduct().getId() чаще нет id обычно известен без загрузки объекта целиком
item.getProduct().getName() да имя — это уже данные из таблицы product
item.getProduct().toString() почти наверняка да toString обычно читает поля, а поля нужны из БД
log.info("{}", item.getProduct()) часто да логирование вызывает toString или обращается к полям

Я нарочно использую слова «обычно», «чаще всего», «часто»: конкретный момент может зависеть от деталей реализации и провайдера, но SQL-логи не врут. Если после строчки кода вы увидели select ... from product where id=?, значит это и был ваш «первый доступ» на практике.

5. Первый доступ к коллекциям

С коллекциями (CustomerOrder.items) ситуация ещё более коварная для новичка: вы можете получить коллекцию как объект, но она всё ещё может быть пустой “в голове” (не загруженной), пока вы не начнёте просить у неё элементы.

То есть order.getItems() часто возвращает не ArrayList, а специальную Hibernate-коллекцию-обёртку, которая умеет при необходимости сходить в базу и загрузить элементы. Это выглядит «как список», но по поведению — «список с доступом к базе».

Покажем это без философии:

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

@Service
public class OrderDebugService {

    private final CustomerOrderRepository orderRepository;

    public OrderDebugService(CustomerOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true) // Нужна транзакция, чтобы lazy-коллекция могла загрузиться при обращении
    public void printItemsClass(Long orderId) {
        // Загружаем только сам заказ (без позиций)
        CustomerOrder order = orderRepository.findById(orderId).orElseThrow();

        // Смотрим, что за тип у items: чаще всего это PersistentBag/Set и т.п.
        System.out.println(order.getItems().getClass().getName());
        // например: org.hibernate.collection.spi.PersistentBag
    }
}

Теперь самое важное: что считается «первым доступом» к коллекции? Это любое действие, которому нужны элементы коллекции или хотя бы их количество.

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

@Service
public class OrderReadService {

    private final CustomerOrderRepository orderRepository;

    public OrderReadService(CustomerOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true) // Транзакция нужна, потому что size() может триггернуть запрос
    public int countItems(Long orderId) {
        CustomerOrder order = orderRepository.findById(orderId).orElseThrow();

        // Для lazy-коллекции size() — это часто реальный поход в базу
        return order.getItems().size();
    }
}

И вот здесь важная дисциплина мышления: size() — это не «безобидная операция над коллекцией», как в обычной Java-жизни. Для lazy-коллекции size() — это часто «пожалуйста, сходи в базу и разберись, сколько там элементов».

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

Код Обычно запускает SQL для lazy коллекции? Почему
order.getItems() чаще нет вы получили обёртку, а не элементы
order.getItems().size() да нужен размер → нужны данные
order.getItems().isEmpty() да нужно понять пусто ли → нужны данные
for (var i : order.getItems()) да итерация требует элементы
order.getItems().stream() да стримить нечего без элементов
order.getItems().get(0) да нужен конкретный элемент

Ещё раз: «первый доступ» — это не “когда вы получили ссылку на коллекцию”, а “когда вы сделали что-то, что требует её содержимого”.

Пример: заказ → позиции → товар

Когда вы учите lazy loading на абстрактных примерах, мозг часто говорит: «Ну да, прокси, обёртки… где моя реальная боль?». Давайте сделаем сценарий, который максимально похож на настоящий прикладной код.

Представим, что нам нужно получить «короткое описание заказа» для текста в логах или ответа в каком-то read-use-case сервисе: номер заказа и имя первого товара (грубо, но наглядно). Это цепочка сразу из двух lazy-шагов: сначала коллекция items, потом to-one связь product.

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

@Service
public class OrderSnippetService {

    private final CustomerOrderRepository orderRepository;

    public OrderSnippetService(CustomerOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true) // Внутри транзакции оба lazy-шага смогут догрузиться
    public String describeFirstItem(Long orderId) {
        // На этом запросе читаем только корень: CustomerOrder
        CustomerOrder order = orderRepository.findById(orderId).orElseThrow();

        // 1) Первый доступ к items: Hibernate подгрузит позиции заказа
        OrderItem first = order.getItems().get(0);

        // 2) Первый доступ к product: Hibernate подгрузит сам Product
        String productName = first.getProduct().getName();

        return order.getOrderNumber() + ": " + productName;
    }
}

Если включить SQL logs, будет видно примерно такую последовательность смыслов: сначала улетит SELECT за заказом, потом — SELECT за позициями заказа, потом — SELECT за товаром.

И вот здесь появляется очень практическая мысль, которая пригодится вам в следующих лекциях дня: стоимость чтения определяется тем, куда реально “дотрагивается” код. Не тем, что «в сущности есть связь», а тем, что конкретный use case по этой связи прошёл.

А ещё это пример, который помогает не путаться: “первый доступ” — это не абстрактная категория, это конкретная строка get(0) и getName, которая изменила поведение программы: «просто объект в памяти» превратился в «объект, который сходил в базу».

6. Случайный первый доступ: логирование и toString()

Самое неприятное в lazy loading для новичка — не то, что он существует, а то, что он иногда включается «не там». Вы вроде пишете бизнес-код аккуратно, а потом добавляете безобидный лог — и внезапно приложение начинает выполнять запросы «само». В этот момент обычно рождается миф «Hibernate делает лишние запросы». На практике почти всегда виноват случайный первый доступ.

Классический антипример — toString, который лезет в коллекции:

@Override
public String toString() {
    // Внимание: items.size() может триггернуть подгрузку lazy-коллекции (и SQL) в неожиданный момент
    return "CustomerOrder{orderNumber='" + orderNumber
            + "', items=" + items.size() + "}";
}

На вид — мило. На деле — items.size() может стать тем самым первым доступом к lazy-коллекции, причём в самых неожиданных местах: в логировании, в дебаггере IDE, в исключениях, в тестах. И если сущность уже живёт вне контекста, такой вызов может закончиться падением. Пока здесь важен сам факт: технический код тоже способен стать первым доступом к lazy-связи.

Более безопасный стиль — делать toString только по простым полям корня, без ассоциаций:

@Override
public String toString() {
    return "CustomerOrder{orderNumber='" + orderNumber
            + "', totalAmount=" + totalAmount + "}";
}

Да, иногда хочется «чтобы красиво». Но если выбирать между “красиво” и “предсказуемо”, в data-layer почти всегда выигрывает предсказуемость. Особенно в учебном проекте, где мы хотим видеть причинно-следственную цепочку «код → SQL», а не «код → сюрприз».

7. Типичные ошибки при lazy loading

В этой теме очень легко наступить на грабли, потому что Java-код выглядит невинно, а последствия проявляются только в SQL-логах и времени ответа. Ниже — ошибки, которые я вижу чаще всего, когда студенты впервые начинают жить с lazy loading в реальном проекте, а не в tutorial-мире «одна сущность — одна таблица — один запрос».

Ошибка №1: воспринимать LAZY как “данных нет”.
Lazy loading не говорит «связи не существует». Он говорит «данные связи будут загружены позже, если ты действительно их попросишь». Если держать в голове именно это, исчезает половина паники вида «у меня product = null», хотя на самом деле там просто прокси.

Ошибка №2: думать, что “первый доступ” — это только getXxx() на поле связи.
Для коллекций первый доступ почти никогда не getItems(), а size(), isEmpty(), итерация, stream() или get(0). Если вы это забываете, вы будете искренне удивляться, почему “вот эта короткая строчка” внезапно стала ходить в базу.

Ошибка №3: делать toString() (или логирование) по ассоциациям и коллекциям.
Выглядит удобно, но превращает обычную отладку в сценарий, который меняет поведение приложения. Вы хотели “посмотреть объект”, а случайно попросили ORM загрузить пол-каталога. С этого обычно начинаются легенды про «Hibernate сам всё делает» (и да, сам — но по вашей просьбе, просто она спряталась в toString()).

Ошибка №4: лечить любую неожиданную подгрузку переводом связей в EAGER.
Это “быстрое обезболивающее”, которое часто портит ситуацию: чтения становятся тяжелее, граф сущностей раздувается, а проблема контроля чтения никуда не исчезает — она просто маскируется. На уровне этой лекции достаточно запомнить: EAGER — не универсальное “сделай хорошо”, а всего лишь другой компромисс, иногда очень дорогой.

Ошибка №5: пытаться понять lazy loading по ощущениям, а не по SQL.
Пока вы не смотрите SQL-логи, вы будете спорить с реальностью на уровне «мне кажется, оно загрузилось». Самый простой, честный и “не мистический” метод — увидеть, после какой строки кода улетел SELECT. Это и есть ваш первый доступ в данном сценарии.

1
Задача
Spring Data JPA, 21 уровень, 0 лекция
Недоступна
Первый доступ к товару через lazy many-to-one
Первый доступ к товару через lazy many-to-one
1
Задача
Spring Data JPA, 21 уровень, 0 лекция
Недоступна
Первый доступ к lazy-коллекции через size()
Первый доступ к lazy-коллекции через size()
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ