JavaRush /Курси /Hibernate deep-dive /Повторний замір результатів аудиту

Повторний замір результатів аудиту

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

1. Пастка «здається швидше» і повторний замір

Якщо ви хоч раз «оптимізували» запит і потім урочисто казали: «Ну, здається, стало швидше», то ви вже майже в клубі дорослих інженерів. Проблема в тому, що «здається» — це не метрика. Це настрій. А настрій, як відомо, у понеділок не такий, як у пʼятницю (і з SQL іноді та сама історія). Повторний замір — це фінальний крок циклу аудиту, який перетворює вашу гіпотезу на доказ: ви показуєте не емоції, а числа і структуру роботи ORM.

Весь аудит продуктивності в межах курсу можна тримати в голові як маленький цикл:

flowchart TD
    A[Початковий замір] --> B[Одна зміна]
    B --> C[Повторний замір]
    C --> D{Метрики стали кращими?}
    D -->|Так| E[Формулюємо результат: симптом → причина → зміна → підсумок]
    D -->|Ні| F[Гіпотеза слабка: повертаємося до аналізу]
    F --> A

Тобто зараз ми не відкриваємо новий набір інструментів. Ми просто доводимо до кінця той самий цикл аудиту: той самий сценарій, той самий набір метрик і одна зміна між двома прогонами.

Зверніть увагу: «повторний замір» — не окрема бюрократія заради бюрократії. Це момент, коли ви перевіряєте, що поліпшилася причина, а не просто «випадково пощастило»: JVM прогрілася, PostgreSQL потрапив у кеш, сусід перестав дивитися 4K-відео в тій самій Wi‑Fi-мережі.

2. Чесний повторний замір: сценарій і умови

Найчастіша проблема повторних замірів — ми порівнюємо не одне й те саме. У голові здається, що «я ж викликав той самий метод», а фактично змінилися набір даних, параметри фільтра, розмір сторінки, профілі Spring Boot або ви просто вдруге дивилися не на той фрагмент логу. Тому перед повторним заміром важливо буквально змусити себе бути занудою: занудство тут виконує роль ременя безпеки.

Перше, що потрібно тримати стабільним, — сам сценарій використання. Наприклад, «список останніх замовлень зі статусом NEW за тиждень, сортування за createdAt desc, показуємо orderNumber, email клієнта і totalAmount». Не «список замовлень узагалі», не «ну там десь список», а конкретна операція. Друге — вхідні параметри. Якщо baseline ви знімали на status=NEW, а після оптимізації раптом перевірили status=CANCELLED, то ви фактично змінили розподіл даних, і план може стати іншим, а метрики — «кращими» лише тому, що даних менше.

Третє — набір даних. Якщо baseline ви робили на маленькому seed-наборі, а повторний замір — на big-dataset, то порівняння перетворюється на порівняння яблук і вантажівок. І навпаки теж погано: оптимізацію, зроблену для великого набору, на малому ви не побачите. Тому в межах Commerce Persistence Lab ми робимо так: обираємо один профіль даних — малий або великий — і тримаємо його фіксованим у межах порівняння.

Нарешті, є ще «прихований гравець» — прогрів. Час першого запуску сценарію часто гірший, тому що JVM ще не розігналася, а PostgreSQL ще не встиг підтягнути сторінки в буферний кеш. У межах нашого курсу ми не влаштовуємо змагання з мікробенчмарками, але базову чесність дотримуємо: або робимо прогін-розминку, або насамперед дивимося на структурні метрики (query/statement/entity/collection/flush), які набагато стабільніші за час.

Невеликий (і дуже життєвий) приклад «прогріву» на одному й тому самому сценарії:

import org.hibernate.stat.Statistics;

// Перший прогін: прогріваємо JVM/кеші БД тим самим заміром, яким потім будемо вимірювати.
AuditSnapshot warmup = run(stats, orderQueryService::findRecentNewOrders);

