JavaRush /Курсы /Spring Data JPA /Две модели изменения данных

Две модели изменения данных

Spring Data JPA
14 уровень , 0 лекция
Открыта

1. Разделяем изменения: сущности и bulk

Когда разработчик только начинает, кажется, что изменение данных — это всегда одно и то же: «нашёл объект, поменял поле, сохранил». Но в реальном backend очень быстро появляются массовые операции: «всем товарам категории выключить статус», «удалить всё старое», «обнулить скидки», «закрыть заказы в ошибочном состоянии». На уровне результата это похоже, а на уровне механики — два разных мира.

Представьте, что вы ведёте наш mini-shop и вам прилетает задача: «С сегодняшнего дня товары категории CLEARANCE (распродажа) должны стать неактивными». Можно честно загрузить каждый товар как объект и обновить его. А можно сказать базе: «поставь нужное значение всем строкам, которые подходят под условие». Оба способа законны, но они по-разному выглядят в коде, по-разному выполняются в SQL и по-разному ведут себя в приложении.

Мини-история из mini-shop: «Надо деактивировать товары»

Давайте привяжем разговор к проекту, чтобы это не было лекцией в вакууме. У нас есть Product, у него есть status (например, ACTIVE и INACTIVE), и есть связь с категорией. Бизнес-операция звучит просто: «часть товаров должна стать INACTIVE». Вопрос не в том, можем ли мы это сделать, а в том, как именно мы будем это делать: через объектный путь (сущности) или через массовый путь (bulk).

Для контекста вспомним очень маленький фрагмент enum статуса (скорее всего он у вас уже есть, но пусть будет «якорь»):

package com.example.shopdatajpa.catalog.entity;

// Статус товара в каталоге mini-shop
public enum ProductStatus {
    ACTIVE,   // товар активен и доступен
    INACTIVE  // товар выключен/скрыт
}

Смена статуса — отличный учебный пример, потому что он достаточно простой для понимания, но при этом реально встречается в коммерческом коде. И уже на этой простоте видно: два подхода дают разную форму кода и разную форму результата.

2. Entity-based: работаем объектами

Сначала разберём «классический» путь, который интуитивно понятен: мы работаем с сущностями как с объектами. Мы их загружаем, меняем поля, удаляем, сохраняем. Этот путь очень похож на то, как вы работаете с обычными Java-объектами — и именно поэтому он так комфортен для новичка. Но важно понимать его цену: если объектов много, то и работы становится много.

В entity-based подходе главный герой — конкретная сущность. Мы мыслим не «условием», а «вот этим товаром», «вот этим заказом». Это часто совпадает с тем, как думает бизнес: «обнови этот товар», «отмени этот заказ», «переименуй эту категорию». И в таких кейсах объектный путь обычно самый читаемый.

Один объект: найти, изменить, сохранить

Самый простой и самый «человеческий» сценарий: изменить статус конкретного товара по id. Здесь никто не спорит — объектный путь очень прозрачный. Мы читаем одну сущность, меняем поле и сохраняем.

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;

public void deactivateOne(Product product) {
    // Меняем состояние конкретного объекта в памяти
    product.setStatus(ProductStatus.INACTIVE);

    // Дальше обычно будет сохранение через repository,
    // чтобы изменение дошло до базы
}

В реальном сервисе это будет выглядеть примерно так (коротко и по делу):

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;

public void deactivateOne(Long productId) {
    // Загружаем сущность из базы (или падаем, если не нашли)
    Product product = productRepository.findById(productId).orElseThrow();

    // Меняем поле у сущности
    product.setStatus(ProductStatus.INACTIVE);

    // Сохраняем изменения (в терминах JPA это приведёт к UPDATE)
    productRepository.save(product);
}

Здесь важная мысль: результатом операции для нашего кода становится обновлённый объект (точнее, объект с новым состоянием в памяти). Мы можем тут же делать дополнительные проверки, логировать конкретное имя товара, применять индивидуальную логику. То есть код «держит в руках» сущность и может делать с ней что угодно.

Много объектов: цикл по сущностям

Теперь усложним задачу: деактивировать все товары определённой категории. Первая реакция новичка часто такая: «Окей, найду список, пройдусь по нему циклом и сохраню каждый». Это рабочий вариант, и он действительно меняет много данных. Но важно назвать вещи своими именами: это всё ещё объектный путь. Просто объектов стало много.

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;

