Skip to content

Instantly share code, notes, and snippets.

@dmitry-osin
Created January 9, 2026 23:24
Show Gist options
  • Select an option

  • Save dmitry-osin/aab8dea303fc009f25c17c0cdf49276a to your computer and use it in GitHub Desktop.

Select an option

Save dmitry-osin/aab8dea303fc009f25c17c0cdf49276a to your computer and use it in GitHub Desktop.
Hibernate от новичка до эксперта

Уровень 1: Фундамент и Философия (Junior)

1. Основы JDBC и SQL (Зачем нам вообще ORM?)

Прежде чем радоваться магии Hibernate, нужно понять боль, которую он лечит.

Проблема JDBC

В "голом" Java для выполнения одного запроса нужно:

  1. Открыть соединение (Connection).
  2. Создать выражение (PreparedStatement).
  3. Вставить параметры (защита от SQL Injection).
  4. Выполнить запрос.
  5. Пройтись циклом по ResultSet.
  6. Смапить колонки из ResultSet в поля Java-объекта вручную.
  7. Закрыть все ресурсы в блоке 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

ORM (Object-Relational Mapping) автоматизирует пункты 2–6. Вы работаете с объектами, библиотека генерирует SQL.


2. Введение в JPA и Конфигурация

JPA vs Hibernate

Это самый частый вопрос на собеседованиях.

  • 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!)
}

Стратегии генерации ID (@GeneratedValue)

  1. AUTO: Hibernate сам выбирает стратегию (опасно для продакшена, может вести себя непредсказуемо).
  2. IDENTITY: Использует автоинкремент базы данных (MySQL AUTO_INCREMENT, Postgres SERIAL).
  • Минус: Hibernate не может узнать ID, пока не сделает INSERT. Это отключает Batch-вставку (оптимизацию).
  1. SEQUENCE: Использует объект Sequence в БД (Oracle, Postgres).
  • Плюс: Самый производительный вариант. Позволяет узнать ID до вставки в БД.
  1. TABLE: Хранит счетчики в отдельной таблице. Медленно, использовать только при необходимости.

3. Жизненный цикл Entity и Persistence Context

Это сердце Hibernate. Если вы поймете это, вы поймете 80% проблем.

Persistence Context (Контекст Персистентности) — это "кэш первого уровня" (L1 Cache). Это область памяти, где Hibernate хранит объекты, которые он загрузил или сохраняет в рамках одной транзакции (Сессии).

4 состояния сущности (Entity States)

  1. Transient (New): Объект просто создан через new. Hibernate о нем ничего не знает. В БД его нет.
  2. Managed (Persistent): Объект находится в контексте. У него есть ID. Все изменения в полях этого объекта будут автоматически записаны в БД при завершении транзакции.
  3. Detached: Объект есть в БД, но сессия Hibernate уже закрыта или объект принудительно выкинут из контекста. Изменения в нем не сохранятся.
  4. Removed: Объект помечен на удаление. При коммите транзакции произойдет DELETE.

Dirty Checking (Грязная проверка)

Новички часто пишут лишний код, вызывая 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 сам.
}

Основные методы EntityManager

  • persist(entity): Делает Transient -> Managed. (Планирует INSERT).
  • merge(entity): Делает Detached -> Managed. (Загружает копию из БД, копирует поля из переданного объекта, возвращает managed-копию).
  • remove(entity): Делает Managed -> Removed.
  • flush(): Принудительно отправляет SQL команды из памяти в базу данных (но не делает Commit).

Конец Уровня 1.

Главный вывод этого блока: Hibernate — это не просто генератор SQL. Это система управления состоянием объектов. Вы меняете объекты в памяти, а Hibernate синхронизирует это состояние с базой.


Уровень 2: Отношения и Базовый Spring Data (Middle)

4. Маппинг отношений (Associations)

Главная сложность здесь — понять разницу между объектной моделью и реляционной.

  • В БД: Отношения строятся через внешние ключи (Foreign Key). Ключ всегда находится в одной таблице (кроме ManyToMany).
  • В Java: Объекты ссылаются друг на друга. Ссылки могут быть в обе стороны.

Владеющая сторона (Owning Side) vs Обратная сторона (Inverse Side)

Это концепция, на которой ломается 90% новичков.

  • Owning Side (Владеющая сторона): Это сторона, у которой в таблице БД физически находится колонка FOREIGN KEY. Hibernate смотрит только на эту сторону, чтобы понять, что нужно сохранить в базу.
  • Inverse Side (Обратная сторона): Это сторона, которая просто ссылается на другую, используя mappedBy. Она нужна только для удобства программиста (чтобы получить список постов у юзера), но Hibernate игнорирует изменения в этой коллекции при сохранении связей, если не обновлена владеющая сторона.

Пример: One-To-Many (Один ко Многим)

Классика: У одного 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) — создаст запись во внешнем ключе в БД.

Каскадные операции (Cascade) и Orphan Removal

  • CascadeType.PERSIST: Сохраняю юзера -> автоматически сохраняются его новые посты.
  • CascadeType.ALL: Включает в себя всё (сохранение, обновление, удаление).
  • orphanRemoval = true: Если я удалю пост из списка user.getPosts().remove(post), Hibernate отправит DELETE запрос в БД. Без этой настройки связь просто разорвется (FK станет NULL), но запись останется (если нет constraint).

