JavaRush /Курси /Hibernate deep-dive /Осі фінального persistence-аудиту

Осі фінального persistence-аудиту

Hibernate deep-dive
Рівень 30 , Лекція 0
Відкрита

1. Persistence audit: рамка перевірки

Фінальний persistence-аудит — це не «взяти лупу й знайти, де забули fetch=LAZY». Це радше техогляд автомобіля: ми не сперечаємося, чи подобається нам колір кузова (код-стайл), а перевіряємо, куди насправді йде бензин (SQL), чи тримають гальма (конкурентність) і чи не відвалюється колесо на повороті (коректність). Починаємо завжди з use case.

До цього місця у вас уже окремо розкладені flush, lazy loading, merge, locking, bulk і fetching. Фінальний аудит потрібен саме для того, щоб перестати тримати їх як розрізнені теми: спочатку вибираємо hot path і вісь ризику, потім шукаємо red flags, далі перевіряємо підозри за допомогою SQL і тестів, і лише після цього вирішуємо, що виправляти першим.

Ключовий зсув мислення тут такий: ми перевіряємо поведінку системи в конкретному сценарії, а не «якість сутностей у вакуумі». Навіть ідеальний @Entity може стати джерелом проблем, якщо його використовують як універсальну read-модель, тягнуть через шари й випадково обходять граф у стрімах, логах або серіалізації. І навпаки: іноді «не найкрасивіший» шматок репозиторію може бути цілком нормальним, якщо він чітко обслуговує один hot path і підкріплений тестом.

Щоб аудит не перетворювався на хаотичне «пройшовся очима по репозиторіях», ми вводимо осі аудиту — окремі виміри перевірки. У нашому курсі їх чотири, і вони спеціально збігаються з тим, що ми вивчали весь місяць:

Вісь аудиту Що ми перевіряємо по суті На що дивимося в коді/SQL Приклад із Commerce Persistence Lab
Correctness Система змінює й читає дані передбачувано і правильно state transitions, час flush, merge, побічні ефекти bulk, видимість soft delete «перейменувати товар», «bulk-видалення» та подальше читання в тій самій транзакції
Performance Система робить розумну кількість SQL і не тягне зайві дані query count, N+1, ширина SELECT, форма join, batching/bulk vs loop список замовлень/товарів, детальна картка замовлення, імпорт/масове оновлення
Concurrency За паралельного запису дані не губляться й не ламаються @Version, lock modes, межі транзакції, довжина критичної секції резервування залишків InventoryItem
Mapping quality Модель зв’язків і мапінг не створюють «мін» для runtime owning side, cascade, orphan removal, рівність, стратегія id, обмеження/індекси PurchaseOrderOrderItem, link entity ProductCategoryAssignment

Ще одне важливе слово, яке сьогодні постійно спливатиме, — hot path. Це найважливіший, найчастіший або найдорожчий сценарій, з якого варто починати. Якщо намагатися «аудитувати все одразу», ви закінчите в стані «я перевірив 200 методів і так і не зрозумів, що було важливим». Аудит — це завжди про пріоритети й докази, а не про героїзм.

2. Correctness: передбачувані зміни даних

Correctness — найменш «романтична» вісь: вона не про швидкість і не про красу. Вона про те, що дані в базі відповідають вашим очікуванням, а очікування відповідають бізнес-правилам. Тут новачки часто спотикаються, тому що ORM уміє робити багато чого «сам», і здається, ніби все під контролем… доки не виявляється, що bulk update залишив у persistence context застарілі об’єкти.

Найпростіше запитання correctness-аудиту звучить буденно, але саме воно рятує проєкти: “що вважається коректним результатом цього use case?”. Наприклад, «перейменувати товар» означає, що наприкінці транзакції справді пішов UPDATE product set name = ... where id = ..., і не пішло нічого зайвого. «Видалити товар» у soft delete-моделі означає не DELETE, а зміну прапорця або стану й коректне приховування з читань. «Оновити замовлення» означає, що ми не втратили позиції, не створили дублікатів і не отримали несподіване каскадне DELETE через незграбний orphanRemoval.

