1. Batching включён, но запросы одиночные
IDENTITY уже не мешает вставкам, flush()/clear() держат под контролем размер контекста — и тут всплывает ещё одна вещь: batch может почти не собраться, если flush выдаёт рваный поток SQL. То есть проблема уже не в самом факте массовой записи, а в том, насколько однотипными доходят операции до JDBC.
Представьте, что вы включили hibernate.jdbc.batch_size, сделали всё «как в примерах», запускаете импорт — и видите в логах всё те же одиночные INSERT/UPDATE, как будто batching вообще не пришёл на работу. Это очень частая ситуация: batch size задаёт возможность, но реальное поведение зависит от того, насколько ваш поток SQL похож на «серийное производство», а не на «хаотичный склад, где всё вперемешку».
Ключевой момент здесь в том, что JDBC batching работает вокруг PreparedStatement. Условно, драйвер умеет сказать: «Окей, у меня есть один и тот же SQL вида insert into product (...) values (?, ?, ?) — давайте я выполню его 50 раз одной пачкой». Но если ваш flush выдаёт последовательность INSERT product, потом INSERT category, потом снова INSERT product, потом INSERT category, то с точки зрения драйвера это уже два разных PreparedStatement, между которыми он вынужден постоянно переключаться. В результате пачки либо получаются маленькими (например, по 1–2 операции), либо вообще «схлопываются» до одиночных выполнений.
И вот здесь появляется тема сегодняшней лекции: hibernate.order_inserts и hibernate.order_updates. Это не «ещё два магических флажка ускорения», а попытка Hibernate переупорядочить накопленные операции записи так, чтобы рядом стояли похожие SQL-операции. То есть Hibernate пытается сделать для вашего write-потока то, что вы бы сделали сами, если бы были очень терпеливым человеком: сложить все одинаковые бумажки в одну стопку, а потом аккуратно отнести их в БД.
Чтобы не было путаницы: это никак не связано с ORDER BY в SELECT. Мы не сортируем данные, которые читаем. Мы сортируем операции записи (INSERT/UPDATE) перед тем, как отправить их в базу во время flush.
2. Flush и ActionQueue
Если пытаться представить Hibernate как «человека», то до flush он больше похож на бухгалтера: он записывает в свою тетрадку, что вы создали сущность, поменяли поле, удалили элемент коллекции, но не бежит каждый раз в базу с криком «СРОЧНО UPDATE!!!». А вот в момент flush он превращается в курьера: достаёт тетрадку и начинает реально отправлять SQL в БД. И именно в этот момент можно что-то «упорядочить».
Технически внутри Hibernate есть структура, которую обычно описывают как очередь/набор очередей действий — что-то вроде «плана работ на flush». В ней отдельно копятся insert-операции, update-операции, удаления, операции по коллекциям и т.д. На лекциях про flush мы уже обсуждали, что Hibernate выполняет этот план не в произвольном порядке, а в определённой логике, чтобы не ломать внешние ключи и целостность.
hibernate.order_inserts и hibernate.order_updates вмешиваются не в вашу бизнес-логику и не в dirty checking, а в то, как именно будет упорядочен этот план действий. То есть ваш Java-код остаётся тем же: вы вызываете persist(), меняете поля у managed-сущностей, работает каскад — всё как раньше. Меняется только порядок, в котором Hibernate будет отправлять SQL во время flush.
Полезно держать в голове такую схему (упрощённую, но рабочую):
flowchart TD
%% До flush Hibernate только "копит" действия, SQL обычно ещё не улетает в базу
A["Вы вызываете persist()/меняете managed-entity"] --> B["Hibernate копит действия в очереди flush"]
%% Во время flush Hibernate формирует и выполняет план записи в БД
B --> C["Наступает flush: AUTO или вручную"]
C --> D{"Включены order_inserts / order_updates?"}
D -- нет --> E["Выполняем действия в естественном порядке очередей"]
D -- да --> F["Сортируем insert/update/collection actions для лучшей группировки"]
E --> G["JDBC driver выполняет SQL"]
F --> G["JDBC driver выполняет SQL (больше шансов на batch)"]
Важное следствие: ordering работает внутри конкретного flush. Если вы делаете flush слишком часто, у Hibernate просто не накапливается достаточная «куча» действий, которую было бы выгодно сортировать. И наоборот: если вы копите 100 000 действий в одном flush, вы получите и потенциал batching, и потенциальную боль по памяти/времени сортировки. Поэтому ordering — это усилитель аккуратно построенного write-flow, а не волшебная замена flush()/clear().
3. hibernate.order_inserts: группируем INSERT
Когда вы включаете hibernate.order_inserts, Hibernate начинает упорядочивать insert-операции по типу сущности и значению первичного ключа, чтобы JDBC batching работал эффективнее. По умолчанию значение этого свойства false. В документации важна ещё одна фраза: порядок сортировки учитывает зависимости внешних ключей, то есть Hibernate не будет «ради красоты» вставлять детей раньше родителей и провоцировать FK-ошибки.
Польза для batching
У batching есть довольно скучная, но честная механика: драйвер бьёт batching по границам PreparedStatement. Для Product и Category SQL будет разный, значит PreparedStatement разные. Если вы вперемешку вставляете продукты и категории, то вы как будто заставляете водителя маршрутки каждые 10 метров менять автобус на трамвай и обратно — формально движение есть, но что-то тут явно не оптимально.
order_inserts делает проще: «Давай сначала все продукты, потом все категории (но не нарушая FK-зависимости)». В результате PreparedStatement для product может накопить пачку из, например, 20 вставок, а потом подготовится пачка для category.
Мини-сценарий в нашем Commerce Persistence Lab
Возьмём предельно житейский кусок кода, который вполне может появиться в лабораторном импорте каталога. Мы чередуем persist() разных сущностей, потому что входные данные пришли «парами», и рука сама написала один цикл:
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class CatalogMixedImportService {
private final EntityManager em;
public CatalogMixedImportService(EntityManager em) {
this.em = em; // EntityManager нужен, чтобы persist() складывал действия в persistence context
}
@Transactional
public void importMixed(List<Product> products, List<Category> categories) {
// Важно: предполагаем, что списки синхронизированы по размеру и соответствию элементов
for (int i = 0; i < products.size(); i++) {
// На уровне кода это выглядит как "две persist подряд"
em.persist(products.get(i)); // будет INSERT в таблицу product (во время flush)
em.persist(categories.get(i)); // будет INSERT в таблицу category (во время flush)
// Из-за такого чередования без ordering SQL часто получается "рваным", и batching недобирается
}
// SQL фактически полетит в БД не здесь, а на flush/commit (если не было принудительного flush ранее)
}
}
На уровне Java это выглядит как «две одинаковые операции в цикле». Но на уровне SQL Hibernate увидит две разные таблицы, и без ordering поток может стать «рваным»: insert product, insert category, insert product, insert category… И batch по каждой таблице может не успевать набраться до вашего batch_size.
С включённым hibernate.order_inserts Hibernate получает шанс во время flush переупорядочить insert-действия так, чтобы они шли группами по типам сущностей (с оглядкой на зависимости).
SQL-след (упрощённо)
Пусть ваша БД видит примерно такие запросы (для иллюстрации):
Без упорядочивания:
-- Запросы чередуются: драйверу приходится прыгать между разными PreparedStatement
insert into product (sku, name) values (?, ?);
insert into category (code, name) values (?, ?);
insert into product (sku, name) values (?, ?);
insert into category (code, name) values (?, ?);
С hibernate.order_inserts=true у Hibernate появляется шанс превратить это во что-то ближе к:
-- Запросы сгруппированы по таблице: выше шанс, что это реально уйдёт одной пачкой (batch)
insert into product (sku, name) values (?, ?);
insert into product (sku, name) values (?, ?);
insert into category (code, name) values (?, ?);
insert into category (code, name) values (?, ?);
И вот тут batching начинает выглядеть как batching: два одинаковых insert into product ... можно реально отправить пачкой, а потом два одинаковых insert into category ... — другой пачкой.
Важный «психологический» момент
Когда ordering включён, вы можете увидеть в логах, что SQL идёт не «как в цикле». Это нормально. Hibernate не обязан сохранять тот порядок SQL, который вам психологически приятен, он обязан сохранять корректность данных. А порядок SQL — это уже часть стратегии выполнения, и она как раз настраивается этими флагами.
Если вам нужно, чтобы SQL шёл строго как в коде (обычно это нужно только для специфических триггеров или отладочных экспериментов), ordering лучше не включать. В нормальном же backend-потоке опираться на порядок DML — не самая надёжная идея, потому что flush сам по себе уже может произойти раньше, чем «вы ожидали» (например, перед запросом).
4. hibernate.order_updates: группируем UPDATE и коллекции
hibernate.order_updates по смыслу очень похож на order_inserts, но он работает на update-потоке и чуть шире по охвату. По умолчанию это свойство тоже выключено (false). Когда оно включено, Hibernate упорядочивает update-операции по типу сущности и первичному ключу, а также упорядочивает операции, связанные с изменением коллекций, по роли коллекции и значению внешнего ключа — опять же, ради более эффективного JDBC batching.
В документации есть ещё один тонкий нюанс: такое упорядочивание также снижает шанс unique key violation, когда элемент коллекции «переезжает» от одного родителя к другому, потому что Hibernate старается выполнять операции удаления/«removal» до операций, которые removal не содержат. Это не «железная гарантия», но полезная защита от очень неприятных редких конфликтов.
Рваный update-поток
С insert-потоком обычно всё понятно: вы создаёте новые объекты. А update-поток часто появляется «сам»: вы загрузили набор сущностей, поменяли поля, Hibernate сделал dirty checking — и в flush накопилась куча UPDATE.
Если вы в одной транзакции меняете сразу несколько типов сущностей (например, товары и остатки, или заказы и позиции), то update-запросы могут чередоваться, особенно если изменения происходят в разных участках кода, через каскады, хелперы и прочую «жизнь проекта».
order_updates пытается разложить эти изменения в «аккуратные стопки»: сначала все обновления одного типа, потом другого. Это повышает шанс, что JDBC batching сможет реально собрать пачки по PreparedStatement.
Небольшой пример из домена: обновляем товары и остатки в одной транзакции
Представим сервисный сценарий, который по какой-то причине обновляет и Product, и InventoryItem в одной транзакции. Это не обязательно «правильно» с точки зрения домена, но в лабораторном проекте такие штуки нужны, чтобы видеть поведение Hibernate:
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
@Service
public class ReindexService {
private final EntityManager em;
public ReindexService(EntityManager em) {
this.em = em; // Нужен, чтобы сущности были managed в рамках транзакции
}
@Transactional
public void touchProductAndInventory(Product product, InventoryItem item) {
// Важно: product и item должны быть managed (загружены в этой сессии или merge'нуты),
// иначе Hibernate может не сделать ожидаемый UPDATE на flush.
product.setName(product.getName() + " *"); // будет UPDATE product (dirty checking на flush)
item.setUpdatedAt(Instant.now()); // будет UPDATE inventory_item (dirty checking на flush)
// Если таких пар много, без order_updates UPDATE'ы часто будут прыгать между таблицами
// и batching будет хуже работать.
}
}
Если такой метод вызывается в цикле для многих пар product+inventory, то без order_updates flush может легко получить последовательность обновлений, где SQL будет прыгать между таблицами, что ухудшает batching. А hibernate.order_updates=true даёт шанс сгруппировать update-запросы по типу сущности и PK.
Коллекции и роль коллекции
Фраза «роль коллекции» звучит как что-то из RPG («у вашей коллекции роль “маг” или “танк”?»), но в Hibernate это намного прозаичнее. Роль коллекции — это по сути «какая именно коллекция в какой сущности»: например, PurchaseOrder.items или Customer.addresses. Даже если элементы коллекции — это отдельные entity, вокруг изменения коллекции Hibernate всё равно выполняет набор действий: добавить элемент, удалить элемент, поменять связь, иногда обновить FK-колонку.
order_updates может упорядочивать такие действия, чтобы уменьшить хаос и увеличить шанс batching именно там, где изменение коллекции генерирует много однотипных SQL.
Ordering updates и риск deadlock
В прошлых модулях (про locking) мы обсуждали, что deadlock’и часто возникают, когда две транзакции берут блокировки на строки в разном порядке: одна обновляет id=10 потом id=20, другая — наоборот. Если update’ы упорядочены по PK, обе транзакции чаще будут обновлять строки в одинаковом порядке, и риск взаимной блокировки становится ниже.
Но здесь важно не впасть в «магическое мышление». hibernate.order_updates — это тюнинг write-потока ради batching и чуть более детерминированной последовательности. Он не заменяет корректные transaction boundaries, не лечит неправильные lock-стратегии и не превращает конкурентность в сказку со счастливым концом.
5. Включаем ordering в Spring Boot 4
На практике здесь нужны всего две Hibernate-property рядом с уже включённым batching. Если hibernate.jdbc.batch_size всё ещё равен 0, ordering нечего усиливать: сортировать можно сколько угодно, batch от этого сам не появится.
spring:
jpa:
properties:
hibernate.order_inserts: true
hibernate.order_updates: true
Если batch size задаёте локально на Session, эти два флага всё равно остаются полезными: они влияют на порядок действий внутри flush, а не на то, откуда взялся размер партии.
order_inserts и order_updates не ускоряют SQL в вакууме. Они лишь делают write-поток более однородным внутри flush, чтобы JDBC batching чаще собирал полноценные пачки по одинаковым PreparedStatement.
Если ваш insert/update поток и так однотипный, разницы может почти не быть. Но в mixed write-flow эти две строки часто дают тот самый эффект “batching вроде включён, а теперь наконец начал выглядеть как batching”.
6. Когда ordering заметен, а когда нет
Обычно такие флаги хочется включить «на всякий случай». Это человеческое желание, я не осуждаю — я сам так делал, потом сам же разгребал последствия и писал себе в голове баг-репорт. Поэтому полезно заранее понимать, где order_inserts/order_updates дают видимый профит, а где вы, скорее всего, потратите время на настройку и не увидите разницы.
Наблюдение простое: ordering особенно заметен, когда внутри одного flush у вас есть много операций записи разных типов, и эти операции изначально попадают в очередь «перемешанными». Это бывает из-за чередования persist() разных сущностей, из-за каскадов, из-за сложного графа изменений, из-за того, что код «размазан» по нескольким методам и не держит в голове подготовку батча.
Если же у вас сценарий вида «берём 5000 Product и обновляем им одно поле в цикле», то у вас и так получится серия однотипных UPDATE product ..., и ordering может ничего не добавить: там уже нечего улучшать, поток и так однородный.
Удобно зафиксировать это в маленькой табличке (без религии, просто как инженерный ориентир):
| Сценарий записи в рамках одного flush | Что происходит без ordering | Что может улучшиться с ordering |
|---|---|---|
| Массовые INSERT одного типа (Product только) | SQL уже однородный | Обычно почти без разницы |
| Чередование разных типов (Product, Category, Assignment) | SQL прыгает между таблицами | Больше шансов собрать полноценные батчи по типам |
| Массовые UPDATE по разным типам в одной транзакции | update-поток перемешан | Группировка UPDATE по типу и PK, лучше batching |
| Очень частый flush() (например, каждую итерацию) | batching и ordering не успевают «развернуться» | Эффект минимален, потому что нечего сортировать |
| GenerationType.IDENTITY на сущностях с insert-heavy сценарием | Hibernate не может нормально батчить insert | ordering не спасает принципиальную проблему |
И ещё один важный нюанс: ordering — это дополнительная работа (сортировка, перестройка очередей). Если вы включаете эти флаги в сценарии, где batching почти невозможен или не нужен, вы можете получить «настроили, стало даже чуть медленнее». И это не баг Hibernate, это честный результат: вы добавили вычисления, а выиграть было негде.
7. Типичные ошибки
Вокруг этих настроек легко попасть в ловушку «поставил флажок — стало быстрее». Поэтому полезно заранее увидеть несколько грабель, чтобы наступить на них хотя бы один раз в теории, а не три раза в проде.
Ошибка №1: включить ordering, но забыть, что batching выключен.
Иногда в конфиге появляется hibernate.order_inserts=true, а hibernate.jdbc.batch_size остаётся 0. В результате Hibernate честно сортирует insert/update действия, но JDBC batching как механизм не включён, и вы не увидите обещанного эффекта. Более того, вы добавили сортировку, но не получили отдачу. Напоминание очень простое: batching включается только при положительном hibernate.jdbc.batch_size.
Ошибка №2: ожидать, что ordering исправит проблему IDENTITY.
Если стратегия генерации id делает batch insert принципиально невозможным (как это бывает с IDENTITY в Hibernate), то order_inserts не станет «обходным путём». Он не превращает невозможное в возможное. В таких сценариях вы сначала решаете проблему стратегии идентификатора и формы insert-flow, и только потом рассматриваете ordering как тюнинг.
Ошибка №3: делать flush слишком часто и удивляться, что ordering “не работает”.
Ordering работает внутри flush, а значит ему нужно, чтобы flush имел достаточно накопленных действий. Если вы вызываете flush() на каждой итерации (или flush постоянно срабатывает автоматически из-за ваших же запросов внутри транзакции), то сортировать просто нечего: в очереди слишком мало операций, чтобы группировка дала разницу.
Ошибка №4: путать hibernate.order_inserts/hibernate.order_updates с ORDER BY и настройками чтения.
Очень частая путаница: кто-то слышит слово «order» и начинает искать, как это влияет на сортировку списка товаров в админке. Никак. Это настройка порядка DML во время flush. Для чтения у вас другой инструментарий: запросы, JOIN FETCH, EntityGraph, projections и так далее — но это вообще другая часть курса и другая “ось мира”.
Ошибка №5: рассчитывать на конкретный порядок SQL как на часть бизнес-логики.
С включённым ordering SQL может перестать идти в том порядке, в котором вы делали persist() или меняли поля. Но даже без ordering порядок DML во время flush может зависеть от каскадов, зависимостей и внутренних правил Hibernate. Если у вас есть бизнес-требование “в БД сначала обязательно должна появиться запись X, потом Y” — это обычно сигнал, что вы смешали бизнес-правила с инфраструктурной деталью. Hibernate гарантирует корректность, а не «приятный глазу» порядок логов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