5. Знакомство со Spring Data JPA

Spring Data JPA — это абстракция над Hibernate. Она позволяет не писать реализацию DAO (Data Access Object) вручную. Вы объявляете интерфейс, Spring генерирует реализацию на лету (через Proxy).

Иерархия интерфейсов

  1. Repository: Маркерный интерфейс.
  2. CrudRepository: Базовые методы (save, findById, delete, count).
  3. JpaRepository: Расширяет PagingAndSortingRepository. Добавляет JPA-специфичные методы (flush, saveAllAndFlush). Обычно наследуются именно от него.
public interface UserRepository extends JpaRepository<User, Long> {
    // Реализация не нужна! Spring сам поймет, что делать.
}

Магия Derived Queries (Запросы из имени метода)

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...).

@Query (Когда имя метода слишком длинное)

Позволяет писать запросы на 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.

Мы научились связывать таблицы и быстро создавать слой доступа к данным. Но пока что наши маппинги довольно простые.


Уровень 3: Продвинутые возможности и Типы (Senior)

6. Продвинутый маппинг

Встраиваемые объекты (Value Objects)

Не все данные заслуживают своей собственной таблицы. В 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 не существует.

Маппинг Enums

Критически важный момент. По умолчанию Hibernate сохраняет Enum как ORDINAL (числовой индекс: 0, 1, 2...). Проблема: Если вы вставите новое значение в середину Enum'а, все индексы съедут. Данные в базе превратятся в мусор.

Решение: Всегда используйте STRING.

@Enumerated(EnumType.STRING) // Сохраняет "ACTIVE", "BANNED" текстом
private UserStatus status;

Наследование (Inheritance)

Как положить иерархию классов Java в реляционную БД?

Есть три стратегии:

  1. SINGLE_TABLE (По умолчанию):
  • Все поля всех наследников лежат в одной гигантской таблице.
  • Появляется колонка DTYPE (Discriminator), определяющая класс.
  • Плюс: Очень быстро (нет JOIN).
  • Минус: Нельзя поставить NOT NULL на поля наследников. Таблица пухнет.
  1. JOINED (Нормализованная):
  • Есть таблица родителя и отдельные таблицы для каждого наследника.
  • При выборке Hibernate делает JOIN.
  • Плюс: Чистая схема БД, работают Constraints.
  • Минус: Медленно при выборке (много JOIN-ов) и вставке (несколько INSERT).
  1. TABLE_PER_CLASS:
  • Таблица родителя не создается (обычно он абстрактный). У каждого наследника своя таблица со всеми полями (своими + родительскими).
  • Минус: Полиморфные запросы (найти всех Animal) очень медленные, так как используется UNION ALL по всем таблицам.

7. Продвинутое построение запросов

Когда findByEmail уже не хватает (например, фильтр в интернет-магазине с 20 параметрами, половина из которых может быть null), нам нужны динамические запросы.

Criteria API

Это стандартный способ строить запросы программно, без склейки строк (String Concatenation), что защищает от SQL Injection. Однако чистый JPA Criteria API очень многословен и сложен для чтения.

Spring Data Specifications

Это элегантная обертка над 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.

Projections (Проекции)

Частая ошибка: загружать тяжелую сущность 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-разработчика — производительность.


Уровень 4: Производительность и Оптимизация (Expert)

8. Проблема N+1 и Стратегии выборки (Fetching)

Это враг №1 в мире ORM.

Что такое N+1?

Представьте, что у вас есть список пользователей (User), и у каждого есть город (City). Вы хотите вывести список пользователей и их города.

List<User> users = userRepository.findAll(); // 1 запрос (SELECT * FROM users)

for (User user : users) {
    System.out.println(user.getCity().getName()); // N запросов!
}

Если у вас 1000 пользователей, Hibernate сделает:

  1. Один запрос для получения всех юзеров.
  2. 1000 запросов для получения города для каждого юзера (потому что связи LAZY инициализируются при обращении).

Итого: 1 + 1000 = 1001 запрос. База данных захлебнется.

Решение 1: JOIN FETCH (JPQL)

Мы явно говорим Hibernate: "Когда грузишь юзеров, сразу подтяни и города одним JOIN-ом".

@Query("SELECT u FROM User u JOIN FETCH u.city")
List<User> findAllWithCities();

Результат: 1 запрос SELECT ... FROM users INNER JOIN cities ....

Решение 2: @EntityGraph (Spring Data)

Декларативное решение, чтобы не писать JPQL.

// Подтянуть поле 'city' сразу
@EntityGraph(attributePaths = {"city"})
List<User> findAll();

Решение 3: Hibernate Batch Fetching

Если вам не нужен 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.

FetchType: EAGER vs LAZY

  • EAGER (Жадная): Грузит связь всегда.
  • Опасность: Если у User есть Group (EAGER), а у Group есть Permissions (EAGER)... загрузка одного юзера вытянет полбазы.
  • Правило: Всегда используйте LAZY по умолчанию. @OneToMany и @ManyToMany уже LAZY, а вот @ManyToOne и @OneToOne — EAGER. Всегда переопределяйте их на LAZY!

9. Кэширование (L1 и L2)

L1 Cache (Session Cache)

