1. Массовые операции: один размер — не один подход
Когда вы впервые сталкиваетесь с задачей «обновить 50 000 строк», мозг начинает искать один универсальный рецепт. Хочется верить, что где-то есть волшебная кнопка “Make it fast”, и она называется либо “batching”, либо “bulk”, либо “какой-то хитрый режим Hibernate”. Реальность прозаичнее: разные сценарии требуют разной семантики изменения данных, а уже потом — разной техники исполнения.
К этому месту у нас уже есть три разных режима: managed entity-loop, bulk mutation query и StatelessSession. Поэтому дальше важнее не заново разбирать механику каждого, а научиться быстро выбирать подход под смысл операции, объём данных и цену persistence context.
Представьте, что вам нужно поменять объявления на подъездах в большом районе. Иногда вам действительно нужно зайти в каждый подъезд, посмотреть, что там висит, и аккуратно заменить только то, что соответствует условию — это entity-loop. Иногда вам нужно просто поменять шаблон на сервере, и все объявления в электронных табло обновятся одним махом — это bulk update. А иногда вы хотите пройтись по подъездам, но без «журнала посещений» и без того, чтобы хранить в голове историю всех подъездов, где вы уже были — это как раз StatelessSession.
В нашем проекте Commerce Persistence Lab эти ситуации выглядят очень жизненно: товары (Product) иногда надо массово скрыть или «переоценить», а исторические снапшоты остатков (InventorySnapshot) периодически нужно чистить, чтобы таблица не превращалась в музей бесконечного прошлого. И вот здесь важно не начать лечить любую проблему одним инструментом «потому что он звучит круче».
2. Таблица: entity-loop, bulk и StatelessSession
Чтобы голова не превращала выбор в философский спор, полезно один раз посмотреть на эти инструменты как на три разных договора с Hibernate. В одном договоре Hibernate берёт на себя много обязанностей и берёт за это «налог» ресурсами. В другом — вы говорите “я сам”, а Hibernate просто выполняет SQL. В третьем — вы тоже говорите “я сам”, но всё ещё остаетесь внутри Hibernate API, просто без persistence context.
Ниже — «шпаргалка на стену» (или на второй монитор, рядом с SQL-логом, чтобы вы чувствовали себя взрослым разработчиком, даже если кофе остыл):
| Подход | Что реально происходит | Когда это естественно | Главная цена/риск | Что делать с persistence context |
|---|---|---|---|---|
entity-loop (обычный /) |
Загружаем managed-сущности → меняем поля → dirty checking → |
Логика зависит от каждой строки, нужны доменные проверки, helper-методы, каскады, привычный lifecycle | Много объектов в памяти, snapshots, дорогой dirty checking, риск «забыли clear в большом цикле» | Следить за размером контекста, в больших циклах думать про |
bulk / (mutation query) |
Один запрос меняет/удаляет строки в БД без загрузки сущностей | «Одно правило на всех» по условию, технические массовые правки, cleanup | Stale persistence context: в памяти останутся старые значения; также bypass managed-модели | Обычно нужен явный протокол: (если были изменения) → bulk → или → повторное чтение |
| StatelessSession | Нет first-level cache, нет automatic dirty checking; операции вызываются явно | Нужна построчная обработка, но не нужен managed-граф; хочется избежать раздувания persistence context | Можно легко «ничего не сохранить», если забыть /; нет каскадов и «магии» |
Persistence context как явление отсутствует — но нужна дисциплина транзакции и явных вызовов |
Эта таблица важна не тем, что её надо выучить. Она важна тем, что вы начинаете задавать правильный первый вопрос: “мне нужна managed-модель или мне нужен эффект в БД?”. Как только вы это спрашиваете, половина ошибок исчезает сама — как только вы перестаёте считать Hibernate «магией», которая обязана всё угадывать.
3. Product: массовый статус и цена
Product в нашем проекте — звезда большинства лабораторий. На нём удобно показывать fetching, soft delete, Envers и многое другое. Но сегодня Product выступает в роли «массовой правки каталога», которая в реальной жизни случается постоянно: распродажа, закрытие линейки, скрытие legacy-ассортимента, заморозка товаров по регуляторным причинам. И каждый раз вопрос один: нам нужна логика “на каждый товар отдельно” или “один запрос и готово”?
С точки зрения разработчика это очень соблазнительное место, чтобы написать красивый Java-код: загрузить список, пробежаться, применить правила, вызвать save (чтобы точно, “на всякий случай”), и уйти с чувством выполненного долга. Но именно тут Hibernate заставляет вас взрослеть: цена этого “на всякий случай” может быть большой, особенно если правило по сути одинаковое для всех строк.
Bulk update для «одного правила на всех»
Если бизнес-идея звучит как “всем ACTIVE поставить HIDDEN” или “всем ACTIVE применить один коэффициент цены”, Product почти всегда становится bulk-кандидатом. Здесь операция описывает множество строк, а не поведение отдельного объекта, поэтому грузить каждый Product в память чаще всего просто дорого и лишне.
Если такой bulk завернули в Spring Data repository, физика не меняется: это всё равно mutation query, а после неё всё ещё нужно помнить про flush → bulk → clear/refresh → reread.
entity-loop для логики на каждом объекте
Когда решение зависит от SKU, доменных проверок, helper-методов, состояния связей или разных правил для разных товаров, bulk перестаёт быть естественным. Тогда managed entity-loop честнее: вы действительно работаете с конкретными объектами и их поведением, а не с множеством строк по одному шаблону.
Цена этого выбора никуда не исчезает — память, snapshots, dirty checking, дисциплина flush/clear на больших объёмах. Но если бизнес-правило живёт на уровне конкретной сущности, это нормальная цена, а не “провал оптимизации”.
StatelessSession для построчной обработки без раздувания PC
Иногда логика всё ещё построчная, но полноценный managed-контекст для неё не нужен. Тогда StatelessSession даёт промежуточный вариант: вы проходите по товарам по одному, но без first-level cache и automatic dirty checking.
Платите вы уже не памятью persistence context, а явностью: транзакция открывается явно, update()/delete() вызываются явно, каскадной “магии” нет. Это хороший режим для технических массовых job-ов, а не для обычного сервисного кода каталога.
4. InventorySnapshot: очистка истории
InventorySnapshot — это сущность, которая по своей природе стремится стать большой. Исторические данные любят копиться: каждый день новые строки, потом ещё и ещё, и в какой-то момент таблица начинает жить отдельной жизнью. И вот тут появляется типичный maintenance-вопрос: “удалить всё старше N дней”. Это почти учебник по bulk delete: одна граница по дате, одно действие, никакой доменной магии не нужно.
Но, как и с товарами, важно понимать: иногда “удалить старое” — не просто порог по дате, а серия условий, исключений и “оставить по одному последнему на товар”. И тогда bulk delete может перестать быть достаточным. Мы рассмотрим оба взгляда, чтобы вы научились задавать правильный вопрос, а не угадывать инструмент по настроению.
Порог по дате — почти всегда bulk delete
Если правило звучит как “удалить все строки, где snapshotDate < border”, cleanup почти всегда относится к bulk delete. Смысл операции здесь совпадает с delete ... where ...: не нужен ни один InventorySnapshot как объект, не нужен remove() в цикле, не нужно раздувать persistence context тысячами строк, которые вы тут же выбросите.
Если такой delete оформлен методом репозитория, это всё равно тот же bulk с теми же правилами для stale-состояния.
Когда cleanup зависит от каждой строки
Если cleanup зависит от нескольких полей, исключений или расчётов по каждой записи, выбор снова раскалывается на два варианта. Нужны lifecycle агрегата, связи и обычная managed-модель — остаёмся в entity-loop. Нужен просто построчный технический проход по большим объёмам без раздувания контекста — честнее взять StatelessSession.
Для InventorySnapshot второй вариант встречается особенно часто: здесь обычно важнее быстро и предсказуемо обработать много строк, чем жить в полном ORM-lifecycle каждой записи.
5. Мини-алгоритм выбора
Даже когда вы всё поняли теоретически, в реальной работе мозг иногда делает так: “тут 1000 строк… значит bulk… хотя… а вдруг…”. Чтобы этого избежать, полезно иметь простой алгоритм выбора. Не идеальный, не математический, но достаточно честный, чтобы не ошибаться на каждом втором pull request.
Ниже — один из вариантов в виде схемы. Это не “единственно верный путь”, а способ быстро прийти к первому осмысленному решению и уже потом уточнить детали по SQL-логу.
flowchart TD
A[Нужно массово изменить/удалить данные] --> B{Логика зависит
от каждой строки?}
B -->|Нет, одно правило| C[Bulk update/delete]
C --> C1[Выполни bulk]
C1 --> C2[После bulk: flush/clear или refresh + перечитать]
B -->|Да, нужна проверка/вычисление| D{Нужен managed-граф
ORM-магия: каскады,
helper-методы?}
D -->|Да| E[Entity-loop в обычной Session/EntityManager]
E --> E1[Следи за размером context; при больших объёмах думай про flush/clear]
D -->|Нет, построчно и явно| F{Объём большой и
PC раздувать нельзя?}
F -->|Да| G[StatelessSession]
F -->|Нет| E
Важный нюанс: этот алгоритм начинается не с “сколько строк”, а с “какая семантика”. Размер данных — это второй вопрос. Если вы начнёте с размера, вы будете выбирать инструмент «по страху» (или “по моде”), а не по смыслу операции. Hibernate такое любит наказывать: иногда не сразу, а через пару месяцев, когда данные станут больше.
6. Типичные ошибки при выборе подхода
Ошибки этого дня редко выглядят как “компилятор ругается”. Обычно всё компилируется и даже проходит happy-path тесты. А потом через неделю кто-то говорит: “у нас странно: после массовой операции UI показывает старые значения” или “почему сервис вдруг стал есть 2 гига памяти”. И вы начинаете узнавать в этом себя.
Ошибка №1: выбирать инструмент только по скорости и забывать про смысл операции.
Новичок часто видит bulk и думает: “О, быстрее — значит всегда лучше”. Но bulk — это про одинаковую массовую правку, а не про доменную логику. Если вам важно соблюсти правила на уровне каждой сущности, bulk превращает бизнес-логику в SQL-строку и делает сопровождение больнее. Правильное мышление начинается с вопроса: “мне нужен managed объект или эффект в БД?”.
Ошибка №2: сделать bulk и продолжить работать со старыми объектами как ни в чём не бывало.
Это классика stale persistence context: вы обновили статус товаров bulk-запросом, а потом читаете product.getStatus() у сущности, которую загрузили до bulk. И получаете старое значение. Не потому что Hibernate “сломался”, а потому что он честно не обязан обновлять ваши объекты, когда вы изменили БД в обход managed-модели. Лекарство простое: либо clear() и перечитать, либо точечный refresh() (если реально нужен один объект).
Ошибка №3: использовать StatelessSession, но ожидать от него automatic dirty checking.
Это особый вид боли: код выглядит “как обычно”, вы меняете поле, выходите из блока try, а в базе — тишина. Потому что в stateless-модели изменение поля — это просто изменение Java-объекта. Hibernate за ним не следит. Если выбрали StatelessSession, то “явный update()” — не опция, а обязательная часть договора.
Ошибка №4: смешивать в одном длинном методе чтение, bulk и дальнейшую бизнес-логику без явных фаз.
Bulk операции требуют дисциплины: “до bulk” и “после bulk” — это две разные реальности для persistence context. Когда вы впихиваете всё в один метод без разделения, вы сами создаёте себе ловушку: сложно понять, какие данные ещё managed, какие уже stale, а какие вообще должны быть перечитаны. Хороший код после bulk выглядит как короткая последовательность шагов: flush → bulk → clear → reread.
Ошибка №5: пытаться решить всё StatelessSession, потому что «там меньше магии».
Иногда после знакомства со stateless подходом возникает желание “выкинуть ORM из ORM” и писать всё явно: get/update/delete везде. Это обычно приводит к тому, что вы добровольно теряете преимущества Hibernate в обычной бизнес-логике: каскады, managed-граф, удобные транзакционные сценарии. StatelessSession — как огнетушитель: он должен быть рядом и иногда спасает, но жить в обнимку с ним — сомнительная идея.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