// Другий прогін: вважаємо його вимірюванням (умовно більш «чесним» для порівняння).
AuditSnapshot measured = run(stats, orderQueryService::findRecentNewOrders);

System.out.println("Прогрів  = " + warmup);   // Прогрів  = AuditSnapshot[...]
System.out.println("Виміряно = " + measured); // Виміряно = AuditSnapshot[...]

Сенс не в тому, щоб робити десять прогонів і будувати графіки (ми не на олімпіаді). Сенс у тому, щоб не приймати «перший прогін після старту застосунку» за абсолютну правду.

3. Що порівнюємо: час і метрики ORM

У performance-аудиті у новачків часто трапляється роман із мілісекундами: «було 240ms, стало 48ms, я геній». Інколи так, ви геній. Але інколи ви просто вимкнули докладний SQL-лог і «прискорили» виведення тексту в консоль. Тому час у нашому курсі — це допоміжна метрика. Головні — ті, що описують структуру роботи Hibernate: скільки запитів, скільки підготовлених statementʼів, скільки entity завантажили, скільки колекцій догрузили, скільки разів виконали flush.

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

Метрика Що показує в контексті Hibernate На яке запитання допомагає відповісти
elapsedMs Грубий час виконання сценарію «Чи став сценарій помітно швидшим за тих самих умов?»
queryExecutionCount Скільки SQL‑запитів реально виконали «Це балакучий сценарій чи один-два великі запити?»
prepareStatementCount Скільки JDBC prepared statements знадобилося «Скільки разів ми реально готували SQL на рівні JDBC?»
entityLoadCount Скільки entity завантажили «Чи не тягнемо ми керований граф, який не потрібен?»
collectionFetchCount Скільки колекцій догрузили окремо «Чи є secondary selects на to-many, чи не зловили ми N+1 на колекціях?»
flushCount Скільки разів був flush «Сценарій точно лише для читання? Чи він викликає побічні зміни / flush перед запитом?»

Наївна (і дуже популярна) помилка — радіти падінню queryExecutionCount, не подивившись на entityLoadCount. Так, можна зробити «один запит», який через JOIN FETCH завантажить половину всесвіту, і query count стане красивим, а система — ні. У попередніх лекціях ми вже обговорювали, що один широкий запит може бути гіршим за серію маленьких, якщо він роздуває result set і тягне зайве.

Ще одна тонкість — flushCount. В ідеальному read-case він зазвичай лишається нульовим. Якщо після вашого «рефакторингу читання» flush раптом зʼявляється, це знак, що в сценарії читання ви почали змінювати managed-сутності (або випадково запускаєте dirty checking і flush перед запитом). Це не «ну буває», а прямий привід зупинитися і запитати: «А ми точно оптимізуємо читання, а не випадково робимо запис?»

4. Фіксація результату: той самий AuditSnapshot і той самий run(...)

Коли ви робите повторний замір, інфраструктура вже не повинна розростатися. Нам потрібен той самий AuditSnapshot і той самий run(stats, scenario), який очищає Statistics, виконує сценарій і знімає значення за тими самими полями. Інакше ви починаєте порівнювати не два прогони одного циклу аудиту, а два різні способи вимірювання.

Тут правило просте: baseline і after знімаються однією й тією самою утилітою, на одному й тому самому наборі полів — elapsedMs, queryCount, prepareStatementCount, entityLoadCount, collectionFetchCount, flushCount. Тоді before/after читається як чесне порівняння сценарію, а не як переклад між різними утилітами.

5. Інтерпретація порівняння метрик

Повторний замір — це не лише «подивилися цифри», а й правильна інтерпретація. В ідеалі поліпшення має проявлятися одночасно у двох площинах: сценарій став швидшим (час — як сигнал) і сценарій став дешевшим за структурою (query/statement/entity/collection/flush — як причина). Але іноді поліпшення в одній площині супроводжується погіршенням в іншій — і це теж важливий результат, просто не завжди приємний.