Включен всегда. Работает в рамках одной транзакции. Если вы загрузили объект по ID, второй раз в той же транзакции Hibernate не пойдет в БД, а вернет объект из памяти.

L2 Cache (Shared Cache)

Работает между сессиями (для всех пользователей). Требует внешнего провайдера (EhCache, Redis, Caffeine).

  • Когда полезно: Справочники, настройки, редко меняющиеся данные.
  • Риски: Рассинхронизация. Если кто-то поправит базу руками (через SQL консоль), кэш об этом не узнает, и приложение будет показывать старые данные.
  • Query Cache: Кэширует результаты запросов (ID сущностей). Работает эффективно только если данные читаются очень часто, а меняются крайне редко. При любом UPDATE в таблице, Query Cache для этой таблицы полностью сбрасывается.

10. Оптимизация записи (Batch Processing)

Сценарий: нужно сохранить 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 в памяти).


11. Прокси и LazyInitializationException

Знаменитая ошибка: LazyInitializationException: could not initialize proxy - no Session.

Почему возникает?

  1. Вы загрузили сущность User (транзакция открыта).
  2. Поле posts (LAZY) не загрузилось. Вместо списка там лежит Proxy (объект-заглушка).
  3. Транзакция закрылась. Сессия Hibernate умерла.
  4. Вы (например, в контроллере или HTML-шаблоне) вызываете user.getPosts().size().
  5. Proxy пытается пойти в БД, но "труба" (Session) уже закрыта. -> Исключение.

Как работает Proxy?

Hibernate использует библиотеки CGLIB или ByteBuddy, чтобы создать класс-наследник вашей сущности на лету. В нем переопределены геттеры. Когда вы вызываете геттер, Proxy проверяет, загружены ли данные. Если нет — делает запрос (если сессия жива).

Как лечить LazyInit?

  1. Правильный способ: Использовать JOIN FETCH или @EntityGraph (см. пункт 8), чтобы загрузить всё нужное, пока транзакция жива.
  2. Спорный способ (Anti-Pattern): Open Session In View (OSIV). В Spring Boot включен по умолчанию. Он держит сессию открытой до самого конца обработки HTTP-запроса. Это удобно (ошибок нет), но держит соединение с БД дольше, чем нужно, снижая пропускную способность системы.

Конец Уровня 4.

Теперь вы знаете, как писать быстрый код. Остался последний рывок — Уровень 5: Конкурентность и Архитектура. Там мы разберем, как не дать двум пользователям одновременно купить последний билет в кино (блокировки) и как управлять транзакциями.


Уровень 5: Конкурентность, Транзакции и Архитектура (Architect)

12. Управление транзакциями

В Spring Data JPA транзакции управляются декларативно через AOP (Aspect Oriented Programming).

Аннотация @Transactional

Вешается на метод сервиса (или класс).

  • При входе: Spring открывает транзакцию (или присоединяется к существующей).
  • При выходе: Spring делает Commit.
  • При Exception: Spring делает Rollback (по умолчанию только для RuntimeException).

Propagation (Распространение)

Что делать, если один транзакционный метод вызывает другой?

  • REQUIRED (По умолчанию): "Если транзакция есть — используй её. Если нет — создай новую". Самый частый вариант.

  • REQUIRES_NEW: "Всегда создавай новую транзакцию. Текущую (если есть) приостанови".

  • Кейс: Логирование ошибок или аудит. Даже если основная бизнес-логика упадет и откатится, запись в лог должна сохраниться.

  • MANDATORY: "Я требую, чтобы меня вызывали только внутри транзакции. Иначе брошу исключение".

Уровни изоляции (Isolation Levels)

Регулируют баланс между скоростью и точностью данных.

  • READ_COMMITTED: (Стандарт для Postgres/Oracle). Видим только закоммиченные данные. Защищает от "Грязного чтения".
  • REPEATABLE_READ: (Стандарт для MySQL). Гарантирует, что если мы прочитали строку дважды в одной транзакции, данные будут одинаковыми.
  • SERIALIZABLE: Полная блокировка. Самый медленный, но самый надежный уровень.

13. Блокировки (Locking)

Как предотвратить перезапись данных при конкурентном доступе?

Optimistic Locking (Оптимистичная блокировка)

Мы надеемся, что конфликты будут редки. Мы не блокируем базу данных, а используем версионирование.

Реализация: Добавляем поле версии в сущность.

@Entity
public class Wallet {
    @Id private Long id;
    private BigDecimal balance;

    @Version // <--- Вся магия здесь
    private Long version;
}

Алгоритм:

  1. Транзакция А считывает кошелек (version = 1).
  2. Транзакция Б считывает кошелек (version = 1).
  3. Транзакция А обновляет баланс и сохраняет. Hibernate проверяет: UPDATE wallet SET balance=?, version=2 WHERE id=? AND version=1. Успех.
  4. Транзакция Б пытается сохранить. Hibernate шлет запрос: WHERE ... AND version=1. Но версия в базе уже 2!
  5. Запрос обновляет 0 строк.
  6. Hibernate бросает OptimisticLockException.
  7. Вы ловите ошибку и просите пользователя повторить действие.

Pessimistic Locking (Пессимистичная блокировка)

Мы предполагаем худшее (конфликтов много), поэтому блокируем данные на уровне БД сразу при чтении.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT w FROM Wallet w WHERE w.id = :id")
Optional<Wallet> findByIdAndLock(Long id);

