JavaRush /Курси /Hibernate deep-dive /Аудит читання: передбачуваний SQL

Аудит читання: передбачуваний SQL

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

1. Мета аудиту: перестати «лікувати» ORM навпомацки

Аудит сценарію читання — це момент, коли ви перестаєте реагувати на ORM як на вередливого кота: «то їсть, то не їсть, то N+1», — і починаєте діяти як інженер. Ви фіксуєте конкретний метод, відтворюєте його поведінку, вимірюєте ціну SQL і перетворюєте «дивні підвантаження» на зрозумілий, повторюваний результат. Тут важливіше не «магічно прискорити все», а зробити вартість читання передбачуваною.

У нас уже є базовий замір: printOrdersWithCustomerEmail() на тому самому OrderReadService показав лінійне зростання кількості запитів. І в нас уже є важливе розрізнення: список і картка — це різні контракти читання. Тепер не потрібен ще один demo-сервіс. Потрібен прикладний аудит того самого сценарію: переміряти його до й після очищення.

Якщо говорити зовсім приземлено, аудит відповідає на три запитання:

  1. який конкретний метод ми вимірюємо;
  2. де цей метод випадково торкається зв’язків і перетворює список на обхід графа;
  3. як змінюється лічильник запитів після того, як список і картка перестали жити в одному коді.

Невелика блок-схема, щоб закріпити в голові саме процес:

flowchart TD
    A[Беремо той самий OrderReadService] --> B[Знімаємо базовий замір кількості запитів]
    B --> C[Розводимо список і картку в цьому ж сервісі]
    C --> D[Повторюємо замір]
    D --> E[Вартість читання стає передбачуваною]

2. Крок 1: беремо той самий базовий замір, а не новий стенд

Найчастіше аудит псує спокуса завести новий OrderReadScenarios, новий runner і випадково змінити половину сценарію одразу. Так не робимо. Залишаємо той самий HibernateStats, той самий OrderReadService і ту саму точку запуску NPlusOneLabRunner. Змінюється не стенд, а лише той метод, який ви викликаєте всередині нього.

Нижче той самий runner після такого рефакторингу. Він спершу вимірює базовий замір, потім чистий список, а потім картку:

package com.example.commerce.labsupport;

import com.example.commerce.orders.query.OrderReadService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

@Profile("lab")
@Component
public class NPlusOneLabRunner implements CommandLineRunner {
    private final OrderReadService service;
    private final HibernateStats hStats;

    public NPlusOneLabRunner(OrderReadService service, HibernateStats hStats) {
        this.service = service;
        this.hStats = hStats;
    }

    @Override
    public void run(String... args) {
        long sampleOrderId = 1001L; // візьміть наявний id із seed-даних

        hStats.stats().clear();
        service.printOrdersWithCustomerEmail();
        System.out.println("базовий замір: кількість запитів = " + hStats.stats().getPrepareStatementCount()); // наприклад, 31

        hStats.stats().clear();
        service.printOrderList();
        System.out.println("список: кількість запитів = " + hStats.stats().getPrepareStatementCount()); // наприклад, 1

        hStats.stats().clear();
        service.printOrderCard(sampleOrderId);
        System.out.println("картка: кількість запитів = " + hStats.stats().getPrepareStatementCount()); // наприклад, 3
    }
}

Тут ключове не число в коментарі, а сама форма порівняння: один і той самий runner, один і той самий набір даних, один і той самий практичний лічильник кількості запитів через getPrepareStatementCount().

3. Крок 2: позначаємо, що саме змінилося в сервісі

Зараз важливо не переповідати fetching із нуля, а побачити одну річ: змінився лише спосіб доступу. Ми не змінюємо домен замовлення і не додаємо нову магію Hibernate. Ми просто перестаємо робити зі списку напівкартку.

Ось як виглядає різниця на рівні методів:

Метод Що він робить Очікувана форма ціни
printOrdersWithCustomerEmail() Читає список замовлень і в циклі лізе в customer.email 1 + N або близько того
printOrderList() Читає список і залишає тільки поля кореневої сутності константна ціна, часто один кореневий запит
printOrderCard(orderId) Читає одне замовлення та дані його картки невелика константа без лінійного зростання разом із розміром списку

Якщо в printOrderList() назад просочилися getCustomer(), getItems().size() або toString() entity, ви не розділили сценарії — ви просто перейменували метод.

4. Крок 3: що саме виправляє поділ list/card

Такий рефакторинг прибирає випадковий обхід графа. Він виправляє саме випадковий обхід графа у списках. Але тут важливо не пообіцяти зайвого.

Що дає поділ Чого він не обіцяє
Список перестає сам тягнути customer і items за звичкою не гарантує автоматично рівно один запит за примусового EAGER
Картка отримує право бути глибшою не скасовує ціну самого мапінгу
До/після стає чесно порівнюваним не лікує будь-яке зайве завантаження однією перестановкою методів

Якщо асоціація вже нав’язана EAGER або типовий to-one робить читання дорожчим, список після очищення може все ще тягнути зайве. Це не означає, що аудит марний. Він уже показав: випадковий доступ прибрано, а решта ціни сидить у підході до fetch на рівні самого мапінгу.

