Когда начинают говорить об оптимизации запросов Hibernate, разговор быстро приходит к N+1, join fetch, entity graph и настройке batch выборки. Но есть более простой случай, который легко пропустить.

Допустим нам нужно сохранить книгу. Её автор уже существует в базе и его ID пришёл в запросе, данные автора: имя, дата рождения и всё остальное нас вообще не интересуют. Нам нужно только записать author_id в таблицу book. Вот здесь возникает вопрос: зачем перед сохранением сначала получать автора? Разберёмся, как от него избавиться и причём здесь метод getReference().

Исходная модель данных

Две обычные сущности:

@Entity
@Table(name = "authors")
public class Author {
    @Id
    private Long id;
    private String name;
}

@Entity
@Table(name = "books")
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private Author author;
}

Допустим по rest получаем такой запрос:

public record CreateBookRequest(
    String title,
    Long authorId
) {}

Автор с таким id уже должен существовать. Посмотрим на вполне очевидную реализацию

Вариант через метод find()

Можно сначала найти автора:

Author author = session.find(
    Author.class,
    request.authorId()
);

Book book = new Book();
book.setTitle(request.title());
book.setAuthor(author);

session.persist(book);

Но если включить логирование SQL запросов, смысл происходящего становится интереснее. В упрощённом виде Hibernate делает следующее:

select
    a.id,
    a.name
from authors a
where a.id = ?;

а затем:

insert into books (author_id, title)
values (?, ?);

Первый запрос нужен потому что find() означает следующее:

найди сущность с этим идентификатором и дай мне её состояние.

Проблема в том, что состояние сущностиAuthor в нашем коде вообще нигде не используется. Нам не нужен author.getName() и не нужны остальные поля. Нам нужен объект, который можно поставить в book.setAuthor() чтобы hibernate записал нужный author_id.

Получается последовательность: получили Author -> получили id + остальные поля -> остальные поля не использовали -> сохранили Book с author_id. Можно ли пропустить первый шаг?

метод getReference()

Вместо метода find() попробуем:

Author author = session.getReference(
    Author.class,
    request.authorId()
);

Book book = new Book();
book.setTitle(request.title());
book.setAuthor(author);

session.persist(book);

На первый взгляд изменилось одно слово. Но семантика другая.

find() просит hibernate загрузить сущность

getReference() говорит примерно следующее:

Мне нужна ссылка на сущность Author с таким id. Я считаю что она существует. Всё её состояние сейчас мне не требуется.

Hibernate может вернуть proxy ссылку без немедленной загрузки всех данных автора. В результате для этого сценария предварительный select … from authors where id = ? может оказаться вообще не нужен.

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

Зачем вообще нужен объект, если у нас уже есть ID?

Это один из моментов ORM, который сначала кажется немного искусственным.

В SQL мы бы написали так:

insert into books (title, author_id)
values ('Hibernate', 42);

То есть 42 просто значение внешнего ключа. Но модель JPA выглядит иначе:

book.setAuthor(...);

Поле имеет тип Author а не Long. Поэтому нельзя просто написать:

book.setAuthor(42L);

Нужен объект Author. При этом загружать весь Author совершенно необязательно. В этом и состоит полезный сценарий для метода getReference().

А что лежит внутри ссылки?

Упрощённо можно думать о нём как об объекте про который hibernate уже знает главное. Тип — Author, id — 42. Но остальные данные пока не загружены. Поэтому такой код сам по себе не требует от нас имени автора:

Author author = session.getReference(Author.class, 42L);
book.setAuthor(author);

Однако важно не сделать неправильный вывод. Использование getReference() не означает:

Hibernate никогда не пойдёт в базу за этим объектом

Правильнее:

Hibernate постарается не загружать состояние объекта пока оно не понадобится

Это существенная разница.

Что будет, если обратиться к имени автора?

Author author = session.getReference(
    Author.class,
    42L
);

System.out.println(author.getName());