Это генерирует SQL: SELECT ... FOR UPDATE. Пока транзакция, вызвавшая этот метод, не завершится, никто другой не сможет ни прочитать (в режиме update), ни изменить эту строку. Остальные будут ждать (висеть).


14. Миграции баз данных (Schema Management)

Проблема ddl-auto

В application.properties часто пишут: spring.jpa.hibernate.ddl-auto=update. Это заставляет Hibernate при запуске сканировать сущности и пытаться ALTER TABLE в базе.

  • На проде это КАТАСТРОФА. Hibernate может непредсказуемо удалить данные или заблокировать таблицы.
  • Нет истории изменений.

Решение: Flyway или Liquibase

Это инструменты версионирования БД. Вы пишете SQL-скрипты миграций, которые хранятся в папке resources/db/migration:

  • V1__init_schema.sql (CREATE TABLE users...)
  • V2__add_index_to_users.sql (CREATE INDEX...)

При запуске приложение проверяет, какие скрипты уже были выполнены, и накатывает только новые. ddl-auto нужно выставить в validate или none.


15. Тестирование

Забудьте про моки (Mockito) для тестирования слоя репозиториев. Если вы замокаете репозиторий, вы протестируете только то, что ваш мок работает. Вы не узнаете, правильный ли SQL генерируется.

@DataJpaTest

Аннотация Spring Boot, которая поднимает урезанный контекст (только JPA компоненты), работает быстро.

H2 vs Testcontainers

  • H2 (In-memory DB): Быстро, но ненадежно. Синтаксис H2 отличается от Postgres. Тест пройдет на H2, но упадет на проде из-за специфичного JSONB поля или оконной функции.
  • Testcontainers: Золотой стандарт индустрии. В Docker поднимается реальный Postgres (чистый, пустой) специально для теста. Тесты идут дольше, но гарантируют 100% совместимость.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // Не подменять на H2!
class UserRepositoryTest extends AbstractContainerBaseTest { 
    // ... тесты
}

Конец Уровня 5.

Вы прошли путь от SELECT * до управления распределенными транзакциями.

Ваш путь к эксперту выглядит так:

  1. Перестаньте использовать EAGER fetch.
  2. Научитесь читать логи Hibernate (включите spring.jpa.show-sql=true и научитесь видеть N+1 глазами).
  3. Используйте Testcontainers.
  4. Никогда не используйте ddl-auto=update на проде.

Уровень 6. Тонкая настройка данных: Конвертеры и Soft Delete

В этой главе мы научимся двум вещам:

  1. Хранить данные в одном виде (например, зашифрованном), а в Java работать с ними в другом.
  2. Удалять данные так, чтобы они оставались в базе (Soft Delete).

16. Attribute Converters (@Converter)

Иногда типы данных в базе и в 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..."
}

🪨 Подводные камни

  1. Поиск по полю: Вы не сможете эффективно искать по этому полю (findByCreditCardNumber). База видит абракадабру. Поиск придется делать в памяти или использовать детерминированное шифрование (что менее безопасно).
  2. autoApply = true: Можно написать @Converter(autoApply = true). Тогда конвертер применится ко всем полям типа String во всем проекте. Это опасно.
  3. Частичные данные: Если вы используете JPQL конструкцию select u.creditCardNumber from User u, конвертер сработает. Но если вы используете Native SQL (select credit_card from users), вы получите зашифрованную строку.

17. Soft Delete (Мягкое удаление)

В корпоративных системах удалять данные физически (DELETE) часто запрещено. Нужна история, аудит или возможность восстановления.

Задача:

  1. При вызове repository.delete(id) делать UPDATE users SET deleted = true WHERE id = ?.
  2. При вызове findAll() не показывать удаленные записи.

Реализация (Современный подход Hibernate 6.3+)

Раньше использовали аннотацию @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

🪨 Подводные камни (Expert Level)

Это тема, на которой часто "ломают копья".

  1. Уникальные индексы (Unique Constraints):
  • Представьте, что у username стоит UNIQUE.
  • Вы удаляете пользователя "admin" (он становится deleted=true).
  • Вы пытаетесь создать нового пользователя "admin".
  • Ошибка БД! Физически старая запись "admin" всё еще там.
  • Решение: Использовать "Partial Index" в PostgreSQL: CREATE UNIQUE INDEX idx_username ON users (username) WHERE deleted = false;
  1. Native SQL:
  • Если вы напишете @Query(value = "SELECT * FROM users", nativeQuery = true), Hibernate не добавит условие deleted = false. Нативные запросы игнорируют аннотации сущности. Вы получите удаленные записи.
  1. Каскадное удаление:
  • Если у User есть List<Order>, и у связи стоит CascadeType.REMOVE, то при удалении User Hibernate попытается удалить и Order.
  • Если у Order тоже стоит @SQLDelete, всё пройдет хорошо (будет цепочка Update-ов).
  • Если нет — Hibernate сделает физический DELETE для ордеров.

18. Entity Listeners (Обратные вызовы)

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).


Уровень 7. Сложная архитектура БД: Multi-tenancy и Репликация

19. Multi-tenancy (Мульти-арендность)

Представьте, что вы пишете CRM-систему (SaaS). Ею пользуются компании "Рога и Копыта" (Tenant A) и "Вектор" (Tenant B). Главное требование: Данные одной компании никогда не должны попасть к другой.

