💾 Раздел 16 · Вопрос #6

Что делает аннотация @BatchSize

Аннотация @BatchSize в Hibernate — это инструмент оптимизации ленивой загрузки (LAZY), который указывает Hibernate загружать связанные сущности или коллекции пакетами (батчами)...


🟢 Junior Level

Что такое @BatchSize простыми словами

Аннотация @BatchSize в Hibernate — это инструмент оптимизации ленивой загрузки (LAZY), который указывает Hibernate загружать связанные сущности или коллекции пакетами (батчами) через конструкцию WHERE ... IN (?, ?, ...), вместо того чтобы выполнять отдельный SQL-запрос на каждый объект.

Это одно из самых эффективных и безопасных средств борьбы с проблемой N+1:

\[\text{Количество SQL-запросов} = 1 + \left\lceil \frac{N}{\text{batchSize}} \right\rceil\]
Без @BatchSize (Проблема N+1):
1. SELECT * FROM orders;                                -- 1 запрос, вернул 100 заказов
2. SELECT * FROM order_items WHERE order_id = 1;        -- Запрос для заказа 1
3. SELECT * FROM order_items WHERE order_id = 2;        -- Запрос для заказа 2
...
101. SELECT * FROM order_items WHERE order_id = 100;    -- 100 запросов! Итого: 101 запрос.

С аннотацией @BatchSize(size = 25):
1. SELECT * FROM orders;                                -- 1 запрос (100 заказов)
2. SELECT * FROM order_items WHERE order_id IN (?, ..., ?); -- 1 запрос на 25 заказов сразу!
3. SELECT * FROM order_items WHERE order_id IN (?, ..., ?); -- еще 25 заказов
4. SELECT * FROM order_items WHERE order_id IN (?, ..., ?); -- еще 25 заказов
5. SELECT * FROM order_items WHERE order_id IN (?, ..., ?); -- еще 25 заказов
ИТОГО: 5 запросов вместо 101 (сокращение числа обращений к базе на 95%)!

Где размещается аннотация

Аннотацию можно повесить в двух местах с разным эффектом:

1. Над коллекцией (@OneToMany / @ManyToMany)

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

@Entity
public class Order {
    @Id
    private Long id;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 30) // Подгружать позиции пачками по 30 заказов
    private List<OrderItem> items = new ArrayList<>();
}

2. Над классом сущности (@Entity)

Пакетно инициализирует неинициализированные прокси родительских сущностей (@ManyToOne / @OneToOne):

@Entity
@BatchSize(size = 30) // Инициализировать прокси User пачками по 30 штук
public class User {
    @Id
    private Long id;
    private String name;
}

🟡 Middle Level

Как Hibernate находит сущности для объединения в батч

При первом обращении к ленивому полю одного объекта (например, orders.get(0).getItems().size()):

  1. Hibernate перехватывает обращение через обертку PersistentCollection.
  2. Hibernate заглядывает в текущий контекст постоянства (PersistenceContext / L1-кэш) сессии.
  3. Он находит все другие находящиеся в памяти экземпляры сущностей того же типа (Order), у которых коллекция items еще не инициализирована.
  4. Hibernate берет идентификаторы найденных сущностей (до лимита size = N) и конструирует единый SQL-запрос с оператором IN.
  5. Полученный результат раскладывается по коллекциям соответствующих объектов Order в оперативной памяти.
  6. Когда цикл доходит до orders.get(1).getItems(), данные уже находятся в памяти — второй SQL-запрос не отправляется!

Глобальная настройка: default_batch_fetch_size

Вместо того чтобы вручную расставлять @BatchSize над десятками сущностей и коллекций, в Spring Boot принято включать глобальную настройку в application.yml:

spring:
  jpa:
    properties:
      hibernate:
        # Рекомендуемый диапазон для продакшена: от 20 до 50
        default_batch_fetch_size: 30

Эта строчка конфигурации автоматически включает пакетную выборку для абсолютно всех ленивых связей и коллекций во всем приложении. Если над конкретным полем стоит @BatchSize, ее локальное значение переопределит глобальное.


