编程

Entities 或 DTO – 应该使用哪种投影?

663 2024-07-24 04:01:00

JPA 和 Hibernate 允许你在 JPQL 和 Criteria 查询中使用 DTO 和实体(Entity)作为投影。当我在在线培训或研讨会上谈论 Hibernate 的性能时,经常被问到,使用哪种投影是否重要。

答案是:是的!为用例选择正确的投影可能会对性能产生巨大的影响。

我并不是说只检索你需要的数据。很明显,检索不必要的信息不会为你带来任何性能优势。

DTO 和 Entity 之间主要的差异

实体和 DTO 之间还有另一个经常被忽视的区别。持久化上下文管理实体。

当你想更新一个实体时,这是一件很棒的事情。你只需要用新值调用一个 setter 方法。Hibernate 将处理所需的 SQL 语句,并将更改写入数据库。

使用起来很舒服,但你不能免费得到它。Hibernate 必须对所有托管实体执行脏检查,以确定是否需要在数据库中存储任何更改。这需要时间,当你只想向客户端发送一些信息时,这是完全不必要的。

你还需要记住,Hibernate 和任何其他 JPA 实现都将所有托管实体存储在一级缓存中。这似乎是一件好事。它可以防止重复查询的执行,并且是 Hibernate 的写后优化所必需的。但是管理一级缓存需要时间,如果选择数百或数千个实体,甚至可能成为一个问题。

因此,使用实体会产生开销,使用 DTO 时可以避免这种开销。但这是否意味着你不应该使用实体?

不是的。

写操作的投影

实体(Entity)投影对于所有写入操作都很有用。Hibernate 和任何其他 JPA 实现都可以管理实体的状态,并创建所需的 SQL 语句来将更改持久化到数据库中。这使得大多数创建、更新和删除操作的实现非常简单高效。

EntityManager em = emf.createEntityManager();
em.getTransaction().begin();

Author a = em.find(Author.class, 1L);
a.setFirstName("Thorben");

em.getTransaction().commit();
em.close();

读操作的投影

但是只读操作应该以不同的方式处理。如果你只想从数据库中读取一些数据,Hibernate 不需要管理任何状态或执行脏检查。

因此,从理论角度来看,DTO 应该是读取数据的更好投影。但这真的有什么不同吗?

我做了一个小的性能测试来回答这个问题。

测试准备

我使用了以下域模型进行测试。它由一个 Author 和一个 Book 实体组成,它们通过多对一的关联相关联。因此,每本书(Book)都由一位作者(Author)撰写。

@Entity
public class Author {

	@Id
	@GeneratedValue(strategy = GenerationType.AUTO)
	private Long id;

	@Version
	private int version;

	private String firstName;

	private String lastName;
	
	@OneToMany(mappedBy = "author")
	private List bookList = new ArrayList();

	...
}

为了确保 Hibernate不会获取任何额外的数据,我将 Book 实体上 @ManyToOne 关联的 FetchType 设置为 LAZY

@Entity
public class Book {
	
	@Id
	@GeneratedValue(strategy = GenerationType.AUTO)
	private Long id;

	@Version
	private int version;

	private String title;
	
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "fk_author")
	private Author author;

	...
}

我创建了一个包含 10 位作者(Author)的测试数据库。他们每人写了 10本书(BOOK)。因此,数据库总共包含 100 本书。

在每个测试中,我将使用不同的投影来检索所有 100 本书,并测量执行查询和事务所需的时间。为了减少任何副作用的影响,我做了 1000 次,并测量了平均时间。

好的,那我们开始吧。

检索 Entity

实体投影是大多数应用中最受欢迎的投影。你已经拥有了实体,JPA 可以轻松地将其用作投影。

所以,让我们运行这个小测试用例,并测量检索 100 个 Book 实体需要多长时间。

