Мягкое удаление в Hibernate: неочевидные факты

в 12:48, , рубрики: crud, hibernate, java, jpa, Блог компании Haulmont, Программирование

Мягкое удаление (soft deletion) — это популярная в энтерпрайз разработке стратегия удаления, когда вместо физического стирания та или иная запись помечается как удаленная, а потом фильтруется во всех запросах на чтение. Применение мягкого удаления может быть оправдано целым набором требований: аудит, возможность восстановления удаленных записей, а иногда необходимо уметь удалять данные, при этом сохраняя на них ссылки из других записей… 

Нам, как авторам JPA Buddy (плагина для IntelliJ), пришлось с этим плотно разбираться. В этой статье мы рассмотрим детали, которые зачастую не упоминаются в большинстве публикаций по этой теме, хотя крайне важны для принятия решения о способе реализации мягкого удаления в вашем приложении. Давайте посмотрим, с чем вы, вероятно, намучаетесь. 

Мягкое удаление в Hibernate: неочевидные факты - 1

@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-диаграммой с картинки ниже: 

Мягкое удаление в Hibernate: неочевидные факты - 2

У нас есть статья (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> comment = commentRepository.findById(id); 
comment.ifPresent(com -> logger.info(com.getArticle().getText()));

И вот что мы видим в отладчике: 

Мягкое удаление в Hibernate: неочевидные факты - 3

Поле, ссылающееся на мягко удаленную статью, инициализировалось прокси-объектом, при обращении к которому мы ловим исключение: EntityNotFoundException! Как тебе такое, Илон Маск Влад Михалча? Можно ждать чего угодно: null или нормальной загрузки, но такое… 

Eager ManyToOne & OneToOne

Повторяем эксперимент с одной лишь разницей: меняем ленивую загрузку ссылки на статью на жадную (FetchType.EAGER). Грузим точно так же: 

Optional<Comment> comment = commentRepository.findById(id); 
comment.ifPresent(com -> logger.info(com.getArticle().getText())); 

 Что мы видим в отладке: 

Мягкое удаление в Hibernate: неочевидные факты - 4

Теперь наша недоудаленная статья загрузилась без всяких проблем. Т.е. мы просто, тихо и спокойно получили якобы удаленный объект!  

На самом деле все еще хуже… Если мы возьмем и позовем комментарии не через поиск по Id, а просто списком через Spring Data JPA: 

Iterable<Comment> comments = commentRepository.findAll(); 

Снова EntityNotFoundException?! 

Почему это так важно?  

Представим себе, что мы изначально имели умолчальную жадную загрузку. Тогда наш код был бы написан так: 

Optional<Comment> 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, вне зависимости от того, определили ли вы явно ленивую загрузку:

Мягкое удаление в Hibernate: неочевидные факты - 5

Де-факто, это ничем не лучше, чем всегда использовать жадную подгрузку для всех ToOne ассоциаций. А это и есть корень частых проблем с производительностью. Так что решение так себе.

Проблемы, связанные с хранением в одной таблице 

Поскольку наши «мертвые души» хранятся в одной таблице с «живыми», возникает ряд проблем. Например, у всех ваших сущностей будет один уникальный индекс на двоих. Т.е. удалили мы запись, нет ее для пользователя. А добавить он новую не может, потому что у нас поле какое-то уникальное.  

Чудо, если вы пользуетесь PostgreSQL с частичными индексами: 

CREATE UNIQUE INDEX author_login_idx ON author (login) WHERE deleted = false; 

А что если у вас MySQL, который так не умеет (вроде бы)? 

Заключение 

Кажущаяся простота имплементации мягкого удаления выходит боком в эксплуатации. Нормального системного решения найдено мной не было. Только руками. Вместо delete вызывать update, добавлять в select нужные условия, чтобы не получать того, чего не хотим. Другими словами, снова все сами. 

Может, кто-то все же порешал эти проблемы? Буду рад, если расскажете в комментариях.

Автор: Aleksey Stukalov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js