Уявімо, що ми оптимізували read-case «останні нові замовлення», який раніше робив lazy-доступ до items у циклі (класична причина багаторазових secondary selects). Ми зробили окремий query-метод із правильною формою читання. Тепер ми чесно знімаємо before/after snapshot.

// "До": повільна реалізація, яка спілкується з БД (наприклад, через lazy у циклі).
AuditSnapshot before = run(stats, orderQueryService::findRecentNewOrdersSlow);

// "Після": оптимізований read-case (наприклад, правильний query/projection/fetch plan).
AuditSnapshot after  = run(stats, orderQueryService::findRecentNewOrdersFast);

System.out.println("До     = " + before); // До     = AuditSnapshot[elapsedMs=240, queryCount=18, ...]
System.out.println("Після  = " + after);  // Після  = AuditSnapshot[elapsedMs=48, queryCount=2, ...]

Щоб мозку було легше, корисно один раз уявити порівняння в табличному вигляді (у реальному проєкті ви можете навіть друкувати це як таблицю, але зараз просто очима):

Показник Було (before) Стало (after) Інтерпретація
queryCount 18 2 Добрий знак: перестали «спілкуватися» з БД
collectionFetchCount 30 0 Дуже добрий знак: зникли догрузки колекцій
entityLoadCount 120 20 Часто добрий знак: перестали завантажувати зайвий граф
flushCount 0 0 Чудово: read-case залишився «чистим»
elapsedMs 240 48 Час підтримує історію, але не замінює її

І ось тут важливо: якщо у вас queryCount зменшився, але entityLoadCount виріс у рази, це привід замислитися, чи не «заплатили» ви за поліпшення надто широким selectʼом. Іноді це нормально (наприклад, ви справді хочете завантажити деталі для екрана), але в списковому сценарії це часто означає, що ви знову тягнете write-model як read-model, хоча треба було б читати через проєкцію.

Ще один корисний «червоний прапорець»: зменшили queryCount, але prepareStatementCount майже не змінився. Це не завжди проблема, але іноді означає, що ви насправді не зменшили SQL-навантаження — ви просто замінили один тип запитів іншим, а на JDBC-рівні все так само шумно. Тому ми й дивимося на метрики разом.

І окремо, як уже згадував вище, якщо після зміни flushCount раптом став 1 або 2 — це тривожний сигнал. Зазвичай це означає, що в сценарії читання ви десь змінюєте managed-сутність (навіть «просто trim() рядка»), і Hibernate чесно намагається зберегти це в базі. Для performance audit це важливо не менше, ніж швидкість: ви випадково зробили read-шлях write-шляхом, а це вже питання коректності поведінки, а не лише продуктивності.

6. Формулювання результату audit

На цьому кроці часто трапляється найприкріша річ: ви провели audit, усе зрозуміли, стало краще… а через місяць ніхто не памʼятає, чому воно стало краще і що саме ви змінили. Тому нам потрібне коротке формулювання результату. В ідеалі таке, щоб його можна було вставити в опис PR або хоча б у commit message, і через пів року не читати код як археолог давньої цивілізації.

Гарний шаблон — «симптом → причина → зміна → результат». Він дисциплінує: ви не перестрибуєте одразу до «ми додали індекс», ви спочатку фіксуєте проблему як спостережуваний факт.

Ось приклад на Java text block (і так, це рідкісний випадок, коли текстовий блок — не «краса», а спосіб не перетворити вивід на кашу):

// Формулювання результату має бути придатним для копіювання й вставлення в PR/commit message.
String conclusion = """
    Симптом: повторювані SELECT під час завантаження позицій замовлення.
    Причина: lazy-доступ до items всередині циклу по orders.
    Зміна: окремий read-case із правильною формою читання.
    Результат: queryCount 18 -> 2, collectionFetch 30 -> 0, flush 0 -> 0.
    """;

