1. Важливо бачити SQL у data‑тестах
Ліниве завантаження не завжди дає гучний збій. Часто все навпаки: контекст живий, код зелений, article.getCategory() і article.getAttachments() працюють, а в лог уже повзе пачка додаткових запитів. Це той самий ризик читання графа Article, тільки тепер симптом не LazyInitializationException, а ціна SQL.
Коли розробник лише починає працювати з JPA, він часто сприймає базу як «чорну скриньку»: якщо дані повернулися, значить, усе добре. Але в реальному застосунку важливий не лише факт правильних даних, а й те, яким способом їх отримали. Іноді ви отримуєте правильний список статей, але разом із ним — десятки зайвих запитів, і робоче середовище починає «грітися», як ноутбук на колінах після запуску гри.
У цій лекції ми не будемо перетворювати @DataJpaTest на лабораторію для вимірювання продуктивності й не будемо рахувати запити «по мілісекундах». Наша мета простіша й практичніша: зробити симптом видимим. Ви маєте вміти відкрити SQL‑лог і зрозуміти: «Ого, я зараз випадково влаштував N+1‑подібну історію». Це вміння — як у лікаря помітити температуру: діагноз ще не поставили, але вже зрозуміло, що казати «все нормально» — не варіант.
Щоб не було відчуття магії, домовимося про роль тесту: тест тут — не про «все оптимально», а про «ми вміємо помітити підозріле читання і не переконувати себе, що це нормально лише тому, що тест зелений».
2. Що таке N+1‑симптом простими словами
N+1‑подібна проблема звучить як назва інді‑гурту, але в JPA це доволі конкретний і неприємний патерн. Суть у тому, що ви спочатку робите один запит — це «1» — за списком кореневих сутностей, наприклад статей, а потім під час доступу до зв’язків робите ще N запитів: по одному на кожну статтю. У результаті застосунок працює правильно, але SQL‑лог перетворюється на серіал на 8 сезонів: усе довго, багато повторів, сюжет зрозумілий, а дивитися боляче.
У ContentHub є класичний ґрунт для такого симптому: Article пов’язаний із Category (зазвичай @ManyToOne) і з ArticleAttachment (зазвичай @OneToMany). Якщо ці зв’язки ліниві, а вони часто такі й є, то під час доступу до article.getCategory() або article.getAttachments() ORM може почати довантажувати дані окремими запитами.
Корисно побачити це як послідовність, а не як абстракцію:
sequenceDiagram
participant App as Код читання
participant DB as База даних
Note over App,DB: Симптом: один запит за статтями + N запитів за зв’язками
App->>DB: "SELECT * FROM article"
DB-->>App: "3 статті"
App->>DB: "SELECT * FROM category WHERE id=?"
DB-->>App: "категорія для Article#1"
App->>DB: "SELECT * FROM category WHERE id=?"
DB-->>App: "категорія для Article#2"
App->>DB: "SELECT * FROM category WHERE id=?"
DB-->>App: "категорія для Article#3"
Важливо: сьогодні ми не лікуємо й не переписуємо запити «правильно». Ми вчимося бачити, що така послідовність взагалі сталася. Це вже половина успіху, бо найдорожче в розробці — не запити, а самовпевненість: «Та нормально все, воно ж працює».
3. Як @DataJpaTest приховує надлишкові запити
Підступ тут у тому, що @DataJpaTest за замовчуванням виконує кожен тест у транзакції. Це зручно: тест ізольований, після нього все відкочується. Але саме це створює ефект «усе поруч»: об’єкти живуть у persistence context, Hibernate пам’ятає, що вже завантажував, і іноді не повторює запитів там, де в реальному застосунку вони повторилися б через інші межі транзакції та інший сценарій читання.
Друга пастка — ви можете підготувати дані так, що вони вже опиняться в контексті, а отже, читання «на місці» майже нічого не запросить. Наприклад, ви щойно persist‑нули категорію та статтю, а потім у тому ж тесті читаєте статтю й торкаєтеся категорії. З високою ймовірністю JPA вже тримає категорію в першому рівні кешу, і SQL‑лог буде тихішим, ніж у реальному сценарії читання.
Тому правило сьогоднішньої лекції звучить дуже приземлено: якщо ви хочете побачити SQL‑симптоми під час читання, то перед головним читанням потрібно очистити persistence context. Ми це робили раніше для чесних перевірок constraint’ів; сьогодні робимо те саме, але вже заради спостережуваності шляху читання.
4. Підготовка: fixture, flush()/clear() і SQL‑лог
Спочатку трохи «заземлимося», щоб говорити про одне й те саме. Нам потрібен невеликий набір даних: кілька категорій і кілька статей. Саме кілька, бо на одному записі N+1‑симптом виглядає як «ну, один зайвий запит… і що». А на трьох — уже помітно, а на десяти — стає соромно.
Далі нам потрібно зробити те, що ви вже вмієте: записали дані → flush() → clear() → читаємо заново. Це створює ситуацію, коли наступний findAll() або findBy...() буде реальним читанням із бази, а не перевіркою того, що в пам’яті в нас і так усе гарно.
Міні‑приклад підготовки довідника може виглядати так: не ідеальний production‑код, зате хороший навчальний «скелет»:
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
void seedCategories(TestEntityManager em) {
// Створюємо мінімальний набір довідкових даних (категорій) для сценаріїв читання
em.persist(new Category("java", "Java"));
em.persist(new Category("spring", "Spring"));
em.persist(new Category("db", "Databases"));
// Важливо зафіксувати зміни в БД, щоб наступне читання справді пішло в базу
em.flush();
}
Цього фрагмента спеціально недостатньо для всього експерименту: він створює лише довідкові дані. Для самих сценаріїв шляху читання далі потрібні ще кілька Article, прив’язаних до цих категорій, а для прикладу з attachments — ще й кілька вкладень, інакше articleRepository.findAll() просто нічого буде читати й діагностувати.
Тепер про SQL‑лог. Зазвичай у навчальному проєкті логування запитів уже ввімкнено, або ви бачите його в консолі тестів. Якщо раптом тиша, корисно пам’ятати, що це налаштовується логами, а не містикою. Приклад тестових налаштувань у YAML — як ідея, не як догма:
logging:
level:
# Показуємо самі SQL-запити Hibernate
org.hibernate.SQL: DEBUG
# Показуємо значення bind-параметрів, інакше всюди буде WHERE id=?
org.hibernate.orm.jdbc.bind: TRACE
Друга стрічка потрібна, щоб бачити параметри bind’ів, інакше всі запити виглядатимуть однаково: «WHERE id=?». У навчальних цілях це іноді заважає, бо ви хочете зрозуміти: це справді різні id чи один і той самий запит повторився випадково.
5. Сценарій: category у циклі
Зараз буде ключовий момент: ми спеціально напишемо шматок коду, який з погляду функціональності виглядає нормально. Ми читаємо статті, потім читаємо код категорії. Це звичайна логіка, вона легко може опинитися в сервісі або мапері DTO. Саме тому важливо вміти бачити її SQL‑наслідки.
Нижче — приклад data‑тесту. Зверніть увагу: ми не робимо assertion «кількість запитів має бути 4» — це була б інша ліга й дуже швидко перетворилося б на крихкий тест. Ми робимо звичайні перевірки даних, а зайві запити спостерігаємо через лог.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class ArticleReadPathDataJpaTest {
@Autowired
private ArticleRepository articleRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void findAll_thenAccessCategory_canShowSuspiciousExtraQueriesInLog() {
// Важливо: очищаємо persistence context, щоб читання було "чесним" — з БД, а не з пам'яті
entityManager.clear();
// Кореневий запит: отримуємо список статей
List<Article> articles = articleRepository.findAll();
// Доступ до лінивого зв'язку всередині циклу може породити N додаткових запитів
for (Article article : articles) {
article.getCategory().getCode();
}
// Перевіряємо дані, а SQL-навантаження спостерігаємо в логу
assertThat(articles).hasSizeGreaterThanOrEqualTo(1);
}
}
Чому тут є entityManager.clear() прямо перед findAll()? Тому що ми хочемо, щоб findAll() справді пішов у базу, а не витягнув із пам’яті те, що ми щойно створювали в цьому тесті, або те, що випадково лишилося в контексті після підготовки.
Далі ми робимо цикл. І ось тут, якщо зв’язок Article → Category у вас lazy, у логу часто видно саме те, що потрібно побачити новачку: спочатку один select, а потім додаткові select по категоріях. На невеликому наборі даних це виглядає приблизно так — приклад умовний, SQL у вас відрізнятиметься за назвами таблиць і стовпців:
-- Один запит за списком статей, потім повторювані запити за category (симптом N+1)
Hibernate: select a1_0.id, a1_0.title, a1_0.category_id from article a1_0
Hibernate: select c1_0.id, c1_0.code, c1_0.name from category c1_0 where c1_0.id=?
Hibernate: select c1_0.id, c1_0.code, c1_0.name from category c1_0 where c1_0.id=?
Hibernate: select c1_0.id, c1_0.code, c1_0.name from category c1_0 where c1_0.id=?
Якщо ви ввімкнули логи bind‑параметрів, то ще й побачите, що id щоразу різний. І це хороший момент для «інженерного клацання»: функціонально код коректний, тест зелений, але SQL‑картина натякає, що на великому списку статей буде боляче.
Ще раз: ми не робимо з цього «тест, що падає», ми робимо з цього видиму діагностику. Це важлива різниця.
6. Сценарій: attachments у циклі
З колекціями N+1‑симптом часто проявляється ще наочніше, бо «один запит на кореневий список + по запиту на колекцію» читається в логу майже як підручник. У ContentHub вкладення — дуже реалістичний кейс: список статей в адмінці, де поруч показується кількість вкладень, або сторінка деталей статті, де потрібно вивести список файлів.
Покажемо дуже схожий тест, але тепер звертатимемося до getAttachments().size(). Знову ж таки: це не «оптимізація», а демонстрація симптому. І так, це той випадок, коли навіть виклик size() може ініціювати SQL — у цьому й підступність lazy‑колекцій.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ArticleReadPathDataJpaTest {
@Autowired
private ArticleRepository articleRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void findAll_thenAccessAttachments_canShowNPlusOneLikeSymptomInLog() {
// Створюємо чесну межу: далі ми хочемо побачити запити саме до БД
entityManager.clear();
// Кореневий запит: список статей
List<Article> articles = articleRepository.findAll();
// Доступ до lazy-колекції в циклі може породити по запиту на кожну статтю
for (Article article : articles) {
article.getAttachments().size();
}
// Assertions по даних — окремо; SQL-поведінку дивимося в логу
assertThat(articles).isNotEmpty();
}
}
Типовий лог для цього сценарію виглядає приблизно так:
-- Один запит за статтями, потім повторювані запити за колекціями article_attachment (симптом N+1)
Hibernate: select a1_0.id, a1_0.title from article a1_0
Hibernate: select at1_0.article_id, at1_0.id, at1_0.original_filename from article_attachment at1_0 where at1_0.article_id=?
Hibernate: select at1_0.article_id, at1_0.id, at1_0.original_filename from article_attachment at1_0 where at1_0.article_id=?
Hibernate: select at1_0.article_id, at1_0.id, at1_0.original_filename from article_attachment at1_0 where at1_0.article_id=?
Якщо статей багато, це перетворюється на «багато однакових запитів». А якщо таке зробити в публічному endpoint’і, де список може містити 20–50 елементів, ви раптово отримуєте 51 запит замість 1–2. І це вже не тонка оптимізація — це той випадок, коли різницю відчуєте навіть без профайлера: за часом, за навантаженням на БД і за сумними очима колег, які «просто хотіли додати поле attachmentsCount у відповідь».
7. Як читати SQL‑лог: три орієнтири
Після перших експериментів із логом у новачка часто виникають два крайні стани. Перший — «жах, усе погано, JPA треба видалити». Другий — «ну і що, працює ж». Нам потрібна нормальна середина: вміти читати картину й робити спокійні інженерні висновки.
Перший орієнтир: спочатку переконайтеся, що ви справді спостерігаєте шлях читання, а не ефект persistence context. Якщо ви забули clear(), ви можете не побачити надлишкових запитів або побачити їх «дивно». На цьому місці найпростіше обманутися: тест зелений, лог тихий, ви робите висновок «N+1 немає», а в реальному коді він раптом з’являється за іншого життєвого циклу.
Другий орієнтир: N+1‑подібний симптом видно на наборі даних. Одна стаття рідко дає корисну картину. Три-чотири — уже так. Тому в демонстраційних тестах ми майже завжди беремо кілька кореневих сутностей.
Третій орієнтир: не всі «зайві запити» однаково погані, і сьогодні ми не робимо остаточний вердикт «у продакшн не можна». Іноді додаткові запити виправдані, бо дані справді потрібні рідко, і lazy‑стратегія економить ресурси в інших сценаріях. Проблема починається, коли «рідко» раптом виявляється «у циклі по списку», а це якраз те, що ми сьогодні вчимося помічати.
Щоб простіше було тримати голову в порядку, ось невелика таблиця спостережуваних симптомів — без «лікування», лише читання картини:
| Спостереження в логу | Що це зазвичай означає | Чому це важливо саме для нас |
|---|---|---|
| Один select по статті + багато однакових select по категорії | ManyToOne завантажується ліниво й довантажується в циклі | Це дуже типово для списку DTO, де беруть categoryCode |
| Один select по статті + багато select по вкладеннях | OneToMany колекція ліниво ініціалізується для кожного article | Це спливає на «покажи кількість вкладень» або «покажи прев’ю списку файлів» |
| Запитів майже немає, хоча ви очікуєте картину | Контекст уже містить сутності, ви спостерігаєте не БД, а пам’ять | Отже, ви тестуєте не те, що думали |
| Запитів є, але їх менше, ніж «по одній штуці на статтю» | 1‑й рівень кешу / однакова категорія у багатьох статей | Картинка все одно може бути поганою на реальних даних |
Якщо хочеться додати трохи самоіронії: persistence context — це як прибирання перед приходом гостей. Начебто чисто, але ви точно знаєте, що частину речей просто переклали до шафи. clear() — це коли ви відчинили шафу й чесно подивилися, що там відбувається.
8. Типові помилки під час пошуку надлишкових SQL‑запитів
Помилка №1: дивитися на логи без clear() і робити висновки «все добре».
Якщо persistence context уже тримає Category або ArticleAttachment, ви можете не побачити додаткових запитів зовсім. Це не означає, що їх не буде в реальному застосунку. У діагностичному сценарії майже завжди потрібна явна межа: flush() — якщо були зміни, потім clear(), а вже після цього основне читання.
Помилка №2: намагатися «спіймати N+1» на одному об’єкті.
На одній статті ви побачите максимум «один зайвий запит», і мозок легко скаже: «дрібниці». N+1 — це саме ефект масштабування: один запит перетворюється на десятки. Тому для спостережуваності потрібні кілька статей, бажано з різними категоріями та вкладеннями, інакше симптом не виглядає симптомом.
Помилка №3: перетворювати спостереження на крихкий тест «має бути рівно X запитів».
Дуже спокусливо «закріпити» проблему так: «має бути 1 запит, інакше тест падає». Але без спеціальної інфраструктури та чіткої стратегії такий тест легко стає шумним: змінюються версії Hibernate, змінюється SQL, змінюються деталі завантаження, і ви отримуєте падіння не за змістом. У межах поточного рівня ми використовуємо лог як діагностичний інструмент, а assertions залишаємо на зміст даних.
Помилка №4: плутати «побачити симптом» і «одразу оптимізувати все підряд».
Сьогоднішня мета — навчитися помічати підозріле читання. Якщо ви одразу кидаєтеся «лагодити» все, ви часто починаєте змінювати архітектуру й запити без розуміння, де саме проблема справді важлива. Набагато корисніше спочатку навчитися бачити картину, а вже потім, коли ви впевнені, що біль реальна, обирати спосіб зміни читання.
Помилка №5: вважати, що lazy loading — це «погано» і треба терміново зробити все EAGER.
FetchType.EAGER іноді справді «ховає» N+1‑симптом, але може створити інші проблеми: неочікувані важкі графи, зайві дані, складні запити, ріст часу читання там, де зв’язки взагалі не потрібні. Ми в цій лекції не обираємо стратегію завантаження «раз і назавжди»; ми вчимося бачити наслідки поточної стратегії на рівні SQL‑поведінки.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