long timeTx = 0;
long timeQuery = 0;
long iterations = 1000;
// Perform 1000 iterations
for (int i = 0; i < iterations; i++) {
	EntityManager em = emf.createEntityManager();

	long startTx = System.currentTimeMillis();
	em.getTransaction().begin();

	// Execute Query
	long startQuery = System.currentTimeMillis();
	List<Book> books = em.createQuery("SELECT b FROM Book b").getResultList();
	long endQuery = System.currentTimeMillis();
	timeQuery += endQuery - startQuery;

	em.getTransaction().commit();
	long endTx = System.currentTimeMillis();

	em.close();
	timeTx += endTx - startTx;
}
System.out.println("Transaction: total " + timeTx + " per iteration " + timeTx / (double)iterations);
System.out.println("Query: total " + timeQuery + " per iteration " + timeQuery / (double)iterations);

平均而言,执行查询、检索结果并将其映射到 100 个 Book 实体需要 2 毫秒。如果包含事务处理,则为 2.89 ms。对于一台小而不太新的笔记本电脑来说,这还不错。

Transaction: total 2890 per iteration 2.89
Query: total 2000 per iteration 2.0

默认 FetchType 对 To-One 关联的影响

当我向你展示 Book 实体时,我指出我将 FetchType 设置为 LAZY 以避免额外查询。默认情况下,一对一关联的 FetchTypeEAGER,它告诉 Hibernate 立即初始化关联。

这需要额外的查询,如果你的查询检索了多个实体,则会对性能产生巨大影响。让我们将 Book 实体更改为使用默认的 FetchType 并执行相同的测试。

@Entity
public class Book {
	
	@ManyToOne
	@JoinColumn(name = "fk_author")
	private Author author;

	...
}

这一小小的变化使测试用例的执行时间增加了两倍多。现在执行查询和映射结果需要 7.797 毫秒,而不是 2 毫秒。每个事务的时间从 2.89 ms 增加到 8.681ms。

Transaction: total 8681 per iteration 8.681
Query: total 7797 per iteration 7.797

因此,对于“对一"关联,请确保将 FetchType 设置为 LAZY

检索不可变(@Immutable)实体(Entity)

有人要求我在测试中添加一个不可变实体。有趣的问题是:返回用 @Immutable 注释的实体的查询性能会更好吗?

Hibernate 知道它不必对这些实体执行任何脏检查,因为它们是不可变的。这可能会带来更好的表现。所以,让我们试试。

我将以下 ImmutableBook 实体添加到测试中。

@Entity
@Table(name = "book")
@Immutable
public class ImmutableBook {
	
	@Id
	@GeneratedValue(strategy = GenerationType.AUTO)
	private Long id;

	@Version
	private int version;

	private String title;
	
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "fk_author")
	private Author author;

	...
}

这是 Book 实体的副本,带有 2 个附加注释。@Immutable 注释告诉 Hibernate 不能更改这个实体。@Table(name = “book”) 将实体映射到 book 表。因此,它映射了与 Book 实体相同的表,我们可以使用与以前相同的数据运行相同的测试。

long timeTx = 0;
long timeQuery = 0;
long iterations = 1000;
// Perform 1000 iterations
for (int i = 0; i < iterations; i++) {
	EntityManager em = emf.createEntityManager();

	long startTx = System.currentTimeMillis();
	em.getTransaction().begin();

	// Execute Query
	long startQuery = System.currentTimeMillis();
	List<Book> books = em.createQuery("SELECT b FROM ImmutableBook b")
			.getResultList();
	long endQuery = System.currentTimeMillis();
	timeQuery += endQuery - startQuery;

	em.getTransaction().commit();
	long endTx = System.currentTimeMillis();

	em.close();
	timeTx += endTx - startTx;
}
System.out.println("Transaction: total " + timeTx + " per iteration " + timeTx / (double)iterations);
System.out.println("Query: total " + timeQuery + " per iteration " + timeQuery / (double)iterations);

有趣的是,无论实体是否不可变,它都没有任何区别。事务和查询的平均执行时间与之前的测试几乎相同。

Transaction: total 2879 per iteration 2.879
Query: total 2047 per iteration 2.047

使用 QueryHints.HINT_READONLY 检索实体

有人建议涵盖一个带有只读查询的测试。所以,有了这部分。

此测试使用我在文章开头展示的 Book 实体。但它需要改变测试用例。
JPA 和 Hibernate 支持一组查询提示,允许你提供有关查询及其执行方式的其他信息。查询提示 QueryHints.HINT_READONLY 告诉 Hibernate 检索只读模式下的实体。因此,Hibernate 不需要对它们执行任何脏检查,它可以应用其他优化。