// Виводимо в лог/консоль, щоб результат був видимий просто під час прогону лабораторної.
System.out.println(conclusion);
/*
Симптом: повторювані SELECT під час завантаження позицій замовлення.
Причина: lazy-доступ до items всередині циклу по orders.
Зміна: окремий read-case із правильною формою читання.
Результат: queryCount 18 -> 2, collectionFetch 30 -> 0, flush 0 -> 0.
*/

Зверніть увагу, що ми спеціально включаємо в «Результат» не лише час. Можна додати elapsedMs, але краще ставитися до нього як до приємного бонусу. А ось структурні цифри — це те, що майже завжди переноситься між середовищами. Логи можуть бути увімкнені або вимкнені, залізо може бути різним, але «замість 18 запитів стало 2» — це факт, який не залежить від настрою вашої JVM.

Ще один момент: якщо після змін метрики не стали кращими, це теж результат. Просто формулювання буде іншим: «Гіпотеза: індекс за (status, created_at) прискорить сортування. Результат: план не змінився, queryCount і elapsedMs майже ті самі. Висновок: проблема не в індексі, а у формі запиту / ширині selectʼа». Звучить не так тріумфально, але це й є інженерна чесність.

7. Типові помилки під час повторного заміру

Після всього дня діагностики дуже хочеться вірити, що тепер ви ніколи не помилитеся. Але Hibernate і PostgreSQL — хлопці терплячі: вони дадуть вам шанс наступити на граблі ще раз, просто граблі будуть трохи дорожчими. Тому ось кілька типових помилок саме на етапі повторного заміру та оформлення результату.

Помилка №1: оголошувати перемогу лише за часом виконання.
Час — штука примхлива: він залежить від логування, прогріву JVM, стану диска і навіть від того, скільки вкладок браузера ви відкрили (іноді це звучить як жарт, але, на жаль, ні). Якщо ви не бачите поліпшення в структурі роботи ORM — у метриках query/statement/entity/collection/flush — ви не довели виправлення причини, а просто побачили вдаліший прогін.

Помилка №2: порівнювати заміри, зняті в різних умовах.
Часто це виглядає невинно: baseline знімали на 20 товарах, повторний замір — на big-dataset; baseline знімали зі статусом ACTIVE, повторний — зі статусом HIDDEN; baseline робили без сортування, а потім додали order by. У підсумку ви порівнюєте два різні сценарії. Це не «ну майже однаково», це різні задачі для БД і ORM, і висновок стає недостовірним.

Помилка №3: зменшити queryCount, але не подивитися на ширину запиту і кількість завантажених сутностей.
Інженерна пастка в тому, що красиві цифри запитів дуже приємно виглядають у звіті. Але один JOIN FETCH може перетворити видачу на величезний result set із дублями рядків, а entityLoadCount раптом злетить угору. Тоді ви «вилікували» N+1, але породили overfetching, і це вже інша проблема. Тому порівняння завжди робиться зв’язкою: запити, завантаження entity/collection і здоровий глузд щодо сценарію використання.

Помилка №4: забути очистити Statistics і отримати “суп із метрик”.
Statistics накопичує дані, і якщо ви не робите statistics.clear(), то у ваших цифрах опиняться запити з минулого сценарію, з warm-up, із зовсім іншого шматка застосунку. Потім починається магія: «чому в мене queryCount=57, якщо в логу я бачу два запити?». Відповідь зазвичай проста: ви не обнулили статистику перед запуском, тож до двох нових запитів у неї домішалися старі.

Помилка №5: не записати висновок «симптом → причина → зміна → результат».
Якщо ви не оформили результат, через пару тижнів ви памʼятаєте тільки «ми щось зробили із запитом, щоб стало швидше». Це не жарт: мозок справді так працює. Коротке формулювання, прив’язане до метрик, перетворює разову перемогу на повторювану навичку і захищає вас від ситуації «давайте відкотимо, бо ніхто не розуміє, навіщо це потрібно».

1
Опитування
SQL Діагностика, рівень 26, лекція 4
Недоступний
SQL Діагностика
Аналіз логів і статистики
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