Подивімося на найпростішу “correctness-частину”, яку ви вже вмієте пояснювати: керована сутність змінюється без save().

import org.springframework.transaction.annotation.Transactional;

@Transactional // Важливо: без транзакції зміни можуть не дожити до flush/commit так, як ви очікуєте
public void renameProduct(Long id, String newName) {
    // Завантажуємо керовану сутність: далі зміни відстежуватиме persistence context
    Product product = productRepository.findById(id).orElseThrow();

    // Змінюємо стан у памʼяті: SQL UPDATE з'явиться пізніше (на flush/commit)
    product.setName(newName);
}

З погляду correctness-аудиту ми тут не перевіряємо, чи «подобається нам метод». Ми перевіряємо, що це справді managed update flow, що транзакція є, і що UPDATE з’явиться рівно один раз і в потрібний момент. Якщо поруч хтось додав saveAndFlush() “про всяк випадок”, audit зобов’язаний поставити запитання: навіщо вам примусовий flush? Він потрібен для коректності — наприклад, щоб дані точно потрапили в БД до наступного запиту в цій самій транзакції, — чи це лише звичка, яка додає шуму?

У correctness дуже швидко спливає друга «класика жанру» — bulk-операції та stale persistence context. Bulk майже завжди робиться заради продуктивності, але correctness-аудит зобов’язаний запитати: а що відбувається з уже завантаженими сутностями?

import org.springframework.transaction.annotation.Transactional;

@Transactional
public ProductStatus staleAfterBulk(Long id) {
    // 1) Завантажуємо сутність у persistence context — тепер це керований об'єкт
    Product product = entityManager.find(Product.class, id);

    // 2) Робимо bulk UPDATE: він іде напряму в БД і НЕ синхронізує вже завантажені керовані сутності
    productRepository.markDeleted(id);

    // 3) Читаємо поле з керованого об'єкта: отримаємо старе значення (stale state)
    //    Зазвичай виправлення такі: clear()/refresh(), окрема транзакція, повторне читання тощо
    return product.getStatus();
}

Це приклад, де correctness і продуктивність буквально тримаються за руки й одночасно намагаються впустити вас лицем у клавіатуру. Bulk оновив рядок у БД, але керований об’єкт product всередині persistence context залишився в старому стані. Якщо після цього код продовжить ухвалювати рішення на основі product, ви отримаєте логічно некоректну поведінку. Виправлення ми вже обговорювали в модулі про bulk: clear(), refresh(), винесення bulk в окрему транзакцію, повторне читання. В аудиті важливо не «вгадати правильні ліки», а впіймати сам ризик і зафіксувати його як finding.

Correctness-аудит також постійно ставить запитання до merge(). Якщо ви бачите, що сервіс приймає DTO, будує detached entity і робить entityManager.merge(dtoAsEntity), потрібно зупинитися й запитати: чи справді це безпечно для вашого графа? Чи не створить це зайві SELECT, чи не притягне несподівані child-об’єкти, чи не видалить щось через orphan removal? У курсі ми не раз доходили до того, що для більшості бізнес-сценаріїв передбачуваніший стиль — find + mutate. В аудиті це перетворюється не на догму, а на запитання: чому тут обрано merge-flow і як доведено його коректність?

І, нарешті, correctness дуже любить вилізати там, де його не чекають: на межі транзакції та lazy loading. Навіть якщо «все працювало» на локальній машині, audit повинен тримати в голові baseline курсу: open-in-view=false. Якщо шар даних віддає назовні entity, а зовнішній шар починає лазити по lazy-зв’язках після транзакції, це correctness-баг, замаскований під «просто виняток».

3. Performance: рахуємо SQL і бачимо ціну

