1. Введение
Массовая деактивация товаров — это очень типичная «админская» операция. Она редко требует умных правил для каждого товара отдельно, зато часто требует одного понятного действия для большого набора данных: «всем подходящим товарам поставить INACTIVE». Именно поэтому этот сценарий идеально ложится на bulk update: один запрос, один критерий, один результат в виде числа строк.
Представьте, что в нашем mini-shop мы хотим «выключить» часть ассортимента. Например, мы удалили категорию из продажи, или решили, что товары старше определённой даты больше не должны быть активными (учебное упрощение, но очень наглядное). Самое важное здесь — не перепутать желание «изменить много объектов» с настоящей bulk-семантикой. Цикл for, даже очень старательный, всё ещё остаётся объектным путём: вы читаете объекты, грузите их в память, и потом делаете много отдельных обновлений.
Bulk-подход — это когда мы не говорим: «дай мне список продуктов, я сам разберусь». Мы говорим: «обнови всё подходящее», и база делает это сама.
2. Правило bulk-деактивации
Bulk-операции любят дисциплину. Если вы попытаетесь засунуть в один bulk-запрос «и это поменяй, и то пересчитай, и там ещё если-иначе по названию», получится либо невозможный запрос, либо запрос, после которого вы сами перестанете доверять данным. Поэтому начнём с аккуратного, узкого правила, которое хорошо подходит именно для bulk.
Возьмём такое правило для каталога: деактивировать товары, которые сейчас ACTIVE, но были созданы раньше заданной границы времени. Граница (threshold) приходит как параметр (например, «всё, что старше 180 дней»), а новый статус фиксированно INACTIVE. Важно, что мы не пытаемся пересчитывать цену, не трогаем связи, не делаем «умный выбор» для каждой строки. Мы делаем один и тот же апдейт для всех подходящих записей — это и есть bulk-случай.
С точки зрения «контракта метода» мы хотим, чтобы репозиторий принимал только критерии и новые значения (старый статус, граница по дате, новый статус), а возвращал число изменённых строк. Это сразу делает сценарий наблюдаемым: мы можем логировать, проверять и понимать результат без попыток «угадать, что обновилось».
Мини-подготовка модели: ProductStatus и createdAt
Чтобы bulk-деактивация была не абстракцией, а частью нашего приложения, нам нужно, чтобы модель это поддерживала. Нам важны две вещи: поле status у Product и поле createdAt, по которому мы сравниваем дату/время. Сами сущности у вас уже есть с предыдущих дней, поэтому ниже будут не «полные классы», а маленькие фрагменты — ровно то, что нужно для понимания bulk-сценария.
Начнём с enum статуса. Он часто выглядит примерно так:
import java.util.Set;
public enum ProductStatus {
// Товар активен и считается «продаваемым» в каталоге
ACTIVE,
// Товар выключен: не участвует в продаже/не показывается
INACTIVE;
// Подсказка для бизнес-логики: какие статусы считаем «можно продавать»
public static final Set<ProductStatus> SELLABLE = Set.of(ACTIVE);
}
Комментарий к этому коду простой: ACTIVE — товар «в продаже» (или хотя бы видимый в каталоге), INACTIVE — товар «выключен». Мы не делаем десять статусов, потому что курс про data-layer, а не про бесконечное моделирование бизнеса.
Теперь — фрагмент Product, где видны интересующие нас поля:
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "product")
public class Product {
// Статус храним строкой, чтобы в базе было читаемо (ACTIVE/INACTIVE), а не 0/1
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private ProductStatus status;
// Время создания: по нему будем сравнивать «старше порога»
@Column(nullable = false)
private Instant createdAt;
}
Здесь важно сразу запомнить одну мысль: bulk update будет менять только то, что вы явно укажете в set .... Если вы не обновляете createdAt или какие-то «аудитные» поля (даже если они у вас уже есть), они останутся как были. Bulk — это не «умный Hibernate, который сам всё понял», bulk — это «SQL-стиль обновления данных».
3. Репозиторий bulk-деактивации
Теперь мы делаем главную деталь лекции: тот самый репозиторный метод, который в одном запросе меняет много строк. Сразу договоримся о стиле: метод называется так, чтобы было видно намерение (деактивировать старые товары), а не просто updateProducts. В bulk-методах «говорящие имена» особенно важны: вы читаете код и сразу понимаете, что это операция масштаба «много строк за раз».
Здесь достаточно одного практического правила: транзакцию открываем на сервисной операции, а репозиторий оставляем местом, где описан сам bulk-запрос. Тогда код читается честно: сервис выражает use case, репозиторий — SQL-like команду базе.
Ниже — компактный фрагмент того, что мы добавляем в ProductRepository:
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
@Modifying(clearAutomatically = true) // После UPDATE чистим persistence-контекст, чтобы не жить со stale state
@Query("""
update Product p
set p.status = :newStatus
where p.status = :oldStatus
and p.createdAt < :threshold
""")
int deactivateOldProducts(@Param("oldStatus") ProductStatus oldStatus,
@Param("threshold") Instant threshold,
@Param("newStatus") ProductStatus newStatus); // Возвращаем число реально обновлённых строк
Обратите внимание на несколько вещей, которые здесь «делают метод bulk-методом», а не «ещё одним JPQL»:
Во-первых, тут есть @Modifying, и это сигнал Spring Data: «это не select, это изменение данных». Без него вы либо получите ошибку, либо очень странные эффекты (обычно — ошибку, и это даже хорошо).
Во-вторых, возвращаемое значение — int. Это количество строк, которые реально изменились. И это очень практично: можно писать changed = ..., логировать и понимать, что произошло.
В-третьих, clearAutomatically = true — это наша страховка от части проблем «старых объектов в памяти». Он не делает магии «перепиши поля у всех уже прочитанных Product» (такого режима нет), но он очищает persistence-контекст, чтобы мы не продолжали жить с иллюзией, что память и база «обязательно синхронны».
Сервисный метод: админская операция и лог результата
Репозиторий — это слой доступа к данным, но bulk-операция обычно всё равно должна быть «упакована» в понятный метод сервиса. Даже если внутри сервиса ровно одна строчка, сервис важен как место, где у операции появляется имя уровня use case: «деактивировать старые товары», а не «вызвать update query».
Сделаем небольшой сервис в catalog.service. Назовём его так, чтобы было видно, что это не «обычный каталог для покупателя», а что-то более «хозяйственное», например CatalogMaintenanceService. Внутри — одна понятная операция: вычислить порог и запустить bulk update.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@Service
public class CatalogMaintenanceService {
private final ProductRepository productRepository;
public CatalogMaintenanceService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional
public int deactivateProductsOlderThanDays(long days) {
// Порог: всё, что создано раньше этого времени, считается «старым»
Instant threshold = Instant.now().minus(days, ChronoUnit.DAYS);
// Bulk-операция: меняем статус сразу для всех подходящих строк
return productRepository.deactivateOldProducts(
ProductStatus.ACTIVE, threshold, ProductStatus.INACTIVE
);
}
}
Здесь мы сознательно возвращаем int. Это позволяет верхнему уровню решить, что делать дальше: просто вывести лог, показать сообщение в админке (если она когда-то появится), или запустить ещё одно действие. И да, иногда хочется, чтобы метод сервиса возвращал список обновлённых товаров. Но это уже другой сценарий, не bulk: для списка объектов нужно читать данные, и это отдельная стоимость.
Если хочется сделать результат более «самодокументируемым», можно логировать число изменённых строк:
int changed = maintenanceService.deactivateProductsOlderThanDays(180);
// В учебном проекте достаточно простого вывода, чтобы увидеть смысл bulk: возвращается число строк
System.out.println("Deactivated = " + changed); // Deactivated = 42
Да, лог в System.out.println — не предел мечтаний, но в учебном проекте он отлично показывает идею: bulk-операция возвращает не сущности, а количество.
4. Почему здесь стоит clearAutomatically
У этого use case есть один неприятный побочный эффект: bulk update меняет строки напрямую, а уже загруженные Product внутри того же persistence context могут остаться со старым status. Поэтому у финального метода и стоит clearAutomatically = true: после массового апдейта мы не продолжаем доверять кэшу managed-сущностей как будто ничего не произошло.
Для такой админской операции это особенно уместно. Сценарий узкий и прямолинейный: мы запускаем bulk update, возвращаем changedRows, а если потом нужны актуальные объекты — перечитываем их заново. Здесь clearAutomatically нужен не ради «магической страховки», а чтобы не держать stale state там, где bulk-семантика и так сознательно идёт мимо объектного пути.
Именно поэтому лучше не смешивать в одном куске кода два режима сразу: «у меня уже есть список Product» и «сейчас я тем же сценарием массово обновлю их bulk-запросом». Чем чище сценарий, тем меньше шансов случайно поверить старым объектам.
5. SQL-картина: один UPDATE против множества операций
Когда вы пишете bulk-update, вы фактически говорите: «пусть база сделает это сама». И база это умеет очень хорошо — это буквально её профессия. Поэтому важно увидеть разницу «по форме SQL», иначе легко обмануться коротким кодом и думать, что раз он короткий, то всегда лучший.
Сравним два подхода на уровне идеи (не привязываясь к точному SQL диалекта).
Entity-based подход обычно выглядит как: «прочитать список → для каждого обновить → сохранить». Даже если Hibernate будет оптимизировать что-то внутри, концептуально это много операций.
Bulk-подход выглядит как один update-запрос.
Вот простая схема:
flowchart TD
A[Entity-based путь] --> B[SELECT products ...]
B --> C[for each Product]
C --> D[изменили поле status]
D --> E[UPDATE product ...]
E --> C
F[Bulk путь] --> G[UPDATE product SET status=... WHERE ...]
G --> H[changedRows: int]
И часто это выражается и в количестве SQL-запросов, и в количестве объектов в памяти. Если у вас «10000 товаров под условие», entity-based подход потенциально означает «10000 объектов в памяти плюс много апдейтов», а bulk-подход — один update, который отдаёт базе всю работу.
Это не означает, что bulk всегда лучше. Это означает, что bulk — это отдельный инструмент: быстрый, прямолинейный, но требующий дисциплины с точки зрения «что является истиной после операции».
6. Границы bulk-деактивации
Самая частая ошибка при знакомстве с bulk — это попытка использовать его как «универсальный способ сделать всё быстрее». Как только вам нужно принять разные решения для разных товаров, bulk-update начинает проигрывать не по производительности, а по выразительности. База умеет многое, но «сложная бизнес-логика на Java» и «один update по критерию» — это разные стили.
Например, представим правило: «деактивировать только товары, у которых имя начинается с TEMP-». Да, можно сделать where p.name like 'TEMP-%', и это ещё bulk-правило. Но если правило становится сложнее: «если имя начинается так — выключить, если цена меньше — оставить, если SKU из списка — не трогать, а ещё для некоторых пересчитать поле…», то вы либо начнёте писать монструозный SQL, либо придёте к тому, что читабельнее пройтись по объектам.
Вот пример, где объектный путь честнее и проще читается (даже если он «длиннее»):
import java.util.List;
// Получаем список товаров по категории — это уже НЕ bulk, это загрузка сущностей в память
List<Product> items = productRepository.findByCategoryId(categoryId);
for (Product item : items) {
// Решение принимаем индивидуально для каждого объекта
if (item.getName().startsWith("TEMP-")) {
item.setStatus(ProductStatus.INACTIVE);
// Сохранение идёт поштучно (потенциально много UPDATE'ов)
productRepository.save(item);
}
}
Этот код не «быстрее», но он прозрачнее по смыслу: у каждого товара мы принимаем решение отдельно. И это главный критерий выбора: если бизнес-правило требует «думать на каждый объект», bulk-подход перестаёт быть естественным.
7. Типичные ошибки в bulk-сценарии массовой деактивации
Bulk-операции выглядят настолько лаконично, что мозг автоматически хочет доверять им больше, чем нужно. «Одна строчка — значит точно правильно». Увы, реальность иногда напоминает: «Одна строчка — значит особенно внимательно». Ниже — ошибки, которые чаще всего ломают bulk-деактивацию именно на уровне ожиданий и семантики.
Ошибка №1: воспринимать bulk как «обновить объекты», а не «обновить строки».
Новичок делает bulk update и потом пытается продолжать работать с уже прочитанными Product, как будто Hibernate обязан был «проставить новое значение в поля». Не обязан. Если вам нужны актуальные данные после bulk-операции, перечитывайте их, и не стесняйтесь воспринимать bulk как прямой SQL-стиль.
Ошибка №2: забыть, что modifying-запрос должен выполняться в транзакции.
Часто это выглядит так: метод репозитория написан, @Modifying поставлен, а при вызове прилетает ошибка вида «transaction required». На этом месте хочется ругать Spring, но он как раз молодец: он не позволяет менять данные «в воздухе». Практический вывод простой: транзакцию открываем на сервисной операции, которая запускает bulk-метод. Репозиторий описывает сам запрос, сервис задаёт рамку записи.
Ошибка №3: не учитывать рассинхрон persistence-контекста и базы.
Это тот самый сценарий «я обновил, но при повторном чтении всё ещё старое». Если вы делаете bulk-операции внутри длинных сценариев, почти всегда нужно продумать, как вы защититесь от stale state. В учебном проекте безопасный и понятный вариант — clearAutomatically = true плюс явное перечитывание нужных сущностей.
Ошибка №4: делать bulk-метод слишком «универсальным комбайном».
Метод вида updateProducts(Map<String, Object> params) выглядит гибко, но на практике превращается в источник хаоса. Bulk-методы выигрывают от конкретики: одно условие, одно обновление, понятная сигнатура. Если нужно пять разных bulk-операций — лучше пять понятных методов, чем один «швейцарский нож», который никто не рискнёт трогать.
Ошибка №5: считать, что bulk-деактивация «сама» обновит остальные поля.
Bulk update меняет только то, что вы указали в set. Если вы ожидаете, что «updatedAt обновится автоматически» или «какие-то связанные значения пересчитаются», то это уже другая семантика. Bulk — это точечное и явное изменение данных. Если вам нужно обновить несколько колонок — обновляйте их в set. Если вам нужно выполнить «жизненный цикл сущности» — это уже entity-based путь.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