Чтобы вернуть name, одного идентификатора недостаточно. Hibernate должен получить состояние Author. И здесь уже появится запрос к базе, тоесть условная граница очень простая.

Вот это хороший сценарий:

Author author = session.getReference(
    Author.class,
    authorId
);

book.setAuthor(author);

А вот это уже совсем другая задача:

Author author = session.getReference(
    Author.class,
    authorId
);

String name = author.getName();

Если вам действительно нужны данные автора, пытаться избежать его загрузки бессмысленно.

При чём здесь N+1?

Здесь легко попасть в ловушку. Например у нас есть список книг:

List<Book> books = session.createQuery(
    "from Book",
    Book.class
).getResultList();

for (Book book : books) {
    System.out.println(
        book.getAuthor().getName()
    );
}

При ленивой связи author такой код потенциально приводит к классической ситуации:

1 запрос — получить книги
+ запрос автора
+ запрос автора
+ запрос автора
+ ...

То есть получается N+1.

Возникает желание вместо этого воспользоваться getReference(), но это проблему не решит. Если сделать:

Author author = session.getReference(
    Author.class,
    authorId
);

System.out.println(author.getName());

нам всё равно потребуется name. А значит ссылку придётся инициализировать. Для десяти разных авторов мы потенциально можем получить дополнительные запросы к этим авторам. Именно поэтому getReference нельзя считать ещё одним аналогом join fetch. У него другая задача.

getReference() не является решением N+1

Это пожалуй главный момент. Если нам нужны данные связанных объектов, стоит смотреть в сторону инструментов предназначенных именно для управления загрузкой:

select b
from Book b
join fetch b.author

либо использовать EntityGraph. В других сценариях может помочь batch выборка. Иногда правильным вариантом будет вообще не загружать entity, а сразу построить DTO нужным запросом.

Например:

select new com.example.BookDto(
    b.id,
    b.title,
    a.id,
    a.name
)
from Book b
join b.author a

Выбор зависит от того, какие именно данные нужны приложению. getReference отвечает на другой вопрос:

Что делать, когда связанная сущность нужна как ссылка, но её данные мне не нужны?

Где это становится заметно

На одном объекте экономия одного SELECT запроса, скорее всего ничего не изменит. Гораздо интереснее массовая операция.

Представим:

List<CreateBookRequest> requests = ...

Для каждой книги нам приходит authorId. Реализация через метод find():

for (CreateBookRequest request : requests) {

    Author author = session.find(
        Author.class,
        request.authorId()
    );

    Book book = new Book();
    book.setTitle(request.title());
    book.setAuthor(author);

    session.persist(book);
}

Если авторы ещё не находятся в persistence контексте, hibernate будет вынужден их искать. Но сами данные Author в этом коде опять не используются.

Можно написать:

for (CreateBookRequest request : requests) {

    Author author = session.getReference(
        Author.class,
        request.authorId()
    );

    Book book = new Book();
    book.setTitle(request.title());
    book.setAuthor(author);

    session.persist(book);
}

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

Но существует ли такой Author?

И здесь у getReference() есть важный подвох. Допустим:

Author author = session.getReference(
    Author.class,
    999999L
);

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

Если позже произойдёт обращение к данным:

author.getName();

несуществующая запись станет проблемой. Поэтому getReference хорошо подходит для ситуации:

я ожидаю, что сущность существует, и отсутствие такой записи является ошибкой.

Но он плохо подходит для проверки:

есть ли вообще такой автор?

Для проверки существования логика должна быть другой. Например метод find() возвращает null если сущность с таким идентификатором не найдена:

Author author = session.find(
    Author.class,
    authorId
);

if (author == null) {
    // обработка отсутствующего автора
}

У этих двух методов просто разные контракты.

А внешний ключ сам всё проверит?

Иногда можно услышать аргумент:

Если автора нет, база всё равно отклонит insert в бд по внешнему ключу.

