1. «Невидимый UPDATE»: откуда он берётся
Transient-объект сам не сохранится, detached уже не отслеживается, а managed живёт внутри текущего persistence context. Значит, главный практический вопрос здесь очень приземлённый: что именно Hibernate делает, пока сущность managed, и почему иногда UPDATE появляется без явного save()?
Если вы пришли в Spring Data JPA после обычных CRUD-уроков, то мозг автоматически хочет простое правило: «хочешь записать — вызови save()». Это удобный рефлекс, но в JPA он быстро начинает вредить. Потому что JPA — не набор отдельных независимых SQL-команд, а механизм, который обслуживает unit of work и умеет «держать сущности в руках» между чтением и коммитом.
Поэтому типичная сцена выглядит так: вы в сервисе загрузили Product, поменяли ему имя, никакого save() не вызвали, а в логах SQL вдруг появляется update product .... И вы сидите, как после фокуса, и подозреваете Hibernate в колдовстве (а он просто работает).
Самая важная мысль лекции: в JPA “сохранение” — это не всегда “вызвать метод save()”. Иногда “сохранение” — это просто “изменить managed-объект внутри транзакции”, и ORM сам поймёт, что делать дальше.
Dirty checking простыми словами: Hibernate как бухгалтер с блокнотом
Dirty checking — это механизм, который позволяет ORM заметить, что managed-сущность изменилась, и подготовить SQL UPDATE. Звучит грозно, но идея очень земная: когда Hibernate загрузил объект, он запоминает «как было». Потом, ближе к синхронизации с базой, он смотрит «как стало». Если отличается — значит, объект “dirty” (условно «испачкан» изменениями) и его надо обновлять.
Важно не перепутать: dirty checking не означает, что UPDATE улетает в базу сразу после setName(...). Dirty checking отвечает за обнаружение изменений. А когда именно уйдёт SQL — это уже вопрос синхронизации контекста с БД: dirty checking только находит изменение, но не обязан отправлять SQL в ту же секунду.
Для нас сейчас достаточно держать в голове такую цепочку событий: внутри транзакции сущность становится managed → вы меняете поле → ORM фиксирует, что объект “dirty” → позже ORM отправит UPDATE.
2. Обновление managed-сущности без save() на примере Product
В нашем проекте shop-data-jpa (mini-shop: каталог, остатки, заказы) сущность Product живёт в каталоге. И самый учебный сценарий — переименовать товар или поменять цену. Давайте сделаем метод сервиса, который меняет имя товара без save().
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogService {
// Репозиторий для доступа к Product в БД
private final ProductRepository productRepository;
public CatalogService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional // Важно: внутри транзакции сущность будет managed
public void renameProduct(long productId, String newName) {
// Загружаем сущность из БД: это managed-экземпляр в persistence context
Product product = productRepository.findById(productId).orElseThrow();
// Меняем поле: Hibernate заметит изменение через dirty checking
product.setName(newName);
}
}
Если включены SQL-логи, вы увидите примерно такую картину: сначала будет select ... from product where id=?, а ближе к завершению транзакции появится update product set name=? where id=?. И это нормально: сущность была managed, вы изменили поле, ORM заметил изменение и позже синхронизировал его с БД.
Чтобы окончательно снять напряжение, полезно проговорить «почему так можно». Внутри @Transactional Product находится в persistence context. Это тот самый случай, когда объект действительно “живой” для ORM: Hibernate знает его id, знает, что он загружен из БД, и умеет отследить изменения.
А вот теперь антипример, который ломает ожидания новичка: если транзакции нет, то у вас почти гарантированно нет долгоживущего managed-состояния, и dirty checking просто не успеет сработать.
import org.springframework.stereotype.Service;
@Service
public class CatalogService {
private final ProductRepository productRepository;
public CatalogService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public void renameProductNoTx(long productId, String newName) {
// Сущность будет managed только в рамках короткой операции репозитория (если вообще будет)
Product product = productRepository.findById(productId).orElseThrow();
// Скорее всего, здесь продукт уже detached (контекст закончился)
product.setName(newName); // в БД, скорее всего, ничего не изменится
}
}
Почему «скорее всего»? Потому что репозиторий может открыть свою короткую транзакцию на время findById(), но после выхода из метода репозитория контекст закончится, и объект станет detached. А detached-объект можно менять сколько угодно — это просто Java-объект, ORM за ним уже не следит.
Это не “особенность Spring”, это прямое следствие модели: dirty checking работает для managed-сущностей внутри живого контекста.
3. Роль save() в Spring Data JPA
На этом месте хочется драматически выкрикнуть: «Так что, save() больше не нужен?!» Нужен. Просто у него другая роль, чем “кнопка отправки UPDATE в базу”. save() нужен, чтобы ввести объект в persistence-мир или вернуть данные обратно в managed-мир, когда вы уже не находитесь в ситуации “загрузил managed → поменял → коммит”.
Если говорить максимально честно и по-взрослому (но всё ещё junior-friendly), у save() в Spring Data JPA две основные ветки поведения. Для нового объекта (transient) save() приводит к persist-семантике: ORM начинает считать объект частью persistence context и готовит INSERT. Для существующего объекта, который уже не managed, save() обычно приводит к merge-семантике: состояние объекта переносится в managed-экземпляр текущего контекста.
То есть save() — это, по смыслу, операция про состояние объекта относительно контекста, а не команда «немедленно запиши в БД».
Проверим на примере создания товара. Новый Product — это transient-объект. Его никто не отслеживает. Тут save() действительно нужен.
import java.math.BigDecimal;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public long createProduct(String sku, String name) {
// Новый объект: transient, ORM его пока не отслеживает
Product product = new Product();
product.setSku(sku);
product.setName(name);
product.setPrice(BigDecimal.valueOf(199.90));
// save(...) введёт объект в persistence context (persist-семантика) и подготовит INSERT
Product saved = productRepository.save(product);
// Обычно id станет доступен после persist/insert (и/или flush), но в коде читается так
return saved.getId();
}
В этом сценарии save() — не “опциональный стиль”, а ключевой шаг: без него объект так и останется просто Java-объектом.
4. save() vs dirty checking: сравнение сценариев
Очень легко начать мыслить так: «либо я делаю save(), либо dirty checking, нужно выбрать правильную религию». На практике это не две религии, а два инструмента под разные ситуации. Dirty checking — это про managed-объекты внутри транзакции. save() — это про “включить объект в persistence-мир” или “правильно обработать не-managed ситуацию”.
Ниже — маленькая табличка, чтобы мозг не пытался запомнить это как набор заклинаний.
| Сценарий | Состояние объекта | Нормальный стиль кода | Что уходит в БД (по смыслу) |
|---|---|---|---|
| Создание нового товара | transient → станет managed | создать объект → save() | INSERT |
| Изменение существующего товара внутри транзакции | managed | findById() → изменить поля | UPDATE через dirty checking |
| Изменение объекта, который уже «выпал» из контекста | detached | либо заново загрузить и поменять, либо save() | UPDATE, но уже через merge-семантику |
Теперь закрепим на мини-примерах.
Правильный update через dirty checking (обычный сервисный стиль)
import java.math.BigDecimal;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void changePrice(long productId, BigDecimal newPrice) {
// Загружаем managed-сущность в рамках транзакции
Product product = productRepository.findById(productId).orElseThrow();
// Изменяем только нужное поле: это и есть нормальный "update" в JPA
product.setPrice(newPrice); // managed + dirty checking
}
Это самый спокойный и предсказуемый способ обновлений: вы явно видите, что работаете с актуальной сущностью из БД, и меняете только нужные поля.
save() для нового объекта (иначе он так и останется “просто объектом”)
import org.springframework.transaction.annotation.Transactional;
@Transactional
public long createCategory(String code, String name) {
// Новый объект: transient, без save он не попадёт в persistence context
Category category = new Category();
category.setCode(code);
category.setName(name);
// save(...) подготовит INSERT и позволит получить id
Category saved = categoryRepository.save(category);
return saved.getId();
}
Здесь dirty checking не поможет, потому что нечего “чекать”: объект ещё не был загружен из БД и не был managed.
save() для не-managed объекта: работает, но легко выстрелить себе в ногу
Вот пример, который встречается часто (особенно когда кто-то пытается “обновлять сущность как DTO”). Допустим, где-то выше по стеку вам принесли объект Product, который уже был когда-то загружен, но сейчас он detached. Вы меняете поле и вызываете save().
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void renameDetachedProduct(Product detachedProduct, String newName) {
// detachedProduct сейчас не отслеживается ORM (это просто объект)
detachedProduct.setName(newName);
// Под капотом будет merge-семантика: состояние "вольётся" в managed-экземпляр
// Важно: сам detachedProduct при этом не становится magically managed
productRepository.save(detachedProduct);
}
Этот код может работать, но он несёт риск “скрытой сложности”: merge-семантика имеет свои нюансы, и главный из них — после save() / merge важен managed-экземпляр, а не старая detached-ссылка. Поэтому для нашего курса и проекта безопаснее считать “основным стилем” именно find → modify внутри транзакции.
5. save() после каждого setter: плохая привычка
У новичка часто появляется ритуал: «поменял поле — сразу save()». Вроде бы логично: “я же хочу сохранить”. Но в JPA этот ритуал обычно делает код хуже, а понимание механики — слабее. Получается, что вы как будто постоянно нажимаете кнопку «Сохранить документ» в Google Docs, который и так сохраняет всё сам. Не то чтобы вредно… но очень суетливо.
Проблема не только в лишних строчках. Проблема в том, что такой стиль маскирует настоящую причину UPDATE. В реальности обновление произойдёт потому, что объект managed и dirty checking увидел изменения, а не потому, что вы “магически попросили сохранить”. Когда вы всегда пишете save(), вы теряете важное инженерное чувство: “сущность сейчас managed или уже detached?”
Ещё один неприятный момент: когда команда привыкает к “save после каждого setter”, она начинает тащить этот стиль и туда, где он уже реально опасен. Например, в местах, где в save() попадает частично заполненный объект “из внешнего мира”. Там save() превращается из безобидного ритуала в настоящий генератор багов (перезатёрли поля, потеряли связь, записали null туда, где не хотели).
И да, это ещё и просто шум. Код должен выражать мысль. Мысль “переименовать товар” — это renameProduct(id, newName), а не “переименовать товар, и на всякий случай ещё три раза позвать save(), вдруг он голодный”.
6. Нормальный стиль update-методов в shop-data-jpa
Чтобы закрепить правильный паттерн именно в рамках нашего проекта, давайте сформулируем “скелет” сервисного update-use-case. Он почти всегда выглядит так: сервис открывает транзакцию, загружает сущность (или несколько сущностей), проверяет бизнес-инварианты, меняет поля, и на этом заканчивает. Если сущности были managed — dirty checking сделает остальное.
Например, сделаем аккуратный метод, который переключает статус товара (допустим, ACTIVE / ARCHIVED). Неважно, как точно называется enum — важен стиль.
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void archiveProduct(long productId) {
// Загружаем товар как managed-сущность
Product product = productRepository.findById(productId).orElseThrow();
// Проверяем бизнес-условие до изменения состояния
if (product.getStatus() == ProductStatus.ARCHIVED) {
return; // уже архивный, ничего делать не нужно
}
// Меняем статус: UPDATE будет сформирован через dirty checking
product.setStatus(ProductStatus.ARCHIVED);
}
Обратите внимание: мы не устраиваем “обряд save()”. Мы удерживаем в голове правильную модель: product сейчас managed, значит изменение будет замечено.
А теперь пример, который очень соблазнителен, но как default — плох: обновлять сущность, создавая “новый объект с id” и вызывая save(). Иногда так делают, когда хотят “частичный апдейт”, но на самом деле это чаще похоже на “частичную катастрофу”, потому что вы легко затрёте поля, которые не передали.
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void badPartialUpdate(Product incomingProduct) {
// incomingProduct может быть неполным: без category, без price и т.д.
// save(...) в таком виде легко превратится в перезатирание данных через merge
productRepository.save(incomingProduct); // риск перезатереть данные
}
В рамках курса мы удерживаем границу: entity — не DTO, и сервисные обновления лучше делать через “загрузить из БД и изменить нужное”. Это самый безопасный и предсказуемый стиль, который хорошо сочетается с dirty checking и объясняет происходящее в логах SQL.
7. Типичные ошибки при save() и dirty checking
В этом месте обычно приятно выдохнуть: “окей, dirty checking — это не магия, а механизм”. Но дальше начинается практика, и там новичка поджидают ошибки не из серии “я не понял определение”, а из серии “я написал код, он даже компилируется, но ведёт себя странно”. Ниже — самые частые грабли, которые стоит узнавать по звуку, как системный администратор узнаёт падение диска по вздоху сервера.
Ошибка №1: ожидать, что dirty checking сработает без транзакции.
Если сущность загрузилась и тут же “выпала” из persistence context, то она перестаёт быть managed, а значит изменения полей становятся просто изменениями Java-объекта. Обычно это выглядит как метод “без @Transactional”, внутри которого вы делаете findById() и set...(), а в базе тишина. Лечится не шаманством, а правильной transaction boundary на сервисе.
Ошибка №2: вызывать save() после каждого изменения managed-сущности.
Код чаще всего будет “работать”, но это создаёт ложную модель: будто обновление происходит из-за save(). Потом вы переносите этот стиль на detached-объекты, на частичные апдейты, на сложные графы — и внезапно получаете неожиданные эффекты. Хороший рефлекс здесь другой: сначала спросить “сущность managed?”, и только потом решать “нужен ли save()”.
Ошибка №3: пытаться обновлять сущность через «входящий объект», как будто entity — это DTO.
Когда вы принимаете объект “снаружи” и делаете save(incoming), вы фактически доверяете ему быть полным и корректным слепком состояния. В реальности он почти никогда не является таким слепком. В итоге вы можете перезатереть поля null-ами, потерять связи, сбросить статус и потом долго искать, кто украл данные (спойлер: никто, вы сами).
Ошибка №4: думать, что save() означает “SQL улетел прямо сейчас”.
В JPA save() — это не “выполнить UPDATE немедленно”, а “сделать так, чтобы ORM правильно управлял объектом”. Момент, когда SQL реально отправляется в БД, связан с синхронизацией контекста (flush) и завершением транзакции (commit). Если вы строите логику вокруг идеи “после save() в БД уже точно всё лежит”, вы рано или поздно встретите поведение, которое кажется багом, хотя это просто другая модель выполнения.
Ошибка №5: менять данные в @Transactional(readOnly = true) и надеяться, что «авось сохранится».
Read-only транзакция — это сигнал намерения и иногда оптимизация, но не место для изменения данных. Где-то изменения могут “случайно” записаться, где-то — “случайно” нет, а в итоге вы получаете код, который живёт на удаче. В нашем проекте лучше держать простое правило: read-only для чтений, обычная транзакция для команд.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