JavaRush /Курси /Spring Test /Надлишкові SQL‑запити під час роботи зі зв’язками

Надлишкові SQL‑запити під час роботи зі зв’язками

Spring Test
Рівень 17 , Лекція 3
Відкрита

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() справді пішов у базу, а не витягнув із пам’яті те, що ми щойно створювали в цьому тесті, або те, що випадково лишилося в контексті після підготовки.

Далі ми робимо цикл. І ось тут, якщо зв’язок ArticleCategory у вас 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‑поведінки.

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