【翻译】Hibernate 软删除:你可能忽略掉的一些东西

我们在设计生产系统的时候,无论是为了方便恢复、便于查找问题还是审计等,总是绕不开软删除这个话题。国内互联网公司大部分均使用 MyBatis 作为 ORM 框架,所以大部分的资料也都是 MyBatis 的各种软删除实现,用起来也都没什么问题。但是手里项目使用的是 Hibernate,虽然 Hibernate 也可以找到很多软删除的方案,当初最开始参考的也是原文列出的第一个 Baeldung 网站的方案,但是由于 Hibernate 大部分时候都是直接对实体进行操作,所以软删除用起来会遇到很多的问题。比如自己就遇到了软删除 + 二级缓存冲突的问题,先抛开这个不谈,毕竟在权衡之下直接关闭二级缓存就解决了问题,在基本使用时软删除也不可避免地会遇到各种问题。正好偶然看到了 JPA Buddy 上一篇文章在说这个问题,索性简单翻译下,希望大家在设计软删除方案的时候能够进行充分的考虑。

原文:Soft Deletion in Hibernate: Things You May Miss


软删除是一种广泛使用的模式,简单来说就是:你仅仅将实体标记为删除状态并通过在 SELECT 查询中过滤他们而不是物理删除这些实体。使用软删除有很多常见的原因:审计、可恢复性、或者可以很方便地在将数据伪造成删除状态的同时保持着对这些“已删除”记录的引用。

在这篇文章中我们将关注一些大部分讲述软删除方案的文章所未提到的细节。

@SQLDelete + @Where

如果你以“soft deletion hibernate”为关键词在谷歌上进行搜索的话,你很可能找到 Eugen ParaschivVlad Mihalcea 或是 Thorben Janssen 写的教程。他们都建议使用 Hibernate 的 @SQLDelete@Where 注解来让程序自动设置删除标识并可以在查询时自动过滤这些数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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
}

@SQLDelete 注解会在 Hibernate 管理的对象被删除时执行我们设定好的原生 SQL 语句。@Where 注解通过将自身的查询条件自动追加到 SELECT 查询语句中来帮你自动过滤掉这些被标记为删除的数据。

这个方法看上去是一剂良药,但是里面有坑。普通查询肯定可以正常工作,但是如果是关联查询呢?

关联查询所遇到的问题

让我们仔细思考一下,假设你拥有一个实体,里面可能关联了另一个实体的集合,或者仅仅是另一个实体对象,但相同的是他们均被软删除。当你获取到这个实体对象时,你期望发生什么?实际上,这里没有几个选项可供选择:被删除的记录呈现或者不呈现在查询结果中,而不同的选择取决于不同的场景用例。比如,我们在商城购物车中有很多的商品,当一件商品被删除时我们期望其从购物车中消失对吧?但是被删除的商品仍应该出现在历史账单中,这难道不矛盾嘛?

让我们来看一下 @SQLDelete@Where 注解在不同类型的关联关系、FetchType 和不同的查询 API 中是怎样工作的。下面是我们将要在后续实验中使用的 ER 图:

Article 和 Author 拥有多对多关系,和 Comment 有着一对多关系,以及和 ArticleDetails 有着一对一的关系。那么问题来了,他们中的某一个在被其他实体引用时被软删除会发生什么?

OneToMany & ManyToMany

Hibernate 会自动在所有对多关系中过滤掉被删除的实体。如果你在删除一个 Author 之前和之后分别运行下面的代码,打印出的 Author 的名字数量会发生变化:

1
2
3
4
5
Optional<Article> articleOptional = articleRepository.findById(4L);
articleOptional.ifPresent(article -> {
article.getAuthors()
.forEach(author -> logger.info(author.getName()));
});

好消息是,无论 FetchType 的值是什么或者用哪种查询方式(比如 entityManager、Criteria API、Spring Data JPA 等),上述结果都成立。

懒加载下的 ManyToOne & OneToOne