Три стратегии реализации

  1. Discriminator Column (Общая схема):
  • Все живут в одной таблице users. Добавляется колонка tenant_id.
  • Плюс: Дешево, легко делать бэкап, миграции простые.
  • Минус: Разработчик забыл добавить WHERE tenant_id = ? в одном запросе -> утечка данных. Hibernate решает это через @TenantId (в версии 6+) или фильтры.
  1. Separate Schema (Раздельные схемы):
  • Одна БД, но разные схемы: schema_tenant_a, schema_tenant_b.
  • Плюс: Хорошая изоляция, можно делать бэкап отдельной схемы.
  • Минус: Сложно управлять миграциями (Flyway должен пройтись по 100 схемам).
  1. Separate Database (Раздельные базы):
  • У каждого клиента свой URL подключения.
  • Плюс: Полная физическая изоляция (VIP клиенты на быстром железе).
  • Минус: Очень дорого по ресурсам.

Реализация в Hibernate (Separate Schema)

Hibernate имеет встроенную поддержку этого механизма. Вам нужно реализовать два интерфейса.

Шаг 1. Кто стучится? (CurrentTenantIdentifierResolver)

Нам нужно понять, какой клиент сейчас делает запрос (обычно извлекаем из 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;
    }
}

Шаг 2. Дай подключение (MultiTenantConnectionProvider)

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 и т.д.
}

🪨 Подводные камни (Expert)

  1. Connection Pooling: Если вы используете стратегию Separate Database, вы не можете создать 100 пулов соединений (HikariCP) по 10 коннектов. 1000 открытых сокетов положат сервер. Придется использовать динамическую маршрутизацию без пулинга или внешние прокси (PgBouncer).
  2. Кэш Hibernate: L2 Cache должен знать о тенантах. Иначе Tenant A получит кэшированные данные Tenant B. В EhCache/Redis это решается добавлением TenantID в ключ кэша.

20. Read/Write Splitting (Репликация)

Когда SELECT запросов становится слишком много, базу разделяют:

  • Master (Leader): Принимает INSERT, UPDATE, DELETE.
  • Replica (Follower/Slave): Принимает только SELECT. Данные копируются с Мастера асинхронно.

Задача: Заставить Spring автоматически отправлять пишущие запросы на Мастера, а читающие — на Реплику.

Реализация: AbstractRoutingDataSource

В 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;
    }
}

Конфигурация:

  1. Создаем два реальных DataSource (MasterDS, ReplicaDS).
  2. Скармливаем их нашему TransactionRoutingDataSource.
  3. В сервисах используем аннотацию:
@Service
public class UserService {

    @Transactional(readOnly = true) // Пойдет на Replica
    public User getUser(Long id) { ... }

    @Transactional // (readOnly = false) Пойдет на Master
    public void createUser(User user) { ... }
}

🪨 Главный подводный камень: Replication Lag

Это классическая проблема распределенных систем.

Сценарий:

  1. Пользователь обновляет профиль (Master DB). Транзакция завершена.
  2. Пользователь сразу перенаправляется на страницу профиля (SELECT -> Replica DB).
  3. Репликация занимает 100-500мс. Данные еще не долетели до Реплики.
  4. Результат: Пользователь видит старые данные. Он паникует и нажимает F5.

Решения:

  1. Грубое: После записи делать принудительное чтение с Мастера для этого пользователя (хранить флаг в сессии).
  2. Архитектурное: Критичные данные (профиль сразу после редактирования) всегда читать с Мастера (@Transactional(readOnly = false) даже для SELECT). Некритичные (лента новостей) — с Реплики.

Конец Уровня 7. Вы узнали, как масштабировать приложение горизонтально.

  • Multi-tenancy позволяет продавать один инстанс приложения сотням клиентов.
  • Replication позволяет выдерживать огромные нагрузки на чтение.

Уровень 8. Продвинутые запросы и Аналитика

21. Проблема чистого JPA и Native Queries

Стандартный JPQL хорош для загрузки объектов, но плох для аналитики. Чего нет в старых версиях (или неудобно в новых):

  • Window Functions (ROW_NUMBER() OVER..., RANK(), PARTITION BY).
  • CTEs (WITH RECURSIVE — для деревьев и иерархий).
  • Slozhnyye JOIN-ы (например, LATERAL JOIN).

Решение 1: Native Query (Лобовая атака)

Мы пишем чистый 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(); // Возвращает массив объектов :(

🪨 Подводные камни

  1. Маппинг (ResultSetMapping): Возвращать List<Object[]> — это ад. Вам придется вручную кастить (String) obj[0], (BigDecimal) obj[1].
  • Решение: Использовать Interface Projections (Spring Data сам смапит колонки по именам геттеров) или @SqlResultSetMapping (очень многословно).
  1. Зависимость от БД: Если вы напишете запрос на диалекте PostgreSQL (используя jsonb_agg), вы не сможете запустить это на H2 или MySQL.
  2. Отсутствие проверки типов: Если вы переименуете поле в Entity, этот запрос сломается только в Runtime (когда вы его запустите).

22. Blaze-Persistence (Секретное оружие)