你可以通过调用 Query 接口上的 setHint 方法来设置此提示。

long timeTx = 0;
long timeQuery = 0;
long iterations = 1000;
// Perform 1000 iterations
for (int i = 0; i < iterations; i++) {
	EntityManager em = emf.createEntityManager();

	long startTx = System.currentTimeMillis();
	em.getTransaction().begin();

	// Execute Query
	long startQuery = System.currentTimeMillis();
	Query query = em.createQuery("SELECT b FROM Book b");
	query.setHint(QueryHints.HINT_READONLY, true);
	query.getResultList();
	long endQuery = System.currentTimeMillis();
	timeQuery += endQuery - startQuery;

	em.getTransaction().commit();
	long endTx = System.currentTimeMillis();

	em.close();
	timeTx += endTx - startTx;
}
System.out.println("Transaction: total " + timeTx + " per iteration " + timeTx / (double)iterations);
System.out.println("Query: total " + timeQuery + " per iteration " + timeQuery / (double)iterations);

你可能会认为,将查询设置为只读可以提供明显的性能优势。Hibernate 必须执行的工作更少,所以它应该更快。

但正如你在下面看到的,执行时间几乎与之前的测试相同。至少在这个测试场景中,将 QueryHints.HINT_READONLY 设置为 true 并不能提高性能。

Transaction: total 2842 per iteration 2.842
Query: total 2006 per iteration 2.006

检索 DTO


加载 100 个 Book 实体大约需要 2 毫秒。让我们看看在 JPQL 查询中使用构造函数表达式获取相同数据的性能是否更好。

当然,你也可以在 Criteria 查询中使用构造函数表达式。

long timeTx = 0;
long timeQuery = 0;
long iterations = 1000;
// Perform 1000 iterations
for (int i = 0; i < iterations; i++) {
	EntityManager em = emf.createEntityManager();

	long startTx = System.currentTimeMillis();
	em.getTransaction().begin();

	// Execute the query
	long startQuery = System.currentTimeMillis();
	List<BookValue> books = em.createQuery("SELECT new org.thoughts.on.java.model.BookValue(b.id, b.title) FROM Book b").getResultList();
	long endQuery = System.currentTimeMillis();
	timeQuery += endQuery - startQuery;

	em.getTransaction().commit();
	long endTx = System.currentTimeMillis();

	em.close();

	timeTx += endTx - startTx;
}
System.out.println("Transaction: total " + timeTx + " per iteration " + timeTx / (double)iterations);
System.out.println("Query: total " + timeQuery + " per iteration " + timeQuery / (double)iterations);

正如预期的那样,DTO 投影的性能比实体投影好得多

Transaction: total 1678 per iteration 1.678
Query: total 1143 per iteration 1.143

平均而言,执行查询需要 1.143 毫秒,执行事务需要 1.678 毫秒。对于查询来说,这是大约 43% 的性能改进,对于事务来说是大约 42% 的性能改进。

对于一个只需要一分钟就能实施的小变化来说,这还不错。

在大多数项目中,DTO 预测的性能改进将更高。它允许你检索用例所需的数据,而不仅仅是实体映射的所有属性。检索较少的数据几乎总是会带来更好的性能。

总结

选择正确的投影比想象的更容易、更重要。

当你想实现一个写操作时,你应该使用一个实体作为你的投影。Hibernate 将管理其状态,你只需在业务逻辑中更新其属性即可。Hibernate 会处理其余的事情。

你已经看到了我的小型性能测试的结果。我的笔记本电脑可能不是运行这些测试的最佳环境,而且它肯定比你的生产环境慢。但是性能改进是如此之大,以至于你应该使用哪种投影是显而易见的。

使用 DTO 投影的查询比检索实体的查询快约 40%。因此,最好花费额外的精力为只读操作创建 DTO,并将其用作投影。

还应该确保为所有关联使用使用 FetchType.LAZY 。正如在测试中所见,即使一个急切地(Eager)获取到一个关联,也可能使查询的执行时间增加两倍。因此,最好使用 FetchType.LAZY 并初始化用例所需的关联。