JavaRush /Курсы /Hibernate deep-dive /Доказательная база проверки

Доказательная база проверки

Hibernate deep-dive
30 уровень , 3 лекция
Открыта

1. Доказательная база в persistence-review

Когда вы впервые начинаете разбирать Hibernate, кажется, что главная сложность — запомнить аннотации и «правильные настройки». На практике сложнее другое: договориться с реальностью, потому что Hibernate очень вежливо делает вид, что всё «просто работает», пока в проде внезапно не появляется 200 запросов на один экран. Доказательная база — это способ переводить спор из «мнений» в «измерения», где выигрывает не тот, кто громче, а тот, у кого есть факты.

Red flag сам по себе — только подозрение. Чтобы finding перестал быть фразой «мне не нравится этот код», его нужно прибить к фактам: к SQL trace, к счётчикам runtime и к тесту, который воспроизводит нужный симптом.

В persistence-review мы почти всегда отвечаем на один из трёх вопросов: «какой SQL реально выполнился», «почему он выполнился именно так» и «как гарантировать, что завтра он снова не станет хуже». Если вы не опираетесь на артефакты, вы начинаете спорить «на ощущениях», а ощущения, как известно, не компилируются. Да и тесты на ощущения в JUnit пока не завезли (и слава богу).

Артефакт проверки: что считаем доказательством

Слово «артефакт» звучит так, будто мы сейчас будем откапывать древний амулет Hibernate 3.0, но смысл проще. Артефакт проверки — это конкретная штука, которую можно приложить к review finding, чтобы другой разработчик мог воспроизвести вывод. Хороший артефакт отвечает на вопрос «покажи», а не «поверь». В нашем курсе такими артефактами чаще всего становятся SQL-лог, численные счётчики из Statistics и интеграционный тест, который воспроизводит проблему.

Очень важно не перепутать «артефакт» и «интерпретацию». Например, фраза «тут N+1» — это интерпретация, а «в SQL trace 1 запрос на список + 50 запросов на customer по order.customer_id» — это артефакт (и он уже почти сам объясняет проблему). Аналогично «flush лишний» — интерпретация, а «в логе видно UPDATE перед SELECT из-за overlapping query» — артефакт.

Мини-цикл «baseline → change → verify → lock-in»

Если упростить жизнь до здорового минимума, любая работа с persistence-качеством — это повторяемый цикл. Сначала мы фиксируем baseline (как сейчас), потом меняем решение (fetch-plan, query, транзакцию, bulk), затем проверяем, что стало лучше, и наконец закрепляем это тестом, чтобы не откатилось. Это буквально «ремонт по инструкции», а не «постучал по телевизору — заработало».

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

flowchart TD
    A[Baseline: SQL trace + statistics] --> B[Change: решение в коде]
    B --> C[Verify: повторный замер]
    C --> D[Lock-in: regression test]
    D --> A

На одном hot path этот цикл и превращается в полноценный review-pass. Пока нам важно научиться стабильно снимать baseline и проверять изменение, а не спорить вслепую.

2. SQL trace: честный свидетель Hibernate

SQL-лог — это как камера наблюдения в магазине. Можно спорить, «кто взял печеньку», можно строить версии, можно ссылаться на «я точно помню», но камера спокойно показывает: вот рука, вот печенька, вот человек делает вид, что он просто проходил мимо. Hibernate в нашем случае — тот самый «прохожий», который иногда делает SELECT так незаметно, что вы его замечаете только по упавшему latency. Поэтому SQL trace — первый и самый сильный артефакт.

В курсе мы сознательно привязываем каждый важный механизм Hibernate к SQL: states, dirty checking, flush, lazy loading, fetching, bulk operations, locking. И финальный audit тоже должен разговаривать на языке SQL. Не потому что «мы любим базы», а потому что в конце концов база выполняет то, что вы ей отправили, а не то, что вы имели в виду.

Включаем SQL trace: профили и логи

SQL trace легко превращается в «шумовую пушку»: включил — и консоль стала похожа на «Матрицу», только вместо кода зелёными буквами печатаются select ... from .... Поэтому дисциплина такая: мы включаем подробный SQL профилем, а не навсегда. В Commerce Persistence Lab у нас для этого есть профиль sql-trace, который делает лог подробным ровно тогда, когда мы диагностируем поведение.

Минимальный, но рабочий фрагмент application-sql-trace.yml выглядит примерно так (ключи могут чуть отличаться в зависимости от настройки логгера, но идея одна — отдельный профиль и явные уровни):

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true # форматируем SQL, чтобы его реально можно было читать

logging:
  level:
    org.hibernate.SQL: debug # печатаем сами SQL-строки
    org.hibernate.orm.jdbc.bind: trace # печатаем bind-параметры (значения для `?`)

Здесь org.hibernate.SQL показывает сами SQL-строки, а org.hibernate.orm.jdbc.bind печатает bind-параметры (то есть значения, которые вы реально передали в ?). Без bind-параметров SQL-лог часто превращается в ребус: «понятно, что он что-то искал… но что именно — загадка века».

Bind parameters: что теряется без них

Наивный SQL-лог без параметров выглядит красивее, но полезности в нём часто меньше. Он покажет форму запроса, но не покажет реальную селективность, из-за которой запрос может вести себя по-разному. Один и тот же select ... where status = ? может быть лёгким, если status=ACTIVE встречается редко, и тяжёлым, если status=ACTIVE — почти все строки. Для performance-аудита это критично.

Ещё одна причина любить bind-параметры — диагностика правильности. Очень неприятный класс багов: вы думаете, что фильтруете «не удалённые» товары, а на самом деле сравниваете deleted = null или вообще не добавили условие. Когда видишь параметры, это ловится глазами за секунду, без сложных философских рассуждений о «возможных причинах».

Читаем SQL-лог: количество, форма, flush-след

Чтение SQL trace — это не «прочитал 500 строк и устал». Это дисциплина: вы сначала ищете структуру, а потом детали. Обычно я начинаю с трёх вопросов: сколько запросов в сценарии, какие запросы повторяются одинаковой формой, и где внезапно появляются UPDATE/INSERT/DELETE там, где вы думали, что «мы же просто читаем».

Пример маленького фрагмента лога (условный, но очень похожий на то, что вы уже видели в лабораториях fetching):

-- 1) Загружаем заказ по id (root-entity)
select o.id, o.order_number, o.customer_id
from purchase_order o
where o.id = ?;

-- 2) Дальше лениво подгружаем клиента по FK (если fetch-plan не "подтянул" его заранее)
select c.id, c.email
from customer c
where c.id = ?;

Если это detail-use case «загрузить заказ и показать email клиента», то два запроса могут быть нормой (зависит от fetch-plan). Но если вы видите такую пару 50 раз подряд в одном запросе списка заказов, это уже почти учебник по N+1. SQL trace в этом смысле честный: он не спорит, он просто показывает, что вы реально сделали.

Ещё один типичный сигнал в SQL trace — неожиданный flush. Представьте, что в сервисе вы меняете объект (dirty checking), а потом делаете запрос «проверить условие». Hibernate может сбросить изменения в БД до выполнения SELECT, чтобы обеспечить корректность. В логе это выглядит как «почему у меня UPDATE в середине метода?». И вот тут SQL trace — единственный способ не гадать.

3. Hibernate Statistics: счётчики runtime

Statistics — это не замена SQL-лога, а «счётчик на приборной панели». Он не показывает детали каждого запроса, зато даёт числа: сколько подготовлено statements, сколько загружено сущностей, сколько инициализировано коллекций, сколько раз происходил flush. Это удобно, когда вам нужно сравнить «было 51 запрос, стало 1» без чтения простыни логов. Но есть нюанс: Statistics прекрасно считает, но не объясняет, поэтому интерпретировать его нужно вместе с use case и SQL.

Важно только не переоценить эти числа: prepareStatementCount — хороший быстрый счётчик SQL-активности, но не замена самому SQL trace. Форму запроса, порядок SELECT/UPDATE и flush-side effects всё равно смотрим в логе.

В финальном аудите Statistics особенно полезен как быстрый «smoke detector». Если на одном запросе списка товаров у вас prepareStatementCount=200, это повод включить SQL trace и искать, где именно вы устроили SQL-чат. А если prepareStatementCount=1, это не гарантирует, что запрос хороший, но хотя бы говорит, что N+1 вы, скорее всего, не сделали.

Включаем statistics и берём их в коде

Чтобы Statistics вообще что-то считал, его нужно включить. Обычно это делается настройкой Hibernate hibernate.generate_statistics=true (в проекте у нас для этого отдельный профиль stats). Важно помнить, что считать статистику постоянно в проде часто не надо: это инструмент диагностики и тестов, а не обязательная часть business-логики.

Дальше возникает практический вопрос: как в коде получить объект Statistics? В Spring Boot приложении у вас обычно есть EntityManagerFactory, который можно «развернуть» в Hibernate SessionFactory, а уже из него взять Statistics. Мини-фрагмент, который вы будете использовать и в тестах, и в lab-support:

import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;

// Вытаскиваем нативный Hibernate API из JPA-обёртки (мы делаем это осознанно для deep-dive)
Statistics stats = entityManagerFactory
        .unwrap(SessionFactory.class) // превращаем EntityManagerFactory в SessionFactory
        .getStatistics(); // берём счётчики runtime

Тут важно слово unwrap: мы честно признаём, что под JPA абстракцией у нас конкретный provider — Hibernate, и берём его API осознанно, потому что мы делаем deep-dive курс. Это не «зло», это инструмент.

Что смотреть в Statistics: ключевые метрики

Если открыть интерфейс Statistics, там будет много методов, и легко начать мерить всё подряд, как начинающий спортсмен покупает умные часы и пытается анализировать… фазу луны. Для нашего финального audit важны самые «приземлённые» вещи: сколько SQL-операций, сколько entity loads, сколько коллекций инициализировано, сколько flush-циклов произошло. Эти метрики хорошо ложатся на оси аудита: performance и correctness.

Небольшая таблица (без фанатизма) поможет держать фокус:

Что хотим понять Что смотреть в Statistics Как обычно интерпретировать
«Насколько chatty сценарий по SQL?» getPrepareStatementCount() Быстрый proxy по числу prepared statements; точную форму и порядок всё равно смотрим в SQL trace
«Сколько сущностей реально загрузили?» getEntityLoadCount() Понимаем ширину materialization (и потенциальный overfetching)
«Сколько коллекций инициализировали?» getCollectionFetchCount() Часто сигнал скрытой ленивой навигации по to-many
«Сколько раз происходил flush?» getFlushCount() Помогает ловить неожиданные flush-триггеры

Да, это упрощение, но нам нужен рабочий инструмент, а не музей метрик. В final review вы должны быстро отвечать на вопросы, а не строить диссертацию на тему «почему prepareStatementCount вырос на 2».

Мини-хелпер для labsupport: снимок статистики до/после

Очень удобный приём для лабораторий и тестов — делать «снимок» статистики в начале и конце сценария. Тогда вы не спорите «много/мало», а говорите «было 0, стало 51». В Java это удобно оформляется маленьким record (и да, record тут прямо в тему: простой DTO без лишней логики).

import org.hibernate.stat.Statistics;

// Мини-DTO: сохраняем то, что нам важно сравнивать "до/после"
public record StatsSnapshot(long statements, long entityLoads) {

    // Делаем снимок из текущего состояния Statistics
    public static StatsSnapshot of(Statistics stats) {
        return new StatsSnapshot(
                stats.getPrepareStatementCount(), // сколько prepared statements Hibernate подготовил
                stats.getEntityLoadCount() // сколько сущностей реально загрузили
        );
    }
}

Дальше в тесте или lab-коде вы можете сделать: stats.clear(), прогнать use case, взять StatsSnapshot.of(stats) и сравнить. Это не заменяет SQL, но отлично работает как «цифровой чек» для review finding.

4. ORM-regression tests: закрепляем поведение

Тесты в persistence-слое — это не столько про «правильно ли сохранились данные» (это тоже важно), сколько про «правильно ли система себя ведёт». ORM-ошибки коварны тем, что код остаётся «логически правильным», но становится дорогим или непредсказуемым. Поэтому regression tests для ORM — это страховка от возвращения старых проблем после «безобидного» рефакторинга, нового маппера или изменения fetch-plan.

И тут есть важная психологическая вещь: хороший ORM-тест — это когда вы проверяете симптом, который реально бывает в проде. Например, N+1, stale persistence context после bulk, лишний flush, optimistic conflict. Проверять «что Hibernate делает SQL» бессмысленно. Проверять «что мы не сделали 51 prepared statement на список» — очень даже.

Интеграционный контекст вместо unit-тестов

Unit-тест — это тест логики в вакууме. ORM же живёт не в вакууме, а в связке: транзакция, persistence context, JDBC, база данных, изоляция, планы выполнения. Поэтому unit-тест на репозиторий с моками обычно похож на тестирование самолёта в комнате: вы можете проверить, что кнопка «шасси» вызывает метод lowerLandingGear(), но вы не проверите, что самолёт реально садится.

Это не значит, что unit-тесты плохие. Это значит, что для ORM-поведения вам нужен интеграционный контекст: хотя бы @DataJpaTest с реальной БД (в нашем курсе — PostgreSQL-oriented подход). И уже в этом контексте вы можете измерять SQL, Statistics и ловить баги, которые в моках просто не существуют.

Шаблон теста на N+1: измеряем, а не верим

Тест на N+1 чаще всего выглядит так: мы запускаем конкретный read-use case, очищаем статистику, выполняем запрос и утверждаем, что счётчик prepared statements соответствует ожиданию. Это не идеальная метрика, но как regression-страховка работает отлично: если завтра кто-то уберёт EntityGraph или перепишет query на findAll(), тест начнёт кричать.

Мини-фрагмент из теста (внутри @DataJpaTest или @SpringBootTest — зависит от того, какой кусок вы проверяете):

import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;

// Given: получаем Statistics из Hibernate (в тесте это легально и полезно)
Statistics stats = entityManager.getEntityManagerFactory()
        .unwrap(SessionFactory.class)
        .getStatistics();

stats.clear(); // Given: обнуляем счётчики, чтобы мерить только этот сценарий

// When: выполняем "детальный" use case
orderRepository.findDetailedById(orderId).orElseThrow();

// Then: фиксируем регрессию по числу prepared statements в этом сценарии
assertThat(stats.getPrepareStatementCount()).isEqualTo(1);

Обратите внимание на «ритуал» stats.clear(). Если его забыть, вы будете мерить не сценарий, а всю историю тестового процесса. В результате тест станет нестабильным и начнёт вести себя как гадалка: «сегодня 3, завтра 7».

И ещё момент: этот тест должен проверять конкретный метод, который предназначен для detail-use case (например, findDetailedById). Если вы проверяете «обычный» findById, то ожидание «1 запрос» может быть некорректным — у вас просто другой контракт.

Тест bulk: stale state и clear()

Bulk update/delete — это отдельная «школа боли», потому что bulk меняет данные в БД, но не синхронизирует уже загруженные managed-объекты в persistence context. Поэтому правильный regression-тест здесь обычно фиксирует не только bulk-операцию, но и протокол после неё: clear() или повторное чтение.

Мини-идея такого теста на примере Product:

import jakarta.persistence.EntityManager;

// Given: загрузили managed-сущность в persistence context
Product p = em.find(Product.class, productId);

// When: делаем bulk update (он обходит persistence context и бьёт прямо в БД)
productRepository.markDeleted(productId);

// Then: обязательно синхронизируемся с БД, иначе дальше будем читать stale-объект
em.clear(); // выбросили stale-объекты из persistence context

Product reloaded = em.find(Product.class, productId); // читаем актуальное состояние из БД
assertThat(reloaded.isDeleted()).isTrue();

Этот фрагмент хорош тем, что он очень «человеческий»: вы буквально показываете, что после bulk вы не имеете права доверять объекту p, и вы делаете корректный шаг синхронизации. Такой тест в будущем защищает вас от «оптимизатора», который решит убрать clear() «потому что вроде не нужно».

Читабельность теста: Given/When/Then в 10 строк

Проблема ORM-тестов — не только в том, чтобы их написать, но и в том, чтобы через месяц вы сами поняли, что вы мерили. Поэтому даже короткий тест полезно структурировать как «Given/When/Then», хотя бы комментариями. Это не религия, а средство защиты от «теста-головоломки».

Пример микро-структуры прямо в коде:

stats.clear(); // Given: чистый baseline, предыдущие SQL/flush нас не интересуют

orderQuery.findOrderPage(); // When: выполняем use case (вот его и меряем)

long sql = stats.getPrepareStatementCount(); // Then: получаем измеримый факт по SQL-активности, а не "ощущение"
assertThat(sql).isLessThanOrEqualTo(2); // Then: фиксируем контракт (и ловим регрессию)

Да, это выглядит почти смешно просто. Но именно такие простые комментарии делают persistence-тесты обучающими и поддерживаемыми, а не «магическими числами без контекста».

Связка SQL + statistics + тесты как один язык

Сильная инженерная позиция в persistence-review выглядит так: «я вижу проблему, я могу показать её в SQL, я могу подтвердить её числом, и я могу закрепить исправление тестом». Это три уровня одной и той же правды. SQL показывает детали, statistics — быстрые численные сигналы, regression test — гарантию, что вы не забудете урок через неделю.

Чтобы этот подход стал «мышечной памятью», полезно держать под рукой простую матрицу. Не в виде списка из 50 пунктов, а в виде «если спрашивают X — докажи Y вот так».

Вопрос ревью Чем доказываем Минимальная проверка
«Тут правда N+1 SQL trace + statistics В логе видно повторяющиеся select ... where id=?, а prepareStatementCount даёт явный всплеск SQL-активности
«Почему появился UPDATE до конца метода?» SQL trace В логе UPDATE перед SELECT, в коде overlapping query или flush-trigger
«Bulk-операция безопасна в этой транзакции?» Тест + clear() protocol Regression test показывает stale-state без clear() и корректность с clear()
«Этот fetch-plan не раздувает результат?» SQL trace Один запрос, но огромный join и дубли root-entity (видно по форме SQL/DISTINCT)
«Мы не потеряли оптимистическую защиту?» Concurrency test + SQL Тест воспроизводит конфликт, SQL update ... where version=? подтверждает версионность

Смысл таблицы в том, что она заставляет вас мыслить не «какой инструмент я люблю», а «какое утверждение я делаю и чем его подтверждаю». Это и есть evidence-based review.

Когда hot path, red flags и evidence лежат рядом, review перестаёт быть теорией и собирается в один проход: сервис → запрос → SQL → finding → priority.

5. Типичные ошибки при сборе доказательств

В доказательной базе есть пара ловушек, которые особенно любят новички. Они выглядят логично «на словах», но приводят к тому, что audit превращается в либо шум, либо ритуал. Давайте проговорим их так, чтобы вы узнавали их по запаху, как программист узнаёт утечку памяти: «вроде всё работает… но вентилятор орёт как реактивный двигатель».

Ошибка №1: включать SQL trace навсегда и перестать его читать.
Если SQL лог всегда включён, вы очень быстро перестаёте его замечать. Это как жить рядом с железной дорогой: первые два дня вы просыпаетесь от каждого поезда, на третий — мозг сдаётся. В итоге у вас «включена наблюдаемость», но она не работает. Правильнее держать подробный SQL в отдельном профиле и включать его осознанно под сценарий.

Ошибка №2: мерить Statistics без изоляции сценария.
Очень частая ситуация: вы посмотрели prepareStatementCount, увидели число 37 и сделали вывод «ужас, N+1». А потом выясняется, что эти 37 — от трёх разных тестов, потому что статистика не была очищена. stats.clear() перед сценарием — это не «лишняя строка», это граница эксперимента. Без неё числа превращаются в гадание.

Ошибка №3: считать, что «1 SQL» автоматически означает «всё хорошо».
Один запрос может быть прекрасен, а может быть монстром с 12 join’ами, дубликатами строк и гигантской шириной результата. Statistics скажет «1», но performance всё равно будет плохой. Поэтому «1 SQL» — это только ответ на вопрос «нет ли N+1», а не знак качества всей выборки. Для качества всё равно нужен взгляд на форму SQL и понимание use case.

Ошибка №4: писать ORM-тесты как «проверку Hibernate», а не как проверку нашего контракта.
Тест «Hibernate делает flush при коммите» бессмысленен: он либо всегда зелёный, либо ломается при апгрейде версии. Тест «наш метод findDetailedById не должен делать больше 1 prepared statement» — полезен, потому что это ваш контракт. ORM-regression tests должны защищать именно ваши ожидания от persistence-слоя, а не универсальные правила ORM.

Ошибка №5: превращать артефакты в религию: «SQL святой, tests всё решат».
SQL trace и тесты — это не цель, а инструменты. Если вы начинаете «доказывать» каждую мелочь тремя разными способами, вы потратите больше времени на измерение, чем на исправление. Держите баланс: сначала сигнал (Statistics), потом объяснение (SQL), потом закрепление (тест) — ровно там, где есть риск регресса и реальная ценность.

1
Задача
Hibernate deep-dive, 30 уровень, 3 лекция
Недоступна
Regression test на количество SQL-запросов
Regression test на количество SQL-запросов
1
Задача
Hibernate deep-dive, 30 уровень, 3 лекция
Недоступна
Regression test на stale persistence context после bulk update
Regression test на stale persistence context после bulk update
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