JavaRush /Курсы /Hibernate deep-dive /Проверка JDBC batching в импорте

Проверка JDBC batching в импорте

Hibernate deep-dive
23 уровень , 4 лекция
Открыта

1. Введение

hibernate.jdbc.batch_size сам по себе ещё ничего не обещает. К этому моменту уже видно, что batching держится сразу на нескольких вещах: id не должен вынуждать INSERT сразу, длинный цикл не должен раздувать persistence context, а write-поток не должен рваться между кучей разных statements.

Если честно, в Hibernate есть два типа оптимизаций: те, которые видно сразу (например, вы убрали N+1 — и SQL-лог перестал быть «романом в 500 страниц»), и те, которые очень легко себе представить, но сложно доказать без правильной оптики. JDBC batching относится как раз ко второй группе. Вы можете поставить hibernate.jdbc.batch_size=50, написать persist() в цикле и… не получить ничего, кроме морального удовлетворения.

Причина простая: batching — это условный механизм. Он срабатывает, только если совпали предпосылки (одинаковые statements, подходящая стратегия id, корректный flush-cycle, драйвер, и т.д.). Поэтому правильное мышление здесь такое: «включил — проверил». Это как ремень безопасности: он полезен, но если вы не пристегнулись, он не спасёт; и если вы пристегнулись «в теории», а замок не защёлкнулся, спасёт тоже не очень.

В нашем проекте Commerce Persistence Lab SQL trace и statistics нужны именно для таких ситуаций: они позволяют проверить batching технически, а не по внутреннему ощущению, что «вроде стало быстрее». Поэтому мы будем смотреть не только на время (оно обманчиво), а на прямые признаки batching.

2. Честное сравнение: без batching и с batching

Чтобы проверка batching была похожа на инженерный эксперимент, а не на гадание на кофейной гуще, нам нужно договориться о дисциплине сравнения. Идея простая: мы фиксируем всё, что не связано с batching напрямую, и меняем только batch-параметры. Тогда любое изменение поведения в SQL-логе и batch-логах можно уверенно связывать с batching, а не с тем, что «мы случайно переписали половину сценария».

Ниже — удобная табличка «что держим неизменным» и «что меняем». Это не бюрократия, а способ не потеряться, когда результат окажется неожиданным (а он обычно оказывается).

Ось сравнения Что делаем для честности Почему это важно
Набор данных Импортируем одинаковое количество товаров с одинаковой структурой данных Иначе вы сравниваете разные задачи
Транзакционная граница В обоих случаях один сервисный метод = одна транзакция Batching живёт внутри одной транзакции и flush-cycle
Id strategy Оставляем
SEQUENCE
(или хотя бы не меняем её между сравнениями)
Иначе эффект будет «про стратегию id», а не про batching
SQL наблюдаемость Включаем SQL trace + bind params + batch logs Без этого вы не докажете batching
Изменяемые параметры Меняем batch_size и flush/clear шаг; ordering трогаем отдельно, если проверяем mixed write-flow Иначе вы не поймёте, что именно дало эффект

Обратите внимание на тонкий момент: если вы одновременно поменяете и id strategy, и batch_size, и добавите flush/clear, а потом увидите ускорение, вы не сможете честно ответить на вопрос «что именно помогло». Поэтому мы будем думать как инженеры: «одна гипотеза — одно изменение, насколько это возможно».

3. Профиль batch-lab: логи для batching

В этой теме особенно приятно то, что нам не нужны ни APM, ни профилировщики, ни шаманский бубен. Нам нужен правильный набор логов. Именно он скажет нам, где Hibernate складывает statements в батч, когда он исполняет батч, и почему он вдруг не сделал этого, хотя мы «очень просили».

Ниже — пример того, как может выглядеть профиль application-batch-lab.yml. Мы намеренно включаем log category org.hibernate.orm.jdbc.batch, потому что именно там появляется «техническое доказательство», что batching реально исполняется. Заодно включаем SQL и bind-параметры, чтобы видеть форму запросов и не гадать, что ушло в БД.