🔴 Senior Level

Главная путаница: Batch Fetching против JDBC Batch Writing

На собеседованиях кандидаты часто путают две совершенно разные настройки Hibernate со словом «batch»:

Параметр За что отвечает Направление данных Что генерирует
@BatchSize / default_batch_fetch_size Чтение (Fetching) Из БД в приложение SELECT ... WHERE id IN (?, ?, ...)
hibernate.jdbc.batch_size Запись (Flushing) Из приложения в БД JDBC PreparedStatement.addBatch() (INSERT, UPDATE, DELETE)

Эти настройки никак не связаны между собой и решают диаметрально противоположные задачи.

Стили формирования запросов: hibernate.batch_fetch_style

Hibernate умеет формировать оператор IN с помощью разных стратегий (BatchFetchStyle):

  1. DYNAMIC: Hibernate формирует точное количество параметров в IN (?, ...) под число оставшихся сущностей.
    • Минус: Если в памяти осталось 17 объектов, сгенерируется IN из 17 параметров, если 23 — из 23. База данных вынуждена каждый раз заново парсить SQL и компилировать Execution Plan, засоряя кэш планов запросов (Prepared Statement Cache).
  2. PADDED (Стандарт производительности для Highload): Размеры батчей округляются вверх до ближайшей степени двойки ($1, 2, 4, 8, 16, 32, \dots$):
    -- Если нужно загрузить 23 объекта при батче 32, сформируется запрос на 32 параметра:
    SELECT * FROM order_items WHERE order_id IN (?, ?, ... 32 параметра);
    -- Недостающие слоты дублируют первый ID
    
    • Плюс: Количество уникальных SQL-запросов строго фиксировано (всего несколько вариантов). СУБД кэширует скомпилированный план запроса, резко снижая нагрузку на CPU базы данных.
  3. LEGACY: Делит батч на фиксированные делители (например, для батча 32 генерируются формы на 32, 16, 10, 9, … 1).

Включение PADDED-стиля в Spring Boot:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 32
        batch_fetch_style: PADDED

Ограничения диалектов СУБД по количеству параметров

При выборе размера батча сеньор обязан учитывать жесткие лимиты реляционных баз данных:

  • Oracle DB: Жесткий предел в 1000 элементов внутри конструкции IN (...) (ошибка ORA-01795: maximum number of expressions in a list is 1000).
  • Microsoft SQL Server: Лимит на 2100 параметров в одном запросе.
  • PostgreSQL: Лимит на $65535$ параметров (ограничение протокола bind).

Рекомендация: Устанавливать @BatchSize больше 100 практически никогда не имеет смысла. Оптимальное эмпирическое значение: 30–50. Оно дает 97–98% сокращения сетевых запросов без риска исчерпания памяти и превышения лимитов СУБД.


4 Tricky Questions

1. Будет ли работать @BatchSize, если родительские сущности загружались не одним списком, а выбирались по отдельности в разных местах метода?

Ответ: Да, будет работать безупречно. В этом заключается фундаментальное преимущество @BatchSize перед FetchMode.SUBSELECT:

  1. FetchMode.SUBSELECT требует, чтобы родители были извлечены строго одним исходным запросом (он переиспользует исходный текст SQL).
  2. @BatchSize абсолютно индифферентен к тому, как сущности попали в память. Он инспектирует текущее состояние PersistenceContext.
  3. Если вы сделали find(Order.class, 1L), затем find(Order.class, 2L), а затем добавили сущности в сессию через merge(), при первом обращении к order1.getItems() Hibernate найдет в сессии неинициализированный order2 и вычитает позиции для обоих заказов за один общий SQL-запрос WHERE order_id IN (1, 2).

2. Решает ли @BatchSize проблему пагинации с дочерними коллекциями, где пасует JOIN FETCH?