Это библиотека, которая работает поверх Hibernate. Она позволяет писать JPQL-подобный код, но с поддержкой всех фич современного SQL (CTE, Window Functions, Values clause, Union).

Это выбор экспертов для сложных проектов.

Пример: CTE (Иерархия категорий)

Допустим, у нас есть дерево категорий (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.

23. Продвинутые Проекции (Projections)

Мы уже говорили, что тянуть сущности целиком — дорого. Spring Data дает три уровня проекций.

Уровень 1: Интерфейсы (Было выше)

Самый простой вариант. Spring генерирует прокси на лету.

Уровень 2: Class-based (DTO) — Самый быстрый

Вы пишете обычный 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...).

Уровень 3: Динамические проекции

Когда один метод репозитория может возвращать разные данные в зависимости от вызова.

// 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);

🪨 Подводный камень (Dynamic Projections)

Динамические проекции отлично работают с Derived Queries (из имени метода). Но если вы используете @Query, вам придется использовать SpEL (Spring Expression Language), что усложняет код, или писать отдельные методы.


24. Hibernate 6: Дедупликация

Маленькая, но важная деталь при переходе на 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 операций).

Уровень 9. Конкурентность (Hardcore): Propagation, Deadlocks и Pool Starvation

25. Propagation (Распространение транзакций)

Мы уже знаем про @Transactional. Но что происходит, когда один транзакционный метод вызывает другой? Это регулирует параметр propagation.

Ключевые стратегии

  1. REQUIRED (По умолчанию):
  • Логика: "Есть транзакция? Я в ней. Нет? Создам новую".
  • Нюанс: Если внутренний метод бросит RuntimeException, он пометит всю транзакцию как rollbackOnly. Даже если вы поймаете исключение во внешнем методе, вы не сможете закоммитить транзакцию. Будет UnexpectedRollbackException.
  1. REQUIRES_NEW (Изоляция):
  • Логика: "Всегда создавай новую. Если есть старая — приостанови её (Suspend)".
  • Кейс: Аудит ошибок.
  • Пример: Вы пытаетесь оплатить заказ. Оплата упала. Вы хотите сохранить запись "Попытка оплаты неудачна" в таблицу логов. Если использовать REQUIRED, то откат оплаты откатит и лог. С REQUIRES_NEW лог сохранится в отдельной транзакции.
  1. NESTED (Вложенная):
  • Логика: Использует Savepoints (точки сохранения) JDBC. Это одна физическая транзакция.
  • Если вложенный метод падает, транзакция откатывается к Savepoint, но внешняя транзакция может продолжить работу.
  • Редкость: Работает только через JDBC, не поддерживается некоторыми драйверами.

☠️ Главная ловушка: Self-Invocation (Вызов самого себя)

Это классический вопрос на собеседовании.

@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 не может перехватить этот вызов и открыть новую транзакцию.

Решение:

  1. Вынести saveAuditLog в другой сервис (AuditService).
  2. (Костыль) Внедрить самого себя (@Autowired private OrderService self) и вызывать self.saveAuditLog().

26. Deadlocks (Взаимоблокировки в БД)

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 невозможен.


27. Pool Starvation (Голодание пула)

Это особый вид Deadlock-а, который происходит не в базе, а в пуле соединений (HikariCP).

Сценарий:

  1. У вас maximum-pool-size = 10.
  2. У вас есть метод А (@Transactional), который вызывает метод Б (@Transactional(REQUIRES_NEW)).
  3. Пришла высокая нагрузка: 10 одновременных запросов.

Что происходит:

  1. 10 потоков зашли в метод А. Они заняли все 10 соединений из пула.
  2. Все 10 потоков дошли до вызова метода Б.
  3. Метод Б требует REQUIRES_NEW, то есть ему нужно новое, отдельное соединение.
  4. Потоки просят у пула 11-е соединение.
  5. Пул пуст. Потоки ждут, пока кто-то вернет соединение.
  6. Но соединения держат сами эти ожидающие потоки (их внешние транзакции А ждут завершения Б).
  7. Приложение висит намертво до таймаута соединения.

Решение:

  1. Избегайте REQUIRES_NEW без крайней необходимости.
  2. Формула размера пула для таких систем: PoolSize = MaxThreads * (MaxNestedTransactions + 1) Если у вас возможна 1 вложенная транзакция, пул должен быть с запасом.

Финал: Ваш путь эксперта (Roadmap завершен)

Поздравляю! Мы прошли путь от новичка, который пишет 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.

Что делать дальше? (Практическое задание)

Теория без практики мертва. Я предлагаю вам Челлендж Выходного Дня.

ТЗ "Банковская система":

  1. Создать сущности Account (с балансом) и Transfer (история).
  2. Реализовать метод перевода денег, который:
  • Защищен от Deadlock (сортировка ID).
  • Использует Optimistic Locking (чтобы никто не изменил баланс параллельно).
  • Сохраняет историю перевода.
  1. Если перевод прошел, но сохранение истории упало — деньги должны вернуться (атомарность).
  2. Написать тест на Testcontainers, который запускает 10 параллельных потоков, гоняющих деньги между двумя счетами. В конце сумма на двух счетах должна остаться неизменной (консистентность).

Дополнение: Популярные аннотации (JPA & Spring Data JPA) и их использование

1. Основные (Identity & Setup)

Эти аннотации определяют сущность и её связь с таблицей.

