1. SQL‑след как «чёрный ящик» Hibernate
SQL-лог у нас уже включён и служит рабочим инструментом. Через него можно увидеть три ситуации, которые снаружи легко перепутать: ожидаемый UPDATE у managed-сущности, случайный UPDATE в read-сценарии и read-only-загрузку, при которой UPDATE не уходит вовсе. Если код выглядит невинно, а в логе есть UPDATE, верим логу.
С точки зрения сегодняшней лекции SQL-лог нужен не для красоты и не для «галочки observability». Он нужен, чтобы ответить на четыре очень конкретных вопроса:
1) Был ли UPDATE вообще?
2) По какой таблице он был?
3) Почему он был — что именно изменилось относительно snapshot?
4) Где он «родился» в коде: это ожидаемая запись или accidental update?
Давайте закрепим простую мысль, которую я буду повторять как мантру (да, это тот момент, когда преподаватель начинает звучать как будильник): SQL-лог — это правда. Код может выглядеть невинно, но если в логе есть UPDATE, значит, кто-то изменил managed-сущность. И этот «кто-то» — внезапно вы. Ну или ваш коллега… но чаще всё-таки вы.
Чтобы было легче мыслить, представим очень упрощённую цепочку:
flowchart TD
A["Начало @Transactional метода"] --> B["find() / query -> сущность становится managed"]
B --> C["Hibernate хранит snapshot"]
C --> D["Код меняет поля сущности"]
D --> E["Hibernate видит отличия от snapshot"]
E --> F["В конце транзакции отправляется SQL UPDATE"]
Обратите внимание: я специально написал «в конце транзакции», а не «на строке setter». Сегодня нам важно лишь сопоставить изменение в коде и результат в SQL-логе, не углубляясь в точный момент синхронизации.
2. Что именно смотреть в sql-trace
Профиль sql-trace у нас считается базовой настройкой проекта. Перед разбором достаточно напомнить себе две вещи: в логе должны быть видны сами SQL-запросы и bind-параметры. Этого уже хватает, чтобы отличить expected update от accidental update.
Что нам нужно от логов именно сегодня
Нам достаточно двух типов сообщений.
Первый тип — сами SQL-запросы: select ..., update ..., insert .... Обычно их даёт логгер org.hibernate.SQL.
Второй тип — значения параметров, которые Hibernate подставляет в prepared statement. Для этого в Hibernate 7 (как и в линии Hibernate 6+) обычно включают логгер org.hibernate.orm.jdbc.bind на уровне TRACE. Тогда вы увидите не только update product set name=? where id=?, но и чем именно был этот ?.
Это важно, потому что без bind-значений вы часто увидите только сам факт «обновление было», но не сможете быстро подтвердить, что изменилось, например, именно имя на Monitor 27.
Мини‑пример конфигурации
Если нужно быстро проверить, что профиль действительно включает нужные сообщения, конфигурация выглядит примерно так (упрощённо):
logging:
level:
org.hibernate.SQL: DEBUG # Печатаем сами SQL-запросы (select/update/insert)
org.hibernate.orm.jdbc.bind: TRACE # Печатаем значения, которые подставляются вместо '?'
spring:
jpa:
properties:
hibernate:
format_sql: true # Делает SQL в логе читабельнее (переносы/отступы)
Здесь нет ничего «магического»: мы просто просим Hibernate печатать SQL и параметры. Форматирование (format_sql) нужно затем, чтобы запросы не выглядели как один длинный чулок, который кот стащил и растянул по комнате.
Как отделять границы сценариев в логе
SQL-лог — штука шумная. Поэтому в лабораторных сценариях очень удобно добавлять в вывод «маячки» — обычные System.out.println, чтобы глазами ловить границы: вот начался сценарий, вот закончился.
Это не «прод-подход», но в учебной лаборатории — идеально. Например:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void demoRenameProduct(Long id) {
// Маячки помогают глазами отделить SQL одного сценария от SQL других сценариев
System.out.println("=== DEMO: renameProduct START ===");
// ... ваш код
System.out.println("=== DEMO: renameProduct END ===");
}
Если вы увидели в консоли эти две строки, а между ними — SQL, то вам уже легче сопоставлять, какой метод породил какой SQL. И да, это тот редкий момент, когда println — не зло, а скотч. Учебный, временный, но полезный.
3. Сценарий №1: меняем Product — ожидаемый UPDATE
Начнём с самого прямого паттерна дня: загрузили managed Product, поменяли поле, в логе ждём SELECT, а затем ожидаемый UPDATE по product. Нас интересует именно этот след: какой запрос ушёл и по какой таблице.
Код: managed update flow без save()
В сервисе каталога пусть будет метод, который меняет имя товара. Важно: он @Transactional, он грузит сущность через репозиторий или EntityManager, затем меняет поле — и не вызывает save().
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogService {
private final ProductRepository productRepository;
public CatalogService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional
public void renameProduct(Long id, String newName) {
// 1) Загружаем сущность => она становится managed в persistence context
Product product = productRepository.findById(id).orElseThrow();
// 2) Меняем поле => dirty checking обнаружит отличие от snapshot
product.setName(newName);
// 3) save() не нужен: UPDATE уйдёт при flush/commit транзакции
}
}
Это и есть тот самый managed update flow, который мы обсуждали в лекциях: объект managed, Hibernate держит snapshot, setter меняет текущее состояние, а дальше Hibernate сам разберётся.
Что ожидаем в SQL‑логе
В логе вы почти наверняка увидите хотя бы один SELECT, потому что findById должен загрузить товар из базы, если его ещё нет в persistence context. Потом — ближе к завершению транзакции — появится UPDATE.
-- 1) Загрузка сущности (SELECT), чтобы сделать её managed
select p1_0.id, p1_0.name, p1_0.sku from product p1_0 where p1_0.id=?
-- binding parameter [1] as [BIGINT] - [1]
-- 2) Обновление (UPDATE) при завершении транзакции/flush
update product set name=? where id=?
-- binding parameter [1] as [VARCHAR] - [Monitor 27]
-- binding parameter [2] as [BIGINT] - [1]
Пока нас интересует очень простой факт: UPDATE появился и он ушёл по таблице product.
Быстрый «чек» глазами
Чтобы не тонуть в деталях, держите маленькую привычку: когда глазами ищете результат сценария, сначала найдите строку update product (или update products — как у вас), а уже потом разглядывайте остальное. Это как искать нужный подъезд: сначала улица и номер дома, потом дверь.
Контрастный мини‑кейс: setter был, а UPDATE нет
Чтобы закрепить идею snapshot-сравнения, иногда полезно сделать «пустое изменение»: присвоить то же самое значение.
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void setSameName(Long id) {
Product product = productRepository.findById(id).orElseThrow();
// Присваиваем то же значение: по факту изменений нет => dirty checking "молчит"
product.setName(product.getName());
}
Если name реально не изменился, Hibernate сравнит текущие поля со snapshot и решит, что сущность не dirty. В SQL вы увидите SELECT, но не увидите UPDATE. И это не «Hibernate забыл», а как раз нормальная работа dirty checking.
4. Сценарий №2: меняем PurchaseOrder — ожидаемый UPDATE
Теперь просто переносим тот же паттерн на PurchaseOrder: другая таблица, та же логика dirty checking.
Код: меняем статус заказа
Предположим, у нас есть OrderService, который подтверждает заказ:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final PurchaseOrderRepository orderRepository;
public OrderService(PurchaseOrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public void confirmOrder(Long orderId) {
// 1) Загружаем заказ => managed
PurchaseOrder order = orderRepository.findById(orderId).orElseThrow();
// 2) Меняем статус => станет dirty
order.setStatus(OrderStatus.CONFIRMED);
// 3) save() не нужен: UPDATE уйдёт при завершении транзакции
}
}
Что ожидаем в SQL‑логе
Минимальная картина: SELECT по таблице заказов и затем UPDATE по той же таблице.
-- 1) SELECT для загрузки заказа
select o1_0.id, o1_0.status from purchase_order o1_0 where o1_0.id=?
-- binding parameter [1] as [BIGINT] - [10]
-- 2) UPDATE из-за изменения status
update purchase_order set status=? where id=?
-- binding parameter [1] as [VARCHAR] - [CONFIRMED]
-- binding parameter [2] as [BIGINT] - [10]
Психологически это важный момент: когда один и тот же рисунок повторяется на Product и PurchaseOrder, становится видно, что дело не в «особенности конкретного репозитория», а в общей модели managed-сущности.
5. Accidental update: чтение превращается в запись
Теперь смотрим на более неприятный вариант: метод выглядит как чтение, а в логе всё равно появляется UPDATE. Здесь важен именно SQL-след: managed-сущность изменили «по пути», поэтому лог получился как у write-сценария.
Код: опасная «нормализация» через setter
Вот пример, который выглядит как «хочу красиво отобразить заголовок товара», но внутри меняет managed-объект:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public String loadProductTitleDangerously(Long id) {
// Загружаем managed-сущность
Product product = productRepository.findById(id).orElseThrow();
// ВНИМАНИЕ: это запись в managed-объект, даже если "кажется косметикой"
product.setName(product.getName().trim());
// Возврат строки не делает метод read-only автоматически
return product.getSku() + " " + product.getName();
}
Если в БД имя было " Keyboard " (с пробелами), trim() даст "Keyboard", это будет отличием от snapshot — и Hibernate подготовит UPDATE.
Как это выглядит в SQL‑логе
Вы увидите SELECT — загрузили товар — и затем UPDATE, хотя метод формально «возвращает String» и как будто ничего не «сохраняет».
-- SELECT: загрузили сущность => она managed
select p1_0.id, p1_0.name, p1_0.sku from product p1_0 where p1_0.id=?
-- binding parameter [1] as [BIGINT] - [1]
-- UPDATE: т.к. name изменился относительно snapshot
update product set name=? where id=?
-- binding parameter [1] as [VARCHAR] - [Keyboard]
-- binding parameter [2] as [BIGINT] - [1]
И это именно тот момент, где SQL-лог снимает споры. Не «мне кажется, Hibernate что-то делает», а «вот конкретный UPDATE и вот конкретное новое значение».
Безопасный вариант: локальная переменная вместо изменения сущности
Сделаем тот же результат для UI, но не тронем сущность:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public String loadProductTitleSafely(Long id) {
Product product = productRepository.findById(id).orElseThrow();
// Нормализуем в локальную переменную, не меняя managed-сущность
String normalizedName = product.getName().trim();
return product.getSku() + " " + normalizedName;
}
Теперь у вас есть нормализованное имя, но snapshot сущности не менялся — и UPDATE в логе не появится.
Почему так происходит (в одной фразе)
Потому что Hibernate не знает, что вы «хотели просто красиво вывести». Он видит реальность: в managed-объекте поменялось поле. Всё.
6. Read-only: меняем в памяти, SQL молчит
Для полноты картины нужна обратная сигнатура: объект в памяти мы тронули, а UPDATE в логе так и не появился. Это и есть read-only-путь.
Read-only query через hint
Самый локальный и безопасный способ — пометить конкретный запрос как read-only:
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional
public List<Product> loadCatalogReadOnly(EntityManager entityManager) {
// Запрос возвращает сущности Product, но мы просим Hibernate не отслеживать их на dirty checking
TypedQuery<Product> q = entityManager.createQuery(
"select p from Product p", Product.class
);
// Hibernate-specific hint: объекты, загруженные этим запросом, будут read-only
return q.setHint("org.hibernate.readOnly", true)
.getResultList();
}
Идея простая: всё, что загружено этим запросом, не участвует в dirty checking.
Если вы после этого сделаете так:
// Даже если вызвать setter, Hibernate не должен отправить UPDATE, т.к. сущность read-only
products.get(0).setName("Temporary");
то в логе не должно появиться UPDATE product, даже если вы поменяли поле в памяти. В памяти вы объект изменили, да. Но Hibernate уже решил: «за этим объектом я не слежу как за кандидатом на запись».
Точечный session.setReadOnly(entity, true) и setDefaultReadOnly(true) дают ту же идею: объект может измениться в памяти, но Hibernate не будет автоматически готовить UPDATE.
Что мы проверяем в логе
Самая важная проверка — отсутствие UPDATE по таблице, которую мы «трогали» в памяти. Это тот редкий случай, когда отсутствие лога — хороший результат. И да, в мире Hibernate отсутствие UPDATE иногда радует сильнее, чем наличие. Примерно как отсутствие сообщений «у вас ошибка на проде» в пятницу вечером.
7. Алгоритм: код → SQL
На практике проблема почти никогда не выглядит как учебный кейс «я поменял name, получил update». Чаще она звучит так: «Почему в этом сценарии в БД ушёл UPDATE? Я же ничего не сохранял». И вот тут нужен небольшой, но железобетонный алгоритм чтения логов, чтобы не превращаться в шамана, который танцует вокруг saveAndFlush().
Я предлагаю держать в голове простой «детективный» процесс: сначала выписываете факты из кода, затем сверяете их с SQL, а потом возвращаетесь в код, если где-то нашли расхождение.
Вот схема:
flowchart TD
A["Шаг 1: Найди @Transactional границу"] --> B["Шаг 2: Какие сущности стали managed?"]
B --> C["Шаг 3: Какие поля были реально изменены?"]
C --> D["Шаг 4: Что ты ожидаешь в SQL?"]
D --> E["Шаг 5: Найди в логе UPDATE и его table/where"]
E --> F{"Совпало?"}
F -->|Да| G["Готово: поведение объяснимо"]
F -->|Нет| H["Ищи скрытый setter / маппер / нормализацию"]
H --> B
Таблица «код → ожидание → что проверять»
Чтобы совсем упростить жизнь, можно пользоваться такой табличкой (и да, это тот случай, когда таблица полезнее, чем ещё один абзац):
| Сценарий в коде | Что делаем с managed‑сущностью | Что ожидаем в SQL | На что смотрим в логе |
|---|---|---|---|
| findById + setName("...") | Явное изменение поля | UPDATE product ... where id=? | Есть ли update product, совпадает ли id |
| findById + setStatus(...) | Явное изменение поля | UPDATE purchase_order ... where id=? | Есть ли update purchase_order, совпадает ли id |
| «Чтение», но внутри setX(x.trim()) | Непреднамеренная запись | UPDATE появляется «вдруг» | Какая таблица обновилась, какой параметр биндится |
| read-only query + изменение в памяти | Меняем объект, но Hibernate «не следит» | UPDATE не должен появиться | Отсутствие update ... по этой таблице |
Здесь есть важный методический момент: когда вы начинаете, не пытайтесь анализировать всё сразу — каждую колонку, порядок, оптимальность. Сначала научитесь отвечать на вопрос: какая сущность стала dirty и почему. Это фундамент. Остальное — уже надстройка.
8. Типичные ошибки при чтении SQL‑лога
Ошибка №1: читать SQL-лог без привязки к коду и транзакции.
Очень легко открыть консоль, увидеть десятки запросов и начать паниковать: «Почему их так много?!». Но без границы @Transactional вы не понимаете, какие SQL относятся к конкретному сценарию. Лечится просто: ставьте «маячки» (println/логгер) и всегда начинайте анализ с вопроса «какой метод я сейчас выполняю?».
Ошибка №2: искать UPDATE только там, где есть save().
Это одна из самых устойчивых ложных привычек. В Hibernate UPDATE появляется не потому, что вы вызвали save(), а потому что managed-сущность стала dirty относительно snapshot. Поэтому правильный поиск причины начинается с вопроса «какое поле поменялось?», а не с «кто вызывал репозиторий».
Ошибка №3: считать любой «широкий» UPDATE признаком бага.
Иногда вы поменяли одно поле, а в SQL обновляется несколько колонок. Новички пугаются: «Hibernate обновляет лишнее!». На раннем этапе курса это нормально воспринимать как техническую деталь генерации SQL. Сначала убедитесь, что UPDATE ожидаем и ушёл по правильной таблице. Глубокие оптимизации и влияние маппинга мы будем обсуждать позже.
Ошибка №4: пропускать bind-параметры и анализировать только «голый SQL».
update product set name=? where id=? без значений — это как чек без суммы: вроде понятно, что покупка была, но что именно купили — загадка. Для расследования accidental update вам почти всегда нужны значения параметров. Если binder-логи выключены, включите профиль sql-trace и снова запустите сценарий.
Ошибка №5: путать read-only сценарий с обычным managed-сценарием.
Если вы загрузили сущности обычным find()/findById(), а потом ожидаете, что «это было чтение, значит обновления не будет», вы сами себя обманываете. Чтение — это не намерение в вашей голове, а конкретное поведение Hibernate. Если сценарий нужно защитить от записи, используйте read-only query или entity-level read-only и проверяйте результат по SQL-логу.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