# src/main/resources/application-batch-lab.yml
spring:
  config:
    activate:
      on-profile: batch-lab
  jpa:
    properties:
      # Здесь batch size будем включать локально через Session#setJdbcBatchSize(...)
      # Поэтому глобально можно оставить 0, чтобы baseline точно был "без batching".
      hibernate.jdbc.batch_size: 0

      # Эти флаги мы уже обсуждали: они не "включают batching",
      # но помогают группировке операций, когда batching уже возможен.
      hibernate.order_inserts: true
      hibernate.order_updates: true

      # Важно: статистика нужна именно для учебной диагностики (flush'и, количество SQL и т.п.)
      # В production её обычно включают точечно.
      hibernate.generate_statistics: true

logging:
  level:
    # Показываем SQL, чтобы видеть форму запросов (даже если драйвер реально шлёт их пачкой)
    org.hibernate.SQL: debug

    # Параметры биндинга: полезно, чтобы не гадать, "какие значения летят", но лог очень шумный
    org.hibernate.orm.jdbc.bind: trace

    # Главное место, где видна жизнь batching
    org.hibernate.orm.jdbc.batch: debug

    # Иногда полезно для учебных логов, но не надо оставлять вечно включённым
    org.hibernate.stat: debug

Такой профиль даёт нам честный baseline: глобально batching выключен, а конкретный import-flow ниже сам включает его локально на Session. Ordering здесь ничего не «включает», оно просто остаётся рядом как тюнинг для write-flow, когда batching действительно разрешён.

Если вы запускаете приложение через Gradle, то типичный запуск выглядит так:

./gradlew bootRun --args='--spring.profiles.active=local,batch-lab'

Здесь важно не само заклинание, а смысл: мы включили профиль, который делает batching «наблюдаемым». Без этого вы будете смотреть на секунды и миллисекунды, а миллисекунды — существа хитрые: они любят зависеть от прогрева JVM, состояния диска, настроения ноутбука и фазы Луны.

4. Детерминированный dataset через draft-объекты

Когда мы импортируем товары, соблазн огромен: «сгенерирую List<Product> и два раза вызову сервис». Но это очень быстро приводит к странностям, потому что entity — объект с жизненным циклом. Он может стать managed, потом detached, и вы вдруг начнёте повторно использовать сущности в нетипичном состоянии. А мы сейчас проверяем batching, а не тренируемся в merge().

Поэтому практичнее держать входные данные импорта в виде «черновика»: простой неизменяемый объект (например, record), из которого сервис будет каждый раз создавать новую entity. Это даёт нам чистоту эксперимента: каждый прогон создаёт новые transient-объекты, и мы не таскаем за собой хвост из старых managed/detached состояний.

Вот минимальный ProductDraft:

// src/main/java/com/example/commerce/catalog/service/ProductDraft.java
package com.example.commerce.catalog.service;

import java.math.BigDecimal;

// Черновик данных для импорта:
// это НЕ entity, у него нет жизненного цикла persistence context,
// и его безопасно переиспользовать/генерировать для разных прогонов.
public record ProductDraft(
        String sku,
        String name,
        BigDecimal priceAmount
) {
}

А вот небольшой генератор входных данных. Обратите внимание: мы добавляем runTag в SKU, чтобы два прогона не конфликтовали по уникальности sku. Строго говоря, для идеального сравнения лучше удалять вставленные строки и импортировать один и тот же SKU-набор, но для учебной проверки batching нам важнее увидеть сам факт batching по логам, а не делать научную статью по микробенчмаркам.

// src/main/java/com/example/commerce/labsupport/batch/ProductDraftFactory.java
package com.example.commerce.labsupport.batch;

import com.example.commerce.catalog.service.ProductDraft;

import java.math.BigDecimal;
import java.util.List;
import java.util.stream.IntStream;

