Мягкое удаление (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)
corporaldev
23.09.2021 09:07+4Есть два типа людей: те, которые думают, что soft-delete - это генианльно и те, которые его уже выпилили из проекта.
yakDev
23.09.2021 09:21+5Отличная статья, для тех кто еще не ошибся. Называть это "мягким удалением" на мой взгляд неправильно. Например для статьи, ее можно не "мягко-удалять", а "скрыть", "заархивировать", "распубликовать". И тогда все встанет на свои места.
aleksey-stukalov Автор
23.09.2021 09:21+4Действительно, бизнес-функции стоит называть по бизнесовому :)
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
.aleksey-stukalov Автор
23.09.2021 20:48+1Спасибо! Но это не отменяет того, что поведение не должно зависить от типа FetchType. А потом в Spring Data кто-то определяет EntityGraph и все снова летит к чертям...
sgbogdanov
23.09.2021 20:53Да тут все плохо, но с этим уже как-то можно жить с hibernate. Мне в проекте по другой причине нужно было добавить дефолтную фильтрацию. Пробовал @Where, @Filter, @Loader. И везде свои дыры и баги.
kemsky
23.09.2021 23:15+1За все время так и не нашел аргументов за ленивое удаление, особенно если оно настроено для всего подряд. За пять лет пригодилось может один или два раза и то для дебага.
elegorod
24.09.2021 17:40+2Мягкое удаление делаем ручками. Часто вместо него можно сделать галочку Активный, а на странице списка объектов — фильтр, который по умолчанию показывает только активные. Если юзер по ошибке удалил — он может сам восстановить объект (вернуть галочку), не нужно просить запускать SQL апдейт для этого. И можно отредактировать уже «удалённый» объект при необходимости.
ya_zh_programmist
24.09.2021 19:45А как это выглядит в спрингдате?
elegorod
25.09.2021 13:11+2Спрингдату не используем, везде вручную добавляем условия по active = true
weaver3
26.09.2021 09:26+2Самый неочевидный факт в мягком удалении, это то зачем оно нужно. Часто под мягким удалением подразумемвают замену системной delete операции. По этому и появляется нужда в системном подходе к решению. На самом же деле это не системная, а бизнес операция. Спустить бизнес уровень на системный всегда чревато серьезными последствиями. Особенно такими что многние подходы и библиотеки станут неприменимы.
gleb_l
Знаете, что из всего этого следует? Что в отличие от хард, soft deletion - это бизнес-операция, а не техническая операция хранилища. И решать ее нужно, соответственно, на бизнес-, а не на техническом уровне. С той же статьей, например, комментарии к софтово-удаленной статье можно таким же образом софтово-удалить.
aleksey-stukalov Автор
100% согласен
archimed7592
Если потом восстановить софтово-удаленную статью, то не получится отличить комментарии, которые софтово-удалены вместе со статьей от комментариев, которые софтово-удалены сами по себе по кнопке "удалить комментарий".
gleb_l
...значит, нужен гипер-флаг, говорящий о том, что удаление прилетело сверху, и computed-колонка, которая объединяет эти флаги по ИЛИ )
archimed7592
Конечно, нужен. Лишь подтверждение тому, что у задачи отсутствует чисто-техническое решение, как вы верно заметили в своём изначальном комментарии.
TerraV
Зачем удалять комментарии каскадом не пойму. Статья удалена софтово. К ней есть джойны у комментариев. Состояние комментариев однозначно (видим, не видим потому что удалена статья, не видим потому что удален комментарий), зачем его портить? По какому хочешь критерию по такому и тащишь.
aleksey-stukalov Автор
Более того, если их удалять, то
Оверинженер в действии :)
Вообще, с помощью софтделишена решать задачу data recovery идея не очень. Ведь нужно знать контекст удаления. Плюс, потом базу будем мигрировать вместе с удаленными сущностиями, а ведь иногда поля могут становиться обязательными... И чем заполнять удаленные данные? Де-факто мы их трогать вообще после удаления не должны... Вобщем одни минусы. Почему знаю, я так делал :). Потом переписал на жесткое удаление с сохранинием фактически удаляемых данных в монгу + отедльная логика восстановления в зависимости от версии модели.
Zlatoverov
Если пользоваться мягким удалением, то для многих может быть неожиданностью, что не работат стандартные хуки связанные с delete -
preRemove/postRemove
, при это вpostUpdate
нельзя понять контекст что запись была мягко удалена.TerraV
Если у вас разработка на триггерах - ну что ж, вы, самураи, сами выбрали этот путь.
Deprecated
Как и
CascadeType.REMOVE