让我们试着想象一下我们软删除了一篇文章,但我们不希望删除这篇文章下面的评论,因为这样在恢复文章时评论也会自动恢复。

1
2
3
4
5
6
7
8
9
@Entity
@Table(name = "comment")
public class Comment {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "article_id")
private Article article;
...
}

现在,让我们尝试获取一条对应文章被删除的评论:

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

如果你在第一行打一个断点,你可以看到 article 属性的值是 Hibernate Proxy 对象。

这很容易理解,因为 Hibernate 并不能提前知道这个实体是否已经被删除。但是当我们执行 com.getArticle().getText() 方法时,却抛出了 EntityNotFoundException 异常,这是你所期望的吗?

提前加载下的 ManyToOne & OneToOne

让我们将 FetchType 的值改为 EAGER 后再次重复相同的实验。现在我们在程序不使用代理的情况下获取到了关联的 article 对象,虽然 Hibernate 知道 article 已经被删除了。让我们进行同样的测试:

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

被关联查询且被软删除的 article 对象在没有报任何异常的情况下被加载了,其软删除标志为也依然为 true

这种不一致的行为很容易被解释,提前加载让 Hibernate 查询时 join 了 article 表。Hibernate 面临着立即抛出 EntityNotFoundException 异常还是正常加载的抉择,但因为 article 已经被加载了,@Where 注解失去了它的作用,所以 Hibernate 只是简单地为对象赋了值。

现在让我们批量查询 comments:

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

现在我们又遇到了 EntityNotFoundException 异常!出现异常是因为 findAll 方法分离查询被关联的 Article 对象,详情可以参考 Spring data findAll() does not fetch eagerly

任何时候被软删除的对象在被使用分离查询时都会导致上面的异常,因为 Hibernate 在生成分离查询时应用了 @Where 所定义的条件,所以很显然 Hibernate 并不能查询到被软删除的实体。所以很显然,这种查询并不能查到 Hibernate 所期望的结果,所以在这种情况下导致了 EntityNotFoundException 异常。

所以更有趣的是你需要进行很多实验来认识到 Hibernate 在使用不同的 API 时是怎样获取数据的。首先,无论是对多还是对一的实体,在使用 DSL 查询时你均会遇到同样的异常。与此同时,criteria API 会在提前加载的一对一关系下返回被删除的实体,但会在提前加载的多对一关系中抛出 EntityNotFoundException 异常。这难道不令人困惑吗?

避免 EntityNotFoundException 异常的解决方式

这里有一个可以避免 EntityNotFoundException 异常的解决方法。Hibernate 介绍@NotFound 注解,它能够让程序不再抛出异常,而是直接将关联对象的值设置为 null

这个解决办法看起来是一个灵丹妙药,但它同时带来了额外的缺点:无论 FetchType 设置为什么,所有的对一关系均会提前加载。

约束和索引带来的问题

我们知道被软删除的实体和正常的实体均会被唯一约束和索引所限制,因此,常规的索引在这种情况下不再适用。假设一个 Author 实体的 login 字段有唯一约束,在软删除表中已有的记录后,记录仍然存在,所以正常状态的 Author 实体的同样的字段也不能再被设置为被软删除的相同的值。

如果你正在使用 PostgreSQL,那么你很幸运,因为你可以使用部分索引:

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

但如果你使用的是 MySQL,那么很不幸,这个问题无解。

结论

所以你可以看到,软删除是一个很容易被理解但不容易去实现的模式。看上去现在还没有一个完美的解决方案,至少 Hibernate 现阶段还没有提供。

在一些简单的场景,你确实可以直接采用使用 @SQLDelete + @Where 注解的方式,但在软删除实体出现在一对一或者多对一关联关系的时候,你并不能期望他们行为一致。只是简单地改变 FetchType 的值,或是使用 @EntityGraph,或者将你的查询从 Criteria API 迁移到 DSL 查询,或是其他任何改变都很可能影响查询结果:从被意外抛出异常到意外地成功加载了被删除的实体,或是直接获得了意料之外的 null 值。