5. Крок 4: повторюємо замір SQL

Ось тепер до/після справді порівнювані. Ми вимірюємо не два різні світи, а один і той самий сервіс до й після очищення.

Нижче — типова форма результату на одному й тому самому seed-наборі даних:

Сценарій Що зазвичай показує лічильник запитів Як це читати
baseline printOrdersWithCustomerEmail() наприклад, 31 запит на 30 замовленнях один root-запит + повторювані SELECT-запити на клієнтів
printOrderList() часто 1 випадковий обхід зник, список знову компактний
printOrderCard(orderId) невелика константа, наприклад, 23 деталі ізольовані в одній картці, а не розмазуються по списку

Найважливіше тут — не сама цифра, а форма зростання. Поганий список живе за формулою 1 + N. Хороший список зазвичай стає константним. Картка теж може робити кілька запитів, але це не N+1, тому що її ціна не зростає разом із довжиною списку замовлень.

І ще одне важливе застереження: list query proxy = 1 — це не універсальна обіцянка для будь-якого проєкту. Якщо після очищення список усе ще дорожчий, ніж ви очікували, дивіться SQL trace. Там зазвичай видно, чи залишилася вартість на рівні мапінгу: EAGER, secondary select або інший нав’язаний fetch.

6. Мінічекліст передбачуваного читання

Наприкінці дня корисно зафіксувати не набір анотацій, а набір запитань, які ви ставите до методу читання ще до того, як він починає поводитися дивно. Це допомагає не лише виправляти N+1, а й запобігати йому під час наступного рефакторингу.

Ось компактна таблиця, яку зручно тримати в голові під час code review будь-якого read-методу:

Запитання до read-методу Якщо відповідь «так», що це зазвичай означає
Метод читає список кореневих сутностей? Будь-який доступ до асоціації в циклі — кандидат на N+1
Усередині є getCustomer(), getItems(), getDetails()? Ви, можливо, змішали список і деталі в одному сценарії
Після очищення список усе ще читає зайве? Дивіться на мапінг і підхід до fetch: можливо, ціну нав’язує EAGER або типовий to-one
Метод друкує/логує entity цілком (log.info("{}", o)) Ви можете випадково викликати ліниве підвантаження через toString()
Є базовий замір вимірювань (query proxy до змін)? Без базового заміру ви не відрізните покращення від випадковості
Повторний замір зроблено після змін? Якщо ні — у вас поки немає доказу, що стало краще

Зверніть увагу: тут немає жодного слова про «зробити все EAGER» або «одразу написати хитрий запит». Це спеціально. На рівні сьогоднішньої лекції ми досягаємо ефекту найдешевшим способом: дисципліною сценарію.

І саме тут з’являється наступне інженерне запитання. Якщо зв’язані дані справді входять до контракту читання, далі потрібен уже не загальний заборонний підхід до зв’язків, а точний підхід до fetch — JOIN FETCH, EntityGraph, @BatchSize та подібні інструменти. Тільки тепер у вас хоча б є базовий замір, з яким їх можна порівнювати.

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

В аудиті N+1 майже завжди повторюються одні й ті самі граблі. І хороша новина в тому, що це інженерні граблі, а не кармічні: якщо бачите закономірність, її можна впіймати наперед.

Помилка № 1: намагатися виправити N+1 глобально, без вибору одного сценарію.
Коли розробник бігає по проєкту й змінює то fetch, то логування, то окремі репозиторії, він сам позбавляє себе базового заміру. У підсумку незрозуміло, що саме допомогло, а за тиждень проблема повертається в новій формі. Аудит працює лише тоді, коли ви фіксуєте одну точку входу та змінюєте один фактор за раз.

Помилка № 2: змінити разом і сервіс, і runner, і набір даних, а потім назвати це «до/після».
Якщо ви завели новий OrderReadScenarios, новий QueryCountDemo і заодно змінили seed-дані, у вас уже немає чесного порівняння. Ви вимірюєте інший експеримент. Набагато надійніше тримати той самий сервіс і ту саму точку заміру, а змінювати лише спосіб доступу.

Помилка № 3: вважати, що поділ list/card відбувся, хоча в списку все ще живуть гетери зв’язків.
Метод може називатися printOrderList(), але якщо всередині є getCustomer(), getItems().size() або невдалий toString(), то це все ще картка в циклі. Hibernate не читає назву методу — він читає фактичний доступ до даних.

Помилка № 4: сприймати N+1 просто як «багато запитів».
Картка одного замовлення може робити 23 запити, і це не N+1. Справжня проблема починається там, де кількість запитів зростає разом із розміром списку. Тому дивіться не лише на число, а й на форму зростання.

Помилка № 5: думати, що очищення способу доступу автоматично прибирає будь-яке зайве підвантаження.
Воно чудово прибирає випадковий обхід, але не скасовує примусовий EAGER та інші рішення на рівні мапінгу. Якщо список усе ще дорогий після очищення, це не означає, що аудит зламався. Це означає, що ви дійшли до наступного, точнішого запитання про підхід до fetch.

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