Мягкое удаление в Hibernate: неочевидные факты
Мягкое удаление (soft deletion) — это популярная в энтерпрайз разработке стратегия удаления, когда вместо физического стирания та или иная запись помечается как удаленная, а потом фильтруется во всех запросах на чтение. Применение мягкого удаления может быть оправдано целым набором требований: аудит, возможность восстановления удаленных записей, а иногда необходимо уметь удалять данные, при этом сохраняя на них ссылки из других записей…
Вообщем, нам, как авторам JPA Buddy (плагина для IntelliJ), пришлось с этим плотно разбираться. В этой статье мы рассмотрим детали, которые зачастую не упоминаются в большинстве публикаций по этой теме, хотя крайне важны для принятия решения о способе реализации мягкого удаления в вашем приложении. Давайте посмотрим, с чем вы, вероятно, намучаетесь.
@SQLDelete + @Where
С чего начинается решение почти каждой задачи? Google. Вбиваем «soft deletion hibernate» и смотрим: Eugen Paraschiv, Vlad Mihalcea и Thorben Janssen — сильные мира Spring и Hibernate дают нам четкий посыл к действию. Просто определяем аннотации @SQLDelete
и @Where
— и готово:
@Entity
@Table(name = "article")
@SQLDelete(sql = "update article set deleted=true where id=?")
@Where(clause = "deleted = false")
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
@Column(name = "id", nullable = false)
private Long id;
@Column(name = "deleted", nullable = false)
private Boolean deleted = false;
// other properties, getters and setters omitted
}
Аннотация @Where
определяет, какое условие добавить в одноименный раздел запроса. @SQLDelete
поступает еще проще, просто подменяет DELETE FROM TABLE WHERE ID=?
на то, что определено в этой аннотации. Казалось бы, все четко и понятно, но давайте посмотрим, что будет происходить с ассоциациями?
Проблемы с ассоциациями (ссылками)
Давайте подумаем вот над каким вопросом. Мы загружаем сущность, которая ссылается на коллекцию других ассоциированных сущностей (OneToMany). А теперь давайте представим, что часть из этих сущностей мягко удалены. При загрузке головной сущности — каким бы было ваше ожидание от ассоциированной коллекции? Должны ли эти недоудаленные записи загрузиться с deleted = true
или должны быть отфильтрованы? Тот же самый вопрос можно задать и при ссылке не на коллекцию, а на единичную мягко удаленную сущность.
Ожидания могут разойтись, ведь правильное поведение зависит от конкретной задачи. Иногда надо отфильтровывать удалённые сущности из коллекций, а иногда нет. Иногда надо загружать null вместо единичной ссылки на удаленную сущность, а иногда нужно ее грузить как ни в чем не бывало. Спорить об этом бессмысленно, но одно можно сказать точно — поведение должно быть детерминированным.
Давайте поэкспериментируем. Для этого зададим простенькую модель данных, описываемую ER-диаграммой с картинки ниже:
У нас есть статья (Article), у которой есть коллекция авторов (Author). Также у статьи есть множество комментариев (Comment), в свою очередь, у комментариев есть ссылка на статью. Каждая статья также ссылается на свое резюме (ArticleDetails), и наоборот. Наш вопрос простой: как ведут себя мягко удаленные сущности, когда они выступают не в качестве искомой сущности, а в качестве ассоциации.
OneToMany & ManyToMany
Начнем с приятного. Во всех ToMany-случаях поведение консистентное, и Hibernate отфильтровывает удаленные сущности из коллекций. Результат будет аналогичным, как бы мы ни делали запрос (через entityManager, Criteria API, Spring Data JPA и т.д.) и какой бы способ подгрузки ассоциации ни был определен (Lazy или Eager).
Спойлер: на этом хорошие новости, как говорится, все…
Lazy ManyToOne & OneToOne
Обратимся к нашему примеру и представим, что мы удалили (естественно, мягко) статью. При этом мы решили это сделать таким образом, чтобы комментарии, ассоциированные со статьей, не удалялись. Это, кстати, обычная практика — восстановим статью, а комментарии тут как тут. Да и вообще, их же кто-то писал, старался. Пусть останутся у человека в истории.
@Entity
@Table(name = "comment")
public class Comment {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "article_id")
private Article article;
...
}
Теперь давайте загрузим комментарий, который ссылается на удаленную статью, при этом применяет для ассоциации ленивую загрузку (FetchType.LAZY
):
Optional comment = commentRepository.findById(id);
comment.ifPresent(com -> logger.info(com.getArticle().getText()));
И вот что мы видим в отладчике:
Поле, ссылающееся на мягко удаленную статью, инициализировалось прокси-объектом, при обращении к которому мы ловим исключение: EntityNotFoundException
! Как тебе такое, Илон Маск Влад Михалча? Можно ждать чего угодно: null или нормальной загрузки, но такое…
Eager ManyToOne & OneToOne
Повторяем эксперимент с одной лишь разницей: меняем ленивую загрузку ссылки на статью на жадную (FetchType.EAGER
). Грузим точно так же:
Optional comment = commentRepository.findById(id);
comment.ifPresent(com -> logger.info(com.getArticle().getText()));
Что мы видим в отладке:
Теперь наша недоудаленная статья загрузилась без всяких проблем. Т.е. мы просто, тихо и спокойно получили якобы удаленный объект!
На самом деле все еще хуже… Если мы возьмем и позовем комментарии не через поиск по Id, а просто списком через Spring Data JPA:
Iterable comments = commentRepository.findAll();
Снова EntityNotFoundException
?!
Почему это так важно?
Представим себе, что мы изначально имели умолчальную жадную загрузку. Тогда наш код был бы написан так:
Optional comment = commentRepository.findById(id);
if (comment.getArticle().getDeleted()) {
//Логика обработки, если статья удалена
} else {
//Логика обработки, если статья НЕ удалена
}
Затем вы делаете оптимизацию и устанавливаете тип загрузки связи в ленивую. Все. Если автотесты не покрыли этот кусочек кода, жди беды прямо в проде.
По-настоящему устойчивый код будет выглядеть либо так:
try {
if (comment.getArticle().getDeleted()) {
//Логика обработки, если статья удалена
} else {
//Логика обработки, если статья НЕ удалена
}
} catch (EntityNotFoundException e) {
//Логика обработки, если статья удалена x2
}
Либо чуточку лучше, но все равно ужасно:
if (HibernateProxy.class.isInstance(entity.getArticle())
|| comment.getArticle().getDeleted()) {
//Логика обработки, если статья удалена
} else {
//Логика обработки, если статья НЕ удалена
}
Почему так?
На самом деле, объяснение такого поведения достаточно простое. Если Hibernate делает отдельный запрос к сущности по id с объявленным @Where
, то мы получаем исключение. Если делается join
, то удаленная сущность попадает в результирующий набор данных и спокойно отображается на ассоциации. Это тесно связано с известной проблемой N+1
запроса. Во всех случаях, где вы имеете N+1
, будет исключение. А во всех, где его нет, — исключения не будет, а будет join
и гладкая загрузка удаленных записей.
Отдельный вопрос может вызывать пример с жадной загрузкой и findAll, когда мы получили исключение, в то время как findById таким не страдал, а покорно все загружал. Если вас это удивляет после предыдущего абзаца — вы в потенциальной опасности J. На самом деле, такой способ загрузки порождает N+1 запрос и влечет к большим последствиям с производительностью.
Кстати, результат будет также зависеть от того, как вы делаете запрос. Например, с QueryDSL вы снова уткнетесь в выброшенное исключение. В то же время, для Eager
OneToOne
запрос через Criteria API загрузит удаленную сущность, а при ManyToOne
швырнет EntityNotFoundException
.
Избегаем EntityNotFoundException
На самом деле EntityNotFoundException
можно достаточно легко забороть с помощью аннотации @NotFound. В этом случае вместо исключения мы будем получать null. Но такое решение тоже выглядит достаточно спорным, ведь использование @NotFound
над полем делает его EAGER
, вне зависимости от того, определили ли вы явно ленивую загрузку:
Де-факто, это ничем не лучше, чем всегда использовать жадную подгрузку для всех ToOne ассоциаций. А это и есть корень частых проблем с производительностью. Так что решение так себе.
Проблемы, связанные с хранением в одной таблице
Поскольку наши «мертвые души» хранятся в одной таблице с «живыми», возникает ряд проблем. Например, у всех ваших сущностей будет один уникальный индекс на двоих. Т.е. удалили мы запись, нет ее для пользователя. А добавить он новую не может, потому что у нас поле какое-то уникальное.
Чудо, если вы пользуетесь PostgreSQL с частичными индексами:
CREATE UNIQUE INDEX author_login_idx ON author (login) WHERE deleted = false;
А что если у вас MySQL, который так не умеет (вроде бы)?
Заключение
Кажущаяся простота имплементации мягкого удаления выходит боком в эксплуатации. Нормального системного решения найдено мной не было. Только руками. Вместо delete вызывать update, добавлять в select нужные условия, чтобы не получать того, чего не хотим. Другими словами, снова все сами.
Может, кто-то все же порешал эти проблемы? Буду рад, если расскажете в комментариях.