Привет, Друзья!
На связи Михаил Поливаха, технический лидер проекта 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 в примере ниже) всегда владеем связью. Возьмём абсолютно классику:
@Entity class 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 лежит в нашей таблице:
@Entity class 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:
@Entity class 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? |
|---|---|---|---|
| у нас | да, по значению FK | Работает |
| у нас | да, по значению FK | Работает |
| у чужого | не надо — связь гарантированно есть | Работает |
| у чужого | нет — нужен отдельный 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 не ломается никогда.@OneToOneowning — то же самое, FK у нас.@OneToOneinverseoptional=false— связь гарантированно есть. Lazy работает.@OneToOneinverseoptional=true— приходится делать отдельный запрос, а отдельный запрос убивает саму идею ленивости. Поэтому Hibernate грузит eagerly.
Так что когда в следующий раз увидите в логах необъяснимый лишний SELECT на @OneToOne, сначала спросите себя: владею ли я этой связью, и nullable ли она? В 99% случаев ответ будет ровно там.
Удачи Всем!
