1. Роль Statistics поруч із SQL-логом
Якщо SQL-лог — це відеозапис із камер спостереження, то Hibernate Statistics — це лічильники на вході: «скільки людей зайшло», «скільки разів відчинялися двері», «скільки разів охоронець бігав за кавою». Відео дає деталі й контекст, але на ньому складно швидко порівняти два запуски одного сценарію. Лічильники не розкажуть історію повністю, зате дають компактну картину, яку можна повторити й порівняти без здогадів.
SQL-лог уже показав форму проблеми. Тепер хочеться зрозуміти масштаб: це один важкий запит, серія secondary selects чи просто відчуття, що «чогось забагато». Ось тут і потрібні лічильники.
Практично це виглядає так: ви запускаєте один і той самий read-case (наприклад, «сторінка каталогу активних товарів») у двох варіантах реалізації. SQL-лог каже вам, що було виконано, а Statistics відповідає, скільки разів і в якому масштабі. І саме це «скільки» дуже швидко ловить класичні проблеми: N+1, неочікувані підвантаження колекцій, матеріалізацію надто великого графа, а також випадки, коли в «читанні» раптом відбувається flush.
Дуже важливий момент: Statistics не замінюють SQL-лог. Вони — як стислий підсумок після прочитаного роману. Коли роман поганий, підсумок буде виглядати так: «сторінок багато, сенсу мало». Але коли ви робите аудит шару даних, підсумок усе одно потрібен — інакше ви щоразу знову переглядатимете «відео».
2. Увімкнення Statistics профілем
Hibernate Statistics не вмикаються «завжди» з однієї простої причини: збір метрик коштує ресурсів. Він додає трохи накладних витрат на кожен запит і на багато внутрішніх операцій ORM. У навчальному проєкті це нормально — ми якраз вчимося вимірювати. У production-сервісі тримати статистику постійно увімкненою зазвичай не хочеться: надто легко зробити систему трохи повільнішою заради красивих графіків.
У Commerce Persistence Lab ми працюємо з профілями. Тому типовий і правильний спосіб — вмикати статистику окремим профілем stats, щоб за замовчуванням застосунок був звичайним, а для діагностики перетворювався на лабораторію.
Мінімальне налаштування в application-stats.yml може виглядати так:
spring:
jpa:
properties:
hibernate:
# Увімкнути збір статистики Hibernate (інакше всі лічильники будуть нульовими)
generate_statistics: true
Сама по собі ця настройка вмикає збір статистики. Далі ви вже вирішуєте, як саме її читати: вручну через Statistics у коді (це наш основний шлях сьогодні) або ще й через логер, коли вам зручно бачити зведення в логах. Але логер — штука примхлива: ви отримаєте багато тексту, а нам зараз потрібен саме зріз із п’яти чисел для одного сценарію.
І так, це нормально, коли ви ввімкнули профіль stats, а застосунок став трохи «важчим» на кожен запит. Зараз це не баг і не «Hibernate знову гальмує», а свідома плата за мікроскоп: коли ви вмикаєте вимірювання, ви завжди платите невелику ціну самого вимірювання.
3. Доступ до Statistics у Spring Boot
До цього моменту ми багато говорили про Hibernate, але код у нас усе одно в Spring Boot, а на поверхні — JPA (EntityManager, EntityManagerFactory). Тому логічне питання: де в цьому багатошаровому пирозі захований об’єкт, який дає нам статистику?
Відповідь проста: Statistics живуть у Hibernate SessionFactory. У Spring Boot-застосунку з JPA ми зазвичай маємо EntityManagerFactory, і його можна «розгорнути» назад до рівня Hibernate через unwrap(...). Це один із небагатьох випадків, коли ми свідомо спускаємося під абстракцію JPA — тому що нам потрібна специфічна для Hibernate діагностика.
Найпряміший спосіб (у будь-якому місці, де є EntityManagerFactory) виглядає так:
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
// Спускаємося з JPA-абстракції в Hibernate, аби отримати доступ до статистики
Statistics statistics = entityManagerFactory
.unwrap(SessionFactory.class) // дістаємо Hibernate SessionFactory
.getStatistics(); // беремо об’єкт статистики (лічильники для всього SessionFactory)
Щоб не копіювати цей код по проєкту, зручніше один раз оформити його як Spring Bean і далі просто впроваджувати Statistics у потрібні сервіси. Це не обов’язкова архітектура, а навчальна інфраструктура, яка робить лабораторні коротшими й зрозумілішими.
Приклад конфігурації:
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class HibernateStatsConfig {
@Bean
Statistics hibernateStatistics(EntityManagerFactory emf) {
// Впроваджуємо Statistics як bean, аби не дублювати unwrap(...) у проєкті
return emf.unwrap(SessionFactory.class).getStatistics();
}
}
Тепер у будь-якому місці проєкту (наприклад, у com.example.commerce.labsupport) можна просто прийняти Statistics у конструктор. Так, це «глобальний» об’єкт на весь SessionFactory. І саме тому далі ми постійно підкреслюватимемо: вимірюємо один сценарій, очищаємо лічильники перед заміром і не робимо це в багатопотоковому режимі, де всі одночасно оптимізують усе.
4. Зняття snapshot без самообману
У Hibernate Statistics є підступна особливість: вони здаються простими, і тому їх дуже легко використовувати неправильно. Найчастіша помилка новачка — подивитися на лічильники «як є» й зробити висновки. Це приблизно як вимірювати швидкість машини, не скинувши одометр і не уточнивши, чи ви взагалі їхали, чи стояли на паркінгу з увімкненим двигуном.
Правильний ритуал (так, це той рідкісний випадок, коли ритуал корисний) виглядає так: ви обираєте один read-case або write-case, очищаєте лічильники, виконуєте сценарій, знімаєте значення, а потім порівнюєте їх з іншим варіантом реалізації. Коли ви не викликаєте clear(), у ваш замір потрапляє все: запуски застосунку, логи міграцій, випадкові фонові підвантаження й навіть те, що ви робили в сусідній вкладці IDE, поки «просто перевіряли».
Мінімальний «зріз» під один сценарій може виглядати так:
import org.hibernate.stat.Statistics;
// Важливо: скидаємо лічильники перед заміром конкретного сценарію
statistics.clear();
catalogQueryService.findActiveProducts(); // один конкретний use case
// Знімаємо значення одразу після виконання сценарію
System.out.println("queries=" + statistics.getQueryExecutionCount()); // queries=1
System.out.println("statements=" + statistics.getPrepareStatementCount()); // statements=1
Зверніть увагу на тонкий момент: ми не намагаємося вимірювати весь застосунок. Ми вимірюємо один use case. По-хорошому, у цей момент ви маєте чітко назвати сценарій людськими словами: «сторінка каталогу активних товарів», «детальне завантаження замовлення з позиціями», «таблиця замовлень зі статусом NEW». Коли ви не можете назвати сценарій, ваші цифри також буде складно інтерпретувати.
І ще одна важлива деталь: навіть read-case може раптово тригерити flush (ми це вже обговорювали в темі про flush і dirty checking). Тому сьогодні ми не просто дивимося на запити, а тримаємо в голові, чи не було запису там, де ми його не очікували.
5. П’ять ключових лічильників
Statistics уміють дуже багато, але саме через це новачки швидко тонуть у цифрах і починають або ігнорувати інструмент, або навпаки — перетворювати його на релігію («метрика сказала 7, значить погано»). Ми зробимо по-дорослому: виберемо мінімальний набір, який дає максимальну користь для діагностики persistence layer саме в межах курсу.
Сьогодні нас цікавлять п’ять лічильників: queryExecutionCount, prepareStatementCount, entityLoadCount, collectionFetchCount, flushCount. Вони добре накривають три головні питання діагностики: скільки query-based-активності пройшло через ORM, скільки SQL-роботи реально пішло на JDBC-рівень, скільки даних матеріалізувалося в керовані об’єкти, і чи не відбулося прихованого запису (flush) у сценарії, який мав би бути суто читаючим.
Для наочності — компактна таблиця, яку зручно тримати в голові:
| Метрика (Statistics) | Що в побутовому сенсі рахує | Які симптоми ловить найшвидше |
|---|---|---|
| getQueryExecutionCount() | Скільки запитів пройшло через query-механізм Hibernate; це не універсальний лічильник усієї SQL-активності | Швидкий сигнал chatty-поведінки та N+1; дивіться разом із SQL-логом, getPrepareStatementCount() і fetch-лічильниками |
| getPrepareStatementCount() | Скільки JDBC PreparedStatement було підготовлено (приблизно: скільки SQL-операцій реально пішло) | Реальний обсяг SQL-роботи, включно з неочікуваними UPDATE |
| getEntityLoadCount() | Скільки entity-екземплярів було завантажено | Overfetching і «витягнули забагато сутностей» |
| getCollectionFetchCount() | Скільки разів Hibernate підвантажував колекції (to-many) | Ліниві колекції, вторинні SELECT, N+1 на колекціях |
| getFlushCount() | Скільки разів відбувався flush | У сценарії читання є запис, flush-before-query, приховані зміни |
Далі розберемо кожну метрику окремо, але з важливою установкою: ми не читаємо їх по одній. Одна цифра рідко дає правильний діагноз. Зазвичай діагноз народжується з поєднання двох-трьох показників і спостереження в SQL-лозі.
queryExecutionCount: швидкий сигнал chatty-сценарію
Коли розробник уперше вмикає SQL-лог, він часто відчуває дивну суміш емоцій: ніби й корисно, але очі за хвилину вже хочуть закритися й піти у відпустку. queryExecutionCount — це спосіб швидко зафіксувати, чи не перетворюється один сценарій на chatty query-based поведінку. Але важливо не переоцінювати його: це не універсальний лічильник усієї SQL-активності Hibernate.
Уявімо простий сценарій каталогу: ми хочемо отримати список активних товарів. Ми робимо один JPQL-запит — і очікуємо один запит до БД. Тоді queryExecutionCount в ідеальному базовому варіанті дорівнюватиме 1 (або приблизно такому значенню, якщо сценарій включає ще якісь обов’язкові службові запити, але в лабораторії ми намагаємося прибрати шум).
Мініприклад заміру:
// Знімаємо метрику строго під один сценарій
statistics.clear();
catalogQueryService.findActiveProducts();
// Має бути близько до 1 у «здоровому» сценарії (один root query)
System.out.println(statistics.getQueryExecutionCount()); // 1
Коли ж ви повертаєте List<Product>, а потім у коді проходитеся по ньому й торкаєтеся product.getDetails() або product.getAssignments(), сценарій стає chatty. Це майже напевно буде видно в SQL-лозі, а кількісно часто краще ловиться зростанням prepareStatementCount, collectionFetchCount та інших fetch-specific лічильників. queryExecutionCount тут залишається корисним швидким сигналом, але одного цього числа вже замало.
prepareStatementCount: скільки SQL реально пішло
Після першого знайомства з queryExecutionCount новачку часто хочеться жити щасливо: «О, я все зрозумів, просто зменшуємо кількість запитів — і буде швидко». І ось тут prepareStatementCount акуратно повертає нас на землю. Бо запити, підготовка SQL-команд і реальна SQL-робота — це близькі, але не однакові речі.
prepareStatementCount ближче до реальності JDBC: скільки разів Hibernate довелося підготувати SQL-команди для виконання. Якщо ви, наприклад, у «читаючому» сценарії випадково змінили managed-сутність (у стилі setName(trim())), то Hibernate може відправити UPDATE під час flush, і prepareStatementCount зросте. При цьому queryExecutionCount може залишитися невеликим, бо UPDATE — це не «query» у JPQL-сенсі, але це реальна робота БД.
Мініприклад, де prepareStatementCount ловить приховану роботу:
// Скидаємо лічильники перед тим, як перевіряти «чистоту» read-case
statistics.clear();
catalogQueryService.findAndAccidentallyMutateProductName(productId);
// Порівнюємо «запити» й «реальні statements»: UPDATE може спливти тільки в другому
System.out.println("queries=" + statistics.getQueryExecutionCount()); // queries=1
System.out.println("statements=" + statistics.getPrepareStatementCount()); // statements=2
Сама цифра «2» тут не важлива як абсолютна. Важливо, що вона стала більшою, ніж ви очікували для чистого читання. Це сигнал: «подивіться SQL-лог — там явно є щось, крім SELECT-ів». І це саме те, що нам потрібно в аудиті: швидко помітити неочікуваний запис.
entityLoadCount: скільки сутностей завантажили
Є дуже популярна пастка, особливо після знайомства з JOIN FETCH. Розробник робить «один запит» і радіє: queryExecutionCount = 1. А потім раптом застосунок усе одно працює важко, і memory footprint здається великим. Чому? Бо «один запит» може принести дуже багато даних, які Hibernate перетворить на керовані entity-об’єкти.
entityLoadCount показує, скільки entity-екземплярів було завантажено. Це не «розмір result set у рядках» і не «кількість колонок». Це саме «скільки сутностей з’явилося в persistence context як керовані об’єкти». Для діагностики це вкрай зручно: ви можете побачити, що read-case тягне «цілий всесвіт сутностей», хоча за бізнес-сенсом вам потрібен був лише список із 20 рядків із чотирма колонками.
Мініприклад порівняння двох підходів на одному сценарії (концептуально):
// У цьому сценарії нас цікавить саме обсяг матеріалізованих сутностей
statistics.clear();
catalogQueryService.findCatalogPageWithEntities();
// Коли число велике — це прямий сигнал overfetching, навіть якщо запит формально один
System.out.println("entities=" + statistics.getEntityLoadCount()); // entities=240
Коли ви побачили, що на «просту сторінку каталогу» завантажилося 240 сутностей, це привід замислитися: ви точно хотіли матеріалізувати стільки об’єктів? Чи цей сценарій мав би бути projection-based і «тонким»? Ми не сперечаємося «entity vs projection» заново (це було раніше), але statistics допомагають побачити ціну рішення в цифрах.
collectionFetchCount: підвантаження колекцій
З lazy-loading у to-many зв’язків є хитрий ефект: ви можете чесно зробити один root query, отримати список замовлень, а потім десь «по дорозі» викликати order.getItems().size() або пройтися по items у циклі. Для Java-коду це виглядає безневинно, а для Hibernate — це сигнал «ініціалізуй колекцію», тобто виконай додаткові запити.
collectionFetchCount показує, скільки разів такі колекції були підвантажені. Для практики це просто золота метрика: коли ви читаєте список замовлень і бачите collectionFetchCount > 0, значить десь ви торкнулися to-many колекції ліниво, і це майже гарантовано веде або до N+1, або до серії secondary SELECT-запитів.
Уявімо сценарій списку замовлень:
// Перевіряємо, чи не «вистрілюють» ліниві to-many-колекції під час обходу результату
statistics.clear();
orderQueryService.findRecentOrdersAndTouchItems();
// Будь-яке значення > 0 — привід шукати вторинні SELECT-запити в SQL-лозі
System.out.println("collections=" + statistics.getCollectionFetchCount()); // collections=30
Число «30» в коментарі — просто ілюстрація: зазвичай воно приблизно дорівнює кількості замовлень у списку, бо кожне замовлення підвантажує свою колекцію окремо. І далі ви вже йдете в SQL-лог і бачите повторюваний select ... from order_item where order_id=? багато разів. Statistics тут працюють як «детектор диму»: вони не пояснюють пожежу в деталях, але кажуть «дим є, біжіть дивитися, звідки».
flushCount: тривожний сигнал для read-case
Flush — тема, яка вже встигла зіпсувати настрій багатьом людям, бо він уміє відбуватися «раніше, ніж ви очікували». Але в діагностиці flushCount — дуже вдячний індикатор. Особливо коли ми вимірюємо read-case.
Коли ви аналізуєте сценарій читання, то ваш ідеальний flushCount зазвичай дорівнює 0. Він може стати більшим за нуль, коли ви всередині транзакції щось змінили (навіть випадково), або коли Hibernate був змушений синхронізувати контекст, щоби забезпечити коректність запиту. У будь-якому разі це сигнал: «обережно, сценарій не суто читаючий».
Мініприклад:
// Read-case: за змістом тут не має бути синхронізації (flush) з базою
statistics.clear();
orderQueryService.findOrdersReadCase();
// Ідеально: 0. Коли > 0 — шукаємо accidental update / flush-before-query
System.out.println("flushes=" + statistics.getFlushCount()); // flushes=0
Коли тут раптом flushes=1, не потрібно панікувати й «лагодити Hibernate». Потрібно спокійно відкрити SQL-лог і зрозуміти, що стало причиною flush. Часто це банальний accidental update через «трохи поправили рядок», «поставили значення за замовчуванням», «торкнулися embeddable» або «спрацював listener». Ми сьогодні не заглиблюємося в callbacks, але сигнал flush у read-case — це завжди привід перевірити, чи не зламався у вас кордон між читанням і записом.
6. Комбінації метрик: діагностичні візерунки
Після розбору п’яти лічильників виникає логічне питання: «Гаразд, а що вважається “добре”, а що “погано”?». Відповідь не універсальна, але є прості візерунки, які в реальній розробці допомагають за хвилину зрозуміти, у який бік копати далі. Це схоже на медицину рівня «температура і тиск»: діагноз вони не ставлять, але дуже допомагають обрати наступний крок.
Ось кілька типових комбінацій у вигляді таблиці — саме як підказка, а не як закон природи:
| Що ви бачите в метриках | Що це часто означає | Що ви робите далі (у межах діагностики) |
|---|---|---|
| queryExecutionCount і/або prepareStatementCount помітно більше очікуваного, collectionFetchCount значущий | N+1 або вторинні SELECT-запити через to-many | Відкриваєте SQL-лог і шукаєте повторюваний запит за order_id / product_id |
| queryExecutionCount невеликий, але entityLoadCount величезний | Один запит тягне забагато сутностей (overfetching) | Дивитеся, чому read-case читає entity graph замість тонкого read-model |
| flushCount > 0 у read-case | У сценарії читання є запис / зміна або flush-before-query | Шукаєте accidental update, неочікувані UPDATE у SQL, побічні зміни |
| prepareStatementCount більше, ніж очікували за змістом | У сценарії є «зайвий SQL» (не лише SELECT), або додаткові службові операції | Співставляєте з SQL-логом: що за statements додалися |
Зверніть увагу: ця таблиця ще не штовхає нас автоматично до EXPLAIN. Коли лічильники показують chatty-поведінку ORM — багато запитів, колекційні підвантаження, зайві entity loads, — проблему спочатку лікують на рівні форми читання. До generated SQL, EXPLAIN і плану PostgreSQL має сенс переходити тоді, коли запитів уже небагато, а важкість сидить в одному конкретному SQL.
Важливо: ми спеціально не перетворюємо це на «чекліст оптимізації». Це саме «візерунки діагностики». Сенс дня — не в тому, щоби прямо зараз усе пришвидшити будь-якою ціною, а в тому, щоби вміти сказати: «проблема схожа на N+1» або «проблема схожа на overfetching», і далі вже цілеспрямовано відкрити SQL та підтвердити гіпотезу.
Щоб закріпити ідею «порівняння двох варіантів», можна подумки уявити такий експеримент: одна реалізація каталогу повертає entities, інша — projection. Ми не пишемо тут величезний код, але знімаємо два зрізи поспіль:
// Порівнюємо два варіанти реалізації «на чистих лічильниках»
statistics.clear();
catalogQueryService.findCatalogPageWithEntities();
long entitiesQueries = statistics.getQueryExecutionCount();
statistics.clear();
catalogQueryService.findCatalogPageWithProjection();
long projectionQueries = statistics.getQueryExecutionCount();
// Виводимо лише один показник, аби швидко побачити різницю у формі поведінки ORM
System.out.println("entitiesQueries=" + entitiesQueries); // entitiesQueries=18
System.out.println("projectionQueries=" + projectionQueries); // projectionQueries=2
Це не доказ продуктивності. Це доказ форми поведінки ORM: у першому варіанті запитів значно більше. Далі ви вже відкриваєте SQL-трейс і дивитеся, чому їх так багато (часто там і буде той самий повторюваний secondary select).
7. Мініінфраструктура для snapshot
Коли ви робите десятий замір за день, друкувати п’ять чисел окремо стає стомливо. Для цього місця достатньо маленького локального знімка лічильників. Не нового каркаса проєкту, а просто компактної упаковки одного заміру після конкретного сценарію.
Наприклад, просто поруч із експериментом можна тримати такий record:
// Локальний знімок лічильників для одного конкретного заміру
public record StatisticsSlice(
long queryCount,
long prepareStatementCount,
long entityLoadCount,
long collectionFetchCount,
long flushCount
) {
}
Цього вже достатньо, щоб після сценарію забрати значення й вивести їх одним рядком:
statistics.clear();
catalogQueryService.findActiveProducts();
StatisticsSlice slice = new StatisticsSlice(
statistics.getQueryExecutionCount(),
statistics.getPrepareStatementCount(),
statistics.getEntityLoadCount(),
statistics.getCollectionFetchCount(),
statistics.getFlushCount()
);
System.out.println(slice); // StatisticsSlice[queryCount=1, prepareStatementCount=1, entityLoadCount=20, collectionFetchCount=0, flushCount=0]
Єдине правило безпеки, яке важливо проговорити вголос: statistics.clear() очищає лічильники глобально для всього SessionFactory. Тому в реальному багатопотоковому застосунку так робити «просто так» не можна — ви зітрете цифри іншим сценаріям. Але в нашій лабораторії ми вимірюємо локально, ізольовано, і саме тому цей трюк доречний.
8. Типові помилки під час роботи з Hibernate Statistics
Помилка №1: статистика вимкнена, а ви вимірюєте «порожнечу».
Найприкріший сценарій виглядає так: ви написали гарний код заміру, викликали getQueryExecutionCount(), а він уперто повертає нуль. Зазвичай причина проста — hibernate.generate_statistics не ввімкнено (або профіль stats не активовано). У цей момент хочеться звинуватити Hibernate в безсердечності, але він тут ні до чого: лічильник вимкнений — лічильник не рахує.
Помилка №2: забули statistics.clear() і отримали «цифри всього життя».
Коли не очищати лічильники перед кожним заміром, ви отримуєте статистику не сценарію, а всього, що сталося з моменту старту застосунку. Туди потраплять міграції, seed-дані, ваші випадкові запити й навіть те, що зробив сусідній тест. Такі цифри не можна чесно порівнювати між собою, бо ви порівнюєте два різні життєві контексти.
Помилка №3: виміряли не один use case, а «приблизно щось».
Statistics корисні лише тоді, коли ви можете сказати: «ось цей конкретний метод / сценарій я зараз вимірюю». Коли всередині вимірювання ви викликаєте кілька сервісів, потім робите ще один запит «про всяк випадок», потім логируєте сутності й випадково ініціалізуєте колекції — snapshot перетворюється на кашу. Намагайтеся вимірювати один сценарій у максимально чистому вигляді.
Помилка №4: вірити одній цифрі й ігнорувати інші.
Іноді розробник бачить queryExecutionCount=1 і святкує перемогу, хоча entityLoadCount вилетів у космос. Або навпаки: пишається тим, що зменшив entityLoadCount, але випадково збільшив collectionFetchCount. ORM-діагностика майже завжди багатовимірна: дивитися треба на зв’язку метрик, а не на одну.
Помилка №5: порівнювати два запуски в різних умовах і на різних даних.
Коли в першому замірі у вас 20 товарів, а в другому 2000 товарів (бо ви ввімкнули big-dataset), то відмінності в лічильниках будуть не лише через ваш код. Це банально різні сценарії. Для порівняння фіксуйте вхідні параметри: розмір сторінки, фільтр, статус і, за можливості, один і той самий набір даних.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