public class ProductDraftFactory {

    public List<ProductDraft> generate(int count, String runTag) {
        // Важно: runTag делает SKU уникальными между разными прогонами (BASE vs BATCH),
        // чтобы уникальные ограничения (если они есть) не ломали эксперимент.
        return IntStream.rangeClosed(1, count)
                .mapToObj(i -> new ProductDraft(
                        runTag + "-SKU-" + i,          // уникальный SKU внутри прогона
                        "Imported Product " + i,       // имя нам важно только как "похожее" поле
                        BigDecimal.valueOf(10.00)      // цена одинаковая: мы проверяем batching, а не бизнес-данные
                ))
                .toList();
    }
}

Да, все цены одинаковые. И это нормально. Мы сейчас измеряем поведение JDBC batching, а не бизнес-реализм. Наша цель — чтобы SQL был максимально однотипным, а значит, лучше видно «пачки».

5. Сервис импорта: baseline и batching

Теперь перейдём к самому сердцу лекции: к сервису, который делает импорт. Мы сделаем две реализации внутри одного ProductImportService. Причина простая: нам удобно запускать их рядом и сравнивать в одном и том же окружении, не меняя конфиги и не собирая приложение заново.

В baseline-варианте мы явно ставим batch size 0 (то есть «без batching») на уровне Session. В варианте с batching ставим, например, 20 и добавляем управляемый flush/clear шаг, чтобы persistence context не рос бесконечно.

// src/main/java/com/example/commerce/catalog/service/ProductImportService.java
package com.example.commerce.catalog.service;

import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import com.example.commerce.common.jpa.Money;
import jakarta.persistence.EntityManager;
import org.hibernate.Session;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class ProductImportService {

    private final EntityManager em;

    public ProductImportService(EntityManager em) {
        this.em = em;
    }

    @Transactional
    public void importWithoutBatch(List<ProductDraft> drafts) {
        // Явно отключаем batching для baseline-прогона:
        // так мы точно знаем, что сравнение честное и "без магии из конфига".
        em.unwrap(Session.class).setJdbcBatchSize(0);

        for (ProductDraft draft : drafts) {
            // В baseline Hibernate будет выполнять вставки "по одной" (на уровне JDBC).
            em.persist(toEntity(draft));
        }
    }

    @Transactional
    public void importWithBatch(List<ProductDraft> drafts, int batchSize) {
        // Разрешаем Hibernate накапливать JDBC-операции в пачки до batchSize.
        // Это ещё НЕ означает, что пачка "прямо сейчас" улетела в БД.
        em.unwrap(Session.class).setJdbcBatchSize(batchSize);

        for (int i = 0; i < drafts.size(); i++) {
            em.persist(toEntity(drafts.get(i)));

            // Управляем размером persistence context:
            // flush = отправляем накопившиеся изменения в БД,
            // clear = выкидываем managed-объекты из контекста, чтобы он не раздувался.
            if ((i + 1) % batchSize == 0) {
                em.flush();
                em.clear();
            }
        }

        // Добиваем "хвост": если размер списка не кратен batchSize,
        // последняя пачка всё равно должна уйти в БД до commit.
        em.flush();
        em.clear();
    }

    private Product toEntity(ProductDraft draft) {
        // Каждый draft превращаем в НОВУЮ transient entity:
        // это важно для чистоты эксперимента (без повторного использования managed/detached объектов).
        Product p = new Product();
        p.setSku(draft.sku());
        p.setName(draft.name());
        p.setStatus(ProductStatus.ACTIVE);
        p.setPrice(Money.usd(draft.priceAmount()));
        return p;
    }
}

Здесь есть один момент, который часто «не доезжает» до головы на первых порах. setJdbcBatchSize() — это не «выполнить батч», это «разрешить Hibernate накапливать операции в батч до такого-то размера». Реальное «исполнение пачки» произойдёт в момент flush/commit, и именно это мы будем смотреть в логах.