import java.util.List;

public void deactivateAllInCategory(Long categoryId) {
    // Сначала вытаскиваем все товары категории в память (List<Product>)
    List<Product> items = productRepository.findByCategoryId(categoryId);

    // Потом идём по ним как по обычным объектам
    for (Product product : items) {
        // Меняем поле у каждой сущности отдельно
        product.setStatus(ProductStatus.INACTIVE);

        // И сохраняем каждую сущность (часто это означает много операций на уровне ORM/SQL)
        productRepository.save(product);
    }
}

Если вы смотрите на этот код и думаете «вроде коротко», то это нормально. Новички часто оценивают сложность по длине метода. Но реальная стоимость здесь не в строках Java, а в том, что вы делаете с базой: вы сначала загружаете список (а значит, тянете данные в память), потом потенциально отправляете много UPDATE-ов (или как минимум много действий в ORM, которые приводят к множеству SQL). Даже если это не катастрофа, это другая модель работы.

И вот здесь появляется первая важная развилка: мы действительно хотим пройтись по товарам как по объектам, потому что у каждого товара есть индивидуальная логика? Или мы хотим выполнить один массовый шаг «всем поставить статус»?

Плюсы и цена entity-based подхода

Теперь важно не превращать сравнение в «bulk всегда лучше». Нет. Entity-based подход ценен тем, что он естественно поддерживает логику «на каждый объект своё правило». Например, один товар вы деактивируете, другой пропускаете, третий деактивируете и пишете лог с причиной, четвёртый переводите в другой статус. Как только появляется ветвление и индивидуальность, объектный путь становится читаемым и безопасным.

Цена проявляется в массовых сценариях: вам приходится сначала материализовать сущности, держать их в памяти, потенциально делать много отдельных изменений. Это не «плохо», это просто другая модель. И если вы не отличаете её от bulk-подхода, вы будете путать ожидания: «я же написал цикл — значит это bulk». Нет, bulk — это когда вы мыслите не объектами, а условием и одним запросом.

3. Bulk: один запрос по условию

Теперь перейдём ко второй модели — и она обычно вызывает у новичка лёгкое недоверие. Потому что вместо привычного «дай мне объекты» мы говорим базе: «измени данные вот по такому условию», а в ответ получаем не список сущностей, а число затронутых строк. На психологическом уровне это ощущается как «не по-объектному», но с точки зрения баз данных это очень естественный способ работы.

Bulk-подход — это когда вы думаете так: «Мне не нужен каждый товар как объект. Мне нужна операция над набором строк». То есть вы описываете критерий и одно действие, которое одинаково применяется ко всем подходящим данным. Именно поэтому bulk так хорошо подходит для сценариев «массово выключить», «массово удалить», «массово обновить поле».

Bulk update: всем строкам поставить значение

Представим, что мы хотим деактивировать товары категории одним SQL. На уровне базы это выглядит почти банально:

-- Массовое обновление: один UPDATE по условию
update product
set status = 'INACTIVE'
where category_id = 5;

Ключевая деталь: это один UPDATE. Не цикл из обновлений, не «сначала выбрали, потом обновили». Просто один запрос, который сразу меняет нужный набор строк.

На уровне Java-кода (пока без технических деталей реализации) логика выглядит так: мы вызываем метод, который возвращает число изменённых строк.

public int deactivateProductsInCategory(Long categoryId) {
    // Запускаем bulk-операцию и получаем ровно то, что она "честно" возвращает: количество строк
    int changed = productRepository.deactivateByCategoryId(categoryId);

    // Обычно это идёт в логирование/метрики, а не в бизнес-логику
    System.out.println("Deactivated rows = " + changed); // например: Deactivated rows = 42

    return changed;
}

Обратите внимание, как меняется мышление. Здесь нам не нужен List<Product>. Нам не нужно делать for. Мы не «работаем с товарами». Мы «выполняем массовую операцию по критерию». И естественный результат такого действия — количество строк, которые реально поменялись.

Важно: как именно объявить такой метод в репозитории, станет следующим естественным вопросом. Сейчас наша задача — зафиксировать смысл и отличить его от объектного подхода.

Bulk delete: удалить всё по условию

Bulk delete — ровно та же идея, только вместо изменения поля мы удаляем строки. На SQL-уровне это опять же один запрос:

-- Массовое удаление: один DELETE по условию
delete from product
where status = 'INACTIVE';

На уровне кода вы снова обычно хотите получить число удалённых строк. Потому что это единственное честное, что bulk-операция может вам «вернуть» без дополнительных чтений.

public int deleteAllInactiveProducts() {
    // В bulk-delete результат — это количество удалённых строк
    int deleted = productRepository.deleteInactiveInBulk();

    // Полезно как минимум залогировать факт массового удаления
    System.out.println("Deleted rows = " + deleted); // например: Deleted rows = 120

    return deleted;
}

Заметьте, насколько это отличается от удаления через productRepository.delete(product). Там у вас есть объект, и вы удаляете конкретный объект. Здесь у вас есть условие, и вы удаляете набор данных.

Возврат: число строк в bulk

Для новичка это частый момент раздражения: «Почему я не могу получить список обновлённых товаров сразу?». Можете, но это будет уже другая операция: вам придётся после bulk update ещё раз выполнять SELECT, чтобы получить актуальные данные. И это, на самом деле, нормально: bulk и чтение — разные действия.

В bulk-модели главное, что вы можете узнать сразу, это «сколько строк база реально затронула». Иногда это нужно только для логов. Иногда — для бизнес-решения. Например, если вы ожидаете деактивировать ровно один товар, а деактивировалось 0, это повод задуматься (возможно, товар не найден или уже был INACTIVE). Если вы ожидали деактивировать 1000, а деактивировалось 12 — тоже повод.

Вот минимальный «проверочный» пример:

public void deactivateAllActiveBefore(Long categoryId) {
    // Bulk-метод возвращает счётчик затронутых строк
    int changed = productRepository.bulkDeactivate(categoryId);

    // Если ничего не поменялось — это отдельный (и полезный) сигнал
    if (changed == 0) {
        System.out.println("Nothing to deactivate"); // Nothing to deactivate
    }
}

Опять же: реализацию bulkDeactivate мы ещё не пишем здесь. Мы сейчас учимся правильно ожидать результат.

4. Сравнение подходов на одном примере

Теперь соберём всё в одну картину и закрепим самое главное: оба подхода могут приводить к одинаковому «бизнес-итогу» (например, товары стали INACTIVE), но они принципиально отличаются по форме кода и по тому, что считается «естественным результатом» операции. Если вы это не различаете, вы будете делать странные вещи: ждать от bulk «обновлённых объектов», или пытаться реализовать bulk через for и save().

Чтобы мозг не держал это как «размытое ощущение», удобнее посмотреть на сравнение в одной таблице. Таблица — это не шпаргалка на экзамен, а способ быстро увидеть, где именно подходы расходятся.

Вопрос Entity-based подход Bulk-подход
Как мы думаем? «Вот конкретные товары как объекты» «Вот условие и массовое действие»
Что пишем в коде? find... → цикл → save/delete один метод «update/delete where ...»
Что естественно вернуть? сущности (объекты) или void число затронутых строк (int)
Когда удобно? когда логика разная для каждого объекта когда всем нужно одно и то же по условию
Цена много объектов в памяти, много действий не держим объекты, один запрос, но другая семантика

Самое важное здесь даже не «что быстрее», а «что честнее отражает намерение». Код — это коммуникация. Когда вы пишете цикл по товарам, вы сообщаете читателю: «мне важно пройтись по каждому объекту». Когда вы пишете bulk-метод, вы сообщаете: «мне важно быстро применить одно изменение к набору строк».

Схема выполнения: от Java-кода до SQL

Чтобы окончательно «прибить» мысль, давайте нарисуем две мини-схемы. Это полезно, потому что многие ошибки в JPA начинаются именно с того, что разработчик не представляет, сколько реальных шагов происходит между строками Java-кода и базой.

Entity-based (много объектов):

flowchart TD
    A["Java: findBy..."] --> B["Получили List<Product>"]
    B --> C["for по каждому Product"]
    C --> D["setStatus / delete"]
    D --> E["save / delete"]
    E --> F["SQL: серия UPDATE/DELETE"]

Bulk (один запрос):

flowchart TD
    A["Java: bulkUpdate(criteria)"] --> B["SQL: один UPDATE/DELETE по условию"]
    B --> C["Результат: changedRows"]

Выглядит почти смешно просто — и это хороший знак. Bulk как раз ценят за прямолинейность: один запрос, понятный критерий, измеримый эффект.

Решение обычно не про «красоту», а про смысл операции. Если вам нужно пройти по каждому товару и для каждого принять отдельное решение, например деактивировать только те, у кого имя начинается с "TEMP-", а остальных оставить, то bulk перестаёт быть естественным. Bulk любит правила вида «всем одинаково». Он не любит «каждому по-своему», потому что тогда вы начинаете пытаться запихнуть сложную логику в один запрос, и это быстро превращается либо в SQL-акробатику, либо в код, который никто не хочет сопровождать.

Если же правило звучит как «всем товарам категории X поставить статус Y» или «всем товарам старше даты D поставить статус INACTIVE», то объектный путь становится лишним. Вы не хотите держать в памяти тысячу товаров ради того, чтобы одинаково поменять одно поле. Здесь критерий и массовое действие — честная модель.

И ещё один тонкий, но важный момент: в entity-based подходе вы часто можете сразу после изменения продолжить работу с объектом. В bulk-подходе вы, по-хорошему, должны принять, что вы работали «с базой напрямую по условию», а не «с объектами». Если после bulk вам нужны актуальные объекты, это будет отдельный шаг чтения. От этой развилки дальше напрямую зависят и способ объявления modifying-query, и ожидаемый результат операции, и то, можно ли после неё доверять уже загруженным объектам.

5. Типичные ошибки при выборе модели изменения

Ошибки здесь почти никогда не про синтаксис. Они про неправильные ожидания. И хорошая новость: как только вы научились задавать себе вопрос «я сейчас работаю объектами или критерием?», половина проблем просто перестаёт возникать.

Ошибка №1: считать, что for + save() — это bulk update.
Цикл по списку сущностей может менять много данных, но это всё ещё объектная модель. Вы загрузили объекты, вы держите их в памяти, вы обновляете их по одному. Bulk — это «один запрос по условию», а не «много маленьких сохранений». Если вы не различаете эти модели, вы будете неверно оценивать стоимость операции и путать семантику.

Ошибка №2: выбирать bulk только потому, что код короче.
Короткий код — не всегда правильный код. Если бизнес-правило требует обработать каждый объект отдельно, проверить несколько условий, сформировать лог или вычислить значение индивидуально, то bulk начнёт либо ломаться по смыслу, либо вы превратите запрос в монстра. В таких кейсах объектный путь — нормальный выбор, даже если он длиннее.

Ошибка №3: ожидать от bulk-операции «обновлённые объекты».
В bulk-модели естественный результат — число строк. Если вам нужны объекты, значит вам нужно отдельное чтение после bulk. Как только вы принимаете это как норму, исчезает куча странных ожиданий вроде «почему я не вижу новые значения в уже имеющемся списке». Мы ещё разберём это глубже дальше, но уже сейчас важно: bulk не обязан возвращать вам сущности.

Ошибка №4: сравнивать подходы только по бизнес-результату и игнорировать механику.
«Ну и там, и там статус стал INACTIVE» — звучит как аргумент. Но механика определяет последствия: количество SQL, количество загруженных объектов, форма возвращаемого результата, поддерживаемость кода. Умение видеть механику — это и есть взросление как backend-разработчика. Не обязательно сразу становиться Hibernate-джедаем, но различать две модели изменения данных нужно уже на этом уровне.

Ошибка №5: забывать, что bulk может оставить “устаревшие” сущности в памяти.
Если вы до bulk-операции уже загрузили Product как сущности (они попали в persistence context), а потом сделали bulk UPDATE напрямую по таблице, то в памяти у вас могут остаться старые значения полей. В результате код может «смотреть на объект» и видеть одно, а база уже хранит другое. Это решается дисциплиной: не смешивать бездумно bulk и работу с уже загруженными сущностями, а также продумывать синхронизацию/повторное чтение там, где это важно.

1
Задача
Spring Data JPA, 14 уровень, 0 лекция
Недоступна
Деактивация одной сущности через объектный путь
Деактивация одной сущности через объектный путь
1
Задача
Spring Data JPA, 14 уровень, 0 лекция
Недоступна
Один SQL UPDATE по критерию
Один SQL UPDATE по критерию
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