@Entity (JPA)

  • Что делает: Помечает класс как сущность JPA. Обязательна.
  • 🪨 Подводный камень: У класса обязательно должен быть пустой конструктор (protected или public). Без него Hibernate не сможет создать экземпляр через рефлексию. Также класс не должен быть final.

@Table(name = "...") (JPA)

  • Что делает: Задает имя таблицы в БД. Если не указать, имя таблицы будет равно имени класса (User -> user).
  • 🪨 Подводный камень: Внимательно с зарезервированными словами SQL (например, Order, User, Group). Если назвать таблицу так без кавычек (или без переименования в @Table), база данных выдаст ошибку синтаксиса.

@Id (JPA)

  • Что делает: Указывает поле первичного ключа.

@GeneratedValue (JPA)

  • Что делает: Указывает стратегию генерации ID.

  • Стратегии:

  • GenerationType.IDENTITY: Автоинкремент на стороне БД (MySQL, Postgres).

  • GenerationType.SEQUENCE: Использование последовательности (Oracle, Postgres).

  • 🪨 Подводный камень (Expert): Использование IDENTITY отключает JDBC Batching (пакетную вставку) для INSERT. Hibernate не может отправить пачку insert-ов, так как ему нужно знать ID сразу после вставки каждой строки. Если нужно вставлять быстро и много — используйте SEQUENCE с allocationSize.


2. Маппинг полей (Basic Mapping)

@Column (JPA)

  • Что делает: Настраивает маппинг на колонку (имя, длина, nullability).
  • 🪨 Подводный камень: Параметр nullable = false работает только для генерации схемы (ddl-auto) или валидации Hibernate. Если база создана отдельно и там нет NOT NULL constraint, Hibernate пропустит null, если не включена валидация.

@Enumerated (JPA)

  • Что делает: Мапит Java Enum.
  • 🪨 Подводный камень (Критичный): По умолчанию использует EnumType.ORDINAL (сохраняет 0, 1, 2...). Если вы добавите значение в середину Enum'а или поменяете их местами, данные в базе перемешаются. **Всегда используйте @Enumerated(EnumType.STRING)**.

@Transient (JPA)

  • Что делает: Говорит Hibernate игнорировать это поле. Оно не сохраняется в БД.
  • 🪨 Подводный камень: Не путать с ключевым словом Java transient. Ключевое слово Java влияет на сериализацию (Serialization), а аннотация — на персистентность.

@Lob (JPA)

  • Что делает: Large Object. Для хранения длинных текстов (TEXT, CLOB) или бинарников (BLOB).
  • 🪨 Подводный камень: В PostgreSQL @Lob на String может мапиться в тип OID, что неудобно. Часто лучше просто использовать @Column(columnDefinition = "TEXT").

3. Связи (Relationships)

Самая опасная зона.

@OneToMany / @ManyToOne (JPA)

  • Что делает: Связь "Один ко многим".

  • 🪨 Подводный камень 1: FetchType.

  • У @OneToMany по умолчанию LAZY (хорошо).

  • У @ManyToOne по умолчанию EAGER (плохо!). **Всегда явно пишите fetch = FetchType.LAZY** для @ManyToOne.

  • 🪨 Подводный камень 2: Бесконечная рекурсия в toString(), equals(), hashCode() или при сериализации в JSON (Jackson). Если User ссылается на Post, а Post на User — стек переполнится. Используйте @JsonIgnore или DTO.

@JoinColumn (JPA)

  • Что делает: Указывает имя колонки внешнего ключа (Foreign Key). Ставится на Владеющей стороне (там, где физически лежит FK).
  • 🪨 Подводный камень: Если не указать, Hibernate сгенерирует страшное имя через подчеркивание, объединив имена двух таблиц.

@ManyToMany (JPA)

  • Что делает: Связь "Многие ко многим".
  • 🪨 Подводный камень: При удалении одной записи из коллекции (users.remove(user)), Hibernate может удалить все записи из промежуточной таблицы для этой сущности и вставить оставшиеся заново. Это очень медленно. Лучше мапить через промежуточную Entity (@OneToMany -> Entity -> @ManyToOne).

4. Hibernate Specific (Power Tools)

Эти аннотации работают только в Hibernate (не часть JPA), но они крайне полезны.

@BatchSize(size = 20) (Hibernate)

  • Что делает: Решает проблему N+1 для коллекций. Грузит связанные сущности пачками по size штук.
  • Где ставить: Над классом сущности или над полем коллекции.

@Formula("sql query") (Hibernate)

  • Что делает: Виртуальное поле, значение которого вычисляется базой данных при выборке.
@Formula("(select count(*) from posts p where p.user_id = id)")
private int postCount;
  • 🪨 Подводный камень: Значение только для чтения (read-only). Если попытаться его изменить в Java и сохранить, ничего не произойдет (или ошибка).

@DynamicInsert / @DynamicUpdate (Hibernate)

  • Что делает: Заставляет Hibernate генерировать SQL INSERT / UPDATE только для тех полей, которые реально изменились (не null).
  • Польза: Уменьшает размер SQL запроса и трафик.
  • 🪨 Подводный камень: Hibernate не может кэшировать скомпилированные PreparedStatement, так как SQL каждый раз разный. Небольшая потеря производительности CPU.

