1. Введение
Первый импульс у большинства Java-разработчиков очень честный: «Мне нужно обновить много товаров — значит я прочитаю много Product, пройду циклом и вызову setPrice()». Это выглядит логично, потому что Hibernate и правда хорошо умеет жить в модели unit of work: вы меняете объект, а он сам потом “как-нибудь” обновится в базе. Проблема в том, что “как-нибудь” может стоить слишком дорого — и чаще всего вы платите за то, что вам вообще не нужно.
Batching уже показал полезную вещь: даже когда N одинаковых UPDATE неизбежны, их можно отправлять в БД заметно дешевле. Но это ещё не отвечает на другой вопрос: а нужны ли эти N операций вообще? Если правило одно и то же для тысяч строк, следующий честный шаг — перестать оптимизировать цикл по привычке и спросить, зачем мы вообще грузим эти сущности в память. Отсюда и весь дальнейший разговор: сначала разберём цену такого loop, потом посмотрим, когда честнее перейти к bulk, почему после него меняются правила игры для persistence context и где может пригодиться построчный режим без полноценного managed-контекста.
Представим знакомый managed-flow на нашем проекте Commerce Persistence Lab. Есть условная административная операция «поставить всем активным товарам одну и ту же цену» (да, это звучит как распродажа в стиле “всё по 89.90”, но для лаборатории — идеально).
import java.math.BigDecimal;
import java.util.List;
// Загружаем сущности в persistence context (они станут managed)
List<Product> products = entityManager.createQuery("""
select p
from Product p
where p.status = :status
""", Product.class)
// Параметр запроса, чтобы не хардкодить значение в JPQL
.setParameter("status", ProductStatus.ACTIVE)
.getResultList();
// Меняем поле у каждой managed-сущности: дальше Hibernate будет делать dirty checking
for (Product product : products) {
product.setPrice(new Money(new BigDecimal("89.90"), "USD"));
}
С точки зрения «как это ощущается», всё красиво: мы получили список объектов и поменяли поле. С точки зрения Hibernate — мы загрузили N сущностей в persistence context, создали для каждой snapshot, а потом при flush() он сравнит текущее состояние со snapshot и сформирует пачку UPDATE. И вот тут важный вопрос лекции: а точно ли нам нужно было тащить N объектов в память ради одинакового изменения?
2. Цена managed-loop: за что вы платите в массовом сценарии
Когда вы делаете «обновление в цикле», вы обычно думаете только про очевидную часть: “я сделаю N обновлений”. Но ORM добавляет несколько скрытых статей расходов, которые в маленьком датасете незаметны, а на тысячах строк превращаются в ощутимую боль. Это примерно как «в магазин за хлебом» пешком — нормально, но если вам нужно перевезти пианино, вы внезапно понимаете, что “пешком” — не универсальный транспорт.
Первый расход — это материализация данных: Hibernate должен прочитать строки из БД, собрать из них Product-объекты, возможно создать proxy для связей, положить всё это в first-level cache. Даже если вы измените только одно поле, вы уже оплатили чтение всех колонок, нужных для сущности, и работу по созданию объектов.
Второй расход — это память persistence context. Загруженные сущности живут там до конца транзакции (или до вашего clear()), а вместе с ними живут snapshots для dirty checking. На большом наборе данных вы упираетесь не только в SQL, но и в банальную физику: память не резиновая, и даже если она резиновая, GC всё равно придёт за вами — просто чуть позже и злее.
Третий расход — это dirty checking. Hibernate не “верит на слово”, что вы поменяли поле. Он сравнивает текущее состояние каждого managed-объекта со snapshot. На N=50 вы этого не заметите. На N=20_000 вы внезапно видите, что commit стал “думать” подозрительно долго — хотя SQL выглядит простым.
Четвёртый расход — это количество SQL-операций. Да, batching может их упаковать, но сам факт остаётся: вы всё равно выполняете большое количество UPDATE (по одному на строку, даже если батчами). И это может быть сильно хуже, чем один SQL UPDATE ... WHERE ....
Чтобы уложить это в голову, полезно сравнить «что мы делаем» не по Java-коду, а по форме работы:
| Подход | Что происходит в памяти приложения | Что происходит в БД | Типичная цена |
|---|---|---|---|
| Entity-loop (managed) | Загружаем N сущностей, держим snapshots, делаем dirty checking | N UPDATE (иногда batched) | CPU+RAM в приложении + много SQL-операций |
| Bulk update/delete (одна mutation query) | Не загружаем сущности как объекты | 1 UPDATE или DELETE по условию | Дёшево по памяти, но меняются правила синхронизации контекста (об этом дальше сегодня) |
И обратите внимание: в этой таблице пока нет слова “правильно/неправильно”. Есть слово “цена”. В зрелом persistence layer мы перестаём спрашивать “как привычнее?” и начинаем спрашивать: какая форма операции соответствует задаче?
3. Когда entity-loop оправдан
Важно не впасть в другую крайность: “Ага! Циклы — зло, давайте всё делать bulk”. Это примерно как после первой зарплаты решить, что готовить дома больше не надо, потому что есть доставка. Доставка прекрасна, но не тогда, когда вы хотите борщ именно как у бабушки (а бабушка, как известно, — это legacy-система с сильными инвариантами).
Entity-loop остаётся отличным решением, когда бизнес-логика реально зависит от состояния каждой сущности и её поведения в памяти. Например, если правила разные для разных товаров, если вы используете доменные методы, если есть проверки, которые не выразишь одним условием в SQL, или если изменение затрагивает не одно поле, а целый граф (и вы хотите жить в рамках обычного unit of work).
Вот простой пример из нашего домена: “если SKU начинается с LEGACY-, скрыть товар”. Это уже не «одно и то же значение всем», а решение по каждому объекту.
import java.util.List;
// Загружаем активные товары как managed-сущности
List<Product> products = entityManager.createQuery("""
select p
from Product p
where p.status = :status
""", Product.class)
.setParameter("status", ProductStatus.ACTIVE)
.getResultList();
for (Product product : products) {
// Решение зависит от данных конкретного товара: это как раз "честный" entity-loop
if (product.getSku().startsWith("LEGACY-")) {
product.setStatus(ProductStatus.HIDDEN);
}
}
Здесь цикл вполне “честный”: вы действительно смотрите на конкретные данные каждой entity и принимаете решение. В bulk-стиле это тоже можно выразить (например, where p.sku like 'LEGACY-%'), но общий принцип такой: если вы начинаете писать “if/else” по данным сущности, это сильный сигнал, что entity-loop может быть оправдан.
А ещё важный нюанс: если вы уже выбрали entity-loop, то вчерашняя техника flush()/clear() в циклах может быть тем самым компромиссом, который удержит память под контролем. Но сегодня мы разбираем шаг раньше: а нужно ли вообще было выбирать loop?
4. Когда цикл неразумен: одно действие для всех
Теперь подойдём к главной мысли лекции. Цикл по сущностям начинает проигрывать, когда ваша операция по смыслу становится операцией над множеством строк, а не над множеством объектов. То есть когда вы делаете одно и то же изменение (или одно и то же удаление) по простому условию. Тогда загрузка каждой entity — это как открыть каждую коробку на складе, чтобы наклеить на неё одну и ту же наклейку. Можно? Можно. Но вы правда хотите?
В нашем проекте есть два очень жизненных примера bulk-кандидатов: массовое изменение цены (Product repricing) и техническая очистка исторических данных (InventorySnapshot cleanup). В обоих случаях часто нет нужды видеть каждый объект в памяти — достаточно одного условия.
Если взять пример с одинаковой ценой для всех активных товаров, то тот же смысл можно выразить одной mutation query:
import java.math.BigDecimal;
// Bulk update: меняем строки в БД напрямую, без загрузки сущностей в память
int updated = entityManager.createQuery("""
update Product p
set p.price = :newPrice
where p.status = :status
""")
// Значение, которое будет записано всем подходящим строкам
.setParameter("newPrice", new Money(new BigDecimal("89.90"), "USD"))
.setParameter("status", ProductStatus.ACTIVE)
// Возвращает количество обновлённых строк
.executeUpdate();
// Это именно количество затронутых строк, а не количество managed-объектов
System.out.println("Updated rows = " + updated); // Updated rows = 42
Заметьте, что здесь мы не говорим “Hibernate, пожалуйста, загрузить мне 42 объекта и потом по одному их обновить”. Мы говорим: “База данных, измени 42 строки по условию”. Это другая форма операции. И именно этот вопрос вы должны задавать себе в начале: мне нужны сущности в памяти или мне нужна массовая операция над строками?
Важный момент, который мы пока только обозначим: bulk-операции меняют данные напрямую в БД, и это означает, что привычная модель persistence context начинает вести себя иначе. Bulk в этот момент обходит часть managed-механики Hibernate, и именно отсюда потом вылезают stale-объекты и вся отдельная дисциплина вокруг flush, clear и refresh.
5. Bulk vs batching: разные задачи
После знакомства с JDBC batching легко появляется естественная путаница: “Если у меня batching, значит я уже оптимизировал массовые операции”. Это очень частая ошибка, и она заслуживает отдельного пояснения, потому что иначе вы будете лечить одно другим и удивляться, почему «вроде всё ускорил», а приложение всё равно ест память как студент доширак.
Batching — это про то, как отправлять много похожих SQL (например, 10_000 INSERT) эффективнее: меньше round-trip’ов, больше работы за один заход. Но batching не отменяет сущностную модель: вы по-прежнему создаёте/загружаете объекты, держите их в persistence context, делаете dirty checking, и по сути выполняете N логических операций, просто упакованных.
Bulk update/delete — это про то, какую операцию вы делаете: одну массовую операцию в БД. Здесь нет “по одной сущности”, и поэтому нет необходимости платить за materialization и snapshots. Зато появляются другие нюансы: bulk не “синхронизирует” managed-объекты автоматически, и это уже другой тип опасности.
Чтобы не запутаться, полезно держать в голове простую формулу:
- если вы видите в коде цикл for (entity : entities) { ... }, то вы в мире entity-loop (даже если у вас batching);
- если вы видите update ... where ... или delete ... where ... как одну команду, то вы в мире bulk.
И да, иногда правильный ответ будет: “мне нужен entity-loop и batching”. Но в этой лекции мы тренируем более базовое умение: вовремя понять, что loop вообще лишний.
6. Bulk-кандидаты: repricing и cleanup
Чтобы теория не осталась “про абстрактные таблицы”, привяжем её к нашим двум сценариям проекта. Это полезно ещё и потому, что эти случаи отлично объясняют разницу между «мне нужно поведение объектов» и «мне нужно массово поправить данные».
Начнём с cleanup исторических InventorySnapshot. Наивный вариант выглядит так: читаем старые snapshot’ы и удаляем каждый через remove(). Это снова entity-loop, только на удалении.
import java.time.LocalDate;
import java.util.List;
LocalDate border = LocalDate.now().minusDays(90);
// Читаем snapshot'ы как сущности (то есть опять материализация + persistence context)
List<InventorySnapshot> snapshots = entityManager.createQuery("""
select s
from InventorySnapshot s
where s.snapshotDate < :border
""", InventorySnapshot.class)
.setParameter("border", border)
.getResultList();
for (InventorySnapshot snapshot : snapshots) {
// remove() работает с managed-сущностью: поэтому мы и тащили её в память
entityManager.remove(snapshot);
}
Интуитивно это “работает”. Но если это чисто техническая очистка по порогу даты, то смысл операции — “удали всё старше даты”. Вам не нужен ни один InventorySnapshot как объект.
Тогда более честная форма операции — bulk delete:
import java.time.LocalDate;
LocalDate border = LocalDate.now().minusDays(90);
// Bulk delete: удаляем строки сразу в БД, не загружая сущности
int deleted = entityManager.createQuery("""
delete from InventorySnapshot s
where s.snapshotDate < :border
""")
.setParameter("border", border)
// Возвращает количество удалённых строк
.executeUpdate();
System.out.println("Deleted rows = " + deleted); // Deleted rows = 12000
Видите, насколько это ближе к смыслу? Мы сделали одну операцию, которая совпадает с задачей. И главное — мы не заставляли Hibernate работать грузчиком, который носит в память тысячи объектов только ради того, чтобы выкинуть их обратно.
С repricing Product история похожая. Если вам нужно применить одинаковое правило к большому набору товаров (например, “все ACTIVE товары сделать HIDDEN” или “всем ACTIVE поставить цену X”), bulk почти всегда выглядит естественнее. А если нужно сложное правило, зависящее от данных товара, то loop остаётся оправданным. В этой лекции цель — научиться видеть разницу по формулировке бизнес-действия.
Проверка перед реализацией
На практике очень помогает один простой “пред-рефакторинг” — ещё до кода. Попробуйте сформулировать вашу задачу двумя фразами: как действие над объектами и как действие над данными. Если версия “над данными” получается короткой и точной (“обнови все строки, где …”), значит, вам стоит хотя бы рассмотреть bulk. Если же без объектов вы не можете выразить правило (“для каждого товара посчитать скидку по куче условий”), скорее всего, вы в entity-loop.
Эту проверку удобно держать в голове как маленькую блок-схему:
flowchart TD
A[Надо массово изменить данные] --> B{Логика зависит
от каждого объекта?}
B -->|Да| C["Entity-loop (managed)"]
B -->|Нет| D{Можно выразить
одной операцией в БД?}
D -->|Да| E["Bulk update / bulk delete"]
D -->|Сомнительно| F[Смешанный подход, но без автоматизма]
Обратите внимание: “сомнительно” — это тоже нормальный ответ. Иногда bulk-запрос получается слишком сложным, и тогда проще честно остаться в entity-loop, но уже понимать, за что вы платите, и заранее включать flush/clear-дисциплину. Главное — перестать выбирать цикл “по привычке”.
7. Типичные ошибки при выборе bulk и loop
Ошибка №1: путать bulk и batching, как будто это одно и то же.
После знакомства с JDBC batching появляется ощущение: “Теперь любые массовые операции будут быстрыми”. Но batching всего лишь упаковывает множество SQL-операций, а не отменяет materialization сущностей и dirty checking. Если вы делаете одинаковое изменение на тысячи строк, batching может быть полезен, но bulk почти всегда будет другой лигой по цене.
Ошибка №2: выбирать bulk только потому, что строк много, и игнорировать смысл операции.
Количество — плохой единственный критерий. Бывает, что строк много, но логика реально индивидуальная: например, нужно проверить данные, принять решение, вызвать доменный метод, поменять разные поля по разным правилам. В таком случае entity-loop будет честнее. Правильный критерий — зависимость от состояния каждой сущности, а не размер списка.
Ошибка №3: сравнивать подходы по длине Java-кода, а не по форме операции.
Иногда bulk выглядит “красивее” просто потому, что кода меньше. Но в зрелой инженерии важнее то, что делает ваш код: сколько данных он тянет в память, сколько SQL выполняет, какие side effects возможны. Смотрите на SQL shape и на роль persistence context, а не на количество строк в IDE.
Ошибка №4: делать техническую очистку (remove() в цикле) без понимания цены persistence context.
Удаление в цикле — это тоже загрузка объектов и удержание их в first-level cache до конца транзакции. Даже если вы вчера научились делать flush/clear, это всё равно может быть неоправданной работой, если задача выражается одним delete ... where .... В bulk-режиме вы не только ускоряете операцию, вы ещё и делаете код ближе к смыслу.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