Если constraint действительно есть, то да, база не позволит записать некорректный внешний ключ. Но вопрос уже относится к архитектуре приложения. Одному приложению достаточно получить ошибку constraint violation. Другому нужно заранее вернуть пользователю ответ:

Author with id 42 not found

Одной ссылки через getReference() недостаточно, существование автора требуется проверить явно. То есть оптимизацию нельзя рассматривать отдельно от требуемого поведения API.

getReference() и закрытая Session

Есть ещё один знакомый источник проблем. Получили reference:

Author author = session.getReference(
    Author.class,
    42L
);

закрыли session, а затем попытались получить незагруженные данные:

session.close();
System.out.println(author.getName());

Если для чтения name требуется инициализация proxy, hibernate уже не сможет выполнить её через закрытую session. Отсюда известный LazyInitializationException. Но сам по себе этот факт не означает, что lazy загрузка плохо. Он означает, что момент загрузки данных оказался не там где ожидал разработчик. Если данные нужны за пределами persistence context, лучше определить fetch заранее, вместо того чтобы надеяться на случайную ленивую загрузку.

А что с методом load()?

В старом Hibernate для подобного кода часто встречался:

session.load(Author.class, authorId);

По смыслу это как раз тот API, с которым многие ассоциируют proxy и отложенную загрузку. Но для современного кода использовать его как основной пример уже не стоит. Начиная с Hibernate 6 методы Session.load() были помечены как deprecated, а рекомендуемой заменой стал session.getReference(). В Hibernate 7 методы Session.load() удалены.

Поэтому старый код:

Author author = session.load(
    Author.class,
    authorId
);

в современном варианте лучше записывать как:

Author author = session.getReference(
    Author.class,
    authorId
);

find() и getReference() не конкуренты

Мне кажется, полезнее всего перестать воспринимать эти методы как быстрый и медленный способы сделать одно и то же. Они выражают разные намерения.

Когда:

session.find(Author.class, id);

фактически:

Мне нужна сущность Author, найди её.

Когда:

session.getReference(Author.class, id);

смысл другой:

Мне нужна ссылка на сущность Author с известным id. Его состояние сейчас загружать не нужно.

Поэтому выбирать стоит не по принципу getReference быстрее find, а по тому что происходит дальше в коде.

Если через две строки написано:

author.getName();
author.getEmail();
author.getBirthDate();

то getReference() вряд ли даёт нам что‑то полезное.

Если же код заканчивается так, это уже хороший вариант:

book.setAuthor(author);

Не нужно пытаться заменять все find() на getReference() просто потому, что так меньше запросов.

Например:

User user = session.getReference(
    User.class,
    userId
);

if (!user.isActive()) {
    throw new IllegalStateException();
}

не выигрывает от reference практически ничего. Чтобы узнать isActive hibernate всё равно должен получить состояние User. То же самое с author.getName().

Если поле требуется бизнес логике оно должно откуда‑то загрузиться, ORM не отменяет необходимость получить данные из базы.

Небольшая памятка

Я бы выбирал getReference() в случаях, когда:

ID уже известен
сущность предположительно существует
её данные не требуются
объект нужен в основном для установки связи

Например:

book.setAuthor(
    session.getReference(
        Author.class,
        authorId
    )
);

А find()когда мне действительно нужна сущность:

Author author = session.find(
    Author.class,
    authorId
);

if (author == null) {
    throw new AuthorNotFoundException(authorId);
}

String name = author.getName();

Для N+1 при чтении коллекций и связей я бы вообще смотрел не на getReference(), а на fetch стратегию конкретного запроса.

Итог

getReference() не является универсальным способом ускорить Hibernate. И уж точно не является заменой join fetch или EntityGraph.

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

Главное здесь даже не про количество SQL запросов. Перед обращением к ORM полезно задать более простой вопрос: мне действительно нужны данные этой сущности или мне нужен только её идентификатор как внешний ключ? Иногда ответ на него убирает запрос лучше любой сложной оптимизации.

Комментарии (0)