1. Envers в ORM-потоке
Как только выяснилось, что updatedAt и логи не дают прошлого как данных, следующий вопрос становится очень приземлённым: где эта история вообще живёт и в какой момент попадает в БД. Вот тут и появляется Envers.
Если вы когда-то видели фразу “Envers делает аудит”, легко представить себе что-то вроде “оно пишет логи”. Но Envers — не логгер, который втихаря печатает строчки в файл. Он встроен в Hibernate так, что изменения audited-сущностей превращаются в дополнительные SQL-операции в той же транзакции. То есть вы меняете Product.price, а в базе появляются не только изменения в product, но и записи в “исторических” таблицах.
Удобная метафора (с человеческим лицом) такая: Envers — это Git для строк, где каждая транзакция похожа на commit. Основная таблица — это “рабочая директория”, где лежит только актуальная версия файла. Audit-таблица — это “история коммитов”, где хранится, как файл выглядел раньше. А таблица REVINFO — это что-то вроде “журнала коммитов”: номера ревизий и время, когда они появились.
При этом очень важно не перепутать: Envers пишет историю только тех изменений, которые проходят через Hibernate. Если вы в обход ORM сделаете UPDATE product ... вручную в psql-консоли, Envers не “поймает” это событие — потому что Hibernate не участвовал.
Подключение Envers: зависимость и wiring
В учебном проекте Commerce Persistence Lab Envers — это часть фиксированного baseline, но полезно понимать механику подключения. В большинстве Spring Boot приложений Envers включается очень “скромно”: вы добавляете зависимость, и Hibernate получает возможность вести аудит. Никакого отдельного сервера, брокера и шаманского бубна — всё в рамках ORM и транзакции.
Минимальная проверка в build.gradle.kts выглядит так (в проекте курса версии уже зафиксированы BOM’ом Spring Boot, поэтому пример намеренно короткий):
dependencies {
// Hibernate ORM (ядро)
implementation("org.hibernate.orm:hibernate-core")
// Envers — модуль аудита для Hibernate
implementation("org.hibernate.orm:hibernate-envers")
}
Дальше начинается самое важное для дисциплины курса: схема живёт через Flyway, а не через ddl-auto=update. Это означает, что появление таблиц REVINFO и *_AUD — не “магия на старте приложения”, а результат миграции. В нашем плане это как раз тот самый файл уровня V6__soft_delete_and_auditing.sql, где и появляются таблицы аудита.
Здесь полезно запомнить простую инженерную мысль: Envers — это поведение runtime, но оно создаёт физические таблицы. Если таблиц нет, Envers не сможет писать историю, и ваш “аудит” закончится на уровне “красивой аннотации”.
3. @Audited: включение аудита
Аннотация @Audited — это главный рубильник. Она говорит Hibernate: “эту сущность (или это поле) надо историзировать”. И вот тут важно: @Audited не означает “когда-нибудь потом я разберусь, что оно делает”. Она означает, что при каждом INSERT/UPDATE/DELETE (или при soft delete — при изменении флага удаления) появятся дополнительные записи в таблицах Envers.
Самый простой вариант — включить аудит на уровне класса. Для Product это может выглядеть так:
package com.example.commerce.catalog.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.envers.Audited;
@Audited // включаем аудит для всей сущности: Envers будет писать снимки в *_AUD
@Entity
@Table(name = "product") // основная ("живая") таблица
public class Product {
@Id // ID попадёт и в основную таблицу, и в audit-таблицу
private Long id;
}
Да, пример выглядит почти смешно: “а где поля?”. Но нам сейчас важна не модель товара, а сам факт: как только класс audited — Envers начинает относиться к нему как к сущности с историей.
В реальном проекте вы, конечно, аудируете не только Product, но и, например, PurchaseOrder — потому что история статусов заказа часто нужна бизнесу (“когда его перевели в PAID, когда в SHIPPED?”). Но здесь важна именно механика фиксации. Сам выбор границы аудита — отдельный инженерный вопрос, и смешивать его с устройством Envers сейчас только вредно.
Ещё один важный момент: @Audited можно ставить и на отдельные поля (property-level аудит). Это позволяет не превращать аудит в бесконечное “всё подряд”. Envers из коробки поддерживает оба подхода.
4. Таблицы аудита: REVINFO и *_AUD
Когда Envers включён, он начинает писать историю в отдельную структуру таблиц. И для новичка тут самый частый вопрос звучит так: “Почему нельзя просто добавить ещё одну таблицу product_history и всё?”. Можно, конечно. Но Envers делает это стандартизированно: отдельно хранит “паспорт ревизии” и отдельно — “снимки сущностей на ревизиях”.
REVINFO — таблица ревизий
Смысл REVINFO очень простой: каждая ревизия получает номер (например, 1, 2, 3, …) и отметку времени. Это общая “ось времени” для всех audited-сущностей.
Типичная форма таблицы (упрощённо) такая:
| Колонка | Смысл |
|---|---|
| REV | номер ревизии (primary key) |
| REVTSTMP | timestamp создания ревизии (обычно в миллисекундах) |
Если в одной транзакции вы изменили и товар, и заказ, то Envers чаще всего создаст одну ревизию в REVINFO и “привяжет” к ней изменения сразу нескольких сущностей. Это удобно: вы можете воспринимать ревизию как “атомарный пакет изменений”.
*_AUD — audit-таблицы сущностей
Для каждой audited-сущности создаётся таблица вида PRODUCT_AUD, PURCHASE_ORDER_AUD и т.д. (в реальном PostgreSQL под Spring naming strategy названия почти наверняка будут в lower_case, но идея не меняется).
Ключевая идея: *_AUD — это append-only история. Вы не обновляете прошлое, вы добавляете новый “снимок”.
Упрощённая структура PRODUCT_AUD выглядит так:
| Колонка | Смысл |
|---|---|
| ID | идентификатор сущности (тот же, что в product) |
| REV | ссылка на ревизию в REVINFO |
| REVTYPE | тип изменения (добавили/изменили/удалили) |
| ... | остальные audited-поля сущности (цена, статус, deleted, …) |
Обычно primary key у *_AUD — составной: (ID, REV). Это логично: один и тот же product.id=10 будет встречаться в истории много раз, но в разных ревизиях.
В виде картинки-отношения это можно представить так:
erDiagram
REVINFO ||--o{ PRODUCT_AUD : "REV"
REVINFO ||--o{ PURCHASE_ORDER_AUD : "REV"
REVINFO {
int REV PK
long REVTSTMP
}
PRODUCT_AUD {
long ID
int REV FK
smallint REVTYPE
}
PURCHASE_ORDER_AUD {
long ID
int REV FK
smallint REVTYPE
}
Смысл такой: REVINFO — это “шкала времени”, а *_AUD — “снимки сущностей, привязанные к точкам на этой шкале”.
5. REVTYPE: тип изменения, не статус
Когда вы первый раз увидите колонку REVTYPE, мозг новичка часто делает классический трюк: “о, это, наверное, статус заказа!”. Нет. REVTYPE — это не OrderStatus и не ProductStatus. Это тип операции, которая произошла с сущностью с точки зрения аудита.
Envers использует три основных значения, которые в Java представлены enum’ом RevisionType:
import org.hibernate.envers.RevisionType;
// Тип изменения в истории (не путать с бизнес-статусами)
RevisionType t1 = RevisionType.ADD; // запись появилась (INSERT)
RevisionType t2 = RevisionType.MOD; // запись изменили (UPDATE)
RevisionType t3 = RevisionType.DEL; // запись удалили (DELETE)
На уровне БД обычно хранится маленькое число (ordinal), которое соответствует этим значениям. Классическая (и самая распространённая) раскладка такая:
| REVTYPE (в БД) | RevisionType | Смысл |
|---|---|---|
| 0 | ADD | сущность появилась (INSERT) |
| 1 | MOD | сущность изменили (UPDATE) |
| 2 | DEL | сущность удалили (DELETE) |
Теперь важная практическая деталь: DEL в Envers означает физическое удаление (когда ORM действительно делает DELETE). Если у вас soft delete (а в нашем курсе он как раз есть), то “удаление” часто превращается в MOD, потому что в БД уходит не DELETE, а UPDATE deleted=true.
Отсюда правило: REVTYPE — это “что Hibernate сделал в SQL-плане”, а не “какой смысл у этого действия в бизнесе”. Бизнес-смысл вы будете интерпретировать по полям сущности на конкретной ревизии (например, deleted=true или status=CANCELLED).
6. Ревизии: flush, dirty checking, транзакция
Если мы забудем про “красивую идею истории” и вернёмся к механике, то Envers встроен в тот же цикл, который мы уже знаем: managed-сущность → dirty checking → flush → SQL.
Самое полезное, что можно держать в голове, звучит так: Envers пишет историю в момент flush/commit, потому что именно там Hibernate принимает окончательное решение “какие изменения реально уйдут в БД”. До flush у вас могут быть изменения в памяти, но базы ещё никто не трогал — и аудитить пока нечего.
Механика Envers не зависит от того, храните ли вы цену как Money или как отдельное число. Для него важно, что у audited-сущности изменилось persistent-поле. Поэтому дальше держим тот же Product, где цена — часть доменной модели.
Представим простой сервисный метод изменения цены товара. Здесь em — это внедрённый EntityManager (в проекте он уже есть, мы его использовали много раз в предыдущих модулях):
import com.example.commerce.common.jpa.Money;
import org.springframework.transaction.annotation.Transactional;
@Transactional // важно: ревизия фиксируется в рамках транзакции
public void changePrice(Long productId, Money newPrice) {
Product p = em.find(Product.class, productId); // managed-сущность в persistence context
p.setPrice(newPrice); // dirty checking запомнит изменение audited-поля
em.flush(); // хотим увидеть SQL прямо тут (и Envers тоже сработает здесь)
}
Если Product помечен @Audited, то при flush() (или при commit транзакции, если flush не вызывали вручную) вы увидите в SQL trace примерно такую картину:
-- обычная часть ORM:
UPDATE product SET price_amount = ? WHERE id = ?;
-- часть Envers:
-- 1) создаём "паспорт" ревизии (REV + timestamp)
INSERT INTO revinfo (...) VALUES (...);
-- 2) сохраняем снимок сущности на этой ревизии (включая REVTYPE)
INSERT INTO product_aud (id, rev, revtype, price_amount, ...) VALUES (...);
Не цепляйтесь за точный порядок строк — он зависит от деталей flush-cycle и диалекта. Важнее смысл: вместо одного SQL-запроса появляется набор, где Envers добавляет “событие ревизии” и “снимок сущности”.
Теперь приятный момент (и он очень “enterprise” по своей природе): если в одной транзакции вы измените сразу несколько audited-сущностей, они обычно получат один и тот же REV. Это делает ревизию настоящим “коммитом”: набор изменений, сделанный атомарно.
Например, вы меняете цену товара и одновременно переводите заказ в отменённое состояние (оба audited):
import com.example.commerce.common.jpa.Money;
import org.springframework.transaction.annotation.Transactional;
@Transactional // один транзакционный "коммит" = одна ревизия в REVINFO
public void discountAndCancel(Long productId, Long orderId, Money discountedPrice) {
Product p = em.find(Product.class, productId);
p.setPrice(discountedPrice); // изменение audited-сущности -> запись в PRODUCT_AUD
PurchaseOrder o = em.find(PurchaseOrder.class, orderId);
o.setStatus(OrderStatus.CANCELLED); // изменение другой audited-сущности -> запись в PURCHASE_ORDER_AUD
}
В результате в REVINFO появится одна запись (одна ревизия), а в PRODUCT_AUD и PURCHASE_ORDER_AUD — по записи на эту ревизию (или больше, если менялись несколько строк). Это объясняет, почему Envers полезен не только как “история поля”, но и как история “что происходило в системе” на уровне транзакций.
И ещё одна связка с прошлым: так как всё это живёт внутри транзакции, rollback откатывает и основную запись, и аудит. То есть Envers не сделает вам “ложную историю” про изменения, которые не были зафиксированы.
Envers и soft delete: MOD вместо DEL
После дня про soft delete мы уже привыкли, что “удалить” в бизнес-смысле и “выполнить SQL DELETE” — это разные вещи. И Envers здесь ведёт себя абсолютно честно: он фиксирует то, что реально произошло на уровне ORM-записи.
Допустим, товар “прячется” так (упрощённо):
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void hideProduct(Long productId) {
Product p = em.find(Product.class, productId); // получаем managed-сущность
p.setDeleted(true); // soft delete как изменение состояния -> в истории это будет MOD, не DEL
}
Если Product audited, то в истории появится ревизия с REVTYPE=MOD, а в снимке вы увидите deleted=true. С точки зрения истории это очень полезно: вы можете восстановить, когда товар был “живой”, а когда его скрыли, и при этом не требуется физический DELETE.
А вот если бы вы делали физическое удаление (em.remove(p)), тогда Envers записал бы DEL. Но в проекте курса мы как раз дисциплинируем себя вокруг soft delete, поэтому “настоящий DEL” может быть редким гостем. И это нормально: Envers не должен подгонять историю под наши слова, он должен отражать реальность данных.
Здесь можно слегка пошутить: Envers — это тот самый коллега, который в code review пишет “вообще-то тут UPDATE, а не delete”, и формально он прав.
7. Типичные ошибки при Envers
Ошибка №1: ожидать, что audit-таблицы появятся “сами”, без миграций.
В проекте с Flyway вы не можете рассчитывать на авто-генерацию схемы. Если REVINFO и *_AUD не созданы миграциями, Envers физически не сможет писать историю. Это обычно проявляется в рантайме как исключения про отсутствующие таблицы. Лечение простое, хотя и скучное: миграция должна быть, как и для любой другой таблицы.
Ошибка №2: путать REVTYPE с бизнес-статусом.
REVTYPE отвечает на вопрос “какая операция произошла с записью аудита”, а не “в каком статусе заказ”. Статусы заказа живут в ваших доменных полях (PurchaseOrder.status), и именно их вы будете читать в истории. REVTYPE=MOD может означать что угодно: от смены статуса до изменения адреса доставки.
Ошибка №3: считать, что Envers пишет “diff”, а не “snapshot”.
Многие ожидают, что аудит хранит “только изменённые поля”. По умолчанию Envers чаще хранит снимок состояния audited-полей на ревизии. Это удобнее для восстановления прошлого состояния, но означает, что история может быть объёмной. Если вам нужен красивый “diff по полям”, это отдельная задача (и это как раз тот случай, который легко увести в соседние темы — поэтому сегодня мы не делаем из этого отдельную религию).
Ошибка №4: игнорировать дополнительный SQL от Envers.
Envers увеличивает количество запросов. Это не “плохо” и не “хорошо”, это цена функциональности. Но если вы не смотрите SQL trace, вы легко начнёте удивляться: “почему обновление цены товара вдруг стало делать три запроса?”. Ответ: потому что вы теперь ведёте историю. И это нужно осознавать заранее, особенно в write-heavy сценариях.
Ошибка №5: думать, что soft delete обязательно даст REVTYPE=DEL.
При soft delete вы меняете состояние (deleted=true или механизм @SoftDelete делает update), значит аудит видит это как модификацию. DEL — это про физический DELETE. Если вы строите “историю удалений” — смотрите на поле удаления в снимке, а не ждите DEL как индикатор “объект исчез”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