Серия подготовки: к обзору тем Java-собеседования.
Объект Java хранит данные в памяти работающей программы. Если программу завершить, эти данные сами по себе не останутся. База данных нужна, чтобы сохранять заказы, пользователей и остатки товаров между запусками и давать нескольким операциям согласованно работать с ними.
В реляционной базе данные организованы в таблицы. Столбцы описывают поля, например номер заказа и сумму, а строка хранит один набор значений. Язык SQL позволяет выбирать, добавлять и менять строки. Разберём 20 вопросов о том, как Java отправляет такие команды и как Hibernate связывает строки с объектами в памяти.
Основа — JDBC из Java 21, PostgreSQL 17, Jakarta Persistence 3.1 и Hibernate ORM 6.6. Не все базы и версии ведут себя одинаково. Учебный пример — магазин с заказами и остатками. JOIN соединяет данные таблиц, группировка собирает итог по группе, а оконные функции считают значения по связанным строкам без их сворачивания в одну. Задачи на них есть в практикуме SQL; здесь разберём путь от Java до сохранённых данных.
JDBC: что именно Java отправляет в базу
1. Чем JDBC, JPA и Hibernate отличаются друг от друга?
JDBC — набор Java-интерфейсов для общения с базой. Программа получает соединение, отправляет SQL и читает результат. Конкретный драйвер реализует эти интерфейсы для нужной базы, например PostgreSQL. При таком подходе вы сами переносите полученные значения в поля объекта заказа.
ORM означает сопоставление объектов и реляционных данных: библиотека знает, что поле объекта соответствует столбцу таблицы, и берёт часть переноса на себя. JPA, теперь Jakarta Persistence, описывает общие правила этой работы. Hibernate — библиотека, которая выполняет эти правила и добавляет собственные возможности. Объект с описанным способом хранения называется entity, сущностью.
Строка заказа остаётся в базе, объект заказа живёт в памяти Java, а ORM связывает их. При обычной работе с такой базой SQL всё равно выполняется. Подвох: короткий вызов repository.findAll(), то есть «получить всё из хранилища», может прочитать огромную таблицу. Скорость зависит от работы базы и объёма переноса, а не от количества строк Java-кода.
2. Как PreparedStatement защищает запрос и что нельзя передать параметром?
Нельзя превращать введённое пользователем имя или номер в часть SQL-команды простым склеиванием строк. PreparedStatement отделяет шаблон команды от значений: на место ? передаётся значение нужного типа. Даже если строка содержит слова SQL, в таком параметре она остаётся данными.
Ниже вход — customerId, номер покупателя, и уже открытое connection, соединение с базой. Программа готовит запрос к таблице orders, передаёт номер в первый параметр и выполняет чтение. ResultSet — объект обхода результата: каждый next() переходит к следующей строке.
String sql = "select id, total from orders where customer_id = ?";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, customerId);
try (ResultSet rows = statement.executeQuery()) {
while (rows.next()) {
long id = rows.getLong("id");
BigDecimal total = rows.getBigDecimal("total");
consumeOrder(id, total);
}
}
}Из каждой строки извлекаются номер id и сумма total; метод consumeOrder передаёт их дальнейшей обработке. BigDecimal хранит десятичную сумму. Когда чтение окончено или возникла ошибка, блоки try автоматически закрывают результат и запрос. Самим соединением здесь управляет вызывающая часть.
Подвох: параметр представляет значение, а не имя столбца или команду сортировки. ORDER BY ? не выбирает столбец по пользовательскому тексту. Допустимые варианты сортировки заранее сопоставляют с известными SQL-фрагментами. Один безопасный параметр не защищает другую часть команды, если её продолжают склеивать из непроверенного ввода.
3. Кто должен закрывать Connection, Statement и ResultSet?
Соединение занимает ресурс базы, запрос и результат тоже могут удерживать ресурсы. Тот, кто получил ресурс во владение, должен обеспечить закрытие и при успехе, и при исключении. В примере выше для запроса и результата это делает try-with-resources — конструкция try с ресурсом в скобках.
В приложении обычно есть пул соединений: ограниченный запас подключений, выдаваемых операциям по очереди. Connection.close() для подключения из пула обычно возвращает его в запас, а не разрывает физическое соединение каждый раз. Если подключения забывают возвращать, новые запросы ждут, даже когда памяти Java ещё достаточно.
Подвох: маленький вспомогательный метод не должен произвольно закрыть переданное соединение, если более крупная операция ещё им пользуется. Так же нельзя вернуть наружу Stream, который будет читать результат позже, а сам запрос уже закрыть. Либо обработайте данные внутри срока жизни ресурса, либо явно передайте обязанность закрытия вызывающему коду.
Транзакции и изменения, которые выполняются одновременно
4. Что означает auto-commit и где заканчивается транзакция?
Транзакция объединяет несколько действий с данными в одну операцию. Покупка должна и уменьшить остаток, и создать заказ. При commit база окончательно принимает изменения транзакции; при rollback отменяет их. Если одно действие сохранится без другого, данные могут стать неверными.
Новое JDBC-соединение по стандартному поведению имеет auto-commit: отдельная команда фиксируется по её завершении. Чтобы объединить несколько команд, эту настройку отключают и явно вызывают commit либо rollback. Если транзакцией управляет Spring, за это отвечает его менеджер транзакций — специальный компонент управления соединением и завершением.
Подвох: два вызова хранилища подряд не доказывают, что у них общая транзакция. Нужно одно согласованное управление и нужное соединение. При ручной работе учитывают состояние соединения перед возвратом в пул. При управлении Spring не нужно самовольно фиксировать его ресурс: сначала определите, кому принадлежит решение о конце операции.
5. Чем уровни изоляции PostgreSQL отличаются от общей таблицы SQL?
Два покупателя могут одновременно читать и менять данные. Изоляция задаёт, какие изменения одной транзакции видны другой. В PostgreSQL 17 стандартный Read Committed даёт обычному SELECT снимок на начало этой команды: она видит чужие изменения, зафиксированные до её начала, и собственные предыдущие изменения.
Если другой покупатель завершил покупку между двумя чтениями, второе чтение в той же транзакции может увидеть другой остаток. При Repeatable Read снимок сохраняется для транзакции; в PostgreSQL он также исключает phantom reads — появление или исчезновение подходящих строк при повторном таком чтении. Минимальная общая таблица SQL допускает фантомы на этом уровне, поэтому её нельзя дословно выдавать за поведение PostgreSQL.
Read Uncommitted в PostgreSQL работает как Read Committed. Serializable требует результата, совместимого с выполнением транзакций по очереди, но при конфликте может отклонить одну из них ошибкой сериализации.
Подвох: строгая изоляция не обещает, что каждая попытка завершится успешно. После такой ошибки повторяют всю транзакцию: заново читают данные и проверяют правила. Повторить только последнюю запись со старым расчётом недостаточно.
6. Почему чтение остатка и последующий setQuantity могут потерять изменение?
Представьте остаток 5. Два покупателя читают это число, каждый вычитает единицу в Java и получает 4. Первый сохраняет 4, затем второй тоже сохраняет 4. Было две покупки, но остаток уменьшился только на одну: поздняя запись затёрла результат первой. Это потерянное обновление; сами слова «всё в транзакциях» ещё не исключают его.
Для простого списания лучше передать базе условие и вычисление одной командой. Вход ниже — номер товара вместо ?. Таблица stock хранит остаток quantity, столбец product_id указывает товар. Команда уменьшает текущий остаток лишь при наличии хотя бы одной единицы.
update stock
set quantity = quantity - 1
where product_id = ? and quantity > 0Результат — число изменённых строк. Одна означает выполненное списание, ноль — что подходящей строки с положительным остатком нет. В PostgreSQL Read Committed конфликтующее изменение строки ждёт, затем условие проверяется на актуальной версии строки. Следующий покупатель не записывает свой заранее вычисленный остаток.
Подвох: такая команда решает условие одной строки. Если покупка должна согласовать несколько товаров или ещё и общий лимит, нужно отдельно проверить все правила и границу транзакции.
7. Когда выбирать блокировку строки, а когда проверку версии?
Есть два основных подхода. Можно перед изменением получить блокировку строки, например через SELECT ... FOR UPDATE. Другая операция, желающая конфликтующим образом изменить эту строку, будет ждать освобождения. Это пессимистическая стратегия: заранее предполагаем столкновение.
Можно разрешить читать одновременно, а при сохранении проверить, не устарели ли прочитанные данные. Это оптимистическая стратегия; её вариант с полем версии разберём ниже. Для остатка сравним оба подхода с уже рассмотренной условной командой:
| Стратегия | Что происходит при конкуренции | Что обсудить |
|---|---|---|
| Блокировка строки | Вторая операция ждёт завершения первой. | Как долго держать блокировку и что делать при слишком долгом ожидании. |
| Проверка версии | Попытка сохранить устаревшие данные сообщает конфликт. | Повторить расчёт с новыми данными или попросить пользователя подтвердить изменение. |
| Условный UPDATE | База проверяет условие и меняет значение одной командой. | Хватает ли одной строки для всех правил покупки. |
Подвох: нельзя держать блокировку, пока пользователь несколько минут заполняет форму. Долгие транзакции мешают другим. Если операции захватывают несколько строк в разном порядке, возможна взаимная блокировка: каждая ждёт ресурс другой. Автоматический повтор оптимистической операции тоже не всегда верен: пользователь мог соглашаться именно со старой ценой.
Индексы и план запроса
8. Почему созданный индекс может не использоваться?
Индекс — дополнительная структура, помогающая искать строки без полного просмотра таблицы. Это похоже на указатель по значениям столбцов, но его нужно хранить и обновлять при изменениях данных. База выбирает способ чтения по запросу и статистике — сведениям об объёме и распределении значений.
Если нужны почти все строки небольшой таблицы, прочитать её подряд может быть дешевле, чем обращаться через индекс. Составной B-tree, то есть древовидный индекс по нескольким столбцам, (customer_id, created_at) особенно полезен, когда условие ограничивает ведущий столбец — покупателя, а затем дату.
Подвох: в PostgreSQL 17 условие без первого столбца не делает использование такого индекса абсолютно невозможным; оно может потребовать слишком большого просмотра и оказаться невыгодным. Функция над столбцом тоже может изменить доступный способ поиска. Поэтому перед добавлением индекса уточняют точный запрос, сортировку и число нужных строк, затем смотрят выбранный план.
9. Чем EXPLAIN отличается от EXPLAIN ANALYZE?
План запроса показывает, какими шагами база собирается получить результат: прочитать таблицу, найти строки в индексе, соединить данные, отсортировать их. EXPLAIN показывает такой план с оценками работы, но саму исследуемую команду не выполняет.
EXPLAIN ANALYZE выполняет команду и добавляет фактические данные. Сравнивают, сколько строк ожидалось и получилось, как долго работали шаги и сколько раз они повторялись. Быстрый шаг, выполненный тысячи раз, может давать много общей работы. Оценочная стоимость cost не является просто числом миллисекунд. Данные о буферах помогают дополнительно увидеть чтение страниц базы.
Подвох: EXPLAIN ANALYZE UPDATE действительно меняет строки. Это уже выполнение операции со всеми её последствиями, а не безопасный просмотр текста плана. Для сравнения производительности нужны контролируемые данные и измерения; сам факт наличия индекса ещё не доказывает ускорение.
Persistence context: как ORM видит Java-объекты
10. Что такое persistence context и какие бывают состояния entity?
Вернёмся от SQL к Java. Hibernate загрузил строку заказа и создал объект Order в памяти. Теперь нужно знать, какой объект представляет эту строку и какие его изменения предстоит отправить в базу. Для этого служит persistence context — область учёта управляемых объектов. Через неё работает EntityManager, интерфейс поиска, сохранения и удаления entity.
В одном контексте одна идентичность записи, например тип Order и номер 42, соответствует одному управляемому экземпляру. Повторный поиск может вернуть его же. Managed означает, что объект сейчас находится под таким управлением. New — новый объект, ещё не включённый в контекст. Detached — объект, которым больше не управляет контекст. Removed — объект, отмеченный для удаления.
После закрытия контекста ссылка на объект может остаться. Его уже загруженные значения доступны, но простое изменение поля больше не обязано попасть в базу автоматически. Подвох: заполненный id не доказывает ни managed-состояние, ни успешный commit. Объекты из двух разных контекстов могут представлять одну строку и при этом различаться по ==, то есть быть разными экземплярами Java.
11. Чем persist отличается от merge и какой объект использовать после merge?
persist включает новый объект в управление контекста. merge решает другую задачу: переносит значения переданного объекта в управляемый экземпляр и возвращает этот экземпляр. Если входной объект detached, не следует ожидать, что именно он снова стал управляемым.
В примере есть detachedOrder, заказ из уже закончившейся области управления. Метод выполняется в действующей транзакции с новым контекстом. merge возвращает в managed объект текущего контекста, затем адрес меняется именно у него.
Order managed = entityManager.merge(detachedOrder);
managed.changeDeliveryAddress("Улица Садовая, 10");
// detachedOrder не стал автоматически управляемым.
// Его последующее изменение не равно изменению managed.Результат — изменение адреса у отслеживаемого объекта. Оно будет синхронизировано с базой по правилам контекста и транзакции. Последующее изменение исходного detachedOrder само по себе не означает изменение managed.
Подвох: merge может перенести и другие значения устаревшего объекта, хотя пользователь хотел сменить только адрес. Часто понятнее загрузить заказ заново и применить разрешённые поля из DTO — объекта входных данных. Так видны и проверка прав, и проверка текущего статуса заказа.
12. Нужно ли вызывать save после каждого setter и равен ли flush сохранённой транзакции?
Когда entity managed, ORM отслеживает её сохраняемые поля. Если в действующей транзакции поменять адрес через setter — метод изменения поля, — ORM обычно может подготовить обновление без отдельного merge после каждого изменения. Это свойство управления объектом; для detached-ссылки такой гарантии нет.
Flush — синхронизация изменений контекста с базой: ORM выполняет нужные SQL-команды. Она может обнаружить неверные значения, нарушение ограничения или конфликт версии. Но транзакция после flush ещё не закончена и способна откатиться. Commit — её окончательная фиксация, которая тоже может не удаться.
Для изменения адреса порядок выглядит так. Некоторые SQL-команды могут выполняться раньше, поэтому схема разделяет события по смыслу, а не обещает единственное расписание:
- Объект
Метод меняет адрес у Java-объекта заказа в памяти.
- Контекст
ORM учитывает изменение управляемого экземпляра.
- Flush
SQL передаёт нужные изменения в базу внутри транзакции.
- Commit
База принимает изменения транзакции окончательно либо завершение даёт ошибку.
Подвох: ORM может выполнить AUTO flush перед запросом, чтобы тот учитывал уже сделанные изменения. Режим COMMIT также не обещает, что раньше конца синхронизации совсем не будет. Ни присвоенный номер, ни INSERT в журнале команд, ни успешный flush не заменяют успешное окончание транзакции.
Получение данных и связи
13. Означает ли EAGER один JOIN, а LAZY отсутствие запроса?
У заказа может быть связь с покупателем и список позиций. ORM не всегда нужно сразу загружать все связанные данные. EAGER требует получить предусмотренное состояние заранее, но не задаёт единственную SQL-команду: Hibernate может отдельно прочитать заказ и отдельно покупателя.
LAZY обозначает возможность отложить получение данных до обращения к ним; по JPA это подсказка реализации, а не абсолютная гарантия отсутствия ранней загрузки. Например, чтение ещё не полученного списка позиций может вызвать новый SQL. Для такого дочитывания обычно нужен действующий контекст.
Подвох: после закрытия контекста Hibernate может сообщить LazyInitializationException — загрузить отсутствующие данные уже неоткуда. При этом ранее прочитанные поля не исчезают. Замена всех связей на EAGER может загрузить лишнее и всё равно оставить много запросов. Лучше заранее определить данные конкретного ответа и получить их до передачи результата веб-слою.
14. Что такое N+1 и как доказать, что оно есть?
Сначала один запрос получает N заказов. Затем Java-код для каждого заказа читает его покупателя, а эти данные ещё не загружены. Если для каждого запускается отдельный запрос, получилось «один плюс N» — N+1. С ростом списка растёт количество обращений к базе.
Например, 20 заказов с 20 разными покупателями способны привести к 21 запросу. Но это учебный подсчёт, а не обещание точного количества: повторяющиеся покупатели, уже загруженные данные и кеш меняют результат. Кеш — сохранённые ранее данные, которые повторно используют вместо нового чтения.
Доказывают проблему журналом SQL или счётчиком команд. Возможные решения: fetch join получает связанные объекты в запросе, entity graph описывает нужные связи, пакетная загрузка читает несколько связей за раз, DTO-проекция выбирает только нужные поля.
Подвох: тест с одним заказом или заранее загруженными покупателями может скрыть N+1. Для проверки нужны разные связи и понятная граница между подготовкой данных и измеряемым чтением. Загрузить вообще всё заранее — тоже не универсальное ускорение.
15. Почему fetch join коллекции опасно совмещать с пагинацией?
Пагинация — выдача результата страницами, например по 10 заказов. Если соединить заказ со списком позиций, один заказ с пятью позициями даст пять SQL-строк. Ограничить результат десятью строками — не то же самое, что получить десять целых заказов.
При загрузке коллекции через fetch join с ограничением Hibernate может прочитать весь результат и отобрать страницу уже в памяти. Вместо ожидаемой экономии получается лишняя загрузка; можно настроить явную ошибку для такого случая. Один из подходов: сначала получить номера десяти заказов в устойчивом порядке, затем загрузить данные только для этих номеров.
Подвох: второй запрос сам по себе не обязан сохранить порядок первого, его задают или восстанавливают. Если нужны только отдельные поля, DTO-проекция может убрать необходимость загружать все entity. Опасность зависит от кратности связи: соединение с одним покупателем и соединение со многими позициями — разные случаи, поэтому любой join запрещать нельзя.
16. Кто владеет двусторонней связью и что означает mappedBy?
В базе позиция заказа обычно хранит order_id — внешний ключ, номер строки заказа, к которой она относится. В памяти удобно иметь обе стороны: заказ знает список позиций, позиция знает свой заказ. Эти две Java-ссылки нужно согласовать.
Владеющая сторона в JPA определяет сохранение самой связи. В обычной паре «многие позиции к одному заказу» это поле заказа у позиции, где хранится соответствие внешнему ключу. mappedBy = "order" у списка говорит: связь описана полем order другой entity. Это имя Java-поля, а не SQL-столбца.
Вход addLine — объект позиции line. Метод добавляет его в список заказа и устанавливает обратную ссылку. Нижняя часть фрагмента показывает, как поле order связано со столбцом order_id. Остальные поля и методы entity опущены.
// В Order:
@OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST)
private List<OrderLine> lines = new ArrayList<>();
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}
// В OrderLine:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;Результат — согласованные ссылки в памяти и заполненная владеющая сторона для последующего сохранения связи. Подвох: если изменить только список заказа, ORM не обязан угадывать, что нужно записать в order_id. nullable = false также сообщает, что позиция не должна храниться без заказа.
17. Чем cascade отличается от orphanRemoval?
Cascade, каскад, означает передачу определённого действия от одного объекта связанным. В примере выше CascadeType.PERSIST позволяет при включении нового заказа в управление распространить persist на его новые позиции. Это не означает, что любая операция над заказом автоматически применяется ко всем связанным данным.
orphanRemoval решает задачу удаления зависимого объекта после исключения из связи. Например, позиция существует как часть конкретного заказа: при удалении из списка ей может требоваться удаление из базы во время синхронизации. А товар каталога самостоятельный — он нужен другим заказам и должен остаться.
Подвох: CascadeType.ALL включает несколько действий, в том числе удаление. Его нельзя добавлять только ради того, чтобы «всё сохранилось», без проверки, что произойдёт при удалении родителя. И каскад ORM отличается от каскадного ограничения самой базы: нужно понимать, кто выполняет действие и какие записи должны пережить его.
Согласованность состояния: равенство, версии и кеш
18. Как писать equals и hashCode для entity?
Сначала решите, что означает «одна сущность»: два объекта с одинаковым номером строки или только один и тот же экземпляр в памяти? equals задаёт равенство объектов, hashCode используется, например, при размещении в HashSet. Универсального шаблона для любых entity нет: важно, когда появляется номер и какие поля способны меняться.
Если сравнивать только номера и считать два null одинаковыми, два ещё не сохранённых заказа могут ошибочно оказаться равными. Если после выдачи номера меняется hashCode, уже добавленный в HashSet объект может перестать находиться: коллекция ищет его не в той внутренней группе. Изменяемые поля создают похожую проблему.
Подвох: Hibernate может отдавать прокси — объект-посредник для отложенной загрузки, что тоже учитывают в проверке равенства. Автоматическое включение всех полей и связей в equals или toString способно вызвать ненужное чтение базы, обход цепочки объектов или бесконечные взаимные вызовы. Проверьте новые, managed, detached и proxy-объекты на выбранных правилах, а не копируйте шаблон без условий.
19. Что даёт @Version и когда обнаруживается конфликт?
Добавим к остатку номер версии. Когда два покупателя читают версию 7, оба знают состояние на этот момент. При сохранении первого ORM проверяет старую версию и увеличивает её. Попытка второго сохранить данные с прежней версией обнаруживает, что строку уже изменили. Это optimistic locking, оптимистическая проверка конфликта.
Ниже показана только структура StockItem, а не метод покупки. @Entity отмечает сохраняемый класс, @Id — номер записи, @Version — управляемое ORM поле версии. Входом для работы станут прочитанные из базы id, версия и остаток; при последующем изменении количества ORM сможет проверить, не устарел ли объект.
@Entity
class StockItem {
@Id
private Long id;
@Version
private long version;
private int quantity;
protected StockItem() {}
}Результат при одновременном изменении — успешное сохранение подходящей версии либо ошибка конфликта. Она может появиться на flush или commit, а не в момент изменения Java-поля. Ошибочная транзакция откатывается. Для повтора нужно начать новую попытку, перечитать данные и снова проверить условие покупки.
Подвох: версия защищает предусмотренные операции ORM, но не добавляет проверку в любой вручную написанный SQL. Массовые и прямые изменения базы должны отдельно учитывать условия и обновление версии.
20. Почему bulk UPDATE и повторный find могут оставить старые данные в Java?
Представьте, что в памяти уже есть управляемый заказ со статусом «новый». Затем массовый UPDATE на JPQL, языке запросов JPA, меняет статусы подходящих записей прямо в базе. Такая команда не обязана переписать каждое поле уже созданных Java-объектов.
Повторный find в том же контексте может вернуть прежний экземпляр со старым статусом. Такой учёт объектов называют первым уровнем кеша. Это не обязательно общий кеш между разными операциями. До массовой команды нужно завершить необходимые отправки изменений, после — выбрать подходящий момент для обновления объектов или очистки контекста.
Подвох: clear() отделяет все управляемые объекты. Если вызвать его без учёта ещё не синхронизированных изменений, можно потерять ожидаемую запись этих изменений. Второй уровень кеша, общий для нескольких контекстов, и кеш результатов запросов имеют собственные настройки. Ни один общий термин «кеш» не гарантирует свежесть после изменения базы другой программой: нужно определить способ обновления данных и срок их использования.
Как разобрать задачу целиком перед собеседованием
Возьмите покупку последней единицы товара. Назовите таблицы и нужный результат: один заказ, остаток ноль, второй покупатель получает отказ. Затем по шагам объясните чтение, проверку, изменение и окончательную фиксацию. Выберите защиту от одновременного списания и скажите, что произойдёт при ошибке.
Отдельно проверьте три вещи: значения объектов в памяти, SQL, который ушёл в базу, и данные после успешного завершения транзакции. Если они различаются, объясните причину: изменения ещё не отправлены, операция откатилась, объект устарел или обнаружена другая версия. Так вы сможете разбирать незнакомые задачи через последовательность действий.