Ещё обратите внимание, что flush/clear шаг у нас связан с batchSize. Это не железное правило, но хороший старт. Если делать clear() слишком часто, batching может стать менее эффективным (вы чаще «провоцируете» flush). Если делать слишком редко — контекст раздувается. В учебном проекте нам важнее предсказуемость и понятность.

6. Runner лаборатории: запуск

Чтобы проверить batching, нам нужен запуск сценария. Делать это через контроллеры сегодня не хочется, потому что это добавит лишние сущности (HTTP, сериализация, и так далее), а мы в performance-модуле стараемся держать фокус на persistence. Поэтому удобный формат для лаборатории — ApplicationRunner под профиль batch-lab.

Ниже — пример runner’а, который делает два прогона: baseline и batching. Мы измеряем время через System.nanoTime() и печатаем его. Это не «истина в последней инстанции», но полезная цифра для ориентира. Главный же результат всё равно будет в логах batching.

// src/main/java/com/example/commerce/labsupport/batch/ProductImportBatchLabRunner.java
package com.example.commerce.labsupport.batch;

import com.example.commerce.catalog.service.ProductDraft;
import com.example.commerce.catalog.service.ProductImportService;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
@Profile("batch-lab")
public class ProductImportBatchLabRunner implements ApplicationRunner {

    private final ProductImportService importService;
    private final ProductDraftFactory factory = new ProductDraftFactory();

    public ProductImportBatchLabRunner(ProductImportService importService) {
        this.importService = importService;
    }

    @Override
    public void run(ApplicationArguments args) {
        // Генерируем два НЕ пересекающихся набора данных,
        // чтобы уникальные ограничения (например, по sku) не мешали второму прогону.
        List<ProductDraft> baseline = factory.generate(1_000, "BASE");
        List<ProductDraft> batched = factory.generate(1_000, "BATCH");

        // Прогон №1: baseline (batching отключён на уровне Session)
        long t1 = System.nanoTime();
        importService.importWithoutBatch(baseline);
        long t2 = System.nanoTime();
        System.out.println("baseline import = " + (t2 - t1) / 1_000_000 + " ms"); // пример формата вывода; не benchmark

        // Прогон №2: batching включён, batchSize = 20
        // Основное доказательство — логи org.hibernate.orm.jdbc.batch, а не только эти миллисекунды.
        long t3 = System.nanoTime();
        importService.importWithBatch(batched, 20);
        long t4 = System.nanoTime();
        System.out.println("batched import = " + (t4 - t3) / 1_000_000 + " ms");  // пример формата вывода; не benchmark
    }
}

Этот runner нужен не для честного micro-benchmark, а чтобы в одном запуске увидеть два режима и сопоставить их с логами. Второй прогон уже идёт на более прогретой JVM и БД, поэтому миллисекунды здесь — только вспомогательная подпись.

Для понимания процесса полезно представить нашу проверку схемой:

flowchart TD
    A["Запуск приложения с профилем batch-lab"] --> B["Прогон 1: importWithoutBatch"]
    B --> C["Смотрим SQL + org.hibernate.orm.jdbc.batch"]
    C --> D["Прогон 2: importWithBatch + flush/clear"]
    D --> E["Снова смотрим SQL + batch logs"]
    E --> F["Сравниваем: есть ли батчи, как часто исполняются, что с flush"]

Если вы когда-то пытались «ускорить Hibernate» без логов, то вы понимаете, почему эта схема важна: она спасает от самообмана.

7. Признаки batching в логах

Когда вы запускаете baseline импорт без batching, SQL-лог будет выглядеть примерно одинаково: много отдельных insert into ... values .... И это нормально. Но вот ключевой вопрос: как отличить «много insert’ов» от «много insert’ов, отправленных батчами»?

Наивный ответ звучит так: «если batching работает, SQL станет короче». Увы, часто нет. SQL-лог (org.hibernate.SQL) может продолжать писать каждую операцию, даже если драйвер реально отправляет их пачкой. Поэтому главное доказательство batching — это лог категории: org.hibernate.orm.jdbc.batch