@Immutable (Hibernate)

  • Что делает: Гарантирует, что сущность никогда не будет изменена (UPDATE) в БД.
  • Польза: Hibernate отключает Dirty Checking для таких сущностей, что ускоряет работу.

5. Аудит и Версионирование

@Version (JPA)

  • Что делает: Включает Optimistic Locking.
  • 🪨 Подводный камень: Никогда не меняйте это поле вручную (setVersion(...)). Hibernate должен управлять им сам.

@CreationTimestamp / @UpdateTimestamp (Hibernate)

  • Что делает: Автоматически проставляет текущее время при создании или обновлении записи.
  • Аналог в Spring Data: @CreatedDate, @LastModifiedDate (требует настройки @EnableJpaAuditing и слушателя AuditingEntityListener).

6. ☠️ Особая зона: Lombok

Хотя это не JPA, но используется всегда вместе.

@Data (Lombok)

  • Что делает: Генерирует геттеры, сеттеры, equals, hashCode, toString.
  • 🪨 ПОДВОДНЫЙ КАМЕНЬ (ОПАСНО):
  1. equals/hashCode по умолчанию включают все поля. Если там есть @OneToMany (LAZY), вызов hashCode дернет базу данных (LazyInitException или лишний запрос).
  2. toString тоже пойдет по всем полям -> циклическая ссылка -> StackOverflowError.
  3. Решение: Для Entity используйте комбинацию:
  • @Getter
  • @Setter
  • @ToString@ToString.Exclude на ленивых связях)
  • equals/hashCode пишите руками (сравнивайте только по @Id, если он есть, или используйте бизнес-ключ).

Золотой стандарт реализации Entity

Вот «Золотой стандарт» реализации Entity. Этот код решает 99% проблем, связанных с LazyInitializationException, StackOverflowError и проблемами производительности при вставке.

Я разделил это на базовый класс (для переиспользования) и саму сущность.

1. Базовый класс (Auditing & MappedSuperclass)

Выносим технические поля (кто создал, когда обновил), чтобы не мусорить в бизнес-классах.

@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;
}

2. Идеальная Сущность (User)

Обратите внимание на комментарии — там вся соль.

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();
    }
}

3. Вторая сторона (Order)

@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();
    }
}

Почему именно так? (Разбор ключевых решений)

Почему hashCode возвращает константу?

Это неочевидный момент.

  1. Вы создали User u = new User(). У него id = null.
  2. Вы положили его в HashSet. Хэш посчитался (например, от случайного адреса памяти или 0).
  3. Вы сохранили repository.save(u). Ему присвоился id = 100.
  4. Если бы hashCode зависел от id, он бы изменился.
  5. Теперь HashSet не может найти этот объект, потому что он лежит в "старой" корзине (bucket), а ищется в новой. Memory Leak гарантирован. Возврат getClass().hashCode() решает это, делая хэш стабильным в течение всей жизни объекта.

Почему Hibernate.getClass(this)?

Если вы загружаете User лениво (через getReferenceById или как связь), Hibernate подсовывает вам не класс User, а класс User$HibernateProxy$Zw3s.... Обычный this.getClass() вернет разные классы для оригинала и прокси, и equals вернет false, хотя это одна и та же запись в БД.

Почему @BatchSize?

Когда вы будете бежать циклом по users и вызывать getOrders(), без этой аннотации Hibernate делал бы по одному запросу на каждого юзера. С ней он подгрузит ордера сразу для 20 (или 50) юзеров за один раз. Это дешевое решение N+1 без написания сложных запросов.

Почему allocationSize = 50?

Стандартное значение часто 50, но иногда люди ставят 1. Если allocationSize = 1, Hibernate будет ходить в базу за новым ID каждый раз при создании объекта. Если allocationSize = 50, он один раз сходит в базу, получит диапазон (например, 1000-1050) и следующие 50 объектов сохранит в памяти очень быстро, а потом отправит их в БД одним пакетом (Batch).


Тестирование сущностей с помощью TestContainers

Вот полный пример настройки и теста для наших сущностей User и Order.

1. Зависимости (pom.xml)

Вам понадобятся эти библиотеки (версии 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>

2. Базовый класс конфигурации

Чтобы не поднимать контейнер для каждого тест-класса заново (это долго), мы создадим абстрактный класс. Контейнер запустится один раз для всех тестов.

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");
    }
}

3. Сам тест (UserRepositoryTest)

Здесь мы проверяем всё: сохранение, каскады, генерацию 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 сработал!
    }
}

На что обратить внимание в этом коде:

  1. entityManager.flush() и clear(): Это критически важно в тестах @DataJpaTest. По умолчанию транзакция длится весь тест. Если вы сохраните объект и тут же попытаетесь его найти (findById), Hibernate вернет вам объект из памяти (L1 Cache), даже не делая запрос в БД. Вызовы flush (отправить изменения в БД) и clear (очистить память) гарантируют, что следующий запрос реально пойдет в базу данных Postgre и проверит, как всё сохранилось на самом деле.
  2. @AutoConfigureTestDatabase(replace = NONE): Без этой строки Spring проигнорирует ваши настройки Testcontainers и попытается запустить H2.
  3. Тестирование сирот (Orphan Removal): Последний тест доказывает, что удаление объекта из Java-списка orders реально приводит к DELETE FROM orders в базе.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment