JavaRush /Курсы /Spring Data JPA /Derived delete и bulk delete

Derived delete и bulk delete

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

1. Удаление по условию в JPA

Если вы пришли из SQL-мира, то удаление по условию выглядит слишком просто: одна команда DELETE ... WHERE ... и готово. В мире JPA у нас появляется второй слой реальности — объектный, и он местами ведёт себя как заботливый бухгалтер: «Секундочку, а эти сущности точно можно удалить? А каскады? А связанные записи?». Из-за этого один и тот же критерий удаления может иметь два разных «пути исполнения».

Давайте сформулируем проблему на языке нашего mini-shop. Например, у нас есть товары (Product) со статусом INACTIVE. Мы можем захотеть «почистить базу» и удалить их. Критерий понятный: статус. Но дальше начинается развилка.

Первый путь — объектный (entity-based). Мы как будто говорим системе: «Найди все такие Product как сущности, а потом удали каждую как объект». Это похоже на аккуратную уборку: вы берёте каждую вещь в руки, проверяете, что это действительно мусор, и выбрасываете.

Второй путь — массовый (bulk). Мы говорим: «Сделай один delete по критерию». Это как выкинуть весь пакет мусора разом. Быстро, эффективно, но если в пакете были ключи от квартиры — у вас будет интересный вечер.

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

2. Derived delete: удаляем сущности

Derived delete очень коварен именно тем, что выглядит невинно: вы просто называете метод deleteBy..., и он как будто «сам всё удалит». Но под капотом этот подход мыслит сущностями, а значит — включает объектную механику JPA: поиск сущностей, перевод их в состояние removed, применение каскадов и прочие «взрослые» последствия. Это не плохо и не хорошо — это просто другая семантика, которую нужно осознавать.

Представим, что в нашем ProductRepository мы хотим удалить товары по статусу. На уровне интерфейса это может выглядеть так:

import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Derived delete: Spring Data сам построит логику удаления по полю сущности (status)
    // Важно: это "entity-based" удаление, а не гарантированно один SQL-DELETE по условию.
    long deleteByStatus(ProductStatus status);
}

Обратите внимание на возвращаемый тип. long здесь удобен тем, что делает операцию наблюдаемой: вы можете залогировать или проверить, сколько сущностей реально было удалено. Если возвращать void, операция станет «немой», и в отладке вы будете угадывать, удалилось ли вообще что-то.

Что реально происходит при derived delete

Чтобы не оставлять это магией, полезно мысленно развернуть derived delete в «псевдокод», близкий к тому, что делает фреймворк:

-- Псевдошаги: это не "чистый SQL", а упрощённая картинка происходящего
1) SELECT ... найти подходящие Product
2) для каждого Product:
      EntityManager.remove(product)
3) отправить DELETE в БД (обычно несколько delete)

В виде схемы это выглядит так:

flowchart TD
    A["deleteByStatus(INACTIVE)"] --> B["SELECT products WHERE status = INACTIVE"]
    B --> C["for each Product: remove(entity)"]
    C --> D["несколько DELETE ... WHERE id = ?"]

С точки зрения JPA, ключевой момент здесь — «удалить как объект». А удалить как объект означает, что ORM старается вести себя предсказуемо по правилам entity-мира: учесть каскады, учесть orphanRemoval, пометить managed-объекты как удалённые (если они были в persistence context) и так далее.

Мини-наблюдение про SQL

Если включены SQL-логи, вы часто увидите, что derived delete — это не один красивый delete from product where status = ?, а сначала выборка, а потом серия удалений по id. Для небольшой партии данных это может быть нормально, но если вы случайно удаляете «всё за последние 5 лет» — ваш лог будет выглядеть как «бесконечный сериал про DELETE», и вам захочется переключить канал.

Самый простой ответ звучит так: потому что derived delete удаляет сущности, а не «просто строки». А сущности у нас могут быть частью объектного графа. Если у Product есть связанные сущности, и ваш mapping предполагает каскадное удаление, derived delete будет вести себя ближе к вашей объектной модели.

Именно поэтому derived delete можно считать «объектным» удалением даже в случае, когда критерий задан как WHERE status = ....

3. Bulk delete: удаляем строки одним запросом

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

В Spring Data JPA bulk delete обычно оформляется как @Query + @Modifying с JPQL delete:

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Это именно bulk-операция: выполняется JPQL delete без "пробегания" по сущностям в памяти.
    // clearAutomatically = true помогает не оставлять в persistence context "призраков" старых данных.
    @Modifying(clearAutomatically = true)
    @Query("delete from Product p where p.status = :status")
    int deleteByStatusInBulk(@Param("status") ProductStatus status);
}

Здесь сразу несколько важных моментов, и каждый из них «про смысл», а не «про аннотацию ради аннотации».

@Query("delete from Product p ...") — это JPQL, то есть запрос по entity-модели, а не по таблице. Мы пишем Product, а не product. Мы пишем p.status, а не колонку status. Это всё тот же язык, который вы использовали для JPQL-select, просто теперь вместо чтения — удаление.

@Modifying говорит Spring Data: «Это modifying query, она меняет данные». Без этой аннотации вы либо получите ошибку, либо поведение будет совсем не тем, что вы ожидали, но точно не «всё удалится».

Возвращаемый тип int здесь логичен: естественный результат bulk-удаления — число затронутых строк. Bulk delete в принципе не обязан возвращать сущности.

clearAutomatically = true — это страховка от мира, где persistence context может «держать призраков»: сущности, которые вы когда-то загружали, могут продолжить жить в памяти, хотя в базе их уже нет. Мы подробно обсуждали это на прошлой лекции, а здесь просто применяем к delete-сценарию.

На уровне идеи bulk delete стремится к одному запросу вида:

-- Идея: один DELETE по условию, без предварительной загрузки сущностей в память
delete from product where status = 'INACTIVE';

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

4. Семантика: lifecycle, каскады, память и «эффект домино»

Может показаться, что мы уже всё сказали: derived delete — медленнее, bulk delete — быстрее. Но реальная разница глубже и опаснее. Скорость — это просто заметный симптом. А настоящая причина, почему эти два подхода нельзя считать взаимозаменяемыми, — в семантике JPA: удаление как часть жизненного цикла сущности и удаление как операция над таблицей — это разные вселенные.

Чтобы зафиксировать разницу «в одном кадре», давайте сведём её в таблицу. Таблица не заменяет понимание, но отлично ловит мозг за рукав в момент, когда вы уже тянетесь написать «самый короткий метод».

Аспект Derived delete (deleteBy...) Bulk delete (@Modifying delete)
Модель мышления «Удаляю сущности как объекты» «Удаляю строки как данные»
Типичная механика Сначала найти сущности, затем удалить каждую Один JPQL delete по критерию
SQL Часто SELECT + несколько DELETE Обычно один DELETE ... WHERE ...
Память Сущности могут загружаться в память Обычно ничего не загружается
Lifecycle / callbacks Скорее да (удаление идёт через entity-механику) Нет, bulk-удаление это обходит
Cascade / orphanRemoval Обычно учитываются на уровне ORM-модели ORM-каскады не «пробегаются» bulk-запросом
Persistence context Состояние в контексте согласовано с удалением Возможен stale state, часто нужен clearAutomatically
Естественный результат «Удалены сущности» (и можно получить count) «Затронуто N строк»

Теперь давайте проговорим ключевые последствия нормальными человеческими абзацами, без ощущения, что вы читаете мануал к микроволновке.

Про каскады и orphanRemoval

Представьте, что у Product есть связанные ProductDetails, и вы настроили связь так, что детали живут ровно столько же, сколько продукт. В JPA это часто выражается каскадом и/или orphanRemoval.

Условный пример куска mapping (упрощённо, чтобы не утонуть в аннотациях):

import jakarta.persistence.CascadeType;
import jakarta.persistence.OneToOne;

public class Product {

    // Важно: cascade + orphanRemoval дают "объектную" семантику удаления при EntityManager.remove(...)
    // Bulk delete эти правила на уровне ORM не "пробегает".
    @OneToOne(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
    private ProductDetails details;
}

Если вы удаляете Product как сущность (entity-based путь), ORM понимает: «Ага, у него есть зависимый объект, который нужно удалить вместе с ним». Это укладывается в объектную модель.

Если же вы делаете bulk delete delete from Product p where p.status = :status, ORM не ходит по вашим объектам, не трогает их поля и не пытается «по-человечески» распутать граф. Это просто команда к базе удалить строки из таблицы product. Что будет с product_details — зависит от вашей схемы и правил в базе. Иногда это приведёт к ошибке на уровне БД, иногда оставит «мусорные» строки, иногда сработает каскад на уровне foreign key. Но важная мысль здесь не в том, «упадёт или нет», а в том, что bulk delete не обещает вам объектную семантику.

И именно поэтому bulk delete — инструмент для ситуации, где вы заранее уверены, что удаляете данные, которые можно удалить «плоско», без объектного эффекта домино.

Про persistence context: «призраки удалённых сущностей»

Это тот же тип рассинхрона, только ещё неприятнее психологически. При bulk update сущность «врёт», показывая старое значение. При bulk delete сущность может «врать» ещё сильнее: вы можете держать в переменной объект, которого больше нет в базе.

Например, сценарий, который выглядит вполне невинно:

// Сущность загружается и становится managed в persistence context
Product p = productRepository.findById(10L).orElseThrow();

// Bulk delete меняет базу "в обход" entity lifecycle
int deleted = productRepository.deleteByStatusInBulk(ProductStatus.INACTIVE);

System.out.println(deleted); // допустим, 5
System.out.println(p.getId()); // 10 (объект-то в памяти живёт)

Проблема не в том, что p.getId() печатает 10. Проблема в том, что у вас в коде остаётся ощущение, что объект «существует», хотя в базе он мог быть удалён. Отсюда правило: bulk delete требует либо явной очистки контекста (clearAutomatically), либо очень аккуратного построения сценария, чтобы после bulk-операции вы не продолжали жить в старом объектном мире.

Про производительность: «в цикле удалять» — это не bulk, даже если выглядит коротко

Derived delete может выглядеть как «одна строчка», но под капотом это всё равно объектный путь. Если у вас 100000 записей, derived delete может начать их «перебирать» как сущности, и это будет другой класс нагрузки на приложение. Bulk delete в этой ситуации обычно ближе к реальности: один SQL-delete, без materializing кучи сущностей.

Но, и это важное «но»: выбирать bulk delete только потому, что он «быстрее» — примерно как чинить стул бензопилой. Быстро? Да. Точно ли это ремонт? Возможно, но стул уже не тот.

5. Выбор подхода в mini-shop

Когда вы впервые узнаёте про bulk delete, возникает естественное желание: «О! Значит, всегда делаем bulk, ведь он красивый и быстрый». Это нормальная стадия, через неё проходят многие. Потом приходит жизнь и говорит: «А теперь покажи-ка, как ты удалишь продукт, который связан с заказами, остатками и деталями». И вы внезапно начинаете уважать слово “semantics”.

Для нашего mini-shop полезно мыслить так: физическое удаление товара — редкая операция, и чаще мы делаем «деактивацию» (статус INACTIVE). Но раз уж мы сейчас про delete, давайте зафиксируем, когда какой вид удаления выглядит здраво.

Если вы удаляете сущности, у которых важна объектная семантика удаления (например, вы реально хотите, чтобы сработали каскады удаления зависимых сущностей, чтобы граф был удалён согласованно на уровне ORM-модели), то derived delete обычно ближе к ожиданиям. Он «делает вид», что вы действительно удаляете объекты, а не строки.

Если же у вас есть чистый сценарий «удалить много однотипного по критерию», и вы заранее знаете, что это безопасно для вашей схемы и домена, тогда bulk delete может быть идеальным. Типичный учебный пример: почистить тестовые данные или удалить временные товары, созданные ошибочно, если вы гарантируете, что они ни с чем не связаны.

Очень помогает дисциплина именования. Если метод действительно bulk-удаление, его имя должно об этом кричать, а не шептать. Например, deleteByStatusInBulk звучит честнее, чем просто deleteByStatus, потому что заставляет читателя задать правильный вопрос: «Ага, bulk. Значит, без entity-lifecycle, и надо помнить про persistence context».

Вот пример, который в коде выглядит мелочью, а в понимании команды экономит часы:

// Bulk-операция: удаляем "как строки", результат — количество затронутых строк
int removed = productRepository.deleteByStatusInBulk(ProductStatus.INACTIVE);
System.out.println("removed = " + removed); // removed = 5

И рядом derived-вариант, который выглядит «похожим», но живёт иначе:

// Derived delete: удаляем "как сущности", результат — количество удалённых сущностей
long removed = productRepository.deleteByStatus(ProductStatus.INACTIVE);
System.out.println("removed = " + removed); // removed = 5

Обе строчки печатают 5, но первая говорит «я удалил 5 строк», а вторая — «я удалил 5 сущностей (и всё, что следовало из entity-семантики удаления)». Для начинающего разработчика это очень важная развилка: одинаковые цифры на экране не означают одинаковые последствия в системе.

6. Типичные ошибки при удалении в JPA

Ошибка №1: считать derived delete «одним delete запросом» и использовать его для огромных наборов.
Метод deleteByStatus(...) выглядит как одна строчка, и это обманчиво. Если под капотом он сначала загружает сущности и удаляет их объектным путём, то при большом количестве записей вы получите лишнюю нагрузку на память и много SQL-операций. Это особенно неприятно, когда вы ожидали один DELETE ... WHERE ..., а получили «сериал» из SELECT и множества DELETE.

Ошибка №2: использовать bulk delete там, где нужна объектная семантика удаления.
Bulk delete по сути говорит ORM: «Не лезь в мои дела, я удаляю данные сам». Поэтому каскады на уровне JPA, orphanRemoval и lifecycle callbacks не будут работать так, как вы привыкли при EntityManager.remove. Итог бывает разный, но всегда неприятный: от ошибок целостности до «мусорных» зависимых записей.

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

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

Ошибка №5: пытаться «универсализировать» bulk delete в комбайн на все случаи жизни.
Иногда хочется сделать метод вида deleteProductsByFilter(...) с десятком параметров и сложной логикой. Обычно это признак того, что вам на самом деле нужна другая модель или хотя бы другой уровень проектирования запроса. Bulk delete хорош, когда условие узкое и понятное. Если правило удаления начинает ветвиться «для каждого товара по-разному», это уже не bulk-история, и объектный путь становится прозрачнее.

1
Задача
Spring Data JPA, 14 уровень, 3 лекция
Недоступна
Derived delete по имени метода
Derived delete по имени метода
1
Задача
Spring Data JPA, 14 уровень, 3 лекция
Недоступна
Bulk delete с явным именем метода
Bulk delete с явным именем метода
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