Ответ: Да, @BatchSize — это эталонное решение проблемы пагинации коллекций в JPA.

  • При использовании JOIN FETCH на коллекции с Pageable Hibernate вынужден выкачивать всю таблицу в память JVM (HHH000104), так как LIMIT/OFFSET на уровне базы ломают количество строк из-за декартова произведения.
  • При использовании @BatchSize запрос за родителями выполняется стандартно:
    SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 0;
    

    База данных честно и эффективно применяет LIMIT и OFFSET по индексам.

  • Затем, когда бизнес-код обращается к order.getItems(), Hibernate выполняет ровно один дополнительный запрос на 20 заказов:
    SELECT * FROM order_items WHERE order_id IN (id1, id2, ... id20);
    
  • Итог: ровно 2 быстрых SQL-запроса, пагинация работает на стороне СУБД, память JVM не перегружается.

3. Почему @BatchSize на классе @Entity не срабатывает при поиске через entityManager.find(User.class, id)?

Ответ: Метод find(EntityClass, id) принимает ровно один конкретный первичный ключ.

  1. В момент вызова find() у Hibernate нет других кандидатов для объединения в батч. Он выполняет обычный одиночный запрос SELECT * FROM users WHERE id = ?.
  2. Аннотация @BatchSize над классом срабатывает только при инициализации неинициализированных прокси, когда в текущей сессии уже накопилось несколько ссылок на разные сущности этого типа (например, при загрузке 50 заказов, у каждого из которых есть поле @ManyToOne User user, представленное прокси).

4. Может ли @BatchSize привести к деградации производительности по сравнению с обычным LAZY?

Ответ: Да, в двух специфических сценариях:

  1. Случайный единичный доступ: Если в сессию загружено 100 заказов, но бизнес-логике требуется прочитать позиции только одного конкретного заказа, Hibernate с @BatchSize(size = 50) загрузит позиции сразу для первых 50 заказов. Выполнение запроса займет больше времени, а вычитанные 49 коллекций останутся невостребованными и будут впустую занимать память кучи (JVM Heap).
  2. Слишком большой размер батча (size = 1000): Запрос с гигантским списком параметров IN долго парсится оптимизатором СУБД, а результирующий пакет сетевых данных может превысить размер буфера сокета, увеличивая задержку.

🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

«Аннотация @BatchSize оптимизирует ленивую загрузку (LAZY) в Hibernate, объединяя запросы к связанным данным в пакеты с помощью оператора WHERE ... IN (?, ?, ...). Вместо $N$ запросов при классической проблеме N+1 выполняется всего $1 + \lceil N / \text{batchSize} \rceil$ запросов. Аннотация применяется к коллекциям (@OneToMany) или над классами сущностей (@ManyToOne прокси). В production-приложениях стандартом является включение глобальной настройки hibernate.default_batch_fetch_size = 30-50, что автоматически защищает всё приложение от лавины N+1 запросов и является лучшим решением для пагинации связанных коллекций».

Ключевые метрики для Production-мониторинга

  • Количество SQL-запросов типа SELECT ... WHERE ... IN в метриках Hibernate Statistics.
  • Процент попаданий в кэш планов СУБД (Prepared Statement Cache Hit Ratio при использовании PADDED).
  • Время ответа эндпоинтов со сложными иерархическими связями.

Красные флаги (Чего говорить НЕЛЬЗЯ)

  • ❌ «@BatchSize управляет пакетной вставкой INSERT в базу данных» — Это фундаментальная ошибка: @BatchSize управляет исключительно чтением (SELECT), а за вставку отвечает hibernate.jdbc.batch_size.
  • ❌ «Я ставлю batchSize = 10000, чтобы загрузить всё одним запросом» — Это приведет к ошибкам превышения лимитов СУБД (например, ORA-01795 в Oracle) и перегрузке памяти.
  • ❌ «@BatchSize заменяет JOIN FETCH во всех случаях» — Для единичной выборки объекта по ID со связями JOIN FETCH эффективнее, так как делает ровно 1 запрос вместо 2.
  • ❌ «@BatchSize не работает с Spring Data JPA» — Он полностью поддерживается, особенно через глобальное свойство default_batch_fetch_size.

Связанные темы