Мягкое удаление (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> comment = commentRepository.findById(id); 
comment.ifPresent(com -> logger.info(com.getArticle().getText()));

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

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

Eager ManyToOne & OneToOne

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

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

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

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

На самом деле все еще хуже… Если мы возьмем и позовем комментарии не через поиск по 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, вне зависимости от того, определили ли вы явно ленивую загрузку:

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

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

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

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

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

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

Заключение 

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

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

Комментарии (25)


  1. gleb_l
    22.09.2021 16:00
    +14

    Знаете, что из всего этого следует? Что в отличие от хард, soft deletion - это бизнес-операция, а не техническая операция хранилища. И решать ее нужно, соответственно, на бизнес-, а не на техническом уровне. С той же статьей, например, комментарии к софтово-удаленной статье можно таким же образом софтово-удалить.


    1. aleksey-stukalov Автор
      22.09.2021 16:01
      +8

      100% согласен


    1. archimed7592
      22.09.2021 16:55
      +1

      С той же статьей, например, комментарии к софтово-удаленной статье можно таким же образом софтово-удалить.

      Если потом восстановить софтово-удаленную статью, то не получится отличить комментарии, которые софтово-удалены вместе со статьей от комментариев, которые софтово-удалены сами по себе по кнопке "удалить комментарий".


      1. gleb_l
        22.09.2021 16:57
        +1

        ...значит, нужен гипер-флаг, говорящий о том, что удаление прилетело сверху, и computed-колонка, которая объединяет эти флаги по ИЛИ )


        1. archimed7592
          22.09.2021 17:21

          значит, нужен гипер-флаг,

          Конечно, нужен. Лишь подтверждение тому, что у задачи отсутствует чисто-техническое решение, как вы верно заметили в своём изначальном комментарии.


          1. TerraV
            22.09.2021 20:06
            +1

            Зачем удалять комментарии каскадом не пойму. Статья удалена софтово. К ней есть джойны у комментариев. Состояние комментариев однозначно (видим, не видим потому что удалена статья, не видим потому что удален комментарий), зачем его портить? По какому хочешь критерию по такому и тащишь.


            1. aleksey-stukalov Автор
              22.09.2021 20:13
              +3

              Более того, если их удалять, то

              нужен гипер-флаг

              Оверинженер в действии :)

              Вообще, с помощью софтделишена решать задачу data recovery идея не очень. Ведь нужно знать контекст удаления. Плюс, потом базу будем мигрировать вместе с удаленными сущностиями, а ведь иногда поля могут становиться обязательными... И чем заполнять удаленные данные? Де-факто мы их трогать вообще после удаления не должны... Вобщем одни минусы. Почему знаю, я так делал :). Потом переписал на жесткое удаление с сохранинием фактически удаляемых данных в монгу + отедльная логика восстановления в зависимости от версии модели.


              1. Zlatoverov
                23.09.2021 13:45
                +3

                Если пользоваться мягким удалением, то для многих может быть неожиданностью, что не работат стандартные хуки связанные с delete - preRemove/postRemove, при это в postUpdate нельзя понять контекст что запись была мягко удалена.


                1. TerraV
                  23.09.2021 14:20
                  +6

                  Если у вас разработка на триггерах - ну что ж, вы, самураи, сами выбрали этот путь.


                1. Deprecated
                  24.09.2021 10:30
                  +2

                  не работат стандартные хуки связанные с delete

                  Как и CascadeType.REMOVE


  1. aleksandy
    22.09.2021 23:05
    +1

    В 99% soft-delete не нужен. От него геморроя больше, чем профита.

    Хочется иметь возможность восстановить удалённые записи? Envers в помощь. Да, придётся сделать дополнительные приседания, но это хоть как-то управляемо и настраиваемо.


  1. corporaldev
    23.09.2021 09:07
    +4

    Есть два типа людей: те, которые думают, что soft-delete - это генианльно и те, которые его уже выпилили из проекта.


  1. yakDev
    23.09.2021 09:21
    +5

    Отличная статья, для тех кто еще не ошибся. Называть это "мягким удалением" на мой взгляд неправильно. Например для статьи, ее можно не "мягко-удалять", а "скрыть", "заархивировать", "распубликовать". И тогда все встанет на свои места.


    1. aleksey-stukalov Автор
      23.09.2021 09:21
      +4

      Действительно, бизнес-функции стоит называть по бизнесовому :)


  1. sgbogdanov
    23.09.2021 20:46
    +3

    Для @Fetch(FetchMode.JOIN) @ManyToOne(fetch = FetchType.EAGER) Фильтрация @Where включается, если в join добавлено условие фильтрации @JoinColumnOrFormula по полю, не являющемуся id.

    Пример:

    модель

    @Entity
    @Table(name = "post_reference")
    public class PostEagerJoinReference {
    
        @Id
        private Long id;
    
        @Fetch(FetchMode.JOIN)
        @ManyToOne(fetch = FetchType.EAGER)
        @JoinColumnOrFormula(column = @JoinColumn(name = "post_id", referencedColumnName = "id"))
        @JoinColumnOrFormula(formula = @JoinFormula(value = "any", referencedColumnName = "title"))
        private Post post;
    }
    
    @Entity
    @Table(name = "post")
    @Where(clause = "deleted = false")
    public class Post implements Serializable {
    
        @Id
        private Long id;
    
        @Column(name = "title")
        private String title;
    
        @Column(name = "deleted")
        private boolean deleted = false;
    }

    на запрос

    entityManager.find(PostEagerJoinReference.class, 1L);

    генерируется запрос:

    select
        posteagerj0_.id as id1_1_0_,
        posteagerj0_.post_id as post_id2_1_0_,
        any as formula2_0_,
        post1_.id as id1_0_1_,
        post1_.deleted as deleted2_0_1_,
        post1_.title as title3_0_1_
    from
        post_reference posteagerj0_ 
    left outer join
        post post1_ 
            on posteagerj0_.post_id=post1_.id 
            and any=post1_.title 
            and (
                post1_.deleted = false
            )  
    where
        posteagerj0_.id=?

    В качестве поля не являющимся id можно использовать delete = true, тогда фильтр `deleted = false` на JOIN задвоится.

    Кстати еще это приведет вот к такому багу https://hibernate.atlassian.net/browse/HHH-7668, который решается добавлением на сущность implements Serializable.


    1. aleksey-stukalov Автор
      23.09.2021 20:48
      +1

      Спасибо! Но это не отменяет того, что поведение не должно зависить от типа FetchType. А потом в Spring Data кто-то определяет EntityGraph и все снова летит к чертям...


      1. sgbogdanov
        23.09.2021 20:53

        Да тут все плохо, но с этим уже как-то можно жить с hibernate. Мне в проекте по другой причине нужно было добавить дефолтную фильтрацию. Пробовал @Where, @Filter, @Loader. И везде свои дыры и баги.


        1. aleksey-stukalov Автор
          23.09.2021 20:54
          +2

          @Filter вообще отлично работает взде, кроме... кэша :).


          1. sgbogdanov
            23.09.2021 21:00
            +2

            Да и он на фетчинг никак не влияет, только на запросы. Полезная штука)


  1. kemsky
    23.09.2021 23:15
    +1

    За все время так и не нашел аргументов за ленивое удаление, особенно если оно настроено для всего подряд. За пять лет пригодилось может один или два раза и то для дебага.


  1. elegorod
    24.09.2021 17:40
    +2

    Мягкое удаление делаем ручками. Часто вместо него можно сделать галочку Активный, а на странице списка объектов — фильтр, который по умолчанию показывает только активные. Если юзер по ошибке удалил — он может сам восстановить объект (вернуть галочку), не нужно просить запускать SQL апдейт для этого. И можно отредактировать уже «удалённый» объект при необходимости.


    1. ya_zh_programmist
      24.09.2021 19:45

      А как это выглядит в спрингдате?


      1. yakDev
        24.09.2021 19:54
        +2

        Везде приходится писать @Query и перегружать стандартные методы. По другому не выйдет.


      1. elegorod
        25.09.2021 13:11
        +2

        Спрингдату не используем, везде вручную добавляем условия по active = true


  1. weaver3
    26.09.2021 09:26
    +2

    Самый неочевидный факт в мягком удалении, это то зачем оно нужно. Часто под мягким удалением подразумемвают замену системной delete операции. По этому и появляется нужда в системном подходе к решению. На самом же деле это не системная, а бизнес операция. Спустить бизнес уровень на системный всегда чревато серьезными последствиями. Особенно такими что многние подходы и библиотеки станут неприменимы.