Когда batching действительно работает, вы начинаете видеть сообщения про формирование и выполнение батча. Точный текст зависит от версии Hibernate и уровня логирования, но общий смысл будет примерно такой: Hibernate накопил N операций и отправляет batch в JDBC.

Для ориентира можно держать в голове такие признаки:

[org.hibernate.orm.jdbc.batch] ... Executing batch size: 20
[org.hibernate.orm.jdbc.batch] ... Added to batch: ...

Если в baseline-режиме вы не видите вообще ничего из org.hibernate.orm.jdbc.batch, а в batched-режиме видите регулярные сообщения про batches — поздравляю, вы только что доказали batching без шаманства.

Второй полезный сигнал — количество flush’ей. Мы намеренно делаем flush() каждые batchSize записей, поэтому по статистике (или по косвенным признакам в логах) можно увидеть, что flush происходил чаще. Это не доказательство batching, но это подтверждение, что наш сценарий управляемый и мы не просто «надеемся, что commit всё разрулит».

И наконец важное предупреждение: время выполнения само по себе не является доказательством batching. Иногда baseline может оказаться «не сильно хуже» просто потому, что dataset маленький, база локальная, и всё упирается в прогрев JVM. Иногда batched может оказаться даже медленнее из‑за накладных расходов на ordering или из‑за слишком маленького batch size. Поэтому мы всегда смотрим глазами на org.hibernate.orm.jdbc.batch, а не только на секундомер.

8. Интерпретация результата: критерии успеха

Сначала важная оговорка: в таком runner’е baseline всегда идёт первым, а batched — вторым. Ко второму прогону JVM, драйвер и сама база уже немного прогреты, поэтому миллисекунды здесь полезны только как ориентир. Доказательство batching всё равно лежит в org.hibernate.orm.jdbc.batch.

В учебном проекте хочется быстрых побед: «включил batching — стало быстрее». Это приятно, как закрыть тикет за пять минут. Но взрослое инженерное мышление чуть скучнее: мы пытаемся понять, что именно изменилось в поведении системы. JDBC batching — это про уменьшение количества round-trip’ов на уровне JDBC. Если у вас база на том же компьютере, и задержки маленькие, выигрыш может быть не космический. Зато в более «настоящем» окружении (даже просто отдельный контейнер БД, чуть более медленный диск) батчи проявят себя ярче.

Что можно считать хорошим исходом эксперимента:

Вы видите в org.hibernate.orm.jdbc.batch, что Hibernate реально выполняет батчи примерно нужного размера. Если batch size 20, то лог обычно показывает выполнение пачек около 20 операций, кроме последней пачки, которая может быть меньше. Это значит, что batching не просто «разрешён», он реально применяется.

SQL становится более «однотипным» на длинном участке импорта, и в логах меньше «рваного» чередования разных таблиц. Особенно если у вас включён hibernate.order_inserts. В нашем импорте мы пишем только Product, поэтому это должно выглядеть максимально чисто.

Контекст не раздувается, потому что мы делаем clear(). Это вы почувствуете даже без профилировщика: приложение не начнёт резко тормозить на больших количествах, и в худшем случае не словит OutOfMemoryError. Это тоже часть «проверки batching», потому что batching почти всегда идёт рядом с «управлением размерами unit of work».

Если вы видите batching-логи, но ускорения нет, это не повод «отменять batching». Это повод честно сказать: «batched исполняется, но в моих условиях узкое место не в JDBC round-trip’ах». И это, внезапно, очень хороший вывод. Потому что он избавляет от легенд вроде «Hibernate медленный потому что не включили одну настройку».

И если даже после этого entity-loop остаётся слишком дорогим, проблема уже не в том, что batching «плохо включён». Значит, сам подход с managed-сущностями проигрывает по цене, и тогда приходится переходить к bulk-операциям или более низкоуровневому write-flow.