Performance-аудит починається не з «у нас гальмує», а з простої інженерної звички: порахувати, скільки й яких запитів іде на один use case. ORM-проєкти часто гинуть не від «складного SQL», а від «занадто великої кількості простого SQL» — тисяч маленьких SELECT, які окремо невинні, але разом перетворюються на мікрохвильовку для вашого CPU.

Найприємніше в performance-аудиті те, що він легко стає evidence-based: ви ввімкнули sql-trace/stats, прогнали сценарій — і все видно. Не треба гадати по чакрах, достатньо подивитися на лог. Але важливо пам’ятати дисципліну: ми не оптимізуємо «загалом застосунок», ми оптимізуємо конкретний сценарій. Список товарів в адмінці, деталка замовлення, імпорт snapshot-даних — це різні історії й різна ціна.

Класичний red flag для performance — «спочатку отримаємо всі сутності, а потім у Java щось із них витягнемо». Виглядає невинно, але часто запускає N+1 (або хоча б overfetching).

import java.util.List;

public List<String> findOrderEmails() {
    // Потенційна проблема: findAll() завантажує замовлення, а далі ми "гуляємо" по зв'язках
    // Якщо customer LAZY (а зазвичай саме так і є), то на кожній ітерації може статися окремий SELECT
    return orderRepository.findAll().stream()
            .map(order -> order.getCustomer().getEmail()) // потенційний N+1 на customer
            .toList();
}

У performance-аудиті ви не зобов’язані просто зараз переписувати метод. Ви зобов’язані зафіксувати симптом і доказ: скільки запитів пішло, яка форма SQL, де вторинні селекти. І вже потім обираєте рішення за матрицею: projection, EntityGraph, JOIN FETCH, batch fetching або інший підхід. Тут нам важливо побачити сам тип проблеми: «прихована навігація по графу в стрімі» — це майже завжди запрошення на вечірку N+1, а Hibernate на таких вечірках приносить не піцу, а рахунки за інфраструктуру.

Performance-аудит дивиться не лише на читання, а й на записи. Там є окремі улюбленці:

Якщо ви бачите «масову операцію» як цикл по сутностях із save() у кожній ітерації, аудит ставить запитання: чи ввімкнено batching, чи відповідає id strategy, чи є flush()/clear() у циклі, чи не роздувається persistence context до розмірів невеликої держави. І якщо ви бачите масову зміну даних, наприклад «переоцінити каталог», audit запитує: а точно це потрібно робити entity-loop’ом, а не bulk update? І якщо це bulk update — повертайтеся до correctness і перевіряйте stale state. Це нормальне коло, так і має бути.

Ще один важливий маркер performance — ширина результату. Іноді запитів «усього один», але він тягне 25 колонок і п’ять join’ів для списку, якому потрібні три поля. Такі речі ідеально маскуються під фразу «зате без N+1». У голові аудиту має жити проста думка: один запит може бути гіршим за десять, якщо він роздутий і матеріалізує гігантський граф.

4. Concurrency: паралельний запис

Concurrency-аудит майже завжди приходить у проєкт «занадто пізно»: коли дані вже втрачено, а бізнес уже запитав: «чому в нас на складі мінус три айфони». Добра новина — в нашому навчальному проєкті є чесний contested write-path: InventoryItem і резервування залишків. Це ідеальний об’єкт для тренування: паралельні оновлення тут не абстракція, а нормальна ситуація.

Перше запитання concurrency-аудиту звучить грубо, але чесно: чи є в нас захист від lost update? Якщо ні, то «коректність» системи залежить від того, як сильно користувачі збігаються в часі. Спойлер: у проді вони збігаються.

Базовий інструмент — optimistic locking через @Version. І в аудиті це виглядає максимально просто: у contested сутності є version або ні.

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;

@Entity
public class InventoryItem {
    @Id
    private Long id;

    @Version
    private long version; // Версія потрібна, щоб упіймати lost update за конкурентних змін

    private int availableQty; // Доменне поле: доступний залишок, навколо нього зазвичай і відбувається contested write
}

Далі audit перевіряє, що операції запису справді живуть у межах розумної транзакції — короткої й зрозумілої, — і що конфлікт обробляється як очікувана подія, а не як «ой, 500». Ми не йдемо тут у дизайн API, але на рівні шару даних важливо, щоб сервіс міг або повторити спробу, якщо це допустимо, або чесно підняти конфлікт вище.

Якщо optimistic locking не підходить — через високу конкуренцію або дорогу повторюваність, — вступає pessimistic locking. І тут audit перестає бути «про анотацію» і стає «про архітектуру»: наскільки коротка критична секція, чи є timeout, чи не тримаємо ми lock через довгу бізнес-логіку. У цьому курсі ми вже зафіксували правило: не можна тримати DB lock через користувацьку взаємодію та довгі кроки. Concurrency-аудит просто перевіряє, що команда справді живе за цим правилом, а не лише киває на лекціях.

Окрема тонкість: concurrency-проблеми майже ніколи не видно в звичайних unit tests, і навіть інтеграційні перевірки тут мають відтворювати конфлікт детерміновано. Інакше ви не перевіряєте захист від lost update, а граєте в лотерею.

5. Mapping quality: якість мапінгу

Mapping quality зазвичай недооцінюють, бо мапінг виглядає «статичним»: один раз написав анотації — і забув. Але в Hibernate світ жорстокий: те, що ви задали в мапінгу, визначає форму SQL, каскади, flush-order, поведінку колекцій і навіть те, наскільки безпечно логувати сутності. Тому audit зобов’язаний перевіряти не лише «запити», а й «ґрунт, на якому ростуть запити».

Найпрактичніший підхід у mapping-аудиті — запитувати: які правила життєвого циклу виражає цей мапінг? Якщо у вас двонапрямний зв’язок, коректність залежить від синхронізації обох сторін. Якщо у вас cascade — він має виражати ownership, а не «про всяк випадок ALL». Якщо у вас orphanRemoval — ви повинні бути впевнені, що розрив зв’язку справді означає видалення.

Мініприклад, який здається «занадто простим», але регулярно ламає проєкти: helper-метод на агрегаті замовлення.

public void addItem(OrderItem item) {
    // Додаємо в колекцію на боці агрегата (зазвичай inverse side)
    items.add(item);

    // Обовʼязково синхронізуємо owning side, інакше FK/flush-order можуть поводитися "дивно"
    item.setOrder(this);
}

В audit-логіці ми не просто радіємо, що метод існує. Ми перевіряємо, що код справді ним користується, що немає прямого order.getItems().add(item) у сервісах, і що flush-order та FK не призводять до несподіваних UPDATE/NULL на зовнішньому ключі. Мапінг — це не декорація, це контракт.

Mapping quality включає і більш «тонкі» речі. Equality/hashCode() із proxy впливає на Set та orphan removal; toString() може тригерити lazy loading; id strategy впливає на batching; constraints і індекси визначають, наскільки ваша схема захищає інваріанти (sku унікальний, orderNumber унікальний, пара product+category унікальна). Усе це — частина mapping-аудиту, навіть якщо це виглядає як «DBA-тема». У нашому курсі ми свідомо робили це частиною моделі, а не чимось, що «додамо потім».

6. Зв’язок осей і пріоритизація

Одна проблема — кілька осей: не плутаємо симптоми

Доросла частина аудиту починається там, де ви перестаєте розкладати проблеми по красивих коробочках. У реальності одна й та сама помилка часто б’є одразу по кількох осях. І якщо ви лікуєте її лише «в одному місці», вона повертається в іншому вигляді, як бумеранг… тільки бумеранг ще й образливий.

Наприклад, поганий fetch-plan зазвичай проявляється як біль performance (N+1, зайві secondary selects), але дуже легко перетворюється на correctness-біль на межі транзакції: «усередині сервісу не догрузили, зовні спробували прочитати, упали в LazyInitializationException». Так само bulk update починається як performance-рішення («зробимо один UPDATE замість тисячі»), але породжує correctness-ризик stale state всередині persistence context. А відсутність @Version виглядає як concurrency-проблема, але результатом стають некоректні дані — тобто correctness.

Щоб не плутатися, корисно тримати коротку «зв’язку» в голові:

Ситуація З чим її часто плутають Які осі реально задіяні
N+1 у списку «треба поставити EAGER» performance + (часто) mapping quality, іноді correctness (lazy boundary)
bulk update/delete «просто швидкий SQL» performance + correctness (stale state)
немає @Version «ну ми ж рідко пишемо паралельно» concurrency + correctness
неправильний owning side «щось Hibernate дивно оновлює» mapping quality + correctness (і іноді performance через зайві UPDATE)
saveAndFlush() «усюди» «так надійніше» correctness (flush timing) + performance (зайвий flush і SQL)

Сенс таблиці не в тому, щоб завчити її як молитву. Сенс у тому, щоб при будь-якому finding ставити собі два запитання: «яка вісь була видна за симптомом?» і «які осі зачеплені насправді?». Це різко підвищує якість рішень і зменшує шанс «полагодити одне — зламати інше».

Старт перевірки: hot path і пріоритети

Найбільша методична пастка фінального дня — спроба бути героєм і перевірити все підряд. Це закінчується тим, що ви знаходите 30 зауважень, втомлюєтеся, а найнебезпечніше місце, наприклад конкурентний запис залишків, лишається без уваги. Тому audit починається не з обходу коду, а з вибору hot path — того сценарію, де помилка найдорожча.

Hot path — це не обов’язково найкрасивіший сценарій. Це той, який або часто виконується, або критичний за даними, або дуже дорогий для інфраструктури. У нашому Commerce Persistence Lab типові кандидати звучать буденно: список товарів в адмінці (часте читання), деталка замовлення з позиціями (складне читання), резервування залишків (конкурентний запис), bulk repricing (масовий запис), читання історії змін (аудит як окремий шлях).

Далі ми шукаємо red flags — ознаки потенційних проблем ще до точного вимірювання. Вони не доводять помилку, але кажуть: «сюди треба подивитися SQL». Приклади red flags ви вже бачили сотні разів, просто раніше не називали їх так: findAll() і потім обхід графа; EAGER за замовчуванням; saveAndFlush() у сервісі без пояснення; bulk query і далі робота з уже завантаженими entities; відсутність @Version на contested сутності; «прямі» зміни колекцій без helper-методів.

Пріоритизація findings у хорошій команді зазвичай починається з простого правила: correctness і concurrency на hot path — понад усе. Потім ідуть дорогі performance-помилки на частих read-path. І лише потім — «косметика» (хоча косметика теж буває корисною, просто нею не можна підміняти безпеку даних). Red flags самі по собі ще нічого не доводять. Щоб не лікувати їх за звичкою, поруч потрібна зрозуміла матриця рішень: коли доречна projection, коли entity + fetch-plan, коли bulk, а коли native SQL. Аудит — це порядок, а не обсяг.

7. ReviewFinding: результат аудиту

Якщо ви не фіксуєте результати аудиту у повторюваному форматі, він перетворюється на розмову «мені здається, тут щось не так». А «здається» — це чудове слово для поезії, але дуже небезпечне слово для шару даних. Тому фінальний рівень вводить дисципліну: кожен висновок оформлюється як finding, де окремо записані симптом, причина, доказ, рішення й пріоритет.

Формат може бути хоч у Confluence, хоч у Markdown, хоч у Jira — але мислити зручніше через одну мінімальну структуру. У Java це чудово виражається record’ом (і так, це той рідкісний момент, коли record допомагає не тільки скоротити код, а й змушує мозок думати структурно).

public record ReviewFinding(
        String area,      // Де проблема: наприклад, catalog-read / inventory-write
        String symptom,   // Що спостерігаємо: "N+1 у списку товарів"
        String cause,     // Чому це відбувається: "lazy-навігація + entity-loading"
        String evidence,  // Чим доводимо: SQL trace, stats, конкретні запити
        String decision,  // Що робимо: projection / EntityGraph / clear() після bulk тощо
        String priority   // Наскільки терміново: HIGH/MEDIUM/LOW (або ваша шкала)
) {}

Зверніть увагу: тут немає поля «хто винен». Hibernate не ображається, Spring Data теж. Ми фіксуємо інженерну картину. area може бути catalog-read або inventory-write; symptom — «N+1 у списку товарів»; cause — «entity-loading для таблиці + lazy-навігація в mapper’і»; evidence — «SQL trace: 1 + N запитів»; decision — «перевести на projection»; priority — «HIGH».

Навіть один finding уже допомагає команді. А набір findings перетворює audit на керований план поліпшень. Важливо тільки не втрачати останню ланку: finding без evidence — це поки що гіпотеза. SQL trace, statistics і regression test перетворюють його на інженерний факт.

8. Типові помилки під час фінального persistence-аудиту

На фінальному дні легко «зісковзнути» в старі звички: знову сперечатися про анотації, знову оцінювати код за стилем, знову лікувати симптоми універсальними рецептами. Це нормально: мозок любить прості відповіді. Але саме тому корисно заздалегідь знати типові помилки й ловити себе за руку, поки ви не поставили EAGER «щоб не було lazy» і не оголосили це перемогою над Hibernate.

Помилка №1: починати аудит із entity-класів, а не з use case.
Якщо ви починаєте з перегляду анотацій, ви майже гарантовано пропустите реальну проблему, бо проблеми живуть у зв’язці «сервіс → запит → транзакція → SQL». Одна й та сама сутність може бути нормальною в одному сценарії й жахливою в іншому. Починайте з конкретного бізнес-потоку й визначайте, що для нього вважається коректним результатом.

Помилка №2: плутати симптом і причину.
«У нас багато запитів» — симптом. «У нас N+1 через приховану навігацію по графу в стрімі або mapper’і» — причина. «Ми використовуємо bulk update і не очищаємо persistence context» — причина. Якщо ви одразу записуєте «рішення: додамо JOIN FETCH», не зафіксувавши причину, ви ризикуєте зробити гарний патч, який не лікує хворобу або лікує її ціною іншої проблеми.

Помилка №3: вважати всі проблеми однаково важливими.
У реальних проєктах часу на виправлення завжди менше, ніж проблем. Якщо ви не відділяєте «ризики для даних» від «дрібних незручностей», audit перетворюється на довгий список зауважень, який ніхто не впровадить. Звикайте: correctness і concurrency на hot path майже завжди пріоритетніші за локальну оптимізацію рідкого звіту.

Помилка №4: проводити аудит «за відчуттям», без вимірювань.
Фраза «мені здається, тут N+1» має автоматично викликати наступний крок: увімкнути SQL trace/statistics, зафіксувати baseline і подивитися факти. Поведінка ORM спостережувана. Якщо ви не дивитеся на SQL, ви сперечаєтеся з власною уявою, а вона, як правило, дуже впевнена й дуже помиляється.

Помилка №5: починати виправлення з тотального переписування.
Бажання «переписати все правильно» — зрозуміле, але небезпечне. Хороший аудит майже завжди приводить до точкових змін: замінити entity-loading на projection у списку, додати @Version, прибрати зайвий saveAndFlush(), зробити явний fetch-plan на деталці, додати clear() після bulk. Велике переписування без evidence — це часто просто дорогий спосіб отримати нові баги.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