- Уровень 1: Фундамент и Философия (Junior)
- Уровень 2: Отношения и Базовый Spring Data (Middle)
- Уровень 3: Продвинутые возможности и Типы (Senior)
- Уровень 4: Производительность и Оптимизация (Expert)
- Уровень 5: Конкурентность, Транзакции и Архитектура (Architect)
- Уровень 6. Тонкая настройка данных: Конвертеры и Soft Delete
- Уровень 7. Сложная архитектура БД: Multi-tenancy и Репликация
- Уровень 8. Продвинутые запросы и Аналитика
- Уровень 9. Конкурентность (Hardcore): Propagation, Deadlocks и Pool Starvation
- Финал: Ваш путь эксперта (Roadmap завершен)
- Что делать дальше? (Практическое задание)
- Дополнение: Популярные аннотации (JPA & Spring Data JPA) и их использование
- Золотой стандарт реализации Entity
- Тестирование сущностей с помощью TestContainers
Прежде чем радоваться магии Hibernate, нужно понять боль, которую он лечит.
В "голом" Java для выполнения одного запроса нужно:
- Открыть соединение (
Connection). - Создать выражение (
PreparedStatement). - Вставить параметры (защита от SQL Injection).
- Выполнить запрос.
- Пройтись циклом по
ResultSet. - Смапить колонки из
ResultSetв поля Java-объекта вручную. - Закрыть все ресурсы в блоке
finallyилиtry-with-resources.
Пример боли (JDBC):
User user = new User();
try (Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("SELECT * FROM users WHERE id = ?")) {
ps.setLong(1, 1L);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
user.setId(rs.getLong("id"));
user.setName(rs.getString("name"));
// И так для каждого поля...
}
}
} catch (SQLException e) {
throw new RuntimeException(e);
}ORM (Object-Relational Mapping) автоматизирует пункты 2–6. Вы работаете с объектами, библиотека генерирует SQL.
Это самый частый вопрос на собеседованиях.
- JPA (Jakarta Persistence API) — это спецификация (набор интерфейсов и правил). Это как интерфейс
Listв Java. - Hibernate — это реализация этой спецификации. Это как
ArrayList. - Spring Data JPA — это еще одна надстройка над JPA, упрощающая написание репозиториев (о ней позже).
Чтобы Hibernate понял, как связать класс с таблицей, используются аннотации из пакета javax.persistence (или jakarta.persistence в новых версиях).
@Entity // Говорит, что этот класс — сущность БД
@Table(name = "users") // Имя таблицы (если отличается от имени класса)
public class User {
@Id // Первичный ключ
@GeneratedValue(strategy = GenerationType.IDENTITY) // Стратегия генерации
private Long id;
@Column(name = "full_name", nullable = false, length = 100)
private String name;
// Геттеры, сеттеры, пустой конструктор (обязателен для Hibernate!)
}- AUTO: Hibernate сам выбирает стратегию (опасно для продакшена, может вести себя непредсказуемо).
- IDENTITY: Использует автоинкремент базы данных (MySQL
AUTO_INCREMENT, PostgresSERIAL).
- Минус: Hibernate не может узнать ID, пока не сделает
INSERT. Это отключает Batch-вставку (оптимизацию).
- SEQUENCE: Использует объект Sequence в БД (Oracle, Postgres).
- Плюс: Самый производительный вариант. Позволяет узнать ID до вставки в БД.
- TABLE: Хранит счетчики в отдельной таблице. Медленно, использовать только при необходимости.
Это сердце Hibernate. Если вы поймете это, вы поймете 80% проблем.
Persistence Context (Контекст Персистентности) — это "кэш первого уровня" (L1 Cache). Это область памяти, где Hibernate хранит объекты, которые он загрузил или сохраняет в рамках одной транзакции (Сессии).
- Transient (New): Объект просто создан через
new. Hibernate о нем ничего не знает. В БД его нет. - Managed (Persistent): Объект находится в контексте. У него есть ID. Все изменения в полях этого объекта будут автоматически записаны в БД при завершении транзакции.
- Detached: Объект есть в БД, но сессия Hibernate уже закрыта или объект принудительно выкинут из контекста. Изменения в нем не сохранятся.
- Removed: Объект помечен на удаление. При коммите транзакции произойдет
DELETE.
Новички часто пишут лишний код, вызывая save() каждый раз.
Неправильно (в транзакции):
@Transactional
public void updateName(Long id, String newName) {
User user = repository.findById(id).get(); // User стал Managed
user.setName(newName);
repository.save(user); // <--- ЭТО ЛИШНЕЕ!
}Правильно:
@Transactional
public void updateName(Long id, String newName) {
User user = repository.findById(id).get(); // Загрузили в контекст
user.setName(newName);
// Метод завершается -> Транзакция коммитится ->
// Hibernate видит, что поле изменилось -> делает UPDATE сам.
}persist(entity): Делает Transient -> Managed. (Планирует INSERT).merge(entity): Делает Detached -> Managed. (Загружает копию из БД, копирует поля из переданного объекта, возвращает managed-копию).remove(entity): Делает Managed -> Removed.flush(): Принудительно отправляет SQL команды из памяти в базу данных (но не делает Commit).
Конец Уровня 1.
Главный вывод этого блока: Hibernate — это не просто генератор SQL. Это система управления состоянием объектов. Вы меняете объекты в памяти, а Hibernate синхронизирует это состояние с базой.
Главная сложность здесь — понять разницу между объектной моделью и реляционной.
- В БД: Отношения строятся через внешние ключи (Foreign Key). Ключ всегда находится в одной таблице (кроме ManyToMany).
- В Java: Объекты ссылаются друг на друга. Ссылки могут быть в обе стороны.
Это концепция, на которой ломается 90% новичков.
- Owning Side (Владеющая сторона): Это сторона, у которой в таблице БД физически находится колонка
FOREIGN KEY. Hibernate смотрит только на эту сторону, чтобы понять, что нужно сохранить в базу. - Inverse Side (Обратная сторона): Это сторона, которая просто ссылается на другую, используя
mappedBy. Она нужна только для удобства программиста (чтобы получить список постов у юзера), но Hibernate игнорирует изменения в этой коллекции при сохранении связей, если не обновлена владеющая сторона.
Классика: У одного User есть много Post.
Владеющая сторона (Post):
@Entity
public class Post {
@Id
@GeneratedValue
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY) // ВСЕГДА LAZY (подробнее в ур.4)
@JoinColumn(name = "user_id") // Имя колонки FK в таблице posts
private User user; // <-- Это поле управляет связью!
// геттеры, сеттеры
}Обратная сторона (User):
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
// mappedBy = "user" означает: "Иди в класс Post, найди поле 'user'.
// Вот ОНО управляет связью. Я здесь просто для чтения."
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
// ВАЖНО: Хелпер-метод для синхронизации
public void addPost(Post post) {
posts.add(post);
post.setUser(this); // <-- Обязательно связываем владеющую сторону!
}
}Золотое правило: При создании двунаправленной связи вы обязаны обновлять обе стороны!
user.getPosts().add(post)— обновит только кэш в Java.post.setUser(user)— создаст запись во внешнем ключе в БД.
- CascadeType.PERSIST: Сохраняю юзера -> автоматически сохраняются его новые посты.
- CascadeType.ALL: Включает в себя всё (сохранение, обновление, удаление).
- orphanRemoval = true: Если я удалю пост из списка
user.getPosts().remove(post), Hibernate отправитDELETEзапрос в БД. Без этой настройки связь просто разорвется (FK станет NULL), но запись останется (если нет constraint).
Spring Data JPA — это абстракция над Hibernate. Она позволяет не писать реализацию DAO (Data Access Object) вручную. Вы объявляете интерфейс, Spring генерирует реализацию на лету (через Proxy).
- Repository: Маркерный интерфейс.
- CrudRepository: Базовые методы (
save,findById,delete,count). - JpaRepository: Расширяет PagingAndSortingRepository. Добавляет JPA-специфичные методы (flush, saveAllAndFlush). Обычно наследуются именно от него.
public interface UserRepository extends JpaRepository<User, Long> {
// Реализация не нужна! Spring сам поймет, что делать.
}Spring парсит имя метода и превращает его в SQL.
// SELECT * FROM users WHERE email = ?
Optional<User> findByEmail(String email);
// SELECT * FROM users WHERE active = true AND age > ?
List<User> findByActiveTrueAndAgeGreaterThan(int age);
// SELECT count(*) FROM users WHERE name LIKE ?
long countByNameStartingWith(String prefix);Плюс: Быстро, читаемо для простых запросов.
Минус: Имя метода может стать монструозным (findByNameAndAgeAndActiveTrueAnd...).
Позволяет писать запросы на JPQL (Java Persistence Query Language). В JPQL мы оперируем именами классов и полей, а не таблиц и колонок.
@Query("SELECT u FROM User u WHERE u.email = :email AND u.active = true")
User findActiveUserByEmail(@Param("email") String email);Никогда не возвращайте List<User>, если в таблице миллион записей. Используйте Pageable.
// В репозитории
Page<User> findAll(Pageable pageable);
// В сервисе
// Страница 0 (первая), размер 10, сортировка по ID убыванию
Pageable pageRequest = PageRequest.of(0, 10, Sort.by("id").descending());
Page<User> page = userRepository.findAll(pageRequest);
List<User> users = page.getContent(); // Сами данные
int totalPages = page.getTotalPages(); // Общее кол-во страницЭто автоматически сгенерирует два запроса: один SELECT ... LIMIT ... OFFSET для данных, и один SELECT COUNT(*) для вычисления общего числа страниц.
Конец Уровня 2.
Мы научились связывать таблицы и быстро создавать слой доступа к данным. Но пока что наши маппинги довольно простые.
Не все данные заслуживают своей собственной таблицы. В DDD (Domain Driven Design) есть понятие Value Object (объект-значение) — например, Address или Money. У них нет своего ID, они часть сущности.
@Embeddable
public class Address {
private String city;
private String street;
private String zipCode;
// ...
}
@Entity
public class User {
@Id @GeneratedValue private Long id;
@Embedded // Поля Address "распакуются" в таблицу users
@AttributeOverrides({ // Можно переименовать колонки
@AttributeOverride(name = "city", column = @Column(name = "billing_city"))
})
private Address billingAddress;
}Результат в БД: Таблица users будет иметь колонки id, billing_city, street, zipCode. Таблицы address не существует.
Критически важный момент. По умолчанию Hibernate сохраняет Enum как ORDINAL (числовой индекс: 0, 1, 2...).
Проблема: Если вы вставите новое значение в середину Enum'а, все индексы съедут. Данные в базе превратятся в мусор.
Решение: Всегда используйте STRING.
@Enumerated(EnumType.STRING) // Сохраняет "ACTIVE", "BANNED" текстом
private UserStatus status;Как положить иерархию классов Java в реляционную БД?
Есть три стратегии:
- SINGLE_TABLE (По умолчанию):
- Все поля всех наследников лежат в одной гигантской таблице.
- Появляется колонка
DTYPE(Discriminator), определяющая класс. - Плюс: Очень быстро (нет JOIN).
- Минус: Нельзя поставить
NOT NULLна поля наследников. Таблица пухнет.
- JOINED (Нормализованная):
- Есть таблица родителя и отдельные таблицы для каждого наследника.
- При выборке Hibernate делает
JOIN. - Плюс: Чистая схема БД, работают Constraints.
- Минус: Медленно при выборке (много JOIN-ов) и вставке (несколько INSERT).
- TABLE_PER_CLASS:
- Таблица родителя не создается (обычно он абстрактный). У каждого наследника своя таблица со всеми полями (своими + родительскими).
- Минус: Полиморфные запросы (найти всех
Animal) очень медленные, так как используетсяUNION ALLпо всем таблицам.
Когда findByEmail уже не хватает (например, фильтр в интернет-магазине с 20 параметрами, половина из которых может быть null), нам нужны динамические запросы.
Это стандартный способ строить запросы программно, без склейки строк (String Concatenation), что защищает от SQL Injection. Однако чистый JPA Criteria API очень многословен и сложен для чтения.
Это элегантная обертка над Criteria API. Вы создаете маленькие кусочки логики ("Спецификации") и комбинируете их.
// Спецификация: "Цена больше чем X"
public static Specification<Product> hasPriceGreaterThan(BigDecimal price) {
return (root, query, criteriaBuilder) ->
criteriaBuilder.greaterThan(root.get("price"), price);
}
// Спецификация: "В категории Y"
public static Specification<Product> isInCategory(String category) {
return (root, query, criteriaBuilder) ->
criteriaBuilder.equal(root.get("category"), category);
}
// Использование в сервисе:
repository.findAll(
Specification.where(hasPriceGreaterThan(100))
.and(isInCategory("Electronics"))
);Для этого репозиторий должен наследовать JpaSpecificationExecutor.
Частая ошибка: загружать тяжелую сущность User со всеми связями, чтобы просто отобразить "Имя" и "Фамилию" в списке. Это убивает память.
Spring Data позволяет использовать интерфейсные проекции:
// Интерфейс с геттерами только нужных полей
public interface UserSummary {
String getFirstName();
String getLastName();
// Spring даже может вычислить значение (Open Projection), но это медленнее
@Value("#{target.firstName + ' ' + target.lastName}")
String getFullName();
}
// В репозитории
List<UserSummary> findByActiveTrue(); Spring сгенерирует SQL запрос, который выберет только указанные поля, а не SELECT *.
Конец Уровня 3.
Вы научились моделировать сложные данные и строить гибкие фильтры. Но теперь перед вами встает главный враг любого Hibernate-разработчика — производительность.
Это враг №1 в мире ORM.
Представьте, что у вас есть список пользователей (User), и у каждого есть город (City).
Вы хотите вывести список пользователей и их города.
List<User> users = userRepository.findAll(); // 1 запрос (SELECT * FROM users)
for (User user : users) {
System.out.println(user.getCity().getName()); // N запросов!
}Если у вас 1000 пользователей, Hibernate сделает:
- Один запрос для получения всех юзеров.
- 1000 запросов для получения города для каждого юзера (потому что связи LAZY инициализируются при обращении).
Итого: 1 + 1000 = 1001 запрос. База данных захлебнется.
Мы явно говорим Hibernate: "Когда грузишь юзеров, сразу подтяни и города одним JOIN-ом".
@Query("SELECT u FROM User u JOIN FETCH u.city")
List<User> findAllWithCities();Результат: 1 запрос SELECT ... FROM users INNER JOIN cities ....
Декларативное решение, чтобы не писать JPQL.
// Подтянуть поле 'city' сразу
@EntityGraph(attributePaths = {"city"})
List<User> findAll();Если вам не нужен JOIN (например, вы не хотите дублирования строк в декартовом произведении), можно сказать Hibernate грузить связанные сущности "пачками".
@Entity
public class User {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 20) // <--- Магия здесь
private City city;
}Теперь при переборе 1000 юзеров, Hibernate будет делать запросы не по 1, а по 20 ID за раз (WHERE id IN (?, ?, ...)). Вместо 1000 запросов будет 50.
- EAGER (Жадная): Грузит связь всегда.
- Опасность: Если у
UserестьGroup(EAGER), а уGroupестьPermissions(EAGER)... загрузка одного юзера вытянет полбазы. - Правило: Всегда используйте LAZY по умолчанию.
@OneToManyи@ManyToManyуже LAZY, а вот@ManyToOneи@OneToOne— EAGER. Всегда переопределяйте их на LAZY!
Включен всегда. Работает в рамках одной транзакции. Если вы загрузили объект по ID, второй раз в той же транзакции Hibernate не пойдет в БД, а вернет объект из памяти.
Работает между сессиями (для всех пользователей). Требует внешнего провайдера (EhCache, Redis, Caffeine).
- Когда полезно: Справочники, настройки, редко меняющиеся данные.
- Риски: Рассинхронизация. Если кто-то поправит базу руками (через SQL консоль), кэш об этом не узнает, и приложение будет показывать старые данные.
- Query Cache: Кэширует результаты запросов (ID сущностей). Работает эффективно только если данные читаются очень часто, а меняются крайне редко. При любом
UPDATEв таблице, Query Cache для этой таблицы полностью сбрасывается.
Сценарий: нужно сохранить 10 000 объектов.
Плохой вариант:
for (int i = 0; i < 10000; i++) {
repository.save(new User(...));
}Это 10 000 отдельных INSERT запросов. Очень медленно из-за сетевых задержек.
Оптимизация (JDBC Batching): Мы хотим отправлять запросы пачками, например, по 50 штук за один сетевой вызов.
Настройки в application.properties:
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
Главная ловушка:
Batching не работает, если стратегия генерации ID — IDENTITY (стандарт для MySQL/Postgres). Потому что Hibernate должен сделать INSERT, чтобы узнать ID, и не может отложить это "в пачку".
Решение: Использовать SEQUENCE (для Postgres/Oracle) с allocationSize > 1 (чтобы Hibernate резервировал ID в памяти).
Знаменитая ошибка: LazyInitializationException: could not initialize proxy - no Session.
- Вы загрузили сущность
User(транзакция открыта). - Поле
posts(LAZY) не загрузилось. Вместо списка там лежит Proxy (объект-заглушка). - Транзакция закрылась. Сессия Hibernate умерла.
- Вы (например, в контроллере или HTML-шаблоне) вызываете
user.getPosts().size(). - Proxy пытается пойти в БД, но "труба" (Session) уже закрыта. -> Исключение.
Hibernate использует библиотеки CGLIB или ByteBuddy, чтобы создать класс-наследник вашей сущности на лету. В нем переопределены геттеры. Когда вы вызываете геттер, Proxy проверяет, загружены ли данные. Если нет — делает запрос (если сессия жива).
- Правильный способ: Использовать
JOIN FETCHили@EntityGraph(см. пункт 8), чтобы загрузить всё нужное, пока транзакция жива. - Спорный способ (Anti-Pattern): Open Session In View (OSIV). В Spring Boot включен по умолчанию. Он держит сессию открытой до самого конца обработки HTTP-запроса. Это удобно (ошибок нет), но держит соединение с БД дольше, чем нужно, снижая пропускную способность системы.
Конец Уровня 4.
Теперь вы знаете, как писать быстрый код. Остался последний рывок — Уровень 5: Конкурентность и Архитектура. Там мы разберем, как не дать двум пользователям одновременно купить последний билет в кино (блокировки) и как управлять транзакциями.
В Spring Data JPA транзакции управляются декларативно через AOP (Aspect Oriented Programming).
Вешается на метод сервиса (или класс).
- При входе: Spring открывает транзакцию (или присоединяется к существующей).
- При выходе: Spring делает
Commit. - При Exception: Spring делает
Rollback(по умолчанию только дляRuntimeException).
Что делать, если один транзакционный метод вызывает другой?
-
REQUIRED (По умолчанию): "Если транзакция есть — используй её. Если нет — создай новую". Самый частый вариант.
-
REQUIRES_NEW: "Всегда создавай новую транзакцию. Текущую (если есть) приостанови".
-
Кейс: Логирование ошибок или аудит. Даже если основная бизнес-логика упадет и откатится, запись в лог должна сохраниться.
-
MANDATORY: "Я требую, чтобы меня вызывали только внутри транзакции. Иначе брошу исключение".
Регулируют баланс между скоростью и точностью данных.
- READ_COMMITTED: (Стандарт для Postgres/Oracle). Видим только закоммиченные данные. Защищает от "Грязного чтения".
- REPEATABLE_READ: (Стандарт для MySQL). Гарантирует, что если мы прочитали строку дважды в одной транзакции, данные будут одинаковыми.
- SERIALIZABLE: Полная блокировка. Самый медленный, но самый надежный уровень.
Как предотвратить перезапись данных при конкурентном доступе?
Мы надеемся, что конфликты будут редки. Мы не блокируем базу данных, а используем версионирование.
Реализация: Добавляем поле версии в сущность.
@Entity
public class Wallet {
@Id private Long id;
private BigDecimal balance;
@Version // <--- Вся магия здесь
private Long version;
}Алгоритм:
- Транзакция А считывает кошелек (version = 1).
- Транзакция Б считывает кошелек (version = 1).
- Транзакция А обновляет баланс и сохраняет. Hibernate проверяет:
UPDATE wallet SET balance=?, version=2 WHERE id=? AND version=1. Успех. - Транзакция Б пытается сохранить. Hibernate шлет запрос:
WHERE ... AND version=1. Но версия в базе уже 2! - Запрос обновляет 0 строк.
- Hibernate бросает
OptimisticLockException. - Вы ловите ошибку и просите пользователя повторить действие.
Мы предполагаем худшее (конфликтов много), поэтому блокируем данные на уровне БД сразу при чтении.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT w FROM Wallet w WHERE w.id = :id")
Optional<Wallet> findByIdAndLock(Long id);Это генерирует SQL: SELECT ... FOR UPDATE.
Пока транзакция, вызвавшая этот метод, не завершится, никто другой не сможет ни прочитать (в режиме update), ни изменить эту строку. Остальные будут ждать (висеть).
В application.properties часто пишут: spring.jpa.hibernate.ddl-auto=update.
Это заставляет Hibernate при запуске сканировать сущности и пытаться ALTER TABLE в базе.
- На проде это КАТАСТРОФА. Hibernate может непредсказуемо удалить данные или заблокировать таблицы.
- Нет истории изменений.
Это инструменты версионирования БД.
Вы пишете SQL-скрипты миграций, которые хранятся в папке resources/db/migration:
V1__init_schema.sql(CREATE TABLE users...)V2__add_index_to_users.sql(CREATE INDEX...)
При запуске приложение проверяет, какие скрипты уже были выполнены, и накатывает только новые.
ddl-auto нужно выставить в validate или none.
Забудьте про моки (Mockito) для тестирования слоя репозиториев. Если вы замокаете репозиторий, вы протестируете только то, что ваш мок работает. Вы не узнаете, правильный ли SQL генерируется.
Аннотация Spring Boot, которая поднимает урезанный контекст (только JPA компоненты), работает быстро.
- H2 (In-memory DB): Быстро, но ненадежно. Синтаксис H2 отличается от Postgres. Тест пройдет на H2, но упадет на проде из-за специфичного JSONB поля или оконной функции.
- Testcontainers: Золотой стандарт индустрии. В Docker поднимается реальный Postgres (чистый, пустой) специально для теста. Тесты идут дольше, но гарантируют 100% совместимость.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // Не подменять на H2!
class UserRepositoryTest extends AbstractContainerBaseTest {
// ... тесты
}Конец Уровня 5.
Вы прошли путь от SELECT * до управления распределенными транзакциями.
Ваш путь к эксперту выглядит так:
- Перестаньте использовать EAGER fetch.
- Научитесь читать логи Hibernate (включите
spring.jpa.show-sql=trueи научитесь видеть N+1 глазами). - Используйте Testcontainers.
- Никогда не используйте
ddl-auto=updateна проде.
В этой главе мы научимся двум вещам:
- Хранить данные в одном виде (например, зашифрованном), а в Java работать с ними в другом.
- Удалять данные так, чтобы они оставались в базе (Soft Delete).
Иногда типы данных в базе и в Java не совпадают.
- Пример 1: В базе поле
gender— этоCHAR(1)('M', 'F'), а в Java этоEnum Gender. (Решается через@Enumerated, но конвертер гибче). - Пример 2 (Киллер-фича): Шифрование. В базе мы хотим хранить
AES:83h1289..., а в Java видеть1234-5678-9000.
Hibernate вызывает ваш код перед вставкой в БД (convertToDatabaseColumn) и сразу после чтения из БД (convertToEntityAttribute).
Сначала создаем сам конвертер:
@Converter
// <Тип_в_Java, Тип_в_БД>
public class CryptoConverter implements AttributeConverter<String, String> {
@Override
public String convertToDatabaseColumn(String sensitiveData) {
if (sensitiveData == null) return null;
return encrypt(sensitiveData); // Ваш метод шифрования (AES/RSA)
}
@Override
public String convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
return decrypt(dbData); // Ваш метод расшифровки
}
}Применяем в сущности:
@Entity
public class User {
@Id @GeneratedValue private Long id;
@Convert(converter = CryptoConverter.class)
private String creditCardNumber;
// В Java это "4444...", в БД лежит "x8s7f6..."
}- Поиск по полю: Вы не сможете эффективно искать по этому полю (
findByCreditCardNumber). База видит абракадабру. Поиск придется делать в памяти или использовать детерминированное шифрование (что менее безопасно). autoApply = true: Можно написать@Converter(autoApply = true). Тогда конвертер применится ко всем полям типаStringво всем проекте. Это опасно.- Частичные данные: Если вы используете
JPQLконструкциюselect u.creditCardNumber from User u, конвертер сработает. Но если вы используете Native SQL (select credit_card from users), вы получите зашифрованную строку.
В корпоративных системах удалять данные физически (DELETE) часто запрещено. Нужна история, аудит или возможность восстановления.
Задача:
- При вызове
repository.delete(id)делатьUPDATE users SET deleted = true WHERE id = ?. - При вызове
findAll()не показывать удаленные записи.
Раньше использовали аннотацию @Where, но она объявлена Deprecated. Теперь используем @SQLDelete и @SQLRestriction.
@Entity
@Table(name = "users")
// 1. Перехватываем команду DELETE
@SQLDelete(sql = "UPDATE users SET deleted = true WHERE id = ?")
// 2. Добавляем фильтр ко ВСЕМ запросам чтения (SELECT, JOIN и т.д.)
@SQLRestriction("deleted = false")
public class User {
@Id @GeneratedValue private Long id;
private String username;
// Флаг удаления
@Column(nullable = false)
private boolean deleted = false;
}userRepository.deleteById(1L);
// Hibernate выполнит: UPDATE users SET deleted = true WHERE id = 1
List<User> users = userRepository.findAll();
// Hibernate выполнит: SELECT * FROM users WHERE deleted = falseЭто тема, на которой часто "ломают копья".
- Уникальные индексы (Unique Constraints):
- Представьте, что у
usernameстоитUNIQUE. - Вы удаляете пользователя "admin" (он становится
deleted=true). - Вы пытаетесь создать нового пользователя "admin".
- Ошибка БД! Физически старая запись "admin" всё еще там.
- Решение: Использовать "Partial Index" в PostgreSQL:
CREATE UNIQUE INDEX idx_username ON users (username) WHERE deleted = false;
- Native SQL:
- Если вы напишете
@Query(value = "SELECT * FROM users", nativeQuery = true), Hibernate не добавит условиеdeleted = false. Нативные запросы игнорируют аннотации сущности. Вы получите удаленные записи.
- Каскадное удаление:
- Если у
UserестьList<Order>, и у связи стоитCascadeType.REMOVE, то при удалении User Hibernate попытается удалить и Order. - Если у Order тоже стоит
@SQLDelete, всё пройдет хорошо (будет цепочка Update-ов). - Если нет — Hibernate сделает физический
DELETEдля ордеров.
JPA позволяет вынести логику из методов save/update во внешние классы.
Пример: Мы хотим логировать изменения или отправлять уведомления, но не хотим засорять код Сервиса или Сущности.
public class UserAuditListener {
@PrePersist // Перед INSERT
public void beforeCreate(User user) {
System.out.println("Создаем юзера: " + user.getUsername());
}
@PostPersist // После успешного INSERT
public void afterCreate(User user) {
// Пример: Отправить событие в Kafka
// EmailService.sendWelcomeEmail(user.getEmail());
}
}
// Подключаем к сущности
@Entity
@EntityListeners(UserAuditListener.class)
public class User { ... }Внутри EntityListener нельзя использовать репозитории или EntityManager для изменения БД. Вы находитесь внутри процесса "flush". Попытка сохранить другую сущность здесь может привести к непредсказуемому поведению или рекурсии.
Конец Уровня 6. Мы научились "обманывать" базу данных: хранить одно, а показывать другое (Конвертеры), и делать вид, что удалили, хотя просто спрятали (Soft Delete).
Представьте, что вы пишете CRM-систему (SaaS). Ею пользуются компании "Рога и Копыта" (Tenant A) и "Вектор" (Tenant B). Главное требование: Данные одной компании никогда не должны попасть к другой.
- Discriminator Column (Общая схема):
- Все живут в одной таблице
users. Добавляется колонкаtenant_id. - Плюс: Дешево, легко делать бэкап, миграции простые.
- Минус: Разработчик забыл добавить
WHERE tenant_id = ?в одном запросе -> утечка данных. Hibernate решает это через@TenantId(в версии 6+) или фильтры.
- Separate Schema (Раздельные схемы):
- Одна БД, но разные схемы:
schema_tenant_a,schema_tenant_b. - Плюс: Хорошая изоляция, можно делать бэкап отдельной схемы.
- Минус: Сложно управлять миграциями (Flyway должен пройтись по 100 схемам).
- Separate Database (Раздельные базы):
- У каждого клиента свой URL подключения.
- Плюс: Полная физическая изоляция (VIP клиенты на быстром железе).
- Минус: Очень дорого по ресурсам.
Hibernate имеет встроенную поддержку этого механизма. Вам нужно реализовать два интерфейса.
Нам нужно понять, какой клиент сейчас делает запрос (обычно извлекаем из JWT токена или поддомена client.app.com).
@Component
public class TenantResolver implements CurrentTenantIdentifierResolver {
@Override
public String resolveCurrentTenantIdentifier() {
// Достаем ID из ThreadLocal (куда его положил фильтр запросов)
String tenantId = TenantContext.getCurrentTenant();
return tenantId != null ? tenantId : "default_schema";
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}Hibernate просит: "Дай мне Connection для клиента 'tenant_a'". Вы должны переключить схему.
@Component
public class SchemaConnectionProvider implements MultiTenantConnectionProvider {
@Autowired
private DataSource dataSource;
@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
Connection connection = dataSource.getConnection();
// Магия Postgres: переключаем search_path на нужную схему
connection.setSchema(tenantIdentifier);
return connection;
}
// ... остальные методы releaseConnection и т.д.
}- Connection Pooling: Если вы используете стратегию Separate Database, вы не можете создать 100 пулов соединений (HikariCP) по 10 коннектов. 1000 открытых сокетов положат сервер. Придется использовать динамическую маршрутизацию без пулинга или внешние прокси (PgBouncer).
- Кэш Hibernate: L2 Cache должен знать о тенантах. Иначе Tenant A получит кэшированные данные Tenant B. В EhCache/Redis это решается добавлением TenantID в ключ кэша.
Когда SELECT запросов становится слишком много, базу разделяют:
- Master (Leader): Принимает
INSERT,UPDATE,DELETE. - Replica (Follower/Slave): Принимает только
SELECT. Данные копируются с Мастера асинхронно.
Задача: Заставить Spring автоматически отправлять пишущие запросы на Мастера, а читающие — на Реплику.
В Spring есть специальный класс AbstractRoutingDataSource, который притворяется одним DataSource, но внутри переключается между несколькими реальными.
// 1. Определяем типы
public enum DataSourceType { READ_ONLY, READ_WRITE }
// 2. Логика маршрутизации
public class TransactionRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// Проверяем: текущая транзакция ReadOnly?
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
return DataSourceType.READ_ONLY;
}
return DataSourceType.READ_WRITE;
}
}Конфигурация:
- Создаем два реальных DataSource (MasterDS, ReplicaDS).
- Скармливаем их нашему
TransactionRoutingDataSource. - В сервисах используем аннотацию:
@Service
public class UserService {
@Transactional(readOnly = true) // Пойдет на Replica
public User getUser(Long id) { ... }
@Transactional // (readOnly = false) Пойдет на Master
public void createUser(User user) { ... }
}Это классическая проблема распределенных систем.
Сценарий:
- Пользователь обновляет профиль (
Master DB). Транзакция завершена. - Пользователь сразу перенаправляется на страницу профиля (
SELECT->Replica DB). - Репликация занимает 100-500мс. Данные еще не долетели до Реплики.
- Результат: Пользователь видит старые данные. Он паникует и нажимает F5.
Решения:
- Грубое: После записи делать принудительное чтение с Мастера для этого пользователя (хранить флаг в сессии).
- Архитектурное: Критичные данные (профиль сразу после редактирования) всегда читать с Мастера (
@Transactional(readOnly = false)даже для SELECT). Некритичные (лента новостей) — с Реплики.
Конец Уровня 7. Вы узнали, как масштабировать приложение горизонтально.
- Multi-tenancy позволяет продавать один инстанс приложения сотням клиентов.
- Replication позволяет выдерживать огромные нагрузки на чтение.
Стандартный JPQL хорош для загрузки объектов, но плох для аналитики. Чего нет в старых версиях (или неудобно в новых):
- Window Functions (
ROW_NUMBER() OVER...,RANK(),PARTITION BY). - CTEs (
WITH RECURSIVE— для деревьев и иерархий). - Slozhnyye JOIN-ы (например,
LATERAL JOIN).
Мы пишем чистый SQL внутри аннотации.
@Query(value = """
SELECT u.username, sum(o.total) as total_sum,
RANK() OVER (ORDER BY sum(o.total) DESC) as rank
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.username
""", nativeQuery = true)
List<Object[]> findUserRanks(); // Возвращает массив объектов :(- Маппинг (ResultSetMapping): Возвращать
List<Object[]>— это ад. Вам придется вручную кастить(String) obj[0],(BigDecimal) obj[1].
- Решение: Использовать Interface Projections (Spring Data сам смапит колонки по именам геттеров) или
@SqlResultSetMapping(очень многословно).
- Зависимость от БД: Если вы напишете запрос на диалекте PostgreSQL (используя
jsonb_agg), вы не сможете запустить это на H2 или MySQL. - Отсутствие проверки типов: Если вы переименуете поле в Entity, этот запрос сломается только в Runtime (когда вы его запустите).
Это библиотека, которая работает поверх Hibernate. Она позволяет писать JPQL-подобный код, но с поддержкой всех фич современного SQL (CTE, Window Functions, Values clause, Union).
Это выбор экспертов для сложных проектов.
Допустим, у нас есть дерево категорий (Electronics -> Laptops -> Gaming), и нам нужно выбрать всё поддерево одним запросом.
// CriteriaBuilderFactory внедряется Blaze-Persistence
CriteriaBuilder<Category> cb = cbf.create(em, Category.class)
.withRecursive(CategoryCTE.class) // Объявляем CTE
.from(Category.class, "c")
.bind("id").select("c.id")
.bind("parent").select("c.parent")
.where("c.id").eq(rootCategoryId) // Стартовая точка
.unionAll() // Рекурсивная часть
.from(Category.class, "c")
.join(CategoryCTE.class, "cte").on("cte.id").eq("c.parent.id") // Join с собой
.bind("id").select("c.id")
.bind("parent").select("c.parent")
.end()
.select("cte")
.from(CategoryCTE.class, "cte"); // Финальная выборка
List<Category> tree = cb.getResultList();Плюсы:
- Type Safe: Ошибки видны на этапе компиляции.
- Entity View: Возвращает не массив байтов, а типизированные объекты или DTO.
- Performance: Генерирует оптимизированный SQL.
Мы уже говорили, что тянуть сущности целиком — дорого. Spring Data дает три уровня проекций.
Самый простой вариант. Spring генерирует прокси на лету.
Вы пишете обычный Java класс (POJO) с конструктором.
// DTO (Lombok @Value делает класс неизменяемым и добавляет конструктор)
@Value
public class UserStatDto {
String username;
Long orderCount;
}
// Repository
// Внимание: нужно указывать полный путь к классу!
@Query("SELECT new com.example.dto.UserStatDto(u.username, count(o)) " +
"FROM User u JOIN u.orders o GROUP BY u.username")
List<UserStatDto> findUserStats();- Плюс: Никаких прокси. Hibernate сразу вызывает конструктор (
new ...). Максимальная скорость. - Минус: Длинные имена пакетов в запросе (
com.example...).
Когда один метод репозитория может возвращать разные данные в зависимости от вызова.
// Repository
<T> List<T> findByUsername(String username, Class<T> type);
// Service
// Хочу полную сущность
List<User> users = repo.findByUsername("alice", User.class);
// Хочу только краткую сводку
List<UserSummary> summaries = repo.findByUsername("alice", UserSummary.class);
// Хочу DTO
List<UserDto> dtos = repo.findByUsername("alice", UserDto.class);Динамические проекции отлично работают с Derived Queries (из имени метода). Но если вы используете @Query, вам придется использовать SpEL (Spring Expression Language), что усложняет код, или писать отдельные методы.
Маленькая, но важная деталь при переходе на Spring Boot 3.
В Hibernate 5:
Если вы делали LEFT JOIN FETCH для коллекции (User + Orders), результат задваивался (User возвращался столько раз, сколько у него ордеров). Нужно было писать SELECT DISTINCT u ....
При этом DISTINCT часто реально передавался в SQL, заставляя базу делать тяжелую сортировку.
В Hibernate 6: Hibernate делает дедупликацию в памяти автоматически.
- Вам больше не нужно писать
DISTINCTв JPQL для предотвращения дублей сущностей. - Если вы все-таки напишете
DISTINCT, Hibernate 6 попытается понять: нужен он в SQL или только для логики.
Конец Уровня 8.
- Если нужно «просто и быстро» что-то сложное — берите Native Query + Interface Projection.
- Если строите сложную аналитическую систему со слоями — учите Blaze-Persistence.
- Всегда используйте DTO проекции для списков (read-only операций).
Мы уже знаем про @Transactional. Но что происходит, когда один транзакционный метод вызывает другой? Это регулирует параметр propagation.
- REQUIRED (По умолчанию):
- Логика: "Есть транзакция? Я в ней. Нет? Создам новую".
- Нюанс: Если внутренний метод бросит
RuntimeException, он пометит всю транзакцию какrollbackOnly. Даже если вы поймаете исключение во внешнем методе, вы не сможете закоммитить транзакцию. БудетUnexpectedRollbackException.
- REQUIRES_NEW (Изоляция):
- Логика: "Всегда создавай новую. Если есть старая — приостанови её (Suspend)".
- Кейс: Аудит ошибок.
- Пример: Вы пытаетесь оплатить заказ. Оплата упала. Вы хотите сохранить запись "Попытка оплаты неудачна" в таблицу логов. Если использовать
REQUIRED, то откат оплаты откатит и лог. СREQUIRES_NEWлог сохранится в отдельной транзакции.
- NESTED (Вложенная):
- Логика: Использует Savepoints (точки сохранения) JDBC. Это одна физическая транзакция.
- Если вложенный метод падает, транзакция откатывается к Savepoint, но внешняя транзакция может продолжить работу.
- Редкость: Работает только через JDBC, не поддерживается некоторыми драйверами.
Это классический вопрос на собеседовании.
@Service
public class OrderService {
@Transactional // (1)
public void processOrder() {
// Какая-то логика...
saveAuditLog(); // <--- ВЫЗОВ ВНУТРИ ТОГО ЖЕ КЛАССА
}
@Transactional(propagation = Propagation.REQUIRES_NEW) // (2)
public void saveAuditLog() {
repo.save(new Log(...));
}
}Вопрос: Создастся ли новая транзакция для saveAuditLog?
Ответ: НЕТ.
Почему? Spring использует Proxy (обертку) вокруг вашего класса.
- Когда вы вызываете
service.processOrder()извне (из контроллера), вызов идет через Proxy -> открывается транзакция -> вызывается реальный метод. - Когда вы внутри
processOrderвызываетеthis.saveAuditLog(), вы идете мимо Proxy, напрямую к методу. Spring не может перехватить этот вызов и открыть новую транзакцию.
Решение:
- Вынести
saveAuditLogв другой сервис (AuditService). - (Костыль) Внедрить самого себя (
@Autowired private OrderService self) и вызыватьself.saveAuditLog().
Deadlock — это ситуация, когда Транзакция А ждет ресурс, который держит Транзакция Б, а Транзакция Б ждет ресурс, который держит А.
У нас есть счета с ID=1 и ID=2. Два пользователя одновременно переводят деньги.
- Поток 1 (1 -> 2): Блокирует ID=1 ... пытается блокировать ID=2 (ждет).
- Поток 2 (2 -> 1): Блокирует ID=2 ... пытается блокировать ID=1 (ждет).
- Итог: База данных (обычно через пару секунд) обнаруживает цикл и убивает одну из транзакций ошибкой
Deadlock found.
Всегда блокируйте ресурсы в одном и том же порядке (например, сортировка по ID).
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// Сортируем ID перед блокировкой!
Long firstLock = (fromId < toId) ? fromId : toId;
Long secondLock = (fromId < toId) ? toId : fromId;
// Сначала блокируем меньший ID
repository.findByIdAndLock(firstLock);
// Потом больший ID
repository.findByIdAndLock(secondLock);
// Теперь безопасно меняем балансы...
}Теперь оба потока сначала попытаются заблокировать ID=1. Один пройдет, второй будет ждать. Deadlock невозможен.
Это особый вид Deadlock-а, который происходит не в базе, а в пуле соединений (HikariCP).
Сценарий:
- У вас
maximum-pool-size = 10. - У вас есть метод А (
@Transactional), который вызывает метод Б (@Transactional(REQUIRES_NEW)). - Пришла высокая нагрузка: 10 одновременных запросов.
Что происходит:
- 10 потоков зашли в метод А. Они заняли все 10 соединений из пула.
- Все 10 потоков дошли до вызова метода Б.
- Метод Б требует
REQUIRES_NEW, то есть ему нужно новое, отдельное соединение. - Потоки просят у пула 11-е соединение.
- Пул пуст. Потоки ждут, пока кто-то вернет соединение.
- Но соединения держат сами эти ожидающие потоки (их внешние транзакции А ждут завершения Б).
- Приложение висит намертво до таймаута соединения.
Решение:
- Избегайте
REQUIRES_NEWбез крайней необходимости. - Формула размера пула для таких систем:
PoolSize = MaxThreads * (MaxNestedTransactions + 1)Если у вас возможна 1 вложенная транзакция, пул должен быть с запасом.
Поздравляю! Мы прошли путь от новичка, который пишет SELECT *, до архитектора, который понимает, как не убить базу блокировками.
Вот чек-лист навыков, которыми вы теперь владеете (или знаете, где искать):
- ✅ JPA Core: Entity Lifecycle, Dirty Checking, First-level cache.
- ✅ Relationships: Owning side, Cascade, Orphan Removal.
- ✅ Spring Data: Repository, Projections (DTO), Specifications.
- ✅ Performance: N+1, Batching, FetchType.LAZY.
- ✅ Hardcore: Optimistic Locking, Isolation Levels, Propagation.
- ✅ Testing: Testcontainers.
- ✅ Architecture: Soft Delete, Multi-tenancy, Blaze-Persistence.
Теория без практики мертва. Я предлагаю вам Челлендж Выходного Дня.
ТЗ "Банковская система":
- Создать сущности
Account(с балансом) иTransfer(история). - Реализовать метод перевода денег, который:
- Защищен от Deadlock (сортировка ID).
- Использует Optimistic Locking (чтобы никто не изменил баланс параллельно).
- Сохраняет историю перевода.
- Если перевод прошел, но сохранение истории упало — деньги должны вернуться (атомарность).
- Написать тест на Testcontainers, который запускает 10 параллельных потоков, гоняющих деньги между двумя счетами. В конце сумма на двух счетах должна остаться неизменной (консистентность).
Эти аннотации определяют сущность и её связь с таблицей.
- Что делает: Помечает класс как сущность JPA. Обязательна.
- 🪨 Подводный камень: У класса обязательно должен быть пустой конструктор (
protectedилиpublic). Без него Hibernate не сможет создать экземпляр через рефлексию. Также класс не должен бытьfinal.
- Что делает: Задает имя таблицы в БД. Если не указать, имя таблицы будет равно имени класса (User -> user).
- 🪨 Подводный камень: Внимательно с зарезервированными словами SQL (например,
Order,User,Group). Если назвать таблицу так без кавычек (или без переименования в@Table), база данных выдаст ошибку синтаксиса.
- Что делает: Указывает поле первичного ключа.
-
Что делает: Указывает стратегию генерации ID.
-
Стратегии:
-
GenerationType.IDENTITY: Автоинкремент на стороне БД (MySQL, Postgres). -
GenerationType.SEQUENCE: Использование последовательности (Oracle, Postgres). -
🪨 Подводный камень (Expert): Использование
IDENTITYотключает JDBC Batching (пакетную вставку) дляINSERT. Hibernate не может отправить пачку insert-ов, так как ему нужно знать ID сразу после вставки каждой строки. Если нужно вставлять быстро и много — используйтеSEQUENCEсallocationSize.
- Что делает: Настраивает маппинг на колонку (имя, длина, nullability).
- 🪨 Подводный камень: Параметр
nullable = falseработает только для генерации схемы (ddl-auto) или валидации Hibernate. Если база создана отдельно и там нетNOT NULLconstraint, Hibernate пропустит null, если не включена валидация.
- Что делает: Мапит Java Enum.
- 🪨 Подводный камень (Критичный): По умолчанию использует
EnumType.ORDINAL(сохраняет 0, 1, 2...). Если вы добавите значение в середину Enum'а или поменяете их местами, данные в базе перемешаются. **Всегда используйте@Enumerated(EnumType.STRING)**.
- Что делает: Говорит Hibernate игнорировать это поле. Оно не сохраняется в БД.
- 🪨 Подводный камень: Не путать с ключевым словом Java
transient. Ключевое слово Java влияет на сериализацию (Serialization), а аннотация — на персистентность.
- Что делает: Large Object. Для хранения длинных текстов (
TEXT,CLOB) или бинарников (BLOB). - 🪨 Подводный камень: В PostgreSQL
@LobнаStringможет мапиться в типOID, что неудобно. Часто лучше просто использовать@Column(columnDefinition = "TEXT").
Самая опасная зона.
-
Что делает: Связь "Один ко многим".
-
🪨 Подводный камень 1:
FetchType. -
У
@OneToManyпо умолчаниюLAZY(хорошо). -
У
@ManyToOneпо умолчаниюEAGER(плохо!). **Всегда явно пишитеfetch = FetchType.LAZY**для@ManyToOne. -
🪨 Подводный камень 2: Бесконечная рекурсия в
toString(),equals(),hashCode()или при сериализации в JSON (Jackson). Если User ссылается на Post, а Post на User — стек переполнится. Используйте@JsonIgnoreили DTO.
- Что делает: Указывает имя колонки внешнего ключа (Foreign Key). Ставится на Владеющей стороне (там, где физически лежит FK).
- 🪨 Подводный камень: Если не указать, Hibernate сгенерирует страшное имя через подчеркивание, объединив имена двух таблиц.
- Что делает: Связь "Многие ко многим".
- 🪨 Подводный камень: При удалении одной записи из коллекции (
users.remove(user)), Hibernate может удалить все записи из промежуточной таблицы для этой сущности и вставить оставшиеся заново. Это очень медленно. Лучше мапить через промежуточную Entity (@OneToMany-> Entity ->@ManyToOne).
Эти аннотации работают только в Hibernate (не часть JPA), но они крайне полезны.
- Что делает: Решает проблему N+1 для коллекций. Грузит связанные сущности пачками по
sizeштук. - Где ставить: Над классом сущности или над полем коллекции.
- Что делает: Виртуальное поле, значение которого вычисляется базой данных при выборке.
@Formula("(select count(*) from posts p where p.user_id = id)")
private int postCount;- 🪨 Подводный камень: Значение только для чтения (
read-only). Если попытаться его изменить в Java и сохранить, ничего не произойдет (или ошибка).
- Что делает: Заставляет Hibernate генерировать SQL
INSERT/UPDATEтолько для тех полей, которые реально изменились (не null). - Польза: Уменьшает размер SQL запроса и трафик.
- 🪨 Подводный камень: Hibernate не может кэшировать скомпилированные PreparedStatement, так как SQL каждый раз разный. Небольшая потеря производительности CPU.
- Что делает: Гарантирует, что сущность никогда не будет изменена (UPDATE) в БД.
- Польза: Hibernate отключает Dirty Checking для таких сущностей, что ускоряет работу.
- Что делает: Включает Optimistic Locking.
- 🪨 Подводный камень: Никогда не меняйте это поле вручную (
setVersion(...)). Hibernate должен управлять им сам.
- Что делает: Автоматически проставляет текущее время при создании или обновлении записи.
- Аналог в Spring Data:
@CreatedDate,@LastModifiedDate(требует настройки@EnableJpaAuditingи слушателяAuditingEntityListener).
Хотя это не JPA, но используется всегда вместе.
- Что делает: Генерирует геттеры, сеттеры,
equals,hashCode,toString. - 🪨 ПОДВОДНЫЙ КАМЕНЬ (ОПАСНО):
equals/hashCodeпо умолчанию включают все поля. Если там есть@OneToMany(LAZY), вызовhashCodeдернет базу данных (LazyInitException или лишний запрос).toStringтоже пойдет по всем полям -> циклическая ссылка -> StackOverflowError.- Решение: Для Entity используйте комбинацию:
@Getter@Setter@ToString(с@ToString.Excludeна ленивых связях)equals/hashCodeпишите руками (сравнивайте только по@Id, если он есть, или используйте бизнес-ключ).
Вот «Золотой стандарт» реализации Entity. Этот код решает 99% проблем, связанных с LazyInitializationException, StackOverflowError и проблемами производительности при вставке.
Я разделил это на базовый класс (для переиспользования) и саму сущность.
Выносим технические поля (кто создал, когда обновил), чтобы не мусорить в бизнес-классах.
@MappedSuperclass // Поля этого класса попадут в таблицы наследников
@EntityListeners(AuditingEntityListener.class) // Включает магию Spring Data Auditing
@Getter
@Setter
public abstract class BaseEntity {
@CreationTimestamp // Hibernate сам поставит время при INSERT
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp // Hibernate обновит время при каждом UPDATE
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}Обратите внимание на комментарии — там вся соль.
import lombok.*;
import org.hibernate.annotations.BatchSize;
import org.hibernate.proxy.Hibernate;
import jakarta.persistence.*;
import java.util.*;
@Entity
@Table(name = "users")
// 1. LOMBOK: Не используем @Data!
@Getter
@Setter
@NoArgsConstructor // Обязателен для Hibernate
@ToString // Безопасный toString настроим ниже
public class User extends BaseEntity {
@Id
// 2. SEQUENCE: Позволяет использовать Batch Insert (вставку пачками)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq")
@SequenceGenerator(name = "user_seq", sequenceName = "users_id_seq", allocationSize = 50)
private Long id;
@Column(nullable = false, unique = true)
private String username;
// 3. ENUM: Только STRING!
@Enumerated(EnumType.STRING)
private UserStatus status = UserStatus.ACTIVE;
// 4. ОТНОШЕНИЯ: Inverse side (Обратная сторона)
@OneToMany(
mappedBy = "user", // Ссылаемся на поле 'user' в классе Order
cascade = CascadeType.ALL, // Сохраняем User -> сохраняются Orders
orphanRemoval = true // Удаляем Order из списка -> DELETE из БД
)
// 5. FETCHING: Решаем N+1 для коллекций
@BatchSize(size = 20)
// 6. LOMBOK: Исключаем ленивые поля из toString, иначе будет SELECT или ошибка
@ToString.Exclude
private List<Order> orders = new ArrayList<>();
// 7. HELPER METHODS: Синхронизация двунаправленной связи
public void addOrder(Order order) {
orders.add(order);
order.setUser(this); // Важно!
}
public void removeOrder(Order order) {
orders.remove(order);
order.setUser(null);
}
// 8. EQUALS & HASHCODE: Самая сложная часть (Proxy-safe)
@Override
public boolean equals(Object o) {
if (this == o) return true;
// Важно: проверяем через Hibernate.getClass, так как 'o' может быть Proxy
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
User user = (User) o;
// Сравниваем только ID. Если ID null (transient) -> объекты разные
return id != null && Objects.equals(id, user.id);
}
@Override
public int hashCode() {
// Константа! Это предотвращает изменение хэш-кода при первом сохранении (когда появляется ID).
// Да, это превращает HashMap в Linked list в худшем случае, но это безопасно.
return getClass().hashCode();
}
}@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor
public class Order extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String description;
// 9. LAZY: Обязательно меняем EAGER на LAZY
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id") // Владеющая сторона (тут FK)
@ToString.Exclude // Исключаем, чтобы не зациклить toString (User -> Order -> User...)
private User user;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Order order = (Order) o;
return id != null && Objects.equals(id, order.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}Это неочевидный момент.
- Вы создали
User u = new User(). У негоid = null. - Вы положили его в
HashSet. Хэш посчитался (например, от случайного адреса памяти или 0). - Вы сохранили
repository.save(u). Ему присвоилсяid = 100. - Если бы
hashCodeзависел отid, он бы изменился. - Теперь
HashSetне может найти этот объект, потому что он лежит в "старой" корзине (bucket), а ищется в новой. Memory Leak гарантирован. ВозвратgetClass().hashCode()решает это, делая хэш стабильным в течение всей жизни объекта.
Если вы загружаете User лениво (через getReferenceById или как связь), Hibernate подсовывает вам не класс User, а класс User$HibernateProxy$Zw3s....
Обычный this.getClass() вернет разные классы для оригинала и прокси, и equals вернет false, хотя это одна и та же запись в БД.
Когда вы будете бежать циклом по users и вызывать getOrders(), без этой аннотации Hibernate делал бы по одному запросу на каждого юзера. С ней он подгрузит ордера сразу для 20 (или 50) юзеров за один раз. Это дешевое решение N+1 без написания сложных запросов.
Стандартное значение часто 50, но иногда люди ставят 1.
Если allocationSize = 1, Hibernate будет ходить в базу за новым ID каждый раз при создании объекта.
Если allocationSize = 50, он один раз сходит в базу, получит диапазон (например, 1000-1050) и следующие 50 объектов сохранит в памяти очень быстро, а потом отправит их в БД одним пакетом (Batch).
Вот полный пример настройки и теста для наших сущностей User и Order.
Вам понадобятся эти библиотеки (версии Spring Boot подтянет сам, если используете spring-boot-starter-parent):
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Чтобы не поднимать контейнер для каждого тест-класса заново (это долго), мы создадим абстрактный класс. Контейнер запустится один раз для всех тестов.
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
public abstract class AbstractIntegrationTest {
// Поднимаем Docker-контейнер с Postgres 15
private static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:15-alpine")
.withReuse(true); // Оптимизация для локальной разработки
static {
POSTGRES.start();
}
// Переписываем настройки application.properties на лету,
// чтобы Spring подключился к этому контейнеру
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
// Важно: отключаем ddl-auto=update, лучше использовать flyway/liquibase.
// Но для теста сущностей допустимо create-drop
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
}
}Здесь мы проверяем всё: сохранение, каскады, генерацию ID, аудит и удаление сирот.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.context.annotation.Import;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest // Поднимает только JPA контекст (быстро)
// Отключаем попытку Spring заменить базу на H2. Мы хотим наш Docker!
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest extends AbstractIntegrationTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager; // Позволяет работать с контекстом напрямую
@Test
void shouldGenerateIdAndAuditFields() {
// Given
User user = new User();
user.setUsername("test_user");
// When
User savedUser = userRepository.save(user);
// Сбрасываем кэш (flush & clear), чтобы заставить Hibernate сходить в БД
// при следующем чтении. Иначе мы проверим только кэш.
entityManager.flush();
entityManager.clear();
// Then
User foundUser = userRepository.findById(savedUser.getId()).orElseThrow();
assertThat(foundUser.getId()).isNotNull(); // ID сгенерирован (Sequence)
assertThat(foundUser.getCreatedAt()).isNotNull(); // @CreationTimestamp сработал
assertThat(foundUser.getStatus()).isEqualTo(UserStatus.ACTIVE); // Default value
}
@Test
void shouldPersistOrdersCascade() {
// Given
User user = new User();
user.setUsername("buyer");
Order order1 = new Order();
order1.setDescription("Laptop");
Order order2 = new Order();
order2.setDescription("Mouse");
// Используем наш helper-метод!
user.addOrder(order1);
user.addOrder(order2);
// When
userRepository.save(user); // Сохраняем ТОЛЬКО юзера
entityManager.flush();
entityManager.clear();
// Then
User foundUser = userRepository.findById(user.getId()).get();
assertThat(foundUser.getOrders()).hasSize(2);
assertThat(foundUser.getOrders().get(0).getUser()).isNotNull(); // Связь установлена
}
@Test
void shouldRemoveOrphan() {
// 1. Создаем юзера с ордером
User user = new User();
user.setUsername("deleter");
Order order = new Order();
order.setDescription("To delete");
user.addOrder(order);
user = userRepository.save(user);
entityManager.flush();
entityManager.clear();
// 2. Загружаем и удаляем ордер из коллекции
User loadedUser = userRepository.findById(user.getId()).get();
Order loadedOrder = loadedUser.getOrders().get(0);
// Используем helper для разрыва связи
loadedUser.removeOrder(loadedOrder);
// Сохраняем изменения (User)
userRepository.save(loadedUser);
entityManager.flush();
entityManager.clear();
// 3. Проверяем, что ордер исчез из БД
Order deletedOrder = entityManager.find(Order.class, loadedOrder.getId());
assertThat(deletedOrder).isNull(); // orphanRemoval = true сработал!
}
}entityManager.flush()иclear(): Это критически важно в тестах@DataJpaTest. По умолчанию транзакция длится весь тест. Если вы сохраните объект и тут же попытаетесь его найти (findById), Hibernate вернет вам объект из памяти (L1 Cache), даже не делая запрос в БД. Вызовыflush(отправить изменения в БД) иclear(очистить память) гарантируют, что следующий запрос реально пойдет в базу данных Postgre и проверит, как всё сохранилось на самом деле.@AutoConfigureTestDatabase(replace = NONE): Без этой строки Spring проигнорирует ваши настройки Testcontainers и попытается запустить H2.- Тестирование сирот (Orphan Removal): Последний тест доказывает, что удаление объекта из Java-списка
ordersреально приводит кDELETE FROM ordersв базе.