9. Мини-чек batching

Вместо длинных списков полезнее держать короткую карту. Ниже — минимальная версия, которую удобно прокручивать в голове перед тем, как сказать «batching работает».

Шаг Что вы делаете Где вы это подтверждаете
1 Запускаете insert-heavy сценарий в одной транзакции Сервисный метод @Transactional, понятный цикл persist()
2 Убедились, что id strategy не ломает batching В коде/маппинге используется
SEQUENCE
, а не
IDENTITY
3 Включили batch-логи logging.level.org.hibernate.orm.jdbc.batch=DEBUG
4 В batched-варианте задали batch size session.setJdbcBatchSize(20) или hibernate.jdbc.batch_size=20
5 Контролируете размер контекста flush() / clear() в цикле
6 Доказали batching В логах есть сообщения org.hibernate.orm.jdbc.batch про выполнение batch

Этот чек не делает вас «перфоманс-инженером за вечер», но делает вас человеком, который не пишет в PR «включил batching, стало быстрее, наверное».

10. Типичные ошибки при проверке batching в Hibernate

Ошибка №1: сравнивать два запуска, одновременно меняя всё подряд.
Очень легко попасть в ловушку «сейчас я включу batch_size, поменяю стратегию id, добавлю flush/clear, включу ordering, перепишу цикл, и станет быстрее». Иногда действительно станет. Но вы не сможете объяснить почему. В production это превращается в ситуацию, когда через месяц кто-то меняет одну настройку назад — и всё разваливается, потому что никто не понял причинно-следственную связь.

Ошибка №2: считать доказательством batching только уменьшение времени.
Время выполнения может улучшиться из‑за прогрева JVM, из‑за кэша диска, из‑за того, что второй прогон случайно получил более удачное состояние базы. Если вы не посмотрели org.hibernate.orm.jdbc.batch, вы не знаете, был batching или нет. И наоборот: batching может быть, но время почти не изменится, если узкое место у вас вообще не в JDBC round-trip’ах.

Ошибка №3: включить hibernate.jdbc.batch_size, но забыть про IDENTITY.
Это классика жанра. Вы включаете batch size, а Hibernate молча не батчит inserts, потому что ему нужно получить id сразу после каждого INSERT. В логах batching пусто, но вы можете этого не заметить, если не включили org.hibernate.orm.jdbc.batch. Поэтому стратегия id — не тема «для DBA», а часть runtime-поведения.

Ошибка №4: не управлять размером persistence context в большом цикле.
Иногда batching не даёт ожидаемого эффекта просто потому, что приложение начинает тратить время на рост контекста, snapshots и dirty checking инфраструктуры, а не на отправку SQL. В результате вы включили batching, а выигрыш съелся памятью и GC. Это особенно «весело» на 50_000 строках, когда внезапно оказывается, что не база медленная, а вы держите слишком много managed-объектов.

Ошибка №5: делать flush() на каждой итерации.
Так можно случайно «прибить batching молотком». Если вы принуждаете Hibernate синхронизироваться после каждого persist(), вы превращаете unit of work в последовательность микрокоммитов внутри одной транзакции (не буквально, но по смыслу). База будет получать очень частые маленькие порции работы, а batching не успеет накопить пачку. В итоге вы включили batching — и сами же не дали ему шанса проявиться.

1
Задача
Hibernate deep-dive, 23 уровень, 4 лекция
Недоступна
Профиль `batch-lab` для сравнения baseline и batched импорта
Профиль `batch-lab` для сравнения baseline и batched импорта
1
Задача
Hibernate deep-dive, 23 уровень, 4 лекция
Недоступна
Профиль `stats-batch-lab` и вывод Hibernate Statistics
Профиль `stats-batch-lab` и вывод Hibernate Statistics
1
Опрос
Пакетные запросы, 23 уровень, 4 лекция
Недоступен
Пакетные запросы
Оптимизация массовой записи данных
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