Когда Hibernate плевать на ваш OneToOne Lazy Loading

от автора

Привет, Друзья!

На связи Михаил Поливаха, технический лидер проекта Axelix.

В рамках программы Hibernate в Spring АйО Academy мы краем обсудили тему, касаемую того, что @OneToOne отличается от других отношений. В частности, Hibernate может спокойно грузить его Eagerly, даже если вы явно поставите FetchType.LAZY. У парней был закономерный вопрос — почему?

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

Иными словами, я в статье постараюсь детально пояснить:

  • Что на самом деле такое FetchType.LAZY?

  • Почему @OneToOne не всегда возможно сделать Lazy именно в Java?

  • Почему при этом @ManyToOne можно сделать Lazy всегда (предполагая не final класс сущности)?

Разберём по шагам.

Что на самом деле такое FetchType.LAZY?

Давайте сразу зафиксируем одну вещь, которую многие упускают. FetchType.LAZY по спецификации JPA — это всего лишь hint, подсказка провайдеру. Если по спеке, то EAGER — это требование (провайдер обязан загрузить сразу), а LAZY — это лишь пожелание отложить загрузку. Провайдеру разрешено это пожелание проигнорировать, если он “так захочет”.

Так вот, вся статья по сути про то, почему именно Hibernate в одном конкретном случае с @OneToOne это пожелание вынужденно игнорирует. И дело не в какой-то недоработке Hibernate. Дело в самой Java.

Когда вы пишете fetch = LAZY на связи, которая ведёт к одной сущности (@ManyToOne или @OneToOne), вы просите Hibernate: “не ходи в БД за связанной сущностью сразу, сходи только когда мне будет это реально надо”.

Классический способ это реализовать — проксировать сущность. Hibernate в поле кладёт не настоящую сущность, а прокси, внутри которой лежит только идентификатор. Как только вы вызываете на этом объекте любой метод (кроме аксессора для id), прокси “просыпается” и делает тот самый отложенный SELECT.

Простой Пример Когда Lazy Loading Невозможен

Тут первый важный момент, который объясняет оговорку из заголовка списка выше (про final класс сущности). Этот самый прокси — это сгенерированный в рантайме сабкласс вашей сущности. Hibernate наследуется от вашего класса и переопределяет методы, чтобы вклиниться в их вызов. А раз это наследование, то работают обычные правила Java:

  • класс сущности не должен быть final (от final нельзя унаследоваться);

  • методы, которые надо перехватывать, тоже не должны быть final;

  • должен быть доступный (хотя бы package-private) конструктор без аргументов.

Если класс final — Hibernate просто не сможет построить прокси, и проксированная ленивость для него невозможна в принципе. Вот вам и первый случай, когда “именно в Java” lazy не получается. Но это очевидный случай. Дальше будет интереснее.

Краеугольный Камень

Вообще, ключевой нюанс, вокруг которого крутится вся статья:

ORM должен принять решение, что ему вернуть при доступе к прокси (например, через условный getUser) — объект proxy, или же null.

Может, в моменте не совсем понятно, что я имею в виду, но читайте ниже.

Ссылка в Java либо null, либо ссылается на объект (пусть даже на прокси). Третьего не дано — нет такого union типа в Java, как “ссылка, которая то ли null, то ли объект, решим потом”. Hibernate, в момент загрузки основной сущности, обязан принять решение здесь и сейчас: положить в поле прокси или положить туда null.

А теперь главный вопрос, ответ на который и определяет всё: на основании чего Hibernate принимает это решение?

Ситуация с @ManyToOne

В рамках @ManyToOne, например, мы (т.е. сущность Comment в примере ниже) всегда владеем связью. Возьмём абсолютно классику:

@Entityclass Comment {    @Id    Long id;    String text;    @ManyToOne(fetch = FetchType.LAZY)    @JoinColumn(name = "post_id") // <-- этот столбец лежит в таблице comment    Post post;}

Где здесь foreign key? В таблице comment. Столбец post_id физически находится в той же строке, которую Hibernate и так грузит, когда читает Comment.

И это меняет всё. Когда Hibernate выполняет SELECT * FROM comment WHERE id = 1, он в этой же строке уже видит значение post_id. Ему не нужен ни один дополнительный запрос, чтобы ответить на вопрос “а был ли мальчик?” “а есть ли вообще связанный пост?”:

  • post_id IS NULL → связи нет → кладём в поле честный null. Никаких eager запросов.

  • post_id = 42 → связь точно есть, и мы даже знаем её id → кладём прокси с id = 42. Тоже никаких eager запросов.

Видите? В обоих случаях Hibernate принимает решение бесплатно, прямо из той строки, что уже у него на руках. Поэтому @ManyToOne может быть ленив всегда — и когда связь nullable, и когда нет. Разницы нет.

Главная мысль, которую надо унести: при @ManyToOne мы всегда владеем связью. FK лежит на нашей стороне, в нашей таблице. Мы — owning side по определению. По-другому @ManyToOne просто не бывает.

@OneToOne, Вариант Первый. Мы Владеем Связью

А вот с @OneToOne есть один интересный edge-case. Но начнём с простого варианта — когда мы владеем связью и FK лежит в нашей таблице:

@Entityclass User {    @Id    Long id;    @OneToOne(fetch = FetchType.LAZY)    @JoinColumn(name = "passport_id") // <-- столбец в таблице user    Passport passport;}

Это ровно та же история, что и с @ManyToOne. FK passport_id лежит в строке user, которую мы и так читаем. Hibernate загружает User, видит значение passport_id в той же строке и бесплатно решает: null или прокси. Lazy работает идеально.

Запомните: owning @OneToOne ничем не отличается от @ManyToOne с точки зрения lazy loading. FK на нашей стороне — значит, мы владеем связью — значит, ленивость бесплатна.

@OneToOne, Вариант Второй. Мы не Владеем Связью

А теперь обратная сторона — мы не владеем связью, т.е. указываем mappedBy. Например, если смотреть от лица сущности Passport:

@Entityclass Passport {    @Id    Long id;    @OneToOne(        mappedBy = "passport",          // <-- FK НЕ у нас, он в таблице user        fetch = FetchType.LAZY,        optional = true                  // <-- связь nullable (по умолчанию так и есть)    )    User user;}

Вот тот самый случай edge-case: связь nullable, и мы ей не владеем. FK (passport_id) лежит в таблице user, а мы грузим Passport. В нашей строке passport нет ни одного столбца, который бы говорил, есть у этого паспорта пользователь или нет.

И теперь вспомним об этом:

ORM должен принять решение, что ему вернуть при доступе к прокси (например, через условный getUser) — объект proxy, или же null.

Hibernate грузит Passport и обязан прямо сейчас решить — класть в поле user прокси или null. Но как? В строке passport ответа нет.

Ещё раз — вы же понимаете, что Hibernate не может просто так положить туда Proxy а дальше решать — а что если User-а нет? То тогда что? Получается passport.getUser() вернул не null, а User-а нет. А у пользователя код написан:

User user = passport.getUser();if (user != null) {    doProcess(user); // <-- вот там будет бомба внутри}

Чтобы узнать, существует ли вообще строка user с passport_id = наш_id, Hibernate вынужден сходить в таблицу user. Иначе он просто рискует вернуть не null там, где должен вернуть null:

SELECT id FROM user WHERE passport_id = ?
  • Запрос вернул строку → связь есть → можно класть прокси.

  • Запрос не вернул ничего → связи нет → надо класть null.

Но обратите внимание: чтобы узнать, нужен ли вообще прокси, Hibernate уже сделал запрос. А раз запрос уже сделан, какой смысл в ленивости? Откладывать-то уже нечего — поход в БД состоялся. Поэтому Hibernate просто грузит связь жадно прямо здесь же. Ваш fetch = LAZY молча проигнорирован.

Вот вам и весь наратив одной фразой:

Если Hibernate может из той строки, что он и так грузит из master-таблицы, понять, есть связь или нет — lazy работает. Если для этого ответа нужен отдельный запрос в чужую таблицу — lazy не работает, потому что этот запрос уже и есть тот самый поход в БД, который мы хотели отложить.

Спасает optional = false

И теперь очевидно, почему optional = false спасает. Тем самым вы просто говорите Hibernate:

Дружище, не переживай, клади Proxy. Я тебе гарантирую, что там запись будет, там никогда не null

Поменяем одну строчку:

@OneToOne(    mappedBy = "passport",    fetch = FetchType.LAZY,    optional = false                 // <-- Мы гарантируем наличие связи)User user;

По сути optional = false — это контракт: “у каждого паспорта гарантированно есть пользователь”. И этот контракт убирает ровно ту неопределённость, из-за которой всё ломалось.

Вот она, та самая асимметрия:

Связь

Где FK

Можно понять “есть/нет” из master-строки?

Lazy?

@ManyToOne

у нас

да, по значению FK

Работает

@OneToOne owning (@JoinColumn)

у нас

да, по значению FK

Работает

@OneToOne inverse, optional=false

у чужого

не надо — связь гарантированно есть

Работает

@OneToOne inverse, optional=true

у чужого

нет — нужен отдельный SELECT

Не Работает

Единственная клетка, где lazy ломается, — последняя. Nullable связь, которой мы не владеем.

Хорошо, а как с этим жить

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

1. Если связь по факту обязательна — скажите об этом Hibernate. Очень часто люди оставляют optional = true (дефолт) просто по инерции, хотя в их домене паспорт без пользователя не существует в принципе. Поставьте optional = false — и получите lazy бесплатно. Только честно: это должно быть правдой на уровне данных, иначе вы получите EntityNotFoundException на ровном месте, когда строки всё-таки не окажется.

2. По возможности держите FK на той стороне, с которой вы чаще ходите лениво. Owning side всегда ленив. Если вы постоянно грузите Passport и не хотите тащить User, то, может, FK стоит спроектировать так, чтобы владеющей стороной был Passport. Дизайн схемы — это рычаг, и им стоит пользоваться осознанно.

3. Рассмотрите общий primary key (@MapsId). Когда у двух таблиц общий PK, “не владеющая” сторона может определить наличие связи по собственному id, и ситуация становится приятнее. Это кстати вообще каноничный способ моделировать true-@OneToOne.

4. Инструментировать Bytecode. Можно, конечно, байткод инструментировать (hibernate-enhance-maven-plugin или агент). В таком случае Hibernate перестаёт полагаться на прокси и начинает перехватывать доступ к полю напрямую. Тогда даже nullable inverse-связь можно отложить: запрос “есть ли user” откладывается до момента, когда вы реально тронете поле user. Способ рабочий, но имейте в виду — это уже не “просто аннотация”, это меняет ваши классы на этапе сборки, и команда должна понимать, что происходит. Я обычно предлагаю это последним, когда первые три варианта по каким-то причинам не подходят.

Вывод

Вся магия (и вся боль) @OneToOne сводится к одному вопросу: может ли Hibernate, глядя только на строку, которую он и так грузит, ответить — есть связь или нет?

  • @ManyToOne — может всегда, потому что FK лежит у нас. Мы всегда владеем связью. Lazy не ломается никогда.

  • @OneToOne owning — то же самое, FK у нас.

  • @OneToOne inverse optional=false — связь гарантированно есть. Lazy работает.

  • @OneToOne inverse optional=true — приходится делать отдельный запрос, а отдельный запрос убивает саму идею ленивости. Поэтому Hibernate грузит eagerly.

Так что когда в следующий раз увидите в логах необъяснимый лишний SELECT на @OneToOne, сначала спросите себя: владею ли я этой связью, и nullable ли она? В 99% случаев ответ будет ровно там.

Удачи Всем!

ссылка на оригинал статьи https://habr.com/ru/articles/1050462/