Spring Boot 持久化最佳实践(七)
十四、问题
第 103 项:如何通过特定于 Hibernate 的 HINT_PASS_DISTINCT_THROUGH 优化 SELECT DISTINCT
考虑双向惰性一对多关联中涉及的Author和Book实体。数据快照如图 14-1 (有一个作者写了两本书)。
图 14-1
数据快照(提示传递不同传递)
此外,让我们获取Author实体及其所有Book子实体的列表。事实上,SQL 级结果集的大小是由从book表中提取的行数决定的。这会导致Author重复(对象引用重复)。考虑以下查询:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a LEFT JOIN FETCH a.books")
List<Author> fetchWithDuplicates();
}
调用fetchWithDuplicates()将触发下面的 SQL:
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_,
books1_.author_id AS author_i4_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.title AS title3_1_1_,
books1_.author_id AS author_i4_1_0__,
books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
获取的List<Author>包含两个相同的条目:
List<Author> authors = authorRepository.fetchWithDuplicates();
authors.forEach(a -> {
System.out.println("Id: " + a.getId()
+ ": Name: " + a.getName() + " Books: " + a.getBooks());
});
以下是输出结果:
Id: 1: Name: Joana Nimar Books: [Book{id=1, title=A History of Ancient Prague, isbn=001-JN}, Book{id=2, title=A People's History, isbn=002-JN}]
Id: 1: Name: Joana Nimar Books: [Book{id=1, title=A History of Ancient Prague, isbn=001-JN}, Book{id=2, title=A People's History, isbn=002-JN}]
为了便于记录,让我们看看 PostgreSQL(左侧)和 MySQL(右侧)的执行计划,如图 14-2 所示。
图 14-2
PostgreSQL 和 MySQL 执行计划没有明确的
所以,获取的List<Author>包含了同一个Author实体对象的两个引用。想象一个多产的作者写了 20 本书。拥有同一个Author实体的 20 个引用是一种性能损失,您可能(不想)承受得起。
为什么会有重复的?因为 Hibernate 只是返回通过左外部连接获取的结果集。如果有五个作者,每个作者有三本书,结果集将有 5 x 3 = 15 行。因此,List<Author>将有 15 个元素,都是类型Author。尽管如此,Hibernate 将只创建五个实例,但是重复的实例将作为对这五个实例的重复引用保存下来。因此,Java 堆上有 5 个实例和 10 个对它们的引用。
一种解决方法是使用如下的DISTINCT关键字:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books")
List<Author> fetchWithoutHint();
}
调用fetchWithoutHint()将触发下面的 SQL 语句(注意 SQL 查询中出现的DISTINCT关键字):
SELECT DISTINCT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_,
books1_.author_id AS author_i4_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.title AS title3_1_1_,
books1_.author_id AS author_i4_1_0__,
books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
在 JPQL 中,DISTINCT关键字的目的是避免在JOIN FETCH使用带有子关联的父实体时返回相同的父实体。必须从查询结果中消除重复值。
检查输出确认副本已从List<Author>中移除:
Id: 1: Name: Joana Nimar Books: [Book{id=1, title=A History of Ancient Prague, isbn=001-JN}, Book{id=2, title=A People's History, isbn=002-JN}]
但是问题在于,DISTINCT关键字也被传递给了数据库(检查触发的 SQL 语句)。现在,让我们再次看看 PostgreSQL(左侧)和 MySQL(右侧)的执行计划,如图 14-3 所示。
图 14-3
具有不同的 PostgreSQL 和 MySQL 执行计划
即使结果集包含唯一的父子记录(在 JDBC 结果集中没有重复的条目),所选择的执行计划也会受到DISTINCT的影响。PostgreSQL 执行计划使用一个 HashAggregate 阶段来删除重复项,而 MySQL 添加了一个临时表来删除重复项。这是不必要的开销。此外,大多数数据库实际上会自动过滤重复记录。
换句话说,只有当您确实需要从结果集中过滤出重复的记录时,DISTINCT才应该被传递给数据库。
此问题已在 HHH-10965 1 中解决,并在QueryHints.HINT_PASS_DISTINCT_THROUGH中的 Hibernate 5.2.2 中具体化。您可以按如下方式添加此提示:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books")
@QueryHints(value = @QueryHint(name = HINT_PASS_DISTINCT_THROUGH,
value = "false"))
List<Author> fetchWithHint();
}
调用fetchWithHint()将触发下面的 SQL 语句(注意,SQL 查询中不存在DISTINCT关键字):
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_,
books1_.author_id AS author_i4_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.title AS title3_1_1_,
books1_.author_id AS author_i4_1_0__,
books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
检查输出确认副本已从List<Author>中移除:
Id: 1: Name: Joana Nimar Books: [Book{id=1, title=A History of Ancient Prague, isbn=001-JN}, Book{id=2, title=A People's History, isbn=002-JN}]
此外,执行计划不会包含不必要的开销。
请记住,这个提示只对 JPQL 查询实体有用。对于标量查询(例如List<Integer>)或 DTO 来说,这是没有用的。在这种情况下,DISTINCT JPQL 关键字需要传递给底层 SQL 查询。这将指示数据库从结果集中删除重复项。
请注意,如果启用了hibernate.use_sql_comments属性,则HINT_PASS_DISTINCT_THROUGH不起作用。更多详情尽在 HHH-13280??。
而且,盯紧 HHH-13782 3 。
完整的应用可在 GitHub 4 上获得。
项目 104:如何设置 JPA 回调
JPA 回调是用户定义的方法,可用于指示应用对持久性机制内部发生的某些事件做出反应。在 Item 77 中,您看到了如何使用 JPA @PostLoad回调来计算非持久属性。从官方文件中提取的完整回调列表如图 14-4 所示。
图 14-4
jpa 回拨
让我们将所有这些回调添加到Author实体中,如下所示:
@Entity
public class Author implements Serializable {
private static final Logger logger =
Logger.getLogger(Author.class.getName());
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int age;
private String name;
private String genre;
...
@PrePersist
private void prePersist() {
logger.info("@PrePersist callback ...");
}
@PreUpdate
private void preUpdate() {
logger.info("@PreUpdate callback ...");
}
@PreRemove
private void preRemove() {
logger.info("@PreRemove callback ...");
}
@PostLoad
private void postLoad() {
logger.info("@PostLoad callback ...");
}
@PostPersist
private void postPersist() {
logger.info("@PostPersist callback ...");
}
@PostUpdate
private void postUpdate() {
logger.info("@PostUpdate callback ...");
}
@PostRemove
private void postRemove() {
logger.info("@PostRemove callback ...");
}
...
}
保持一个新的Author将触发@PrePersist和@PostPersist。获取一个Author将触发@PostLoad回调。更新一个Author将触发@PreUpdate和@PostUpdate回调。最后,删除一个Author将触发@PreRemove和@PostRemove回调。GitHub 5 上有完整的代码。
通过@EntityListeners 分离侦听器类
有时,您需要为多个实体触发 JPA 回调。例如,让我们假设您有两个实体,Paperback和Ebook,并且您想要在这些实体的实例被加载、持久化等时接收通知。为了完成这个任务,首先通过@MappedSuperclass定义一个非实体类(Book):
@MappedSuperclass
public abstract class Book implements Serializable {
...
}
接下来,Paperback和Ebook扩展这个类:
@Entity
public class Ebook extends Book implements Serializable {
...
}
@Entity
public class Paperback extends Book implements Serializable {
...
}
接下来,定义一个包含 JPA 回调的类。注意,您使用Book作为每个回调的参数。这样,每当一个Paperback或Ebook(或其他扩展Book的实体)被持久化、加载等时,回调就会被通知。:
public class BookListener {
@PrePersist
void onPrePersist(Book book) {
System.out.println("BookListener.onPrePersist(): " + book);
}
@PostPersist
void onPostPersist(Book book) {
System.out.println("BookListener.onPostPersist(): " + book);
}
...
}
最后,使用 JPA 注释@EntityListeners,链接BookListener和Book实体:
@MappedSuperclass
@EntityListeners(BookListener.class)
public abstract class Book implements Serializable {
...
}
当然,您也可以定义多个侦听器类,并且只注释您想要的实体。不要认为使用@MappedSuperclass是强制性的。
完整的应用可在 GitHub 6 上获得。
第 105 项:如何使用 Spring 数据查询生成器来限制结果集的大小,以及计算和删除派生的查询
Spring Data 带有 JPA 的查询构建器机制,它能够解释查询方法名(或派生查询——从方法名派生的查询),并将其转换为 SQL 语句。只要遵循这种机制的命名约定,这是可能的。
限制结果集大小
根据经验,开发人员必须控制结果集的大小,并始终注意结果集大小的变化。永远不要获取不必要的数据。努力将结果集大小限制在将要操作的数据范围内。此外,尽量使用相对较小的结果集(分页对于分割结果集非常有用)。
基本上,查询方法的名称指示 Spring Data 如何将LIMIT子句(或类似的子句,取决于 RDBMS)添加到生成的 SQL 查询中。
可以通过关键字first或top限制获取的结果集,这两个关键字可以互换使用(使用您喜欢的那个)。可选地,可以在top / first后面附加一个数值,以指定要返回的最大结果大小。如果忽略该数字,则假定结果大小为1。
假设Author实体如图 14-5 所示。
图 14-5
作者实体表
我们的目标是获得前五名年龄在 56 岁(??)的作者。使用查询构建器机制就像在AuthorRepository中编写以下查询一样简单:
List<Author> findTop5ByAge(int age);
或者通过第一个关键字:
List<Author> findFirst5ByAge(int age);
在后台,此方法的名称被转换为以下 SQL 查询:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM
author author0_
WHERE
author0_.age =? LIMIT ?
如果结果集应该排序,那么只需使用OrderBy 属性 Desc / Asc。例如,您可以通过name按降序获取前五位年龄为 56 岁的作者,如下所示:
List<Author> findFirst5ByAgeOrderByNameDesc(int age);
这一次,触发的 SQL 如下:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM
author author0_
WHERE
author0_.age =?
ORDER BY
author0_.name DESC LIMIT ?
从恐怖流派中按降序取前五位小于 50 的作者怎么样?将关键字LessThan添加到方法名中可以如下回答这个问题:
List<Author> findFirst5ByGenreAndAgeLessThanOrderByNameDesc(
String genre, int age);
此方法名中的 SQL 如下:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM
author author0_
WHERE
author0_.genre =?
AND author0_.age <?
ORDER BY
author0_.name DESC LIMIT ?
GitHub 7 上有源代码。
此处显示了支持的关键字的完整列表:
|关键词
|
例子
|
SQL
|
| --- | --- | --- |
| And | findByNameAndAge | ...where a.name = ?1 and a.age = ?2 |
| Or | findByNameOrAge | ...where a.name = ?1 or a.age = ?2 |
| Is, Equals | findByName, findByNameIs, findByNameEquals | ...where a.name = ?1 |
| Between | findByStartDateBetween | ...where a.startDate between ?1 and ?2 |
| LessThan | findByAgeLessThan | ...where a.age < ?1 |
| LessThanEquals | findByAgeLessThanEquals | ...where a.age <= ?1 |
| GreaterThan | findByAgeGreaterThan | ...where a.age > ?1 |
| GreaterThanEquals | findByAgeGreaterThanEquals | ...where a.age >= ?1 |
| After | findByStartDateAfter | ...where a.startDate > ?1 |
| Before | findByStartDateBefore | ...where a.startDate < ?1 |
| IsNull | findByAgeIsNull | ...where a.age is null |
| IsNotNull, NotNull | findByAge(Is)NotNull | ...where a.age not null |
| Like | findByNameLike | ...where a.name like ?1 |
| NotLike | findByNameNotLike | ...where a.name not like ?1 |
| StartingWith | findByNameStartingWith | ...where a.name like ?1(参数绑定有追加的%) |
| EndingWith | findByNameEndingWith | ...where a.name like ?1(参数绑定有追加的%) |
| Containing | findByNameContaining | ...where a.name like ?1(参数绑定有追加的%) |
| OrderBy | findByAgeOrderByNameAsc | ...where a.age = ?1 order by a.name asc |
| Not | findByNameNot | ...where a.name <> ?1 |
| In | findByAgeIn(Collection<Age>) | ...where a.age in ?1 |
| NotIn | findByAgeNotIn(Collection<Age>) | ...where a.age not in ?1 |
| True | findByActiveTrue | ...where a.active = true |
| False | findByActiveFalse | ...where a.active = false |
| IgnoreCase | findByNameIgnoreCase | ...where UPPER(a.name) = UPPER(?1) |
如果你不想添加一个WHERE子句,那么就使用findBy()方法。当然,您可以通过findFirst5By()或findTop5By()来限制结果集。
请注意,find...By不是您可以使用的唯一前缀。查询构建器机制从方法中去掉前缀find...By、read...By、query...By和get...By,并开始解析其余部分。所有这些前缀都有相同的意思和工作方式。
查询构建器机制可能非常方便,但是建议避免需要长名称的复杂查询。那些名字很快就会失控。
除了这些关键字,您还可以获取一个Page和一个Slice,如下所示:
Page<Author> queryFirst10ByName(String name, Pageable p)
Slice<Author> findFirst10ByName(String name, Pageable p)
总之,查询构建器机制非常灵活和有用。但是,等等,这还不是全部!这种机制的神奇之处在于它可以与弹簧投影结合使用(DTO)。假设以下预测:
public interface AuthorDto {
public String getName();
public String getAge();
}
您可以通过查询构建器机制获取结果集,如下所示(按年龄升序获取前五位作者的数据):
List<AuthorDto> findFirst5ByOrderByAgeAsc();
生成的 SQL 将只获取所需的数据。它不会在持久性上下文中加载任何东西。避免使用嵌套投影的查询构建器机制。这是完全不同的故事。检查项 28 和项 29 。
统计和删除派生查询
除了类型find...By的查询之外,查询构建器机制还支持派生计数查询和派生删除查询。
派生计数查询
派生计数查询以count...By开始,如下例所示:
long countByGenre(String genre);
触发的SELECT将是:
SELECT
COUNT(author0_.id) AS col_0_0_
FROM author author0_
WHERE author0_.genre = ?
这里还有一个例子:long countDistinctAgeByGenre(String genre);
派生的删除查询
派生的删除查询可以返回已删除记录的数量或已删除记录的列表。返回已删除记录数的派生删除查询以delete...By或remove...By开始,并返回long,如下例所示:
long deleteByGenre(String genre);
返回已删除记录列表的派生删除查询从delete开始...或remove...By并返回List/Set <entity>,如下例:
List<Author> removeByGenre(String genre);
在这两个示例中,执行的 SQL 语句将由一个用于获取持久性上下文中的实体的SELECT和一个用于每个必须删除的实体的DELETE组成:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
WHERE author0_.genre = ?
-- for each author that should be deleted there a DELETE statement as below
DELETE FROM author
WHERE id = ?
这里还有一个例子:List<Author> removeDistinctByGenre(String genre);
完整的应用可在 GitHub 8 上获得。
项目 106:为什么您应该在提交后避免耗时的任务
通常,本项中描述的性能问题会在生产中直接观察到,因为它涉及到重负载(但也可以在负载测试中观察到)。
它是针对 Spring 提交后挂钩的,症状反映在池连接上。最常见的症状是在池连接方法some_pool .getConnection()上观察到的。症状表明连接获取占用了大约 50%的响应时间。实际上,这对于池连接来说是不可接受的,特别是如果您的 SQL 查询很快(例如,不到 5 毫秒),并且对可用和空闲连接的数量有非常好的校准。
真正的原因可能在于提交后挂钩中存在耗时的任务。基本上,在 Spring 实现中,连接通过以下序列:
private void processCommit(DefaultTransactionStatus status)
throws TransactionException {
try {
prepareForCommit(status);
triggerBeforeCommit(status);
triggerBeforeCompletion(status);
doCommit(status);
triggerAfterCommit(status);
triggerAfterCompletion(status);
} finally {
//release connection
cleanupAfterCompletion(status);
}
}
因此,只有在执行了提交后挂钩之后,连接才会被释放回池中。如果您的挂钩很耗时(例如,发送 JMS 消息或 I/O 操作),那么就应该处理严重的性能问题。重新架构整个解决方案可能是最好的选择,但是尝试异步实现钩子或者包含一个挂起的操作也可能是可接受的解决方案。
然而,下面的代码揭示了这个问题。该代码更新了一个Author的年龄,并执行一个 60 秒的虚拟睡眠来模拟一个耗时的提交后任务。这应该有足够的时间来捕获 HikariCP(池连接)日志,并查看该连接在提交后是否仍处于活动状态:
@Transactional
public void updateAuthor() {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
logger.info(() -> "Long running task right after commit ...");
// Right after commit do other stuff but
// keep in mind that the connection will not
// return to pool connection until this code is done
// So, avoid time-consuming tasks here
try {
// This sleep() is just proof that the
// connection is not released
// Check HikariCP log
Thread.sleep(60 * 1000);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
logger.severe(() -> "Exception: " + ex);
}
logger.info(() -> "Long running task done ...");
}
});
logger.info(() -> "Update the author age and commit ...");
Author author = authorRepository.findById(1L).get();
author.setAge(40);
}
输出日志显示,当代码处于 Hibernate 状态时,连接是打开的。因此,连接保持打开状态是没有任何意义的:
Update the author age and commit ...
update author set age=?, name=?, surname=? where id=?
Long running task right after commit ...
Pool stats (total=10, active=1, idle=9, waiting=0)
Long running task done ...
Pool stats (total=10, active=0, idle=10, waiting=0)
完整的代码可以在 GitHub 9 上找到。
第 107 项:如何避免多余的 save()调用
考虑一个名为Author的实体。在其属性中,它有一个age属性。此外,应用计划通过以下方法更新作者的age:
@Transactional
public void updateAuthorRedundantSave() {
Author author = authorRepository.findById(1L).orElseThrow();
author.setAge(44);
authorRepository.save(author);
}
调用此方法将触发以下两条 SQL 语句:
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = ?
UPDATE author
SET age = ?, genre = ?, name = ?
WHERE id = ?
检查粗体行(authorRepository.save(author))——需要这一行吗?正确答案是否定的!当应用从数据库中获取author时,它就成为一个托管实例。这意味着如果实例被修改,Hibernate 将负责触发UPDATE语句。这是通过 Hibernate 脏检查机制在刷新时完成的。换句话说,可以通过以下方法实现相同的行为:
@Transactional
public void updateAuthorRecommended() {
Author author = authorRepository.findById(1L).orElseThrow();
author.setAge(44);
}
调用此方法将触发完全相同的查询。这意味着 Hibernate 已经检测到获取的实体被修改,并代表您触发了UPDATE。
save()的存在与否并不影响查询的数量或类型,但它仍然有性能损失,因为save()方法在幕后触发了一个MergeEvent,它将执行一系列特定于 Hibernate 的内部操作,这些操作在这种情况下是无用的。因此,在这样的场景中,避免显式调用save()方法。
GitHub 10 上有源代码。
项目 108:为什么以及如何防止 N+1 问题
N+1 问题与延迟抓取有关,但是急切抓取也不例外。
一个经典的 N+1 场景从Author和Book之间的双向惰性@OneToMany关联开始,如图 14-6 所示。
图 14-6
@OneToMany 表关系
开发人员首先获取实体集合(例如,List<Book>,这是来自 N+1 的第 1 个查询),然后,对于该集合中的每个实体(Book),他缓慢地获取Author实体(这导致 N 个查询,其中 N 可以达到Book集合的大小)。所以,这是一个经典的 N+1。
数据快照如图 14-7 所示。
图 14-7
数据快照
让我们看看导致 N+1 问题的代码。为了简洁起见,让我们跳过Author和Book源,直接获取作者和书籍:
@Transactional(readOnly = true)
public void fetchBooksAndAuthors() {
List<Book> books = bookRepository.findAll();
for (Book book : books) {
Author author = book.getAuthor();
System.out.println("Book: " + book.getTitle()
+ " Author: " + author.getName());
}
}
对这个数据样本调用fetchBooksAndAuthors()将触发以下 SQL 语句:
-- SELECT that fetches all books (this is 1)
SELECT
book0_.id AS id1_1_,
book0_.author_id AS author_i4_1_,
book0_.isbn AS isbn2_1_,
book0_.title AS title3_1_
FROM book book0_
-- follows 4 SELECTs, one for each book (this is N)
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = ?
当然,开发者可以先获取一个List<Author>,并为每个Author获取相关的书籍作为一个List<Book>。这也导致了 N+1 问题。
显然,如果 N 相对较大(请记住,集合会随着时间的推移而“增长”),这会导致性能显著下降。这就是为什么了解 N+1 问题很重要。但是你如何避免它们呢?解决方案是依靠连接(JOIN FETCH或JOIN(对于 DTO))或者将 N+1 减少到 1 的实体图。
也许最难的部分不是修复 N+1 问题,而是发现它们。为了在开发过程中捕捉 N+1 个问题,监控生成的 SQL 语句的数量,并验证报告的数量是否等于预期的数量(参见第 81 项)。
完整的代码可以在 GitHub 11 上找到。
特定于 Hibernate 的@Fetch(FetchMode。JOIN)和 N+1
导致 N+1 问题的一个常见场景是不正确地使用特定于 Hibernate 的@Fetch(FetchMode.JOIN)。Hibernate 通过org.hibernate.annotations.FetchMode和org.hibernate.annotations.Fetch注释支持三种获取模式:
-
FetchMode.SELECT(默认):在一个父子关联中,对于 N 个父母,会有 N+1 个SELECT语句来加载父母及其关联的子女。这种取货模式可以通过@BatchSize( 第 54 项进行优化。 -
FetchMode.SUBSELECT:在父子关联中,一个SELECT加载父节点,一个SELECT加载所有关联的子节点。会有两个SELECT语句。 -
FetchMode.JOIN:在父子关联中,父节点和关联的子节点被加载到一个SELECT语句中。
在本节中,我们重点介绍FetchMode.JOIN。
在决定使用FetchMode.JOIN之前,一定要评估JOIN FETCH ( 第 39 项)和实体图(第 7 项和第 8 项)。这两种方法都是基于查询使用的,并且都支持HINT_PASS_DISTINCT_THROUGH优化( Item 103 )来删除重复项。如果你需要使用Specification,那么使用实体图。Specification s 用JOIN FETCH忽略。
FetchMode.JOIN获取模式总是触发EAGER加载,因此当父节点被加载时,子节点也被加载,即使它们是不需要的。除了这个缺点,FetchMode.JOIN可能会返回**重复的结果。**您必须自己删除重复的内容(例如,将结果存储在Set中)。
但是,如果你决定使用FetchMode.JOIN,至少要避免接下来讨论的 N+1 问题。
让我们考虑三个实体,Author、Book和Publisher。在Author和Book之间有一个双向的懒惰@OneToMany关联。在Author和Publisher之间有一个单向的懒@ManyToOne关联(作者与某出版社有独家合同)。在Book和Publisher之间,没有关联。
您想要获取所有的书籍(通过 Spring Data 内置的findAll()方法),包括它们的作者,以及这些作者的出版商。在这种情况下,您可能会认为特定于 Hibernate 的FetchMode.JOIN可以如下使用:
@Entity
public class Author implements Serializable {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "publisher_id")
@Fetch(FetchMode.JOIN)
private Publisher publisher;
...
}
@Entity
public class Book implements Serializable {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
@Fetch(FetchMode.JOIN)
private Author author;
...
}
@Entity
public class Publisher implements Serializable {
...
}
服务方法可以通过findAll()获取所有的Book,如下所示:
List<Book> books = bookRepository.findAll();
您可能认为,由于有了FetchMode.JOIN,前面的代码行将触发一个包含正确的JOIN语句的SELECT来获取作者和这些作者的出版商。但是 Hibernate @Fetch(FetchMode.JOIN)对查询方法不起作用。如果您使用EntityManager#find()、Spring Data、findById()或findOne()通过 ID(主键)获取实体,它会起作用。以这种方式使用FetchMode.JOIN会导致 N+1 个问题。
让我们看看代表 N+1 情况的触发 SQL 语句:
-- Select all books
SELECT
book0_.id AS id1_1_,
book0_.author_id AS author_i5_1_,
book0_.isbn AS isbn2_1_,
book0_.price AS price3_1_,
book0_.title AS title4_1_
FROM book book0_
-- For each book, fetch the author and the author's publisher
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_,
author0_.publisher_id AS publishe5_0_0_,
publisher1_.id AS id1_2_1_,
publisher1_.company AS company2_2_1_
FROM author author0_
LEFT OUTER JOIN publisher publisher1_
ON author0_.publisher_id = publisher1_.id
WHERE author0_.id = ?
显然,这不是预期的行为。性能损失影响由 N 的大小给出。N 越大,性能损失影响越大。但是您可以通过使用JOIN FETCH或实体图来消除这个问题。
使用 JOIN FETCH 而不是 FetchMode。加入
可以用JOIN FETCH ( 第 39 项)替代FetchMode.JOIN,通过覆盖findAll():
@Override
@Query("SELECT b FROM Book b LEFT JOIN FETCH b.author a
LEFT JOIN FETCH a.publisher p")
public List<Book> findAll();
或者如果你想要一个INNER JOIN如下:
@Override
@Query("SELECT b, b.author, b.author.publisher FROM Book b")
public List<Book> findAll();
现在,调用findAll()将触发单个SELECT:
SELECT
book0_.id AS id1_1_0_,
author1_.id AS id1_0_1_,
publisher2_.id AS id1_2_2_,
book0_.author_id AS author_i5_1_0_,
book0_.isbn AS isbn2_1_0_,
book0_.price AS price3_1_0_,
book0_.title AS title4_1_0_,
author1_.age AS age2_0_1_,
author1_.genre AS genre3_0_1_,
author1_.name AS name4_0_1_,
author1_.publisher_id AS publishe5_0_1_,
publisher2_.company AS company2_2_2_
FROM book book0_
LEFT OUTER JOIN author author1_
ON book0_.author_id = author1_.id
LEFT OUTER JOIN publisher publisher2_
ON author1_.publisher_id = publisher2_.id
使用实体图代替 FetchMode。加入
您可以使用实体图形(第 7 项和第 8 项)代替FetchMode.JOIN,通过覆盖findAll()如下:
@Override
@EntityGraph(attributePaths = {"author.publisher"})
public List<Book> findAll();
现在,调用findAll()将触发单个SELECT:
SELECT
book0_.id AS id1_1_0_,
author1_.id AS id1_0_1_,
publisher2_.id AS id1_2_2_,
book0_.author_id AS author_i5_1_0_,
book0_.isbn AS isbn2_1_0_,
book0_.price AS price3_1_0_,
book0_.title AS title4_1_0_,
author1_.age AS age2_0_1_,
author1_.genre AS genre3_0_1_,
author1_.name AS name4_0_1_,
author1_.publisher_id AS publishe5_0_1_,
publisher2_.company AS company2_2_2_
FROM book book0_
LEFT OUTER JOIN author author1_
ON book0_.author_id = author1_.id
LEFT OUTER JOIN publisher publisher2_
ON author1_.publisher_id = publisher2_.id
完整的应用可在 GitHub 12 上获得。
第 109 项:如何使用 Hibernate 特有的软删除支持
软删除(或逻辑删除)是指将数据库中的记录标记为已删除,但不是实际(物理)删除它。当它被标记为已删除时,该记录不可用(例如,它没有被添加到结果集中;表现得像真的被删了一样)。该记录可以在以后永久删除(硬删除),也可以恢复(或取消删除)。
通常,这个任务是通过一个额外的列来实现的,这个额外的列保存一个标志值,对于一个已删除的记录,这个标志值被设置为true,对于一个可用的(或活动的)记录,这个标志值被设置为false。但是依赖标志值并不是唯一的可能性。软删除机制可以由时间戳或@Enumerated来控制。
在少数情况下,软删除是正确的选择。著名的使用案例包括临时停用用户、设备、服务等。例如,您可以将在帖子上添加恶意评论的用户列入黑名单,直到您与他讨论并解决问题或决定对其帐户进行物理删除。或者您可以让用户等待,直到他可以确认注册电子邮件地址。如果确认电子邮件的宽限期到期,您将执行注册的物理删除。
从性能的角度来看,只要开发人员在使用这种方法之前考虑一些事情,使用软删除是可以的:
-
虽然您不会丢失任何数据,但如果被软删除的记录占总记录的很大一部分,并且很少/从不计划被恢复或永久删除,那么仅拥有“挂起”数据就会对性能产生影响。大多数情况下,这是无法删除的数据,如历史数据、财务数据、社交媒体数据等。
-
显然,在一个表中进行软删除意味着这个表不仅仅存储必要的数据;如果这成为一个问题(从一开始就预料到这一点是可取的),那么将不必要的数据移动到一个存档的表中可能是一个解决方案。另一个解决方案包括拥有一个镜像表,它通过原始表上的触发器记录所有的删除/更新;此外,一些 RDBMSs 提供不需要您更改代码的支持(例如,Oracle 有闪回技术,而 SQL Server 有临时表)。
-
不可避免地,一部分查询会被一个用于区分可用记录和软删除记录的
WHERE子句“污染”;大量这样的查询会导致性能下降。 -
所采用的解决方案是否考虑了级联软删除?您可能需要此功能,手动操作可能会出现错误和数据问题。
-
大量的软删除会影响索引。
在 Spring Data 为软删除提供内置支持之前(关注 DATAJPA-307 13 ),让我们看看如何通过 Hibernate 支持来解决这个问题。
Hibernate 软删除
软删除实现可以以 Hibernate 为中心。首先定义一个用@MappedSuperclass注释的abstract类,并包含一个名为deleted的标志字段。对于已删除的记录,该字段为true,对于可用的记录,该字段为false(默认):
@MappedSuperclass
public abstract class BaseEntity {
@Column(name = "deleted")
protected boolean deleted;
}
此外,应该利用软删除的实体将扩展BaseEntity。例如,Author和Book实体——在Author和Book之间有一个双向的惰性@OneToMany关联。
除了扩展BaseEntity,这些实体应该:
-
用 Hibernate 特有的
@Where标注,@Where(clause = "deleted = false");这有助于 Hibernate 通过将这个 SQL 条件附加到实体查询来过滤软删除的记录。 -
用 Hibernate 特有的
@SQLDelete标注来触发UPDATESQL 语句,代替DELETESQL 语句;删除一个实体将导致deleted列更新为true,而不是记录的物理删除。
在代码中:
@Entity
@SQLDelete(sql
= "UPDATE author "
+ "SET deleted = true "
+ "WHERE id = ?")
@Where(clause = "deleted = false")
public class Author extends BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String genre;
private int age;
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();
public void removeBook(Book book) {
book.setAuthor(null);
this.books.remove(book);
}
// getters and setters omitted for brevity
}
@Entity
@SQLDelete(sql
= "UPDATE book "
+ "SET deleted = true "
+ "WHERE id = ?")
@Where(clause = "deleted = false")
public class Book extends BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String isbn;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
// getters and setters omitted for brevity
}
测试时间
考虑图 14-8 所示的数据快照(由于deleted值为0或false,所有记录都可用且有效)。
图 14-8
数据快照(没有记录被软删除)
为简单起见,以下示例使用硬编码标识符和直接获取。
删除作者
删除作者很容易。下面的方法通过内置的delete(T entity)方法删除 ID 为 1 的作者(在幕后,该方法依赖于EntityManager.remove()):
@Transactional
public void softDeleteAuthor() {
Author author = authorRepository.findById(1L).get();
authorRepository.delete(author);
}
调用softDeleteAuthor()会触发以下 SQL 语句:
SELECT
author0_.id AS id1_0_0_,
author0_.deleted AS deleted2_0_0_,
author0_.age AS age3_0_0_,
author0_.genre AS genre4_0_0_,
author0_.name AS name5_0_0_
FROM author author0_
WHERE author0_.id = ?
AND (author0_.deleted = 0)
SELECT
books0_.author_id AS author_i5_1_0_,
books0_.id AS id1_1_0_,
books0_.id AS id1_1_1_,
books0_.deleted AS deleted2_1_1_,
books0_.author_id AS author_i5_1_1_,
books0_.isbn AS isbn3_1_1_,
books0_.title AS title4_1_1_
FROM book books0_
WHERE (books0_.deleted = 0)
AND books0_.author_id = ?
UPDATE book
SET deleted = TRUE
WHERE id = ?
UPDATE author
SET deleted = TRUE
WHERE id = ?
两个SELECT语句只提取未被软删除的记录(检查WHERE子句)。接下来,作者被删除(导致将deleted更新为true)。此外,级联机制负责触发子移除,这导致另一次更新。图 14-9 高亮显示被软删除的记录。
图 14-9
数据快照(软删除作者后)
删除图书
要删除一本书,让我们考虑以下服务方法:
@Transactional
public void softDeleteBook() {
Author author = authorRepository.findById(4L).get();
Book book = author.getBooks().get(0);
author.removeBook(book);
}
调用softDeleteBook()会触发以下 SQL 语句:
SELECT
author0_.id AS id1_0_0_,
author0_.deleted AS deleted2_0_0_,
author0_.age AS age3_0_0_,
author0_.genre AS genre4_0_0_,
author0_.name AS name5_0_0_
FROM author author0_
WHERE author0_.id = ?
AND (author0_.deleted = 0)
SELECT
books0_.author_id AS author_i5_1_0_,
books0_.id AS id1_1_0_,
books0_.id AS id1_1_1_,
books0_.deleted AS deleted2_1_1_,
books0_.author_id AS author_i5_1_1_,
books0_.isbn AS isbn3_1_1_,
books0_.title AS title4_1_1_
FROM book books0_
WHERE (books0_.deleted = 0)
AND books0_.author_id = ?
UPDATE book
SET deleted = TRUE
WHERE id = ?
同样,两个SELECT语句只获取未被软删除的记录(检查WHERE子句)。接下来,该作者的第一本书被删除(导致将deleted更新为true)。图 14-10 高亮显示被软删除的记录。
图 14-10
数据快照(软删除图书后)
恢复作者
请记住,当作者被删除时,级联机制会自动删除相关书籍。因此,恢复作者也意味着恢复其相关书籍。
这可以通过 JPQL 来实现。要通过 ID 恢复作者,只需通过 JPQL 触发一个UPDATE语句,将deleted设置为false(或0)。此查询可在AuthorRepository中列出:
@Transactional
@Query(value = "UPDATE Author a SET a.deleted = false WHERE a.id = ?1")
@Modifying
public void restoreById(Long id);
恢复一个作者的书籍相当于将每个关联书籍的deleted设置为false(或0)。有了作者 ID,您可以通过 JPQL 在BookRepository中完成这项工作:
@Transactional
@Query(value = "UPDATE Book b SET b.deleted = false WHERE b.author.id = ?1")
@Modifying
public void restoreByAuthorId(Long id);
以下服务方法恢复了之前删除的作者:
@Transactional
public void restoreAuthor() {
authorRepository.restoreById(1L);
bookRepository.restoreByAuthorId(1L);
}
下面列出了 SQL 语句:
UPDATE author
SET deleted = 0
WHERE id = ?
UPDATE book
SET deleted = 0
WHERE author_id = ?
修复一本书
您可以通过 JPQL 按 ID 恢复某本书,如下所示:
@Transactional
@Query(value = "UPDATE Book b SET b.deleted = false WHERE b.id = ?1")
@Modifying
public void restoreById(Long id);
以下服务方法恢复先前删除的图书:
@Transactional
public void restoreBook() {
bookRepository.restoreById(1L);
}
SQL 语句如下所示:
UPDATE book
SET deleted = 0
WHERE id = ?
有用的查询
使用软删除时,有两个查询非常方便。例如,在软删除的上下文中,调用内置的findAll()方法将只获取具有deleted = false的记录。您可以通过如下本机查询获取所有记录,包括被软删除的记录(该查询适用于作者):
@Query(value = "SELECT * FROM author", nativeQuery = true)
List<Author> findAllIncludingDeleted();
另一个方便的本地查询可以只获取软删除的记录,如下所示:
@Query(value = "SELECT * FROM author AS a WHERE a.deleted = true",
nativeQuery = true)
List<Author> findAllOnlyDeleted();
这些查询不能通过 JPQL 编写,因为目标是防止 Hibernate 在过滤软删除时添加WHERE子句。
在当前持久性上下文中更新已删除的属性
Hibernate 不会代表你更新deleted属性。换句话说,通过@SQLDelete触发的本机UPDATE将更新deleted列,但不会更新被软删除实体的deleted属性。
通常,不需要更新deleted属性,因为被引用的实体在删除后会立即释放。
一旦数据库记录被更新,所有后续查询都使用新的deleted值;因此,可以忽略过时的deleted属性。
然而,如果被引用的实体仍在使用,您应该自己更新被删除的属性。最好的方法是通过 JPA @PreRemove生命周期回调(有关 JPA 生命周期回调的详细信息,请参见第 104 项)。
将authorRemove()方法添加到Author实体中:
@PreRemove
private void authorRemove() {
deleted = true;
}
并且在Book实体中:
@PreRemove
private void bookRemove() {
deleted = true;
}
现在,Hibernate 在对Author或Book实体执行移除操作之前会自动调用这些方法。
如果您注意到被软删除的实体也被提取(例如,通过在一个@ManyToOne关系或其他关系中直接提取),那么很可能您需要在实体级添加一个专用的@Loaded,它也包括deleted列。例如,在Author实体中,这可以按如下方式完成:
@Loader(namedQuery = "findAuthorById")
@NamedQuery(name = "findAuthorById", query =
"SELECT a " +
"FROM Author a " +
"WHERE" +
" a.id = ?1 AND " +
" a.deleted = false")
完整的应用可在 GitHub 14 上获得。
项目 110:为什么以及如何避免 OSIV 反模式
在 Spring Boot,默认情况下使用视图中的开放会话(OSIV ),这通过如下日志消息来表示:
spring.jpa.open-in-view is enabled by default. Therefore, database queries
may be performed during view rendering. Explicitly configure spring.jpa.open in-view to disable this warning.
可以通过将以下配置添加到application.properties文件中来禁用它:
spring.jpa.open-in-view=false
视图中的开放会话是反模式,而不是模式。至少,OSIV 是适得其反的。如果是这样,为什么使用 OSIV?大部分时间是用来避开众所周知的 Hibernate 特有的LazyInitializationException。
Hibernate 特有的LazyInitializationException的一个小故事:一个实体可能有关联,Hibernate 带有代理(Proxy),允许开发人员推迟获取,直到需要关联。然而,为了成功地完成这个任务,需要在获取时打开一个Session。换句话说,当持久性上下文关闭时试图初始化代理将导致LazyInitializationException。在一个常见的场景中,开发人员获取一个没有关联的实体,关闭持久化上下文,然后尝试懒惰地获取关联。这导致了臭名昭著的LazyInitializationException。
OSIV 可以通过强制持久性上下文保持开放来阻止LazyInitializationException,这样视图层(和开发人员)就可以触发代理初始化。换句话说,在处理请求的整个过程中,它将一个 JPA EntityManager绑定到线程。这是好是坏?嗯,拥有一个与请求-响应生命周期一样长的Session可以让您免于受到LazyInitializationException,但是它也为性能损失和不良实践打开了大门。所以,肯定不好!
考虑一个@OneToMany双向懒惰关联中的两个实体Author和Book(一个作者写了多本书)。在代码中:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String genre;
private int age;
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
@JsonManagedReference
private List<Book> books = new ArrayList<>();
// getters and setters omitted for brevity
}
@Entity
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String isbn;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
@JsonBackReference
private Author author;
// getters and setters omitted for brevity
}
@JsonManagedReference和@JsonBackReference被设计用于处理字段之间的双向链接——一个用于Author,另一个用于Book。这是避免杰克逊无限递归问题的常用方法:
-
@JsonManagedReference是引用的前向部分(它被序列化) -
@JsonBackReference是引用的后面部分(没有序列化)这两个注释的替代方法是:
@JsonIdentityInfo、@JsonIgnore、@JsonView,或者一个定制的序列化器。
此外,让我们考虑一个经典的AuthorRepository、BookstoreService和BookstoreController,让我们看看 OSIV 内部是如何工作的:
-
步骤 1:
OpenSessionInViewFilter调用SessionFactory#openSession()并获得一个新的Session。 -
第二步:将
Session绑定到TransactionSynchronizationManager。 -
步骤 3:
OpenSessionInViewFilter调用FilterChain#doFilter(),请求被进一步处理。 -
第四步:调用
DispatcherServlet。 -
步骤 5:
DispatcherServlet将 HTTP 请求路由到底层的BookstoreController。 -
步骤 6:
BookstoreController调用BookstoreService来获得一个Author实体的列表。 -
第七步:
BookstoreService使用与OpenSessionInViewFilter相同的Session进行事务。 -
步骤 8:该事务使用连接池中的新连接。
-
步骤 9:
AuthorRepository获取一个Author实体的列表,而不初始化Book关联。 -
步骤 10:
BookstoreService提交底层事务,但是Session没有关闭,因为它是由OpenSessionInViewFilter从外部打开的。 -
步骤 11:
DispatcherServlet渲染 UI;为了实现这一点,它需要惰性Book关联,因此它触发这个惰性关联的初始化。 -
步骤 12:
OpenSessionInViewFilter可以关闭Session,底层数据库连接被释放到连接池。
OSIV 的主要缺点是什么?嗯,至少以下几点:
-
给连接池带来了很大的压力,因为并发请求会在队列中等待长时间运行的连接被释放。这可能会导致连接池过早耗尽。
-
从 UI 呈现阶段发出的语句将以自动提交模式运行,因为没有显式事务。这迫使数据库进行大量 I/O 操作(将事务日志传输到磁盘)。一种优化包括将
Connection标记为只读,这将允许数据库服务器避免写入事务日志。 -
服务和 UI 层可以触发针对数据库的语句。这违背了 SoC(关注点分离),增加了测试的复杂性。
当然,避免 OSIV 开销的解决方案包括禁用它,并通过控制延迟加载(例如,通过
JOIN和/或JOIN FETCH)来编写查询,以避免潜在的LazyInitializationException。但是这并不能解决由视图层触发的延迟加载所导致的问题。当视图层强制延迟加载时,将不存在主动 HibernateSession,这将导致延迟加载异常。要解决这个问题,使用Hibernate5Module或显式初始化未修补的懒惰关联。
冬眠模块
Hibernate5Module是jackson-datatype-hibernate项目的一部分。符合官方说法,这个项目的目标是“构建 Jackson 模块(jar)来支持 Hibernate 特定数据类型和属性的 JSON 序列化和反序列化;尤其是延迟加载方面。”
Hibernate5Module的存在指示 Jackson 用默认值初始化未被修补的懒惰关联(例如,懒惰关联将用null初始化)。换句话说,杰克森将不再使用 OSIV 来获取懒惰的联想。然而,Hibernate5Module对懒惰联想很有效,但对懒惰基本属性无效(第 23 项)。
将Hibernate5Module添加到项目中是一个两步任务。首先,将以下依赖项添加到pom.xml:
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-hibernate5</artifactId>
</dependency>
第二,设置以下@Bean:
@SpringBootApplication
public class MainApplication {
public static void main(String[] args) {
SpringApplication.run(MainApplication.class, args);
}
@Bean
public Hibernate5Module hibernate5Module() {
return new Hibernate5Module();
}
}
测试时间
让我们通过一个简单的BookstoreService服务方法获取一个没有关联的Book实体的Author:
public Author fetchAuthorWithoutBooks() {
Author author = authorRepository.findByName("Joana Nimar");
return author;
}
在BookstoreController中,让我们调用这个方法:
// The View will NOT force lazy initialization of books
@RequestMapping("/fetchwithoutbooks")
public Author fetchAuthorWithoutBooks() {
Author author = bookstoreService.fetchAuthorWithoutBooks();
return author;
}
访问http://localhost:8080/fetchwithoutbooks URL 会触发以下 SQL 语句:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
WHERE author0_.name = ?
返回的 JSON 如下:
{
"id":4,
"name":"Joana Nimar",
"genre":"History",
"age":34,
"books":null
}
相关的书籍尚未提取。books属性被初始化为null,很可能你不希望它被序列化。为此,只需用@JsonInclude(Include.NON_EMPTY)注释Author实体。触发相同的请求将返回以下 JSON:
{
"id":4,
"name":"Joana Nimar",
"genre":"History",
"age":34
}
完整的代码可以在 GitHub 15 上找到。
显式(手动)初始化未修补的惰性属性
通过显式(手动)初始化未修补的惰性关联,开发人员可以防止视图触发它们的惰性加载。OSIV 保持开启的Session将不再使用,所以你可以放心禁用 OSIV。
测试时间
让我们通过一个简单的服务方法BookstoreService获取一个没有关联的Book实体的Author:
public Author fetchAuthorWithoutBooks() {
Author author = authorRepository.findByName("Joana Nimar");
// explicitly set Books of the Author to null
// in order to avoid fetching them from the database
author.setBooks(null);
// or, to an empty collection
// author.setBooks(Collections.emptyList());
return author;
}
在BookstoreController中,让我们调用这个方法:
// The View will NOT force lazy initialization of books
@RequestMapping("/fetchwithoutbooks")
public Author fetchAuthorWithoutBooks() {
Author author = bookstoreService.fetchAuthorWithoutBooks();
return author;
}
访问http://localhost:8080/fetchwithoutbooks URL 会触发以下 SQL 语句:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
WHERE author0_.name = ?
返回的 JSON 如下:
{
"id":4,
"name":"Joana Nimar",
"genre":"History",
"age":34,
"books":null
}
相关的书籍尚未提取。与之前完全一样,用@JsonInclude (Include.NON_EMPTY)注释Author实体,以避免books属性的序列化。
完整的代码可以在 GitHub 16 上找到。
如果启用了 OSIV,开发人员仍然可以手动初始化未修补的惰性关联,只要他们在事务之外这样做以避免刷新。为什么会这样?既然Session是打开的,为什么手动初始化受管实体的关联不会触发刷新?答案可以在OpenSessionInViewFilter的文档中找到,文档中规定:“默认情况下,该过滤器不会冲洗 HibernateSession,冲洗模式设置为FlushMode.NEVER/MANUAL。它假定与负责刷新的服务层事务结合使用:在读写事务期间,活动事务管理器将临时将刷新模式更改为FlushMode.AUTO,在每个事务结束时刷新模式重置为FlushMode.NEVER/MANUAL。如果您打算在没有事务的情况下使用此过滤器,请考虑更改默认刷新模式(通过flushMode属性)。”
Hibernate 特有的 Hibernate . enable _ lazy _ load _ no _ trans 怎么样
如果您从未听说过 Hibernate 特有的hibernate.enable_lazy_load_no_trans设置,那么您就没有错过任何东西!但是,如果您听说过它并使用它,请阅读本节以了解为什么应该避免这种设置。简而言之,hibernate.enable_lazy_load_no_trans是避免LazyInitializationException的又一招。
考虑以下两种服务方法:
public List<Book> fetchBooks() {
return bookRepository.findByPriceGreaterThan(30);
}
public void displayAuthors(List<Book> books) {
books.forEach(b -> System.out.println(b.getAuthor()));
}
调用fetchBooks()会返回一个List,包含所有比$ 30 贵的书。之后,您将这个列表传递给displayAuthors()方法。显然,在这种情况下调用getAuthor()会导致LazyInitializationException,因为作者是延迟加载的,而且此时没有活动的 Hibernate 会话。
现在,在application.properties中,让我们如下设置hibernate.enable_lazy_load_no_trans:
spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true
这一次,LazyInitializationException没有出现,而是显示了作者。有什么问题吗?嗯,Hibernate 为每个获取的作者打开一个Session。此外,每个作者使用一个数据库事务和连接。显然,这带来了显著的性能损失。甚至不要认为用@Transactional(readOnly=true)注释displayAuthors()方法会通过使用单个事务使情况变得更好。实际上,除了 Hibernate 使用的事务和数据库连接之外,再消耗一个事务和数据库连接会使事情变得更糟。务必避免这种设置!
完整的应用可在 GitHub 17 上获得。
第 111 项:如何在 UTC 时区存储日期/时间(MySQL)
由于处理日期和时间是一个敏感的方面,所以建议只以 UTC(或 GMT)格式在数据库中存储日期、时间和时间戳,并且只在 UI 中处理本地时区转换。
考虑以下实体:
@Entity
public class Screenshot implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Timestamp createOn;
// getters and setters omitted for brevity
}
焦点在createOn时间戳上。从位于America/Los_Angeles时区(时区是任意选择的)的计算机将createOn设置为2018-03-30 10:15:55 UTC,并通过ScreenshotRepository保存,如下所示:
public void saveScreenshotInUTC() {
TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"));
Screenshot screenshot = new Screenshot();
screenshot.setName("Screenshot-1");
screenshot.setCreateOn(new Timestamp(
ZonedDateTime.of(2018, 3, 30, 10, 15, 55, 0,
ZoneId.of("UTC")
).toInstant().toEpochMilli()
));
System.out.println("Timestamp epoch milliseconds before insert: "
+ screenshot.getCreateOn().getTime());
screenshotRepository.save(screenshot);
}
在插入之前,时间戳纪元毫秒将显示值1522404955000。
稍后,在另一个事务中,应用按如下方式提取这些数据:
public void displayScreenshotInUTC() {
Screenshot fetchScreenshot = screenshotRepository
.findByName("Screenshot-1");
System.out.println("Timestamp epoch milliseconds after fetching: "
+ fetchScreenshot.getCreateOn().getTime());
}
获取后的时间戳 epoch 毫秒显示相同的值:1522404955000。
但是,在数据库中,时间戳保存在America/Los_Angeles时区,而不是 UTC。在图的左边 14-11 是我们想要的,而图的右边是我们拥有的。
图 14-11
以 UTC 和当地时区保存日期时间
Hibernate 5.2.3 附带了一个属性,需要设置该属性才能在 UTC 中持久保存日期、时间和时间戳。这个属性是spring.jpa.properties.hibernate.jdbc.time_zone。仅对于 MySQL,JDBC URL 也需要用useLegacyDatetimeCode=false修饰。因此,需要以下设置:
-
spring.jpa.properties.hibernate.jdbc.time_zone=UTC -
spring.datasource.url=jdbc:mysql://...?useLegacyDatetimeCode=false
在application.properties中添加这些设置后,时间戳将保存在 UTC 时区中。时间戳纪元毫秒在插入之前和获取之后显示相同的值(1522404955000)。
GitHub 18 上有源代码。
第 112 项:如何通过 ORDER BY RAND()对小结果集进行混排
考虑从book表(Book实体)获取的一个小结果集。数据快照如图 14-12 所示。
图 14-12
数据快照
目标是打乱这个结果集。因此,执行相同的SELECT应该产生相同的结果集,但是行的顺序不同。
一种快速的方法是在SELECT查询后追加ORDER BY子句来对 SQL 结果集进行排序。接下来,将一个数据库函数传递给ORDER BY,它能够随机化结果集。在 MySQL 中,这个函数是RAND()。大多数数据库都支持这样的功能(例如,在 PostgreSQL 中,它是random())。
在 JPQL 中,混排结果集的查询可以写成如下形式:
@Repository
@Transactional(readOnly = true)
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT b FROM Book b ORDER BY RAND()")
public List<Book> fetchOrderByRnd();
}
生成的 SQL 是:
SELECT
book0_.id AS id1_0_,
book0_.isbn AS isbn2_0_,
book0_.title AS title3_0_
FROM book book0_
ORDER BY RAND()
运行这个查询两次将会发现洗牌正在起作用:
运行 1:
{id=1, title=A History of Ancient Prague, isbn=001-JN},
{id=3, title=The Beatles Anthology, isbn=001-MJ},
{id=2, title=A People's History, isbn=002-JN}
{id=5, title=World History, isbn=003-JN},
{id=4, title=Carrie, isbn=001-OG}]
运行 2:
{id=4, title=Carrie, isbn=001-OG},
{id=5, title=World History, isbn=003-JN},
{id=3, title=The Beatles Anthology, isbn=001-MJ},
{id=1, title=A History of Ancient Prague, isbn=001-JN},
{id=2, title=A People's History, isbn=002-JN}]
DO NOT USE
这种技术适用于大型结果集,因为它非常昂贵。
对于大型结果集,只需依靠其他方法,如TABLESAMPLE或SAMPLE( n )。前者受 PostgreSQL 和 SQL Server 支持。Oracle 支持后者。
完整的应用可在 GitHub 19 上获得。
第 113 项:如何在 WHERE/HAVING 子句中使用子查询
JPQL 查询可以包含子查询。更准确地说,JPQL 允许您在WHERE和HAVING子句中使用子查询。因此,它不像原生 SQL 那样通用。但是,让我们在工作中看到它!
考虑两个不相关的实体,Author和Bestseller。即使在Author和Bestseller之间没有明确的关系,Bestseller实体也会定义一个列来存储作者 id。这个栏目命名为authorId。在代码中:
@Entity
public class Author implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int age;
private String name;
private String genre;
...
}
@Entity
public class Bestseller implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private int ranking;
private Long authorId;
...
}
图 14-13 表示数据快照。
图 14-13
数据快照
所以,最佳选集的作者是凯蒂·朗;最好的历史作者是乔安娜·尼玛尔;最佳恐怖小说作者是奥利维亚·戈伊。这些作者可以通过一个INNER JOIN获取,如下所示:
@Transactional(readOnly = true)
@Query(value = "SELECT a FROM Author a "
+ "INNER JOIN Bestseller b ON a.id = b.authorId")
public List<Author> fetchTheBest();
这将触发以下 SQL:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
INNER JOIN bestseller bestseller1_
ON (author0_.id = bestseller1_.author_id)
但是,另一种方法将依赖于WHERE子句中的SELECT子查询,如下所示:
@Transactional(readOnly = true)
@Query("SELECT a FROM Author a WHERE a.id IN "
+ "(SELECT b.authorId FROM Bestseller b)")
public List<Author> fetchTheBest();
这一次,触发的 SQL 语句是:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
WHERE author0_.id IN (
SELECT
bestseller1_.author_id
FROM bestseller bestseller1_)
但是,哪一个是最好的呢?从可读性或解决类型问题的逻辑方法的角度来说,从 A 中提取,从 B 中提取条件,然后子查询(将 B 放在子查询中,而不是连接中)是首选方法。但是,如果一切都归结于性能,请注意图 14-14 中所示的 MySQL 执行计划。
图 14-14
MySQL 连接与子查询执行计划
PostgreSQL 执行计划如图 14-15 所示。
图 14-15
PostgreSQL 连接与子查询执行计划
从图 14-15 中,很明显使用JOIN比使用子查询更快。
请记住,子查询和连接查询可能在语义上等价,也可能不等价(连接可能返回可以通过DISTINCT删除的重复项)。
即使执行计划是特定于数据库的,从历史上看,不同数据库之间的连接比子查询要快。但是,这不是一个规则(例如,数据量可能会显著影响结果)。当然,不要认为子查询只是不值得关注的连接的替代品。优化子查询也可以提高它们的性能,但这是一个 SQL 范围的话题。所以,标杆!标杆!标杆!
根据经验,只有在不能使用联接,或者可以证明它们比备选联接更快时,才使用子查询。
完整的应用可在 GitHub 20 上获得。
JPQL 也支持GROUP BY。通常,当我们使用GROUP BY时,我们需要返回一个地图,而不是List或Set。例如,我们需要返回一个Map< Group , Count >。如果你是这种情况,那么考虑这个应用 21 。
第 114 项:如何调用存储过程
调用存储过程的最佳方法取决于它的返回类型。让我们从调用一个返回值不是结果集的存储过程开始。
根据经验,不要在应用中实现数据密集型操作。这样的操作应该作为存储过程移到数据库级。虽然简单的操作可以通过调用特定的函数来解决,但是对于复杂的操作,请使用存储过程。数据库经过了高度优化,可以处理海量数据,而应用却没有。通常,存储过程也应该节省数据库的往返行程。
调用不返回结果的存储过程非常简单。当您需要调用以标量值或结果集的形式返回结果的存储过程时,困难就出现了。进一步,让我们看看如何调用几个 MySQL 存储过程。
调用返回值的存储过程(标量数据类型)
考虑下面的 MySQL 存储过程,它对同一给定流派的作者进行计数。此过程返回一个整数:
CREATE DEFINER=root@localhost PROCEDURE
COUNT_AUTHOR_BY_GENRE(IN p_genre CHAR(20), OUT p_count INT)
BEGIN
SELECT COUNT(*) INTO p_count FROM author WHERE genre = p_genre;
END;
您可以分两步调用这个存储过程。首先,Author实体通过 JPA、@NamedStoredProcedureQuery和@StoredProcedureParameter注释定义存储过程名称和参数,如下所示:
可以为存储过程定义四种类型的参数:IN、OUT、INOUT和REF_CURSOR。大多数 RDBMS 都支持前三种类型。引用游标在一些 RDBMS 中可用(如 Oracle、PostgreSQL 等)。)而其他 RDBMS(如 MySQL)没有引用游标。设置REF_CURSOR通常如下完成:
@StoredProcedureParameter(type = void.class,
mode = ParameterMode.REF_CURSOR)
@Entity
@NamedStoredProcedureQueries({
@NamedStoredProcedureQuery(
name = "CountByGenreProcedure",
procedureName = "COUNT_AUTHOR_BY_GENRE",
resultClasses = {Author.class},
parameters = {
@StoredProcedureParameter(
name = "p_genre",
type = String.class,
mode = ParameterMode.IN),
@StoredProcedureParameter(
name = "p_count",
type = Integer.class,
mode = ParameterMode.OUT)})
})
public class Author implements Serializable {
...
}
第二,在AuthorRepository中使用弹簧@Procedure标注。只需指定存储过程名:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Transactional
@Procedure(name = "CountByGenreProcedure")
Integer countByGenre(@Param("p_genre") String genre);
}
调用countByGenre()方法将触发以下语句:
{call COUNT_AUTHOR_BY_GENRE(?,?)}
完整的应用可在 GitHub 22 上获得。
调用返回结果集的存储过程
调用返回结果集的存储过程不会受益于@Procedure。可以在 JIRA 上跟踪支持,DATAJPA-1092 23 。
@Procedure不会像预期的那样工作(至少,在 Spring Boot 2.3.0 中不会,当这本书被写的时候)。
考虑以下两个 MySQL 存储过程:
-
一个存储过程,返回同一给定流派的作者(可以是一个或多个作者)的昵称和年龄列:
-
返回同一给定流派的所有作者的存储过程:
CREATE DEFINER=root@localhost
PROCEDURE FETCH_NICKNAME_AND_AGE_BY_GENRE(
IN p_genre CHAR(20))
BEGIN
SELECT nickname, age FROM author WHERE genre = p_genre;
END;
CREATE DEFINER=root@localhost
PROCEDURE FETCH_AUTHOR_BY_GENRE(
IN p_genre CHAR(20))
BEGIN
SELECT * FROM author WHERE genre = p_genre;
END;
现在,让我们看看如何通过JdbcTemplate、原生 SQL 和EntityManager调用这些存储过程。
通过 JdbcTemplate 调用存储过程
首先,您准备一个对JdbcTemplate有益的服务,如下所示:
@Service
public class BookstoreService {
private final JdbcTemplate jdbcTemplate;
public BookstoreService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@PostConstruct
void init() {
jdbcTemplate.setResultsMapCaseInsensitive(true);
}
// methods that call stored procedures
}
此外,您准备了以下 DTO 类:
public class AuthorDto implements Serializable {
private static final long serialVersionUID = 1L;
private String nickname;
private int age;
public AuthorDto() {
}
// getters and setters omitted for brevity
}
接下来,让我们看看如何调用这两个存储过程。
调用存储过程,返回给定流派的作者(可以是一个或多个作者)的昵称和年龄列
您可以通过BeanPropertyRowMapper获取 DTO 中的结果集。这样,您可以将结果集映射到 DTO,如下所示:
public List<AuthorDto> fetchNicknameAndAgeByGenre() {
SimpleJdbcCall simpleJdbcCall = new SimpleJdbcCall(jdbcTemplate)
.withProcedureName("FETCH_NICKNAME_AND_AGE_BY_GENRE")
.returningResultSet("AuthorResultSet",
BeanPropertyRowMapper.newInstance(AuthorDto.class));
Map<String, Object> authors = simpleJdbcCall.execute(
Map.of("p_genre", "Anthology"));
return (List<AuthorDto>) authors.get("AuthorResultSet");
}
显然,也可以返回单个的AuthorDto。例如,按 ID 而不是按流派提取,结果集将返回单行。
调用返回给定流派的所有作者的存储过程
您可以通过JdbcTemplate和SimpleJdbcCall调用这个存储过程来返回一个List<Author>,如下所示:
public List<Author> fetchAnthologyAuthors() {
SimpleJdbcCall simpleJdbcCall = new SimpleJdbcCall(jdbcTemplate)
.withProcedureName("FETCH_AUTHOR_BY_GENRE")
.returningResultSet("AuthorResultSet",
BeanPropertyRowMapper.newInstance(Author.class));
Map<String, Object> authors = simpleJdbcCall.execute(
Map.of("p_genre", "Anthology"));
return (List<Author>) authors.get("AuthorResultSet");
}
注意结果集是如何映射到一个List<Author>而不是一个List<AuthorDto>的。
完整的应用可在 GitHub 24 上获得。在这个应用中,还有一个调用存储过程的例子,这个存储过程使用 MySQL 特有的SELECT - INTO返回一行。此外,还有一个在 DTO 类中直接获取多个结果集的例子(调用返回多个结果集的存储过程)。如果你不想依赖BeanPropertyRowMapper,只想解剖自己设定的结果,那么这里 25 就是一个例子。
在 Spring Data @Procedure变得更加灵活之前,依靠JdbcTemplate是调用存储过程最通用的方式。
通过本机查询调用存储过程
通过本地查询调用存储过程也是一个不错的选择。
调用存储过程,返回给定流派的作者(可以是一个或多个作者)的昵称和年龄列
您可以调用此存储过程,如下所示:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query(value = "{CALL FETCH_NICKNAME_AND_AGE_BY_GENRE (:p_genre)}", nativeQuery = true)
List<Object[]> fetchNicknameAndAgeByGenreDto(
@Param("p_genre") String genre);
@Query(value = "{CALL FETCH_NICKNAME_AND_AGE_BY_GENRE (:p_genre)}",
nativeQuery = true)
List<AuthorNicknameAndAge> fetchNicknameAndAgeByGenreProj(
@Param("p_genre") String genre);
}
调用fetchNicknameAndAgeByGenreDto()获取结果集作为List<Object[]>,在服务方法中,它被手动映射到一个 d to 类,如下所示:
public class AuthorDto implements Serializable {
private static final long serialVersionUID = 1L;
private final String nickname;
private final int age;
public AuthorDto(String nickname, int age) {
this.nickname = nickname;
this.age = age;
}
// getters omitted for brevity
}
public void fetchAnthologyAuthorsNameAndAgeDto() {
List<Object[]> authorsArray
= authorRepository.fetchNicknameAndAgeByGenreDto("Anthology");
List<AuthorDto> authors = authorsArray.stream()
.map(result -> new AuthorDto(
(String) result[0],
(Integer) result[1]
)).collect(Collectors.toList());
System.out.println("Result: " + authors);
}
调用fetchNicknameAndAgeByGenreProj()获取List<AuthorNicknameAndAge>中的结果集。结果集被自动映射到AuthorNicknameAndAge,这是一个简单的弹簧投影:
public interface AuthorNicknameAndAge {
public String getNickname();
public int getAge();
}
public void fetchAnthologyAuthorsNameAndAgeProj() {
List<AuthorNicknameAndAge> authorsDto
= authorRepository.fetchNicknameAndAgeByGenreProj("Anthology");
System.out.println("Result: ");
authorsDto.forEach(a -> System.out.println(
a.getNickname() + ", " + a.getAge()));
}
调用返回给定流派的所有作者的存储过程
您可以按如下方式调用此存储过程:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query(value = "{CALL FETCH_AUTHOR_BY_GENRE (:p_genre)}",
nativeQuery = true)
List<Author> fetchByGenre(@Param("p_genre") String genre);
}
服务方法非常简单:
public void fetchAnthologyAuthors() {
List<Author> authors = authorRepository.fetchByGenre("Anthology");
System.out.println("Result: " + authors);
}
完整的应用可在 GitHub 26 上获得。
通过 EntityManager 调用存储过程
EntityManager为调用存储过程提供坚实的支持。让我们看看如何为这两个存储过程实现这一点。
调用存储过程,返回给定流派的作者(可以是一个或多个作者)的昵称和年龄列
这一次,该解决方案依赖于一个定制的存储库,它注入了EntityManager并直接与 JPA、StoredProcedureQuery一起工作。调用只返回相同给定流派的所有作者的昵称和年龄的存储过程,可以通过如下定义 DTO 开始:
public class AuthorDto implements Serializable {
private static final long serialVersionUID = 1L;
private final String nickname;
private final int age;
public AuthorDto(String nickname, int age) {
this.nickname = nickname;
this.age = age;
}
// getters omitted for brevity
}
此外,在Author实体中,使用@SqlResultSetMapping将结果集映射到AuthorDto:
@Entity
@SqlResultSetMapping(name = "AuthorDtoMapping",
classes = @ConstructorResult(targetClass = AuthorDto.class,
columns = {
@ColumnResult(name = "nickname"),
@ColumnResult(name = "age")}))
public class Author implements Serializable {
...
}
最后,按如下方式使用EntityManager和StoredProcedureQuery:
@Transactional
public List<AuthorDto> fetchByGenre(String genre) {
StoredProcedureQuery storedProcedure
= entityManager.createStoredProcedureQuery(
"FETCH_NICKNAME_AND_AGE_BY_GENRE", "AuthorDtoMapping");
storedProcedure.registerStoredProcedureParameter(GENRE_PARAM,
String.class, ParameterMode.IN);
storedProcedure.setParameter(GENRE_PARAM, genre);
List<AuthorDto> storedProcedureResults;
try {
storedProcedureResults = storedProcedure.getResultList();
} finally {
storedProcedure.unwrap(ProcedureOutputs.class).release();
}
return storedProcedureResults;
}
调用此方法将导致以下语句:
{call FETCH_NICKNAME_AND_AGE_BY_GENRE(?)}
结果集到AuthorDto的手动映射也是可以实现的。这一次,Author实体非常简单:
@Entity
public class Author implements Serializable {
...
}
映射在fetchByGenre()方法中完成:
@Transactional
public List<AuthorDto> fetchByGenre(String genre) {
StoredProcedureQuery storedProcedure
= entityManager.createStoredProcedureQuery(
"FETCH_NICKNAME_AND_AGE_BY_GENRE");
storedProcedure.registerStoredProcedureParameter(GENRE_PARAM,
String.class, ParameterMode.IN);
storedProcedure.setParameter(GENRE_PARAM, genre);
List<AuthorDto> storedProcedureResults;
try {
List<Object[]> storedProcedureObjects
= storedProcedure.getResultList();
storedProcedureResults = storedProcedureObjects.stream()
.map(result -> new AuthorDto(
(String) result[0],
(Integer) result[1]
)).collect(Collectors.toList());
} finally {
storedProcedure.unwrap(ProcedureOutputs.class).release();
}
return storedProcedureResults;
}
调用此方法将导致以下语句:
{call FETCH_NICKNAME_AND_AGE_BY_GENRE(?)}
调用返回给定流派的所有作者的存储过程
可以分两步调用FETCH_AUTHOR_BY_GENRE。首先,Author实体通过@NamedStoredProcedureQuery和@StoredProcedureParameter定义存储过程名称和参数,如下所示:
@Entity
@NamedStoredProcedureQueries({
@NamedStoredProcedureQuery(
name = "FetchByGenreProcedure",
procedureName = "FETCH_AUTHOR_BY_GENRE",
resultClasses = {Author.class},
parameters = {
@StoredProcedureParameter(
name = "p_genre",
type = String.class,
mode = ParameterMode.IN)})
})
public class Author implements Serializable {
...
}
第二,自定义存储库依赖于StoredProcedureQuery,如下所示:
private static final String GENRE_PARAM = "p_genre";
@PersistenceContext
private EntityManager entityManager;
@Transactional
public List<Author> fetchByGenre(String genre) {
StoredProcedureQuery storedProcedure
= entityManager.createNamedStoredProcedureQuery(
"FetchByGenreProcedure");
storedProcedure.setParameter(GENRE_PARAM, genre);
List<Author> storedProcedureResults;
try {
storedProcedureResults = storedProcedure.getResultList();
} finally {
storedProcedure.unwrap(ProcedureOutputs.class).release();
}
return storedProcedureResults;
}
调用此方法将导致以下语句:
{call FETCH_AUTHOR_BY_GENRE(?)}
另一种方法是通过createStoredProcedureQuery()而不是createNamedStoredProcedureQuery()直接在自定义存储库中定义存储过程。这个时候,Author的实体很简单:
@Entity
public class Author implements Serializable {
...
}
fetchByGenre()的写法如下:
@Transactional
public List<Author> fetchByGenre(String genre) {
StoredProcedureQuery storedProcedure
= entityManager.createStoredProcedureQuery(
"FETCH_AUTHOR_BY_GENRE", Author.class);
storedProcedure.registerStoredProcedureParameter(GENRE_PARAM,
String.class, ParameterMode.IN);
storedProcedure.setParameter(GENRE_PARAM, genre);
List<Author> storedProcedureResults;
try {
storedProcedureResults = storedProcedure.getResultList();
} finally {
storedProcedure.unwrap(ProcedureOutputs.class).release();
}
return storedProcedureResults;
}
调用此方法将导致以下语句:
{call FETCH_AUTHOR_BY_GENRE(?)}
完整的应用可在 GitHub 27 上获得。
请注意,这些示例更喜欢手动关闭用于在后台调用finally子句中的存储过程的CallableStatement,如下所示:
storedProcedure.unwrap(ProcedureOutputs.class).release();
这是为了避免在不需要时保持CallableStatement打开的性能损失。即使在获取结果集之后,CallableStatement也是打开的。呼叫release()会尽快关闭CallableStatement。
您可以轻松测试CallableStatement是否打开,如下所示:
ProcedureOutputs procedureOutputs = storedProcedure .unwrap(ProcedureOutputs.class);
Field csField = procedureOutputs.getClass()
.getDeclaredField("callableStatement");
csField.setAccessible(true);
CallableStatement cs = (CallableStatement) csField.get(procedureOutputs);
System.out.println("Is closed? " + cs.isClosed()); // false
这个问题将在 Hibernate 6(HHH-1321528中修复。)
第 115 项:如何取消代理
您可以通过EntityManager#getReference()方法获得特定于 Hibernate 的代理。在 Spring Boot,这个方法被包装在getOne()方法中,如下面的源代码所示:
@Override
public T getOne(ID id) {
Assert.notNull(id, ID_MUST_NOT_BE_NULL);
return em.getReference(getDomainClass(), id);
}
什么是代理对象?
迟早,每个接触到惰性加载概念的开发人员也会发现特定于 Hibernate 的代理。有些开发者会问:“懒加载是怎么工作的?”另一个开发人员会回答,“它使用特定于 Hibernate 的代理”。因此,代理对象促进了实体的延迟加载。
不要混淆 Hibernate 惰性加载和 Spring Spring 数据 JPA 延迟引导模式 29 。后者指的是 Spring JPA 基础设施和存储库引导。
但是,什么是代理对象呢?首先,代理对象是由 Hibernate 在运行时生成的,它扩展了原始实体(您编写的实体)。此外,Hibernate 用适当的特定于 Hibernate 的持久包装器集合(例如,PersistentList)替换了原始的实体集合(例如,List)。对于Set它有PersistentSet,对于Map它有PersistentMap。它们可以在org.hibernate.collection.*包装中找到。
生成的代理遵循众所周知的代理设计模式。一般来说,这种设计模式的目的是公开另一个对象的代理,以提供对该对象的控制。主要来说,代理对象是一个额外的间接层,用于支持对原始对象的自定义访问,并包装原始对象的复杂性。
特定于 Hibernate 的代理有两个主要任务:
-
将访问基本属性的调用委托给原始实体。
-
依靠持久包装器(
PersistentList、PersistentSet、PersistentMap)来拦截访问未初始化集合的调用(List、Set和Map)。当这样的调用被截获时,它由相关的侦听器处理。这负责发出该集合的正确初始化查询。
当您得到一个LazyInitializationException时,这意味着特定于 Hibernate 的代理所处的上下文丢失了。换句话说,没有持久上下文或Session可用。
实体对象和代理对象不相等
您可以通过EntityManager#find()方法获取一个实体对象。在 Spring Boot,这个调用被包装在findById()方法中。实体对象填充了数据,而代理对象没有。考虑下面的Author实体:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String genre;
private int age;
// getters and setters omitted for brevity
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
return id != null && id.equals(((Author) obj).id);
}
@Override
public int hashCode() {
return 2021;
}
}
以下代码揭示了实体对象不等于代理对象:
@Service
public class BookstoreService {
private final AuthorRepository authorRepository;
private Author author;
...
public void authorNotEqualsProxy() {
// behind findById() we have EntityManager#find()
author = authorRepository.findById(1L).orElseThrow();
// behind getOne() we have EntityManager#getReference()
Author proxy = authorRepository.getOne(1L);
System.out.println("Author class: " + author.getClass().getName());
System.out.println("Proxy class: " + proxy.getClass().getName());
System.out.println("'author' equals 'proxy'? "
+ author.equals(proxy));
}
}
调用authorNotEqualsProxy()会产生以下输出:
Author class: com.bookstore.entity.Author
Proxy class: com.bookstore.entity.Author$HibernateProxy$sfwzCCbF
'author' equals 'proxy'? false
解除代理
从 Hibernate 5.2.10 开始,开发者可以通过专用的方法Hibernate.unproxy()解除代理对象的代理。例如,您可以取消proxy对象的代理,如下所示:
Object unproxy = Hibernate.unproxy(proxy);
通过取消代理的优先级,这意味着代理成为一个实体对象。因此,前面的代码将触发下面的 SQL SELECT:
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = ?
现在,unproxy 可以施放到Author:
Author authorViaUnproxy = (Author) unproxy;
很明显,叫getName()、getGenre()等。将返回预期的数据。
在 Hibernate 5.2.10 之前,代理对象可以通过LazyInitializer解包,如下图:
HibernateProxy hibernateProxy = (HibernateProxy) proxy;
LazyInitializer initializer
= hibernateProxy.getHibernateLazyInitializer();
Object unproxy = initializer.getImplementation();
要检查代理对象的某个属性是否被初始化,只需调用Hibernate.isPropertyInitialized()方法。例如,检查proxy对象的name属性是否在取消其优先级之前被初始化:
// false
boolean nameIsInitialized
= Hibernate.isPropertyInitialized(proxy, "name");
在取消 proxy 对象后调用相同的代码将返回true。
实体对象和非实体对象是相等的
您可以通过添加下面的方法BookstoreService来测试一个实体对象和一个未声明的对象是否相等(这个author对象是之前通过authorNotEqualsProxy()获取的对象):
@Transactional(readOnly = true)
public void authorEqualsUnproxy() {
// behind getOne() we have EntityManager#getReference()
Author proxy = authorRepository.getOne(1L);
Object unproxy = Hibernate.unproxy(proxy);
System.out.println("Author class: " + author.getClass().getName());
System.out.println("Unproxy class: " + unproxy.getClass().getName());
System.out.println("'author' equals 'unproxy'? "
+ author.equals(unproxy));
}
调用authorEqualsUnproxy()输出如下:
Author class: com.bookstore.entity.Author
Unproxy class: com.bookstore.entity.Author
'author' equals 'unproxy'? true
完整的应用可在 GitHub 30 上获得。
第 116 项:如何映射数据库视图
让我们考虑双向惰性@OneToMany关系中涉及的Author和Book实体。此外,让我们考虑如下定义的 MySQL 数据库视图:
CREATE OR REPLACE VIEW GENRE_AND_TITLE_VIEW
AS
SELECT
a.genre,
b.title
FROM
author a
INNER JOIN
book b ON b.author_id = a.id;
这个视图通过一个INNER JOIN获取作者的类型和书名。现在,让我们在应用中获取这个数据库视图并显示其内容。
通常,数据库视图完全映射为数据库表。换句话说,您需要定义一个实体,将视图映射到相应的名称和列。默认情况下,表映射不是只读的,这意味着可以修改内容。根据数据库的不同,可以修改或不修改视图(第 117 项)。通过用@Immutable注释实体视图,可以很容易地防止 Hibernate 修改视图,如下所示(例如,MySQL 对数据库视图的可修改要求在这里 31 )可以找到:
@Entity
@Immutable
@Table(name="genre_and_title_view")
public class GenreAndTitleView implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private String title;
private String genre;
public String getTitle() {
return title;
}
public String getGenre() {
return genre;
}
@Override
public String toString() {
return "AuthorBookView{" + "title=" + title
+ ", genre=" + genre + '}';
}
}
此外,定义一个经典的 Spring 存储库:
@Repository
public interface GenreAndTitleViewRepository
extends JpaRepository<GenreAndTitleView, Long> {
List<GenreAndTitleView> findByGenre(String genre);
}
让我们触发一个findAll()来获取和显示视图数据:
private final GenreAndTitleViewRepository genreAndTitleViewRepository;
...
public void displayView() {
List<GenreAndTitleView> view = genreAndTitleViewRepository.findAll();
System.out.println("View: " + view);
}
调用displayView()会触发下面的SELECT语句:
SELECT
genreandti0_.title AS title1_2_,
genreandti0_.genre AS genre2_2_
FROM genre_and_title_view genreandti0_
或者,您可以只获取特定类型的记录:
public void displayViewByGenre() {
List<GenreAndTitleView> view
= genreAndTitleViewRepository.findByGenre("History");
System.out.println("View: " + view);
}
这一次,调用displayViewByGenre()会触发下面的SELECT语句:
SELECT
genreandti0_.title AS title1_2_,
genreandti0_.genre AS genre2_2_
FROM genre_and_title_view genreandti0_
WHERE genreandti0_.genre = ?
完整的应用可在 GitHub 32 上获得。
第 117 项:如何更新数据库视图
让我们考虑图 14-16 所示的author表(对应于Author实体)和数据快照。
图 14-16
作者表和数据快照
看起来选集的作者非常受欢迎和成功,因此他们在数据库视图中被提取如下:
CREATE OR REPLACE VIEW AUTHOR_ANTHOLOGY_VIEW
AS
SELECT
a.id,
a.name,
a.age,
a.genre
FROM
author a
WHERE a.genre = "Anthology";
此视图映射到以下实体视图:
@Entity
@Table(name = "author_anthology_view")
public class AuthorAnthologyView implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int age;
private String genre;
...
}
这不是一个只读的数据库视图,所以AuthorAnthologyView实体没有用@Immutable注释(关于不可变实体的细节可以在第 16 项中找到)。
触发更新语句
这个数据库视图很少仅仅为了更新作者的年龄而修改。当需要这样的更新时,应用应该针对数据库视图触发一个UPDATE语句,数据库应该自动更新底层表的内容。下面的代码示例更新作者的年龄。首先,存储库:
@Repository
public interface AuthorAnthologyViewRepository extends
JpaRepository<AuthorAnthologyView, Long> {
public AuthorAnthologyView findByName(String name);
}
发球方法(夸蒂斯扬的年龄由 51 更新为52;请注意,我们是非常离散的,我们不要求作者的出生信息):
private final AuthorAnthologyViewRepository authorAnthologyViewRepository;
...
@Transactional
public void updateAuthorAgeViaView() {
AuthorAnthologyView author
= authorAnthologyViewRepository.findByName("Quartis Young");
author.setAge(author.getAge() + 1);
}
调用updateAuthorAgeViaView()会触发以下 SQL 语句:
SELECT
authoranth0_.id AS id1_1_,
authoranth0_.age AS age2_1_,
authoranth0_.genre AS genre3_1_,
authoranth0_.name AS name4_1_
FROM author_anthology_view authoranth0_
WHERE authoranth0_.name = ?
UPDATE author_anthology_view
SET age = ?,
genre = ?,
name = ?
WHERE id = ?
UPDATE语句将更新数据库视图和底层表。
要使视图可更新,视图中的行和基础表中的行之间必须有一对一的关系。虽然这是主要的需求,MySQL 还有其他的需求列在这里 33 。
触发器插入语句
插入新作者也是非常罕见的情况。但是,当需要时,您可以这样做,如下面的服务方法:
public void insertAuthorViaView() {
AuthorAnthologyView newAuthor = new AuthorAnthologyView();
newAuthor.setName("Toij Kalu");
newAuthor.setGenre("Anthology");
newAuthor.setAge(42);
authorAnthologyViewRepository.save(newAuthor);
}
同样,视图中的插入应该由数据库自动传播到底层的author表。调用insertAuthorViaView()触发下面的INSERT语句:
INSERT INTO author_anthology_view (age, genre, name)
VALUES (?, ?, ?)
如果可更新视图还满足以下对视图列的附加要求,则该视图是可插入的:不能有重复的视图列名,视图必须包含基表中没有默认值的所有列,并且视图列必须是简单的列引用(不能是表达式)。请注意,我们的INSERT可以工作,因为author表的模式为数据库视图中不存在的列指定了默认值(参见这里显示的粗体行):
CREATE TABLE author (
id bigint(20) NOT NULL AUTO_INCREMENT,
age int(11) NOT NULL,
genre varchar(255) NOT NULL,
name varchar(255) NOT NULL,
sellrank int(11) NOT NULL DEFAULT -1,
royalties int(11) NOT NULL DEFAULT -1,
rating int(11) NOT NULL DEFAULT -1,
PRIMARY KEY (id)
);
虽然这是主要的需求,MySQL 还有其他的需求列在这里 33 。
此时,你可以插入一个不同于选集的作者。为了确保INSERT s/ UPDATE s 符合视图的定义,考虑 Item 118 ,这就把WITH CHECK OPTION带入了讨论。
触发删除语句
删除作者也是一种相当罕见的情况。但是,在需要时,您可以这样做,如下例所示:
@Transactional
public void deleteAuthorViaView() {
AuthorAnthologyView author
= authorAnthologyViewRepository.findByName("Mark Janel");
authorAnthologyViewRepository.delete(author);
}
调用deleteAuthorViaView()应该从数据库视图和底层表中删除指定的作者:
SELECT
authoranth0_.id AS id1_1_,
authoranth0_.age AS age2_1_,
authoranth0_.genre AS genre3_1_,
authoranth0_.name AS name4_1_
FROM author_anthology_view authoranth0_
WHERE authoranth0_.name = ?
DELETE FROM author_anthology_view
WHERE id = ?
要从DELETE语句中删除的表必须是合并视图。不允许联接视图。虽然这是主要要求,但 MySQL 还有其他要求,这里列出了 34 。
应用UPDATE、INSERT、DELETE s 后,应该得到如图 14-17 所示的数据快照(左边是数据库视图;右边是底层表)。
图 14-17
数据库视图和基础表
完整的应用可在 GitHub 35 上获得。
第 118 项:为什么以及如何使用 WITH CHECK 选项
简而言之,每当您通过数据库视图插入或更新一行基表时,只要数据库视图定义已经显式设置了WITH CHECK OPTION,MySQL 就会确保该操作符合视图的定义。
让我们重申一下来自项目 117 (映射到AuthorAnthologyView)的数据库视图:
CREATE OR REPLACE VIEW AUTHOR_ANTHOLOGY_VIEW
AS
SELECT
a.id,
a.name,
a.age,
a.genre
FROM
author a
WHERE a.genre = "Anthology";
正如您从第 117 项中所知,这个数据库视图是可更新的。因此,应用可以触发更新,更新视图中不可见的数据。例如,通过这个视图考虑下面的INSERT:
public void insertAnthologyAuthorInView() {
AuthorAnthologyView author = new AuthorAnthologyView();
author.setName("Mark Powell");
author.setGenre("History");
author.setAge(45);
authorAnthologyViewRepository.save(author);
}
我们的视图只包含流派选集的作者,该方法通过视图插入流派历史的作者。会发生什么?嗯,新创建的作者在视图中看不到,因为他们的风格是历史。但是,它被插入到底层的author表中!
但是,这可能不是你想要的!最有可能的是,马克·鲍威尔的体裁是文选(注意你调用了一个名为insertAnthologyAuthorInView()的方法,但是我们错选了历史。结果确实令人困惑,因为这个作者没有在视图中公开,而是被添加到底层表中。
WITH CHECK OPTION来救援了。WITH CHECK OPTION防止视图更新或插入不可见的行。按如下方式修改数据库视图定义:
CREATE OR REPLACE VIEW AUTHOR_ANTHOLOGY_VIEW
AS
SELECT
a.id,
a.name,
a.age,
a.genre
FROM
author a
WHERE a.genre = "Anthology" WITH CHECK OPTION;
再次调用insertAnthologyAuthorInView()会导致SQLException异常如下:CHECK OPTION failed 'bookstoredb.author_anthology_view'。所以,这次INSERT操作被阻止。
但是在用选集替换历史之后,INSERT是成功的,并且新作者在视图和底层表格中是可见的:
public void insertAnthologyAuthorInView() {
AuthorAnthologyView author = new AuthorAnthologyView();
author.setName("Mark Powell");
author.setGenre("Anthology");
author.setAge(45);
authorAnthologyViewRepository.save(author);
}
完整的应用可在 GitHub 36 上获得。
第 119 项:如何有效地为行分配数据库临时排名
不同种类的任务(例如,检查第 102 项和第 120 项)要求您为行分配一个数据库临时值序列。实现这一点的有效方法是使用ROW_NUMBER()窗口功能。该窗口功能与RANK()、DENSE_RANK()和NTILE()窗口功能属于同一类别,被称为排序功能。
ROW_NUMBER()窗口函数产生一系列值,从值 1 开始,增量为 1。这是在查询执行时动态计算的临时值序列(非持久)。该窗口函数的语法如下:
ROW_NUMBER() OVER (<partition_definition> <order_definition>)
OVER子句定义了ROW_NUMBER()操作的行窗口。PARTITION BY子句(<partition_definition>)是可选的,用于将行分成更小的集合(如果没有它,整个结果集被视为一个分区)。其语法如下:
PARTITION BY <expression>,[{,<expression>}...]
ORDER BY子句(<order_definition>)的目的是设置行的顺序。值的序列按照这个顺序应用(换句话说,窗口函数将按照这个顺序处理行)。它的语法是:
ORDER BY <expression> [ASC|DESC],[{,<expression>}...]
这个窗口函数几乎在所有数据库中都可用,从 8.x 版本开始,它在 MySQL 中也可用。
MySQL 8+,Oracle 9.2+,PostgreSQL 8.4+,SQL Server 2005+,Firebird 3.0+,DB2,Sybase,Teradata,Vertica 等等都支持ROW_NUMBER()窗口函数。
图 14-18 显示了author表(左侧)和数据快照(右侧)。
图 14-18
作者表和数据快照
要从这个图中获取数据快照作为结果集,首先要定义一个弹簧投影(DTO),如下所示(添加了getRowNum()方法,因为我们想要获取结果集中的rowNum列):
public interface AuthorDto {
public String getName();
public int getAge();
public int getRowNum();
}
此外,编写如下本机查询:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query(value = "SELECT ROW_NUMBER() OVER(ORDER BY age) "
+ "rowNum, name, age FROM author",
nativeQuery = true)
List<AuthorDto> fetchWithSeqNumber();
}
您可以调用fetchWithSeqNumber()并通过服务方法显示结果集,如下所示:
public void fetchAuthorsWithSeqNumber() {
List<AuthorDto> authors = authorRepository.fetchWithSeqNumber();
authors.forEach(a -> System.out.println(a.getRowNum()
+ ", " + a.getName() + ", " + a.getAge()));
}
在前面的查询中,我们在一个OVER子句中使用了ORDER BY。我们可以通过在查询中使用ORDER BY获得相同的结果:
@Query(value = "SELECT ROW_NUMBER() OVER() "
+ "rowNum, name, age FROM author ORDER BY age",
nativeQuery = true)
List<AuthorDto> fetchWithSeqNumber();
然而,查询中的ORDER BY与OVER子句中的ORDER BY不同。
在查询和 OVER 子句中使用 ORDER BY 子句
在前面的查询中,我们在OVER子句或查询中使用了ORDER BY子句。现在,让我们在两个地方都使用它——我们希望根据OVER子句中的ORDER BY分配值的临时序列,并返回根据查询中的ORDER BY排序的结果集。以下查询和图 14-19 突出显示了来自查询的ORDER BY与来自OVER的ORDER BY不同。值序列从OVER分配到ORDER BY上,但是结果集从查询的ORDER BY上排序:
图 14-19
作者表和数据快照
@Query(value = "SELECT ROW_NUMBER() OVER(ORDER BY age) "
+ "rowNum, name, age FROM author ORDER BY name",
nativeQuery = true)
List<AuthorDto> fetchWithSeqNumber();
在 OVER 子句中使用多列
OVER子句支持多列。例如,在以下查询中,根据ORDER BY age, name DESC分配值的临时序列:
@Query(value = "SELECT ROW_NUMBER() OVER(ORDER BY age, name DESC) "
+ "rowNum, name, age FROM author",
nativeQuery = true)
List<AuthorDto> fetchWithSeqNumber();
输出如图 14-20 所示。
图 14-20
作者表和数据快照
通常,您不需要获取结果集中由ROW_NUMBER()产生的临时值序列。您将在查询中内部使用它。在第 120 项中,你可以看到一个使用ROW_NUMBER()和PARTITION BY以及 CTEs(公共表表达式)寻找每组前 N 行的例子。
完整的应用可在 GitHub 37 上获得。
这不是一本以 SQL 为中心的书,所以我们不详细讨论其他排名函数,如RANK()、DENSE_RANK()和NTILE()。然而,学习这些窗口函数是非常可取的,因为它们非常有用。MySQL 从 8.x 版开始支持所有这些。
简而言之:
-
RANK()对于指定结果集中每一行的排名非常有用。GitHub 38 上有一个示例应用。 -
与
RANK()窗口函数相比,DENSE_RANK()避免了分区内的间隙。GitHub 39 上有一个示例应用。 -
NTILE(N)用于在指定的N组数中分配行数。GitHub 40 上有一个示例应用。
项目 120:如何有效地找到每个组的前 N 行
考虑图 14-21 。左侧是作者表的数据快照,右侧是所需的结果集。图 14-21 所示的结果集包含了每个作者的前两行,按销售额降序排列。
图 14-21
数据快照
一般来说,通过 CTE(常用表表达式)和ROW_NUMBER()窗口函数( Item 119 )可以高效地获取每组的前 N 行。从图 14-21 中获取结果集所需的原生查询如下所示:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query(value = "WITH sales AS (SELECT *, ROW_NUMBER() "
+ "OVER (PARTITION BY name ORDER BY sold DESC) AS row_num"
+ " FROM author) SELECT * FROM sales WHERE row_num <= 2",
nativeQuery = true)
List<Author> fetchTop2BySales();
}
当然,您可以轻松地参数化行数,如下所示:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query(value = "WITH sales AS (SELECT *, ROW_NUMBER() "
+ "OVER (PARTITION BY name ORDER BY sold DESC) AS row_num"
+ " FROM author) SELECT * FROM sales WHERE row_num <= ?1",
nativeQuery = true)
List<Author> fetchTopNBySales(int n);
}
完整的应用可在 GitHub 41 上获得。
第 121 项:如何通过规范 API 实现高级搜索
根据多个过滤器在页面中获取数据是一项常见的任务。例如,电子商务网站在页面中列出产品,并提供一套过滤器来帮助客户找到特定的产品或产品类别。实现这种动态查询的典型方法依赖于 JPA Criteria API。另一方面,Spring Boot 应用可以依赖于Specification API。这一项涵盖了用一般方法完成这项任务的主要步骤。
考虑每个过滤器代表一个条件(例如,age > 40、price > 25、name = 'Joana Nimar'等)。).有简单过滤器(单个条件)和复合过滤器(通过逻辑运算符如AND和OR连接多个条件)。让我们考虑一个条件(例如,age > 40)由三部分描述:左侧(age)、右侧(40)和操作(>)。此外,一个条件可能包含一个逻辑运算符,可以是AND或OR。您可以将该信息映射到名为Condition的类中,如下所示(END值不是逻辑运算符;它用于标记复合过滤器的结束):
public final class Condition {
public enum LogicalOperatorType {
AND, OR, END
}
public enum OperationType {
EQUAL, NOT_EQUAL, GREATER_THAN, LESS_THAN, LIKE
}
private final String leftHand;
private final String rightHand;
private final OperationType operation;
private final LogicalOperatorType operator;
public Condition(String leftHand, String rightHand,
OperationType operation, LogicalOperatorType operator) {
this.leftHand = leftHand;
this.rightHand = rightHand;
this.operation = operation;
this.operator = operator;
}
public String getLeftHand() {
return leftHand;
}
public String getRightHand() {
return rightHand;
}
public OperationType getOperation() {
return operation;
}
public LogicalOperatorType getOperator() {
return operator;
}
}
进一步,对于每个支持的Condition(当然,前面的enum还可以增加更多的操作),我们来定义对应的Predicate。每个Condition被传递给Specification实现,并在toPredicate()方法中的Predicate中被转换,如下所示:
public class SpecificationChunk<T> implements Specification<T> {
private final Condition condition;
public SpecificationChunk(Condition condition) {
this.condition = condition;
}
@Override
public Predicate toPredicate(Root<T> root,
CriteriaQuery<?> cquery, CriteriaBuilder cbuilder) {
switch (condition.getOperation()) {
case EQUAL:
return cbuilder.equal(root.get(condition.getLeftHand()),
condition.getRightHand());
case NOT_EQUAL:
return cbuilder.notEqual(root.get(condition.getLeftHand()),
condition.getRightHand());
case GREATER_THAN:
return cbuilder.greaterThan(root.get(condition.getLeftHand()),
condition.getRightHand());
case LESS_THAN:
return cbuilder.lessThan(root.get(condition.getLeftHand()),
condition.getRightHand());
case LIKE:
return cbuilder.like(root.get(condition.getLeftHand()),
condition.getRightHand());
default:
return null;
}
}
}
最后,前面的SpecificationChunk可以用来实现一个Specification构建器。以下实现的高潮是链接符合给定逻辑操作符的SpecificationChunk:
public class SpecificationBuilder<T> {
private final List<Condition> conditions;
public SpecificationBuilder() {
conditions = new ArrayList<>();
}
public SpecificationBuilder<T> with(String leftHand, String rightHand,
OperationType operation, LogicalOperatorType operator) {
conditions.add(new Condition(leftHand, rightHand,
operation, operator));
return this;
}
public Specification<T> build() {
if (conditions.isEmpty()) {
return null;
}
List<Specification<T>> specifications = new ArrayList<>();
for (Condition condition : conditions) {
specifications.add(new SpecificationChunk(condition));
}
Specification<T> finalSpecification = specifications.get(0);
for (int i = 1; i < conditions.size(); i++) {
if (!conditions.get(i - 1).getOperator()
.equals(LogicalOperatorType.END)) {
finalSpecification = conditions.get(i - 1).getOperator()
.equals(LogicalOperatorType.OR)
? Specification.where(finalSpecification)
.or(specifications.get(i))
: Specification.where(finalSpecification)
.and(specifications.get(i));
}
}
return finalSpecification;
}
}
测试时间
为了测试这个实现,让我们看看Author和Book实体(它们之间没有关联)以及下面的两个存储库:
@Repository
public interface AuthorRepository extends
JpaRepository<Author, Long>,
JpaSpecificationExecutor<Author> {
}
@Repository
public interface BookRepository extends
JpaRepository<Book, Long>,
JpaSpecificationExecutor<Book> {
}
获取所有 40 岁以上的作家的体裁选集
下面的服务方法获取所有年龄大于 40 岁的作者和体裁为 ?? 选集的作者:
public void fetchAuthors() {
SpecificationBuilder<Author> specBuilder = new SpecificationBuilder();
Specification<Author> specAuthor = specBuilder
.with("age", "40", GREATER_THAN, AND)
.with("genre", "Anthology", EQUAL, END)
.build();
List<Author> authors = authorRepository.findAll(specAuthor);
System.out.println(authors);
}
在这种情况下,触发的 SQL SELECT如下:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_,
author0_.rating AS rating5_0_
FROM author author0_
WHERE author0_.age > 40
AND author0_.genre = ?
拿一页价格低于 60 英镑的书
下面的服务方法获取价格低于 60 的图书Page:
public void fetchBooksPage(int page, int size) {
SpecificationBuilder<Book> specBuilder = new SpecificationBuilder();
Specification<Book> specBook = specBuilder
.with("price", "60", LESS_THAN, END)
.build();
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "title"));
Page<Book> books = bookRepository.findAll(specBook, pageable);
System.out.println(books);
books.forEach(System.out::println);
}
在这种情况下,触发的 SQL SELECT语句如下:
SELECT
book0_.id AS id1_1_,
book0_.isbn AS isbn2_1_,
book0_.name AS name3_1_,
book0_.price AS price4_1_,
book0_.title AS title5_1_
FROM book book0_
WHERE book0_.price < 60
ORDER BY book0_.title ASC LIMIT ?
SELECT
COUNT(book0_.id) AS col_0_0_
FROM book book0_
WHERE book0_.price < 60
因此,动态创建过滤器相当容易。
下一步是什么
进一步实施可能需要以下内容:
-
添加更多操作和运算符
-
添加对复杂过滤器的支持(例如,使用括号,
(x AND y) OR (x AND z)) -
添加联接
-
添加 DTO 支持
-
添加能够从 URL 查询参数解析条件的解析器
完整的应用可在 GitHub 42 上获得。
第 122 项:如何通过 IN 子句参数填充增强 SQL 语句缓存
考虑Author实体和下面的查询:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a WHERE a.id IN ?1")
public List<Author> fetchIn(List<Long> ids);
}
该查询选择 id 与给定 id 列表匹配的作者列表。以下服务方法提供了不同大小的 id 列表(从 2 到10id):
@Transactional(readOnly=true)
public void fetchAuthorsIn() {
List twoIds = List.of(1L, 2L);
List threeIds = List.of(1L, 2L, 3L);
List fourIds = List.of(1L, 2L, 3L, 4L);
List fiveIds = List.of(1L, 2L, 3L, 4L, 5L);
List sixIds = List.of(1L, 2L, 3L, 4L, 5L, 6L);
List sevenIds = List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L);
List eightIds = List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L);
List nineIds = List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L);
List tenIds = List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L);
authorRepository.fetchIn(twoIds);
authorRepository.fetchIn(threeIds);
authorRepository.fetchIn(fourIds);
authorRepository.fetchIn(fiveIds);
authorRepository.fetchIn(sixIds);
authorRepository.fetchIn(sevenIds);
authorRepository.fetchIn(eightIds);
authorRepository.fetchIn(nineIds);
authorRepository.fetchIn(tenIds);
}
调用fetchAuthorsIn()会产生 10 条SELECT语句,除了绑定参数的数量之外,基本相同。
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
WHERE author0_.id IN (?, ?)
...
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
WHERE author0_.id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
10 条SELECT语句可以产生 10 个执行计划。如果数据库支持执行计划缓存,将缓存 10 个执行计划(例如 Oracle、SQL Server)。这是因为每个IN子句有不同数量的绑定参数。
只有当 SQL 语句字符串与缓存的计划匹配时,才会重用缓存中的执行计划。换句话说,如果您为不同数量的IN子句绑定参数生成完全相同的SELECT,那么您将缓存更少的执行计划。同样,请注意,这仅适用于支持执行计划缓存的数据库,如 Oracle 和 SQL Server。
此外,让我们启用特定于 Hibernate 的hibernate.query.in_clause_parameter_padding属性:
spring.jpa.properties.hibernate.query.in_clause_parameter_padding=true
这一次,生成的SELECT语句将是这些:
SELECT
...
FROM author author0_
WHERE author0_.id IN (1, 2)
-- for 3 and 4 parameters, it uses 4 bind parameters (22)
SELECT
...
FROM author author0_
WHERE author0_.id IN (1, 2, 3, 3)
SELECT
...
FROM author author0_
WHERE author0_.id IN (1, 2, 3, 4)
-- for 5, 6, 7 and 8 parameters, it uses 8 bind parameters (23)
SELECT
...
FROM author author0_
WHERE author0_.id IN (1, 2, 3, 4, 5, 5, 5, 5)
SELECT
...
FROM author author0_
WHERE author0_.id IN (1, 2, 3, 4, 5, 6, 6, 6)
SELECT
...
FROM author author0_
WHERE author0_.id IN (1, 2, 3, 4, 5, 6, 7, 7)
SELECT
...
FROM author author0_
WHERE author0_.id IN (1, 2, 3, 4, 5, 6, 7, 8)
-- for 9, 10, 11, 12, 13, 14, 15, 16 parameters, it uses 16 parameters (24)
SELECT
...
FROM author author0_
WHERE author0_.id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 9, 9, 9, 9, 9)
SELECT
...
FROM author author0_
WHERE author0_.id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10, 10, 10, 10)
因此,为了生成相同的SELECT字符串,Hibernate 使用如下算法填充参数:
-
对于 3 和 4 参数,它使用四个绑定参数(2 2 )
-
对于参数 5、6、7 和 8,它使用八个绑定参数(2 3 )
-
对于 9、10、11、12、13、14、15 和 16 个参数,它使用 16 个参数(2 4 )
-
...
在这种情况下,支持执行计划缓存的数据库将只缓存和重用四个计划,而不是 10 个。那很酷!完整的应用(针对 SQL Server)可在 GitHub 43 上获得。
第 123 项:如何创建规范查询提取连接
考虑双向惰性@OneToMany关联中涉及的Author和Book实体。该项目的目标是定义一个Specification来模拟 JPQL 的 join-fetch 操作。
在内存中连接获取和分页
在项 97 和项 98 中详述了在内存中加入取指和分页的重要主题。如果您不熟悉这个主题和特定于 Hibernate 的 HH000104 警告,请考虑阅读这些内容。
现在,您可以通过JoinType在Specification中指定连接获取。为了适应像findAll(Specification spec, Pageable pageable)(在接下来的例子中使用)这样的方法,您需要检查CriteriaQuery的resultType,并且仅当它不是Long时才应用 join(这是针对特定于偏移分页的计数查询的resultType):
public class JoinFetchSpecification<Author>
implements Specification<Author> {
private final String genre;
public JoinFetchSpecification(String genre) {
this.genre = genre;
}
@Override
public Predicate toPredicate(Root<Author> root,
CriteriaQuery<?> cquery, CriteriaBuilder cbuilder) {
// This is needed to support Pageable queries
// This causes pagination in memory (HHH000104)
Class clazz = cquery.getResultType();
if (clazz.equals(Long.class) || clazz.equals(long.class)) {
return null;
}
root.fetch("books", JoinType.LEFT);
cquery.distinct(true);
// in case you need to add order by via Specification
//cquery.orderBy(cbuilder.asc(root.get("...")));
return cbuilder.equal(root.get("genre"), genre);
}
}
通过调用distinct(true)方法来实现不同的结果。为了利用在项目 103 中讨论的性能优化,让我们覆盖本例中使用的findAll()方法:
@Repository
public interface AuthorRepository
extends JpaRepository<Author, Long>, JpaSpecificationExecutor<Author> {
@Override
@QueryHints(value = @QueryHint(name = HINT_PASS_DISTINCT_THROUGH,
value = "false"))
public Page<Author> findAll(Specification<Author> s, Pageable p);
}
使用JoinFetchSpecification的服务方法可以写成如下形式(选择流派为选集和相关书籍的作者的Page):
public Page<Author> fetchViaJoinFetchSpecification(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "name"));
Page<Author> pageOfAuthors = authorRepository
.findAll(new JoinFetchSpecification("Anthology"), pageable);
return pageOfAuthors;
}
调用fetchViaJoinFetchSpecification()会触发下面两条SELECT语句:
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_,
books1_.author_id AS author_i4_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.title AS title3_1_1_,
books1_.author_id AS author_i4_1_0__,
books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.genre = ?
ORDER BY author0_.name ASC
SELECT
COUNT(author0_.id) AS col_0_0_
FROM author author0_
最后,结果是一个Page<Author>,但是分页是在内存中执行的,并通过 HH000104 警告发出信号。
数据库中的连接提取和分页
在内存中分页可能会导致严重的性能损失,因此建议重新考虑依赖于数据库中分页的实现。在 Item 98 中,您看到了一种解决 HHH000104 警告(表示内存中正在进行分页)的方法,它由两个SELECT查询组成。
第一个SELECT查询仅获取 id 的Page(例如,给定流派的作者的 id 的Page)。该查询可以添加到AuthorRepository:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository
extends JpaRepository<Author, Long>, JpaSpecificationExecutor<Author> {
@Query(value = "SELECT a.id FROM Author a WHERE a.genre = ?1")
Page<Long> fetchPageOfIdsByGenre(String genre, Pageable pageable);
}
这一次,数据库对 id 进行分页(检查相应的 SQL 以查看LIMIT操作)。有了作者的 id 就解决了一半的问题。此外,使用一个Specification来定义连接:
public class JoinFetchInIdsSpecification implements Specification<Author> {
private final List<Long> ids;
public JoinFetchInIdsSpecification(List<Long> ids) {
this.ids = ids;
}
@Override
public Predicate toPredicate(Root<Author> root,
CriteriaQuery<?> cquery, CriteriaBuilder cbuilder) {
root.fetch("books", JoinType.LEFT);
cquery.distinct(true);
// in case you need to add order by via Specification
//cquery.orderBy(cbuilder.asc(root.get("...")));
Expression<String> expression = root.get("id");
return expression.in(ids);
}
}
通过调用distinct(true)方法来实现不同的结果。为了利用在项目 103 中讨论的性能优化,让我们覆盖本例中使用的findAll()方法:
@Override
@QueryHints(value = @QueryHint(name = HINT_PASS_DISTINCT_THROUGH,
value = "false"))
public List<Author> findAll(Specification<Author> spec);
使用JoinFetchInIdsSpecification的服务方法可以写成如下形式(选择流派为选集和相关书籍的作者的Page):
@Transactional(readOnly = true)
public Page<Author> fetchViaJoinFetchInIdsSpecification(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "name"));
Page<Long> pageOfIds = authorRepository.fetchPageOfIdsByGenre(
"Anthology", pageable);
List<Author> listOfAuthors = authorRepository.findAll(
new JoinFetchInIdsSpecification(pageOfIds.getContent()));
Page<Author> pageOfAuthors = new PageImpl(
listOfAuthors, pageable, pageOfIds.getTotalElements());
return pageOfAuthors;
}
调用fetchViaJoinFetchInIdsSpecification()会触发以下三个SELECT语句:
SELECT
author0_.id AS col_0_0_
FROM author author0_
WHERE author0_.genre = ?
ORDER BY author0_.name ASC LIMIT ?
SELECT
COUNT(author0_.id) AS col_0_0_
FROM author author0_
WHERE author0_.genre = ?
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_,
books1_.author_id AS author_i4_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.title AS title3_1_1_,
books1_.author_id AS author_i4_1_0__,
books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.id IN (?, ?, ?)
即使这种方法触发了三个SELECT语句,数据库也会对其进行分页。
完整的应用可在 GitHub 44 上获得。
第 124 项:如何使用特定于 Hibernate 的查询计划缓存
在执行查询之前,必须对其进行编译。例如,一个查询执行 10 次就编译 10 次。为了防止这种行为,Hibernate 提供了查询计划缓存。在这个上下文中,执行 10 次的查询被编译一次并被缓存。随后的九次执行使用缓存的计划。默认情况下,查询计划缓存可以为实体查询(JPQL 和 Criteria API)缓存 2048 个计划,为本地查询缓存 128 个计划。实体查询和本机查询共享 QPC。对于实体查询(JPQL 和 Criteria API),您可以通过hibernate.query.plan_cache_max_size改变默认值,而对于本地查询,我们使用hibernate.query.plan_parameter_metadata_max_size。考虑Author实体和下面两个 JPQL 查询:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a WHERE a.genre = ?1")
List<Author> fetchByGenre(String genre);
@Query("SELECT a FROM Author a WHERE a.age > ?1")
List<Author> fetchByAge(int age);
}
现在让我们将实体查询的 QPC 大小设置为 2。这意味着两个查询都被缓存。接下来,让我们将实体查询的 QPC 大小设置为 1。这意味着一个 JPQL 计划将被缓存,一个将在每次执行时被编译。将每个场景运行 5000 次会显示出时间-性能趋势图,如图 14-22 所示。
图 14-22
查询计划缓存
图 14-22 中显示的时间-性能趋势图是针对 MySQL 在具有以下特征的 Windows 7 机器上获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。
图 14-22 帮助你得出一个清晰的结论。始终确保 QPC 的大小能够缓存所有正在执行的查询。这对于实体查询(JPQL 和 Criteria API)尤其必要。一旦有查询没有被缓存,它们将在每次执行时被重新编译,这将导致严重的时间性能损失。
完整的应用可在 GitHub 45 上获得。
第 125 项:如何通过 Spring Query 通过示例(QBE)检查数据库中是否存在瞬态实体
考虑具有以下属性的Book实体:id、title、genre、price、author和isbn。书店员工负责检查是否有一堆书被添加到数据库中,然后就此写一份报告。他们只需填写一个包含图书详细信息(书名、流派和价格)的表单,然后提交。表单数据通过 Spring 控制器在一个瞬态Book实例中具体化,该控制器将一个端点公开为:public String checkBook(@Validated @ModelAttribute Book book, ...)。
要检查某本书是否存在于数据库中,您可以使用显式 JPQL 或 Spring 数据查询构建器机制,或者更好的是,通过示例查询(QBE) API。在这种情况下,如果实体具有大量属性并且:
-
对于所有属性,我们需要将每个属性值与相应的列值进行比较(例如,如果标题、流派、价格、作者和 ISBN 与数据库行匹配,那么给定的书就存在)。
-
对于属性子集,我们需要将每个属性值与对应的列值进行比较(例如,如果标题、作者和 ISBN 与数据库行匹配,则给定的书存在)。
-
对于属性子集,当属性值和对应的列值第一次匹配时,我们返回 true(例如,如果标题、作者或 ISBN 与数据库行匹配,则给定的书存在)。
-
任何其他情况。
Spring Data Query by Example (QBE)是一种创建查询的便捷方法,它允许您基于一个名为 probe 的示例实体实例来执行查询。在 Spring Data JPA 中,您可以将探针传递给一个org.springframework.data.domain.Example实例。此外,您将Example传递给在库中定义的查询方法,该方法扩展了QueryByExampleExecutor接口(例如,BookRepository扩展了QueryByExampleExecutor):
@Repository
public interface BookRepository extends JpaRepository<Book, Long>,
QueryByExampleExecutor<Book> {
}
QueryByExampleExecutor公开了以下方法(在本例中,您对最后一个方法exists()感兴趣):
-
<S extends T> Optional<S> findOne(Example<S> ex); -
<S extends T> Iterable<S> findAll(Example<S> ex); -
<S extends T> Iterable<S> findAll(Example<S> ex, Sort sort); -
<S extends T> Page<S> findAll(Example<S> ex, Pageable pg); -
<S extends T> long count(Example<S> ex); -
<S extends T> boolean exists(Example<S> ex);默认情况下,具有
null值的字段被忽略,字符串使用特定于数据库的默认值进行匹配。
因此,让我们考虑一个Book实例(又名,探测器):
Book book = new Book();
book.setTitle("Carrie");
book.setGenre("Horror");
book.setIsbn("001-OG");
book.setAuthor("Olivia Goy");
book.setPrice(23);
所有属性的直接比较
你可以通过使用of()工厂方法或使用ExampleMatcher来创建一个Example。这里,我们使用of()方法:
public boolean existsBook(Book book) {
Example<Book> bookExample = Example.of(book);
return bookRepository.exists(bookExample);
}
调用existsBook()会生成以下 SQL 语句:
SELECT
book0_.id AS id1_0_,
book0_.author AS author2_0_,
book0_.genre AS genre3_0_,
book0_.isbn AS isbn4_0_,
book0_.price AS price5_0_,
book0_.title AS title6_0_
FROM book book0_
WHERE book0_.author = ?
AND book0_.title = ?
AND book0_.genre = ?
AND book0_.price = ?
AND book0_.isbn = ?
Binding: [Olivia Goy, Carrie, Horror, 23, 001-OG]
某些属性的直接比较
这一次,我们只想比较书名、作者和 ISBN,而忽略价格和类型。为此,我们使用ExampleMatcher,它保存了如何匹配特定属性的细节。ExampleMatcher是一个全面的界面,具有许多值得您关注的功能,但目前,我们主要关注两个匹配器:
-
matchingAll():将and连词应用于所有非null属性 -
withIgnorePaths():忽略提供的属性路径
existsBook()看起来如下:
public boolean existsBook(Book book) {
Example<Book> bookExample = Example.of(book,
ExampleMatcher.matchingAll().withIgnorePaths("genre", "price"));
return bookRepository.exists(bookExample);
}
触发 SQL 语句是:
SELECT
book0_.id AS id1_0_,
book0_.author AS author2_0_,
book0_.genre AS genre3_0_,
book0_.isbn AS isbn4_0_,
book0_.price AS price5_0_,
book0_.title AS title6_0_
FROM book book0_
WHERE book0_.author = ?
AND book0_.title = ?
AND book0_.isbn = ?
Binding: [Olivia Goy, Carrie, 001-OG]
将 or 连接应用于属性子集
要应用or连接,您需要matchingAny()匹配器,如下所示:
public boolean existsBook(Book book) {
Example<Book> bookExample = Example.of(book,
ExampleMatcher.matchingAny().withIgnorePaths("genre", "price"));
return bookRepository.exists(bookExample);
}
触发 SQL 语句是:
SELECT
book0_.id AS id1_0_,
book0_.author AS author2_0_,
book0_.genre AS genre3_0_,
book0_.isbn AS isbn4_0_,
book0_.price AS price5_0_,
book0_.title AS title6_0_
FROM book book0_
WHERE book0_.author = ?
OR book0_.title = ?
OR book0_.isbn = ?
Binding: [Olivia Goy, Carrie, 001-OG]
当然,您可以轻松地将这三种方法合并成一个方法,并利用 QBE 来生成动态查询。
请注意,QBE API 有一些限制,如下所示:
-
使用
AND关键字组合查询谓词 -
不支持类似
author = ?1 or (title = ?2 and isbn = ?3)的嵌套/分组属性约束 -
仅支持字符串的 starts/contains/ends/regex 匹配和其他属性类型的精确匹配
完整的应用可在 GitHub 46 上获得。
第 126 项:如何通过 Hibernate @DynamicUpdate 在 UPDATE 语句中只包含修改过的列
让我们考虑一个具有以下持久字段的实体:id、name、genre、age、sellrank、royalties和rating。和下一行:
INSERT INTO author (age, name, genre, royalties, sellrank, rating, id)
VALUES (23, "Mark Janel", "Anthology", 1200, 289, 3, 1);
目标是将sellrank更新为222,这可以通过服务方法来完成,如下所示:
@Transactional
public void updateAuthor() {
Author author = authorRepository.findById(1L).orElseThrow();
author.setSellrank(222);
}
调用updateAuthor()会导致下面的UPDATE语句:
UPDATE author
SET age = ?,
genre = ?,
name = ?,
rating = ?,
royalties = ?,
sellrank = ?
WHERE id = ?
Binding: [23, Anthology, Mark Janel, 3, 1200, 222, 1]
即使您只修改了sellrank值,被触发的UPDATE也会包含所有列。要指示 Hibernate 触发一个只包含修改过的列的UPDATE,可以用 Hibernate 特有的@DynamicUpdate在类级别上注释实体,如下所示:
@Entity
@DynamicUpdate
public class Author implements Serializable {
...
}
这一次,触发的UPDATE是这个:
UPDATE author
SET sellrank = ?
WHERE id = ?
Binding: [222, 1]
这一次,只有sellrank列出现在触发的UPDATE中。
使用这种方法有优点也有缺点:
-
如果避免更新索引列,好处是非常明显的。触发包含所有列的
UPDATE将不可避免地更新未修改的索引,这可能会导致严重的性能损失。 -
这个缺点反映在 JDBC 语句缓存中。您不能通过 JDBC 语句缓存为不同的列子集重用相同的
UPDATE(每个触发的UPDATE字符串将被相应地缓存和重用)。
完整的应用可在 GitHub 47 上获得。
第 127 项:如何在 Spring 中使用命名(本地)查询
命名(本地)查询由静态预定义的不可更改的查询字符串表示,该字符串通过关联的名称引用。它们通常用于通过从 Java 代码中提取 JPQL/SQL 查询字符串来改进代码组织。这在 Java EE 应用中特别有用,在这些应用中,JPQL/SQL 与 EJB 组件中的 Java 代码交织在一起。在 Spring 中,您可以通过@Query注释提取存储库中的 JPQL/SQL。然而,您也可以在 Spring 中使用命名(原生)查询。
不幸的是,所支持的方法都没有提供 Spring 特性和命名(本地)查询之间的完全兼容性。至少,直到 Spring Boot 2.3.0。所以,让我们找到最有利的权衡。我们使用众所周知的带有字段的Author实体:id、name、age、genre,以及 Spring Boot 2.3.0。
引用命名(本机)查询
引用命名(本机)查询是通过其名称来完成的。例如,可以通过@Query注释的name元素从典型的 Spring 存储库中引用名为AllFooQuery的命名(本地)查询,如下所示:
AllFooQuery="SELECT f FROM Foo f";
public interface FooRepository extends JpaRepository<Foo, Long> {
@Query(name="AllFooQuery")
public List<Foo> fetchAllFoo();
}
但是 Spring 数据支持一种命名约定,这种约定消除了对@Query(name="...")的需求。命名(本机)查询的名称以实体类名开头,后面跟一个点(。),以及存储库方法的名称。命名(本地)查询的这种命名约定的模式是EntityName.RepositoryMethodName,它允许您在存储库接口中定义与命名查询RepositoryMethodName同名的查询方法。例如,如果实体是Foo,那么可以使用命名(本地)查询,如下所示:
Foo.fetchAllFoo="SELECT f FROM Foo f";
public interface FooRepository extends JpaRepository<Foo, Long> {
public List<Foo> fetchAllFoo();
}
让我们看看同样的例子。
使用@NamedQuery 和@NamedNativeQuery
使用命名(本地)查询的最流行的方法依赖于添加到类级实体的@NamedQuery和@NamedNativeQuery注释。
@NamedQueries({
@NamedQuery(name = "Author.fetchAll",
query = "SELECT a FROM Author a"),
@NamedQuery(name = "Author.fetchByNameAndAge",
query = "SELECT a FROM Author a
WHERE a.name=?1 AND a.age=?2")
})
@NamedNativeQueries({
@NamedNativeQuery(name = "Author.fetchAllNative",
query = "SELECT * FROM author",
resultClass = Author.class),
@NamedNativeQuery(name = "Author.fetchByNameAndAgeNative",
query = "SELECT * FROM author
WHERE name=?1 AND age=?2",
resultClass = Author.class)
})
@Entity
public class Author implements Serializable {
...
}
AuthorRepository引用这些命名(本地)查询如下:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
List<Author> fetchAll();
Author fetchByNameAndAge(String name, int age);
@Query(nativeQuery = true)
List<Author> fetchAllNative();
@Query(nativeQuery = true)
Author fetchByNameAndAgeNative(String name, int age);
}
注意,通过这种方法,您不能使用带有动态排序的命名(本地)查询(Sort)。在Pageable中使用Sort被忽略,因此您需要显式地将ORDER BY添加到查询中。至少这是它在 Spring Boot 2.3.0 中的表现。完整的应用可以在 GitHub 48 上获得,它包含了Sort和Pageable的用例,您可以在您的 Spring Boot 版本下进行测试。
一种更好的方法是使用属性文件来列出命名(本地)查询。在这种情况下,dynamic Sort适用于命名查询,但不适用于命名本地查询。在命名(本地)查询中,在Pageable中使用Sort可以正常工作。你不需要用之前的注释来修改/污染实体。
使用属性文件(jpa-named-queries.properties)
或者,您可以在名为jpa-named-queries.properties.的属性文件中列出命名的(本地)查询,并将该文件放在应用类路径中名为META-INF的文件夹中:
如果你需要改变这个文件的位置,那么使用@EnableJpaRepositories(namedQueriesLocation = "...")。
# Named Queries
# Find all authors
Author.fetchAll
=SELECT a FROM Author a
# Find the author by name and age
Author.fetchByNameAndAge
=SELECT a FROM Author a WHERE a.name=?1 AND a.age=?2
...
# Named Native Queries
# Find all authors (native)
Author.fetchAllNative
=SELECT * FROM author
# Find the author by name and age (native)
Author.fetchByNameAndAgeNative
=SELECT * FROM author WHERE name=?1 AND age=?2
AuthorRepository与使用@NamedQuery和@NamedNativeQuery时完全相同。
这一次,您甚至可以通过Sort声明使用动态排序的命名查询(非命名本地查询),如下所示:
# Find the authors older than age ordered via Sort
Author.fetchViaSortWhere
=SELECT a FROM Author a WHERE a.age > ?1
// in repository
List<Author> fetchViaSortWhere(int age, Sort sort);
// service-method calling fetchViaSortWhere()
public List<Author> fetchAuthorsViaSortWhere() {
return authorRepository.fetchViaSortWhere(
30, Sort.by(Direction.DESC, "name"));
}
触发的SELECT(注意ORDER BY author0_.name DESC的存在)如下:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
WHERE author0_.age > ?
ORDER BY author0_.name DESC
或者,您可以使用包含Sort的Pageable(这适用于命名查询和命名本地查询):
# Find the Pageable of authors older than age ordered via Sort (native)
Author.fetchPageSortWhereNative
=SELECT * FROM author WHERE age > ?1
// in repository
@Query(nativeQuery = true)
Page<Author> fetchPageSortWhereNative(int age, Pageable pageable);
// service-method calling fetchPageSortWhereNative()
public Page<Author> fetchAuthorsPageSortWhereNative() {
return authorRepository.fetchPageSortWhereNative(
30, PageRequest.of(1, 3,
Sort.by(Sort.Direction.DESC, "name")));
}
触发的SELECT语句如下(注意ORDER BY author0_.name DESC LIMIT ?, ?的出现和生成的SELECT COUNT):
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
WHERE author0_.age > ?
ORDER BY author0_.name DESC LIMIT ?, ?
SELECT
COUNT(author0_.id) AS col_0_0_
FROM author author0_
WHERE author0_.age > ?
注意,通过这种方法,您不能使用带有动态排序的命名本地查询(Sort)。这个缺点在使用@NamedQuery和@NamedNativeQuery的情况下也存在。至少这是它在 Spring Boot 2.3.0 中的表现。完整的应用可在 GitHub 49 上获得。
使用这种方法(使用属性文件jpa-named-queries.properties,您可以将动态Sort与命名查询一起使用,并且Pageable中的Sort可以按预期工作。如果你需要这些功能,这是正确的方法。
另一种方法是使用众所周知的orm.xml文件。这个文件应该被添加到应用类路径的一个名为META-INF的文件夹中。这种方法提供了与使用@NamedQuery和@NamedNativeQuery相同的缺点。至少这是它在 Spring Boot 2.3.0 中的表现。完整的应用可在 GitHub 50 上获得。
要将命名(本地)查询与 Spring 投影结合起来,请考虑第 25 项。要使用命名(本地)查询和结果集映射,请考虑第 34 项。
第 128 项:在不同的查询/请求中获取父项和子项的最佳方式
获取只读数据应该通过 DTO 完成,而不是通过托管实体。但是在特定的上下文中获取只读实体并没有什么大不了的,如下所示:
-
我们需要实体的所有属性(因此,DTO 只是实体的镜像)
-
我们操作少量的实体(例如,一个作者有几本书)
-
我们使用
@Transactional(readOnly = true)
在这种情况下,让我们来解决一个我见过很多的常见案例。
让我们假设Author和Book参与了一个双向懒惰@OneToMany关联。接下来,想象一个用户通过 ID 加载某个Author(没有关联的Book)。对相关的Book可能感兴趣也可能不感兴趣;因此,不要用Author加载它们。如果用户对Book感兴趣,那么他们将点击 View Books 按钮。现在,你必须返回与这个Author相关联的List<Book>。
因此,在第一次请求(查询)时,您获取一个Author,如下所示:
// first query/request
public Author fetchAuthor(long id) {
return authorRepository.findById(id).orElseThrow();
}
这个方法将触发一个SELECT来加载带有给定 ID 的作者。在fetchAuthor()执行结束时,返回的作者被分离。如果用户点击查看书籍按钮,你必须返回相关的Book。我经常看到的一种没有创意的方法是再次加载Author,以便通过getBooks()获取关联的Book,如图所示:
// second query/request
@Transactional(readOnly = true)
public List<Book> fetchBooksOfAuthor(Author a) {
Author author = fetchAuthor(a.getId());
List<Book> books = author.getBooks();
Hibernate.initialize(books); // or, books.size();
return books;
}
这种常见的方法有两个主要的缺点。首先,注意这一行:
Hibernate.initialize(books); // or, books.size();
这里,我们强制集合初始化,因为如果我们简单地返回它,它将不会被初始化。为了触发集合初始化,开发者调用books.size()或者依赖Hibernate.initialize(books)。
其次,这种方法触发两个SELECT语句,如下所示:
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = ?
SELECT
books0_.author_id AS author_i4_1_0_,
books0_.id AS id1_1_0_,
books0_.id AS id1_1_1_,
books0_.author_id AS author_i4_1_1_,
books0_.isbn AS isbn2_1_1_,
books0_.title AS title3_1_1_
FROM book books0_
WHERE books0_.author_id = ?
但是你不想再加载Author(比如你不关心Author的丢失更新,你只想在单个SELECT中加载关联的Book。
您可以通过依赖显式 JPQL 或查询构建器属性表达式来避免这种笨拙的解决方案。这样,就只有一个SELECT,而不需要调用size()或Hibernate.initialize()。在BookRepository中,JPQL 可以写成如下形式:
@Repository
@Transactional(readOnly = true)
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT b FROM Book b WHERE b.author = ?1")
List<Book> fetchByAuthor(Author author);
}
服务方法可以重写如下:
// second query/request
public List<Book> fetchBooksOfAuthor(Author a) {
return bookRepository.fetchByAuthor(a);
}
如果你不想写一个 JPQL,你可以使用查询构建器的属性表达式,如下所示(SELECT将代表你生成):
@Repository
@Transactional(readOnly = true)
public interface BookRepository extends JpaRepository<Book, Long> {
List<Book> findByAuthor(Author author);
}
服务方法稍作修改,以调用此查询方法:
// second query/request
public List<Book> fetchBooksOfAuthor(Author a) {
return bookRepository.findByAuthor(a);
}
如果您不熟悉查询构建器的属性表达式,那么可以考虑这个 GitHub 51 的例子。考虑阅读那里的描述。
这一次,两种方法(通过 JPQL 和查询构建器属性表达式)都会产生一个SELECT:
SELECT
book0_.id AS id1_1_,
book0_.author_id AS author_i4_1_,
book0_.isbn AS isbn2_1_,
book0_.title AS title3_1_
FROM book book0_
WHERE book0_.author_id = ?
这样好多了!完整的应用可在 GitHub 52 上获得。完整的应用还包含这样一种情况,对于第一个查询,您加载一个Book,对于第二个查询,您加载那个Book的Author。
第 129 项:如何使用 Update 优化合并操作
在内置的 Spring 数据save()方法背后,有一个EntityManager#persist()或EntityManager#merge()的调用。这里列出了save()方法的源代码:
@Transactional
@Override
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
如果您不熟悉merge操作,请参见附录 A 。
了解save()方法的工作原理很重要,因为它可能是最常用的 Spring 数据内置方法。如果您知道它是如何工作的,那么您将知道如何使用它并减轻它的性能损失。在第 107 项中,你看到了一个调用save()是多余的案例。现在,让我们看一个调用save()会导致严重性能损失的案例。这是在更新(包括更新批处理)分离的实体时。
考虑双向惰性@OneToMany关联中涉及的Author和Book实体。您加载一个Author,分离它,并在分离状态下更新它:
// service-method in BookstoreService class
public Author fetchAuthorById(long id) {
return authorRepository.findById(id).orElseThrow();
}
如果您不熟悉 Hibernate 状态转换,请参见附录 A 。
执行fetchAuthorById()后,返回的Author处于脱离状态。因此,下面的代码更新了这个Author在分离状态下的age:
// fetch an Author and update it in the detached state
Author author = bookstoreService.fetchAuthorById(1L);
author.setAge(author.getAge() + 1);
最后,通过updateAuthorViaMerge()方法将修改传播到数据库:
bookstoreService.updateAuthorViaMerge(author);
updateAuthorViaMerge()简单地调用save()方法:
public void updateAuthorViaMerge(Author author) {
authorRepository.save(author);
}
这里显示了由authorRepository.save(author)行触发的 SQL:
SELECT
author0_.id AS id1_0_1_,
author0_.age AS age2_0_1_,
author0_.genre AS genre3_0_1_,
author0_.name AS name4_0_1_,
author0_.version AS version5_0_1_,
books1_.author_id AS author_i5_1_3_,
books1_.id AS id1_1_3_,
books1_.id AS id1_1_0_,
books1_.author_id AS author_i5_1_0_,
books1_.isbn AS isbn2_1_0_,
books1_.title AS title3_1_0_,
books1_.version AS version4_1_0_
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.id = ?
UPDATE author
SET age = ?,
genre = ?,
name = ?,
version = ?
WHERE id = ?
AND version = ?
因此,调用save()会带来以下两个由于在后台调用merge()而导致的问题:
-
有两个 SQL 语句,一个
SELECT(由merge操作引起)和一个UPDATE(预期的更新) -
SELECT将包含一个LEFT OUTER JOIN来获取相关的Book(但是你不需要相关的书籍)
有两个性能损失。首先是SELECT本身,其次是LEFT OUTER JOIN的存在。
只触发UPDATE并消除这个潜在的昂贵的SELECT怎么样?如果你注入EntityManager,打开Session,并调用Session#update()方法,这是可以实现的,如下所示:
@PersistenceContext
private final EntityManager entityManager;
...
@Transactional
public void updateAuthorViaUpdate(Author author) {
Session session = entityManager.unwrap(Session.class);
session.update(author);
}
这一次,触发的 SQL 只有下面的UPDATE语句:
UPDATE author
SET age = ?,
genre = ?,
name = ?,
version = ?
WHERE id = ?
AND version = ?
Session#update()不支持无版本乐观锁定机制。在这种情况下,仍会触发SELECT。
完整的应用可在 GitHub 53 上获得。
第 130 项:如何通过跳过锁定选项实现基于并发表的队列
如果没有 SQL SKIP LOCKED选项,实现基于并发表的队列(又名作业队列或批处理队列)是一项困难的任务。
考虑图 14-23 所示的领域模型。
图 14-23
领域模型
这家独家书店对他们卖的书非常小心。为了保持高质量,书店评审员执行评审并决定某本书是否被批准或拒绝。
由于这是一个并发的过程,挑战包括协调评审人员,使他们不会在同一时间评审同一本书。要挑选一本书来评论,评论者应该跳过已经评论过的书和当前正在评论的书。图 14-24 描述了该作业队列。
图 14-24
评论队列
这是SKIP LOCKED的工作。此 SQL 选项指示数据库跳过锁定的行,并锁定以前未锁定的行。让我们为 MySQL 8 和 PostgreSQL 9.5 设置这个选项(大多数 RDBMSs 都支持这个选项)。
设置跳过锁定
MySQL 从 8 版本开始引入SKIP LOCKED,PostgreSQL 从 9.5 版本开始。要设置这个 SQL 选项,从BookRepository开始。在这里,执行以下设置:
-
设置
@Lock(LockModeType.PESSIMISTIC_WRITE) -
使用
@QueryHint设置javax.persistence.lock.timeout到SKIP_LOCKED
BookRepository的源代码如下:
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
@QueryHint(name = "javax.persistence.lock.timeout",
value = "" + LockOptions.SKIP_LOCKED)}
)
public List<Book> findTop3ByStatus(BookStatus status, Sort sort);
}
接下来,关注application.properties文件。
对于 MySQL,设置spring.jpa.properties.hibernate.dialect指向 MySQL 8 方言:
spring.jpa.properties.hibernate.dialect
=org.hibernate.dialect.MySQL8Dialect
对于 PostgreSQL,设置spring.jpa.properties.hibernate.dialect指向 PostgreSQL 9.5 方言:
spring.jpa.properties.hibernate.dialect
=org.hibernate.dialect.PostgreSQL95Dialect
设置完成了!
测试时间
测试SKIP LOCKED需要至少两个并发事务。你可以用不同的方法来做这件事。例如,一种简单的方法是使用TransactionTemplate,如以下代码所示:
private final TransactionTemplate template;
private final BookRepository bookRepository;
...
public void fetchBooksViaTwoTransactions() {
template.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
List<Book> books = bookRepository.findTop3ByStatus(
BookStatus.PENDING, Sort.by(Sort.Direction.ASC, "id"));
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
List<Book> books = bookRepository.findTop3ByStatus(
BookStatus.PENDING, Sort.by(Sort.Direction.ASC, "id"));
System.out.println("Second transaction: " + books);
}
});
System.out.println("First transaction: " + books);
}
});
}
运行fetchBooksViaTwoTransactions()会触发以下 SQL 语句:
SELECT
book0_.id AS id1_0_,
book0_.isbn AS isbn2_0_,
book0_.status AS status3_0_,
book0_.title AS title4_0_
FROM book book0_
WHERE book0_.status = ?
ORDER BY book0_.id ASC limit ? FOR UPDATE skip locked
请注意,Hibernate 已经将SKIP LOCKED选项附加到了FOR UPDATE子句中。由于有两个事务,该查询被触发两次。第一个事务获取 id 为1、2和3的书籍:
First transaction: [
Book{id=1, title=A History of Ancient Prague, isbn=001-JN, status=PENDING},
Book{id=2, title=A People's History, isbn=002-JN, status=PENDING},
Book{id=3, title=The Beatles Anthology, isbn=001-MJ, status=PENDING}
]
当第一个事务运行时,第二个事务跳过 id 为1、2和3的图书,取 id 为4、5和6的图书:
Second transaction: [
Book{id=4, title=Carrie, isbn=001-OG, status=PENDING},
Book{id=5, title=Fragments of Horror, isbn=002-OG, status=PENDING},
Book{id=6, title=Anthology Mission, isbn=002-MJ, status=PENDING}
]
查看 MySQL 54 和 PostgreSQL 55 的完整应用。
从锁定类别来看,建议阅读关于 PostgreSQL 咨询锁的内容。关于这个话题的一篇好文章可以在这里找到 56 。
第 131 项:如何在版本化(@ Version)OptimisticLockException 后重试事务
乐观锁定是一种不使用锁的并发控制技术。这对于防止丢失更新非常有用(例如,对于通过无状态 HTTP 协议跨越几个请求的长对话)。
版本化乐观锁定异常
最常见的是,乐观锁定是通过向实体添加一个用@Version注释的字段来实现的;这就是所谓的版本化乐观锁定,它依赖于一个数值,这个数值由 JPA 持久性提供者(Hibernate)自动管理(当数据被修改时加 1)。在一个简单的表达式中,基于这个值,JPA 持久性提供者可以检查由当前事务操作的数据是否已经被并发事务更改。因此,很容易出现更新丢失(关于 SQL 异常的更多细节,请参考附录 E )。
@Version的类型可以是int、Integer、long、short、Short、java.sql.Timestamp中的任意一种。效率最大化,靠short / Short。这将导致数据库消耗更少的空间(例如,在 MySQL 中,这种类型将存储在类型为SMALLINT的列中)。
对于分配的生成器(没有带@GeneratedValue注释的生成器,标识符是手动分配的),使用所选原语类型的相应包装器。这将有助于 Hibernate 检查可空性。对于IDENTITY、SEQUENCE等。,生成器策略直接使用基本类型。
由于 Hibernate 管理@Version属性,所以不需要添加 setter 方法。
以下实体使用了由分配的生成器,并且有一个用@Version注释的Short类型的字段:
@Entity
public class Inventory implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private Long id;
private String title;
private int quantity;
@Version
private Short version;
public Short getVersion() {
return version;
}
// getters and setters omitted for brevity
}
这个实体映射了一个书店的库存。对于每本书,它存储标题和可用数量。多个事务(代表订单)以一定的数量减少数量。作为并发事务,开发人员应该减轻如下场景:
-
最初,数量等于 3
-
事务 A 查询可用量,即 3
-
事务 B 查询可用量,即 3
-
在事务 A 提交之前,事务 B 订购了两本书并提交(因此,数量现在等于 1)
-
事务 A 订购了两本书,因此,它提交并减少了数量 2(因此,数量现在等于-1)
显然,数量为负意味着客户不会收到订单,应用丢失了一个更新。您可以通过乐观锁定来缓解这种情况(或者,UPDATE-IF-ELSE类型的本地条件更新也可以达到这个目的)。
有了实体中的@Version,这个场景的最后一步将导致OptimisticLockException。
更准确地说,在 Spring Boot,OptimisticLockException将导致org.springframework.orm.ObjectOptimisticLockingFailureException或其超级阶级org.springframework.dao.OptimisticLockingFailureException。
因此,上一步触发的UPDATE将看到另一个事务修改了所涉及的数据。现在,业务逻辑可以决定做什么。基本上,有两种方法:
-
如果没有足够的书籍来满足当前事务,则通知客户
-
如果有足够的书籍,则重试该事务,直到成功或没有更多书籍可用
模拟乐观锁定异常
编写导致乐观锁定异常的应用需要至少两个试图更新相同数据的并发事务。这就像两个并发线程(用户)试图执行下面的服务方法:
@Transactional
public void run() {
Inventory inventory = inventoryRepository.findById(1L).orElseThrow();
inventory.setQuantity(inventory.getQuantity() - 2);
}
为了重现乐观锁定异常,可以将前面的方法转换成Runnable。此外,两个线程将同时调用它:
@Service
public class InventoryService implements Runnable {
private final InventoryRepository inventoryRepository;
public InventoryService(InventoryRepository inventoryRepository) {
this.inventoryRepository = inventoryRepository;
}
@Override
@Transactional
public void run() {
Inventory inventory = inventoryRepository
.findById(1L).orElseThrow();
inventory.setQuantity(inventory.getQuantity() - 2);
}
}
两个线程(用户)通过一个Executor调用这个Runnable(这指示事务管理器创建两个事务和两个实体管理器):
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.execute(inventoryService);
executor.execute(inventoryService);
完整的源代码可以在 GitHub 57 上找到。运行代码应该会产生一个ObjectOptimisticLockingFailureException。两个线程都将触发一个SELECT,并将为quantity和version获取相同的值。此外,只有一个线程将触发成功的UPDATE,并将更新quantity(减少 2)和version(增加 1)。第二个UPDATE将因乐观锁定异常而失败,因为版本不匹配(检测到一个丢失更新)。
正在重试事务
您可以通过db-util库重试事务。这个库公开了一个名为@Retry的注释。您可以通过on和times属性设置触发重试的异常类型和重试次数。例如,如果发生OptimisticLockException,您可以重试 10 次事务,如下所示:
@Retry(times = 10, on = OptimisticLockException.class)
public void methodProneToOptimisticLockException() { ... }
对 Spring Boot 来说,恰当的例外是:
@Retry(times = 10, on = OptimisticLockingFailureException.class)
public void methodProneToOptimisticLockingFailureException() { ... }
但是,在使用@Retry之前,开发人员应该将db-util依赖项添加到应用中,并执行一些设置。对于 Maven 来说,应该添加到pom.xml的依赖项是:
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>db-util</artifactId>
<version>1.0.4</version>
</dependency>
此外,按如下方式配置OptimisticConcurrencyControlAspect bean:
@SpringBootApplication
@EnableAspectJAutoProxy
public class MainApplication {
@Bean
public OptimisticConcurrencyControlAspect
optimisticConcurrencyControlAspect() {
return new OptimisticConcurrencyControlAspect();
}
...
}
@Retry的一个重要方面是它不能用在用@Transactional注释的方法上(例如,它不能用来注释run()方法)。尝试这样做将导致类型的异常:
IllegalTransactionStateException: You shouldn't retry an operation from within an existing Transaction. This is because we can't retry if the current Transaction was already rolled back!.
官方的解释是“当您不在运行的事务中时,重试业务逻辑操作更安全”。因此,一种简单的方法是编写一个中间服务,如下所示:
@Service
public class BookstoreService implements Runnable {
private final InventoryService inventoryService;
public BookstoreService(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
@Override
@Retry(times = 10, on = OptimisticLockingFailureException.class)
public void run() {
inventoryService.updateQuantity();
}
}
InventoryService变成了:
@Service
public class InventoryService {
private final InventoryRepository inventoryRepository;
public InventoryService(InventoryRepository inventoryRepository) {
this.inventoryRepository = inventoryRepository;
}
@Transactional
public void updateQuantity() {
Inventory inventory = inventoryRepository.findById(1L).orElseThrow();
inventory.setQuantity(inventory.getQuantity() - 2);
}
}
Executor将变成:
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.execute(bookstoreService);
executor.execute(bookstoreService);
完整的代码可以在 GitHub 58 上找到。
依靠TransactionTemplate而不是@Transactional可以避免中间服务。例如:
@Service
public class InventoryService implements Runnable {
private final InventoryRepository inventoryRepository;
private final TransactionTemplate transactionTemplate;
public InventoryService(InventoryRepository inventoryRepository,
TransactionTemplate transactionTemplate) {
this.inventoryRepository = inventoryRepository;
this.transactionTemplate = transactionTemplate;
}
@Override
@Retry(times = 10, on = OptimisticLockingFailureException.class)
public void run() {
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
public void doInTransactionWithoutResult( TransactionStatus status){
Inventory inventory
= inventoryRepository.findById(1L).orElseThrow();
inventory.setQuantity(inventory.getQuantity() - 2);
}
});
}
}
完整的代码可以在 GitHub 59 上找到。
测试场景
先考虑一下 10 本书名的书,一本的人民历史。version字段最初等于0。
事务 A 触发一个SELECT获取一个Inventory实体,如下所示:
SELECT
inventory0_.id AS id1_0_0_,
inventory0_.quantity AS quantity2_0_0_,
inventory0_.title AS title3_0_0_,
inventory0_.version AS version4_0_0_
FROM inventory inventory0_
WHERE inventory0_.id = ?
Binding:[1] Extracted:[10, A People's History, 0]
事务 B 触发类似的SELECT并获取相同的数据。当事务 A 仍然活动时,事务 B 触发一个UPDATE来订购两本书(提交):
UPDATE inventory
SET quantity = ?,
title = ?,
version = ?
WHERE id = ?
AND version = ?
Binding:[8, A People's History, 1, 1, 0]
事务 B 将quantity从 10 减少到 8 ,并将version从 0 增加到 1 。此外,事务 A 试图触发UPDATE订购两本书。事务 A 不知道事务 B,因此它试图将quantity从 10 减少到 8 并将version从 0 增加到 1 :
UPDATE inventory
SET quantity = ?,
title = ?,
version = ?
WHERE id = ?
AND version = ?
Binding:[8, A People's History, 1, 1, 0]
来自UPDATE的version值与来自数据库的version值不同,因此抛出一个OptimisticLockException。这将把重试机制带入场景中。该机制重试事务 A(重试次数减 1)。符合事务 A,再次触发SELECT:
SELECT
inventory0_.id AS id1_0_0_,
inventory0_.quantity AS quantity2_0_0_,
inventory0_.title AS title3_0_0_,
inventory0_.version AS version4_0_0_
FROM inventory inventory0_
WHERE inventory0_.id = ?
Binding:[1] Extracted:[8, A People's History, 1]
这次取的quantity是 8 ,version是 1 。因此,事务 B 更新的数据对事务 A 是可见的。此外,事务 A 触发一个UPDATE将quantity从 8 减少到 6 并将version从 1 增加到 2 :
UPDATE inventory
SET quantity = ?,
title = ?,
version = ?
WHERE id = ?
AND version = ?
Binding:[6, A People's History, 2, 1, 1]
与此同时,没有其他事务更改过数据。换句话说,没有其他事务修改过version。这意味着事务 A 提交了,重试机制做得非常好。
第 132 项:如何在无版本乐观锁定异常后重试事务
除了版本化乐观锁定,Hibernate ORM 还支持无版本乐观锁定(不需要@Version)。
无版本乐观锁定异常
基本上,无版本乐观锁定依赖于添加到UPDATE语句中的WHERE子句。该子句检查应该更新的数据自从在当前持久性上下文中提取以来是否已经更改。
Versionless Optimistic Locking
只要当前的持久化上下文是打开的,就可以工作,这避免了分离实体(Hibernate 不能再跟踪任何变化)。
使用无版本乐观锁定的更好方法如下:
@Entity
@DynamicUpdate
@OptimisticLocking(type = OptimisticLockType.DIRTY)
public class Inventory implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private Long id;
private String title;
private int quantity;
// getters and setters omitted for brevity
}
这个实体映射了一个书店的库存。对于每本书,它存储标题和可用数量。多次事务(代表订单)会将数量减少一定的数量。作为并发事务,开发人员应该减少丢失的更新并避免以负值结束。
设置OptimisticLockType.DIRTY指示 Hibernate 自动将修改后的列(例如,对应于quantity属性的列)添加到UPDATE WHERE子句中。在这种情况下,以及在OptimisticLockType.ALL的情况下,需要@DynamicUpdate注释(实体的所有属性将用于验证实体版本)。
您可以通过@OptimisticLock(excluded = true)注释在字段级别从版本控制中排除某个字段(例如,子集合的更改不应该触发父版本更新)。
下面是一个通用示例:
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@OptimisticLock(excluded = true)
private List<Foo> foos = new ArrayList<>();
模拟乐观锁定异常
考虑阅读第项 131 的“模拟乐观锁定异常”一节,因为代码(除了实体代码)完全相同。完整的应用可在 GitHub 60 上获得。
正在重试事务
考虑阅读第项 131 的“重试事务”一节,因为它介绍了进一步使用的db-util库的安装和配置。这里提出的考虑因素对于无版本乐观锁也是有效的。代码(实体代码除外)相同。完整的应用可以在 GitHub 这里 61 和这里 62 获得。
测试场景
考虑标题为一部人民历史的 10 本书的初始数量。
事务 A 触发一个SELECT获取一个Inventory实体,如下所示:
SELECT
inventory0_.id AS id1_0_0_,
inventory0_.quantity AS quantity2_0_0_,
inventory0_.title AS title3_0_0_
FROM inventory inventory0_
WHERE inventory0_.id = ?
Binding:[1] Extracted:[10, A People's History]
事务 B 触发类似的SELECT并获取相同的数据。当事务 A 仍然活动时,事务 B 触发一个UPDATE来订购两本书(提交):
UPDATE inventory
SET quantity = ?
WHERE id = ?
AND quantity = ?
Binding:[8, 1, 10]
事务 B 将quantity从 10 减少到 8 。此外,事务 A 试图触发一个UPDATE来订购两本书。事务 A 不知道事务 B,所以它试图将quantity从 10 减少到 8 :
UPDATE inventory
SET quantity = ?
WHERE id = ?
AND quantity = ?
Binding:[8, 1, 10]
来自UPDATE WHERE的quantity值与来自数据库的quantity值不同。因此,抛出一个OptimisticLockException。这将把重试机制带入场景中。该机制重试事务 A(重试次数减 1)。符合事务 A,再次触发SELECT:
SELECT
inventory0_.id AS id1_0_0_,
inventory0_.quantity AS quantity2_0_0_,
inventory0_.title AS title3_0_0_
FROM inventory inventory0_
WHERE inventory0_.id = ?
Binding:[1] Extracted:[8, A People's History]
这次取来的quantity是 8 。因此,事务 B 更新的数据对事务 A 是可见的。此外,事务 A 触发UPDATE以将quantity从 8 减少到 6 :
UPDATE inventory
SET quantity = ?
WHERE id = ?
AND quantity = ?
Binding:[6, 1, 8]
与此同时,没有其他事务更改过数据。换句话说,没有其他事务修改过quantity。这意味着事务 A 提交了,重试机制做得非常好。
第 133 项:如何处理版本化乐观锁定和分离的实体
将该项目视为项目 134 的序言。
版本化的乐观锁定适用于分离的实体,而 Hibernate ORM 无版本化的乐观锁定不起作用。
这假设Inventory实体已经准备好了@Version。此外,空的(没有显式查询)经典的InventoryRepository和一个InventoryService也是可用的。一个简单的场景可能会导致乐观锁定异常:
-
在
InventoryService中,下面的方法获取 ID 为1的Inventory实体(这是事务 A):public Inventory firstTransactionFetchesAndReturn() { Inventory firstInventory = inventoryRepository.findById(1L).orElseThrow(); return firstInventory; } -
在
InventoryService中,下面的方法为相同的 ID (1)获取一个Inventory实体并更新数据(这是事务 B):@Transactional public void secondTransactionFetchesAndReturn() { Inventory secondInventory = inventoryRepository.findById(1L).orElseThrow(); secondInventory.setQuantity(secondInventory.getQuantity() - 1); } -
最后,在
InventoryService中,下面的方法更新在事务 A 中获取的实体(这是事务 C):public void thirdTransactionMergesAndUpdates(Inventory firstInventory) { // calls EntityManager#merge() behind the scene inventoryRepository.save(firstInventory); // this ends up in Optimistic Locking exception }
有了这三个方法,先调用firstTransactionFetchesAndReturn()。这将触发以下SELECT:
SELECT
inventory0_.id AS id1_0_0_,
inventory0_.quantity AS quantity2_0_0_,
inventory0_.title AS title3_0_0_,
inventory0_.version AS version4_0_0_
FROM inventory inventory0_
WHERE inventory0_.id = ?
Binding:[1] Extracted:[10, A People's History, 0]
此时,取出的version就是0。事务提交,持久性上下文关闭。返回的Inventory成为一个分离的实体。
进一步,调用secondTransactionFetchesAndReturn()。这将触发以下 SQL 语句:
SELECT
inventory0_.id AS id1_0_0_,
inventory0_.quantity AS quantity2_0_0_,
inventory0_.title AS title3_0_0_,
inventory0_.version AS version4_0_0_
FROM inventory inventory0_
WHERE inventory0_.id = ?
Binding:[1] Extracted:[10, A People's History, 0]
UPDATE inventory
SET quantity = ?,
title = ?,
version = ?
WHERE id = ?
AND version = ?
Binding:[9, A People's History, 1, 1, 0]
此时,version被更新为1。此事务也修改了数量。持久性上下文已关闭。
接下来,调用thirdTransactionMergesAndUpdates()并作为参数传递您之前获取的分离实体。Spring 检查实体并得出结论,这应该被合并。因此,在幕后(save()调用的背后),它调用EntityManager#merge()。
此外,JPA 提供者从数据库(通过SELECT)获取等同于分离实体的持久对象(因为没有这样的对象),并将分离的实体复制到持久实体:
SELECT
inventory0_.id AS id1_0_0_,
inventory0_.quantity AS quantity2_0_0_,
inventory0_.title AS title3_0_0_,
inventory0_.version AS version4_0_0_
FROM inventory inventory0_
WHERE inventory0_.id = ?
Binding:[1] Extracted:[9, A People's History, 1]
在合并操作中,分离的实体不会被管理。分离的实体被复制到托管实体中(在持久性上下文中可用)。
此时,Hibernate 断定提取的实体的version和分离的实体的version不匹配。这将导致 Spring Boot 报告为ObjectOptimisticLockingFailureException的乐观锁定异常。
GitHub 63 上有源代码。
不要尝试重试使用merge()的事务。每次重试只会从数据库中获取版本与分离实体的版本不匹配的实体,从而导致乐观锁定异常。
第 134 项:如何在长 HTTP 对话中使用乐观锁定机制和分离实体
以下场景是 web 应用中的常见情况,被称为长对话。换句话说,一堆逻辑上相关的请求(操作)形成了一个有状态的长对话,其中也包含了客户端思考周期(例如,适合于实现向导)。主要地,这个读➤modify ➤write 流被认为是可能跨越多个物理事务的逻辑或应用级事务(例如,在下面的例子中,应用级事务跨越两个物理事务)。
应用级事务也应该适合 ACID 属性。换句话说,您必须控制并发性(例如,通过乐观锁定机制,该机制适用于应用级和物理事务)并拥有应用级的可重复读取。这样,你就防止了lost updates(详情见附录 E )。请记住第 21 条中的内容,只要使用实体查询,持久化上下文就能保证会话级的可重复读取。提取投影不会利用会话级可重复读取。
此外,请注意,在长对话中,只有最后一个物理事务是可写的,以便将更改传播到数据库(刷新和提交)。如果应用级事务具有中间物理可写事务,那么它不能维持应用级事务的原子性。换句话说,在应用级事务的上下文中,虽然物理事务可能会提交,但后续事务可能会回滚。
如果您不想将由多个物理只读事务组成的逻辑事务与最后一个可写事务一起使用,您可以禁用自动刷新,并在最后一个物理事务中启用它:
// disable auto-flush
entityManager.unwrap(Session.class)
.setHibernateFlushMode(FlushMode.MANUAL);
然后,在最后一个物理事务中,启用它:
// enable auto-flush
entityManager.unwrap(Session.class)
.setHibernateFlushMode(FlushMode.AUTO);
分离的实体通常用于通过无状态 HTTP 协议跨越多个请求的长对话中(另一种方法依赖于扩展的持久性上下文,其中实体在多个 HTTP 请求之间保持连接)。
一个典型的场景如下:
-
HTTP 请求 A 命中控制器端点。
-
控制器进一步委派作业,并导致在持久上下文 A 中获取实体 A(实体 A 计划由客户机修改)。
-
持久性上下文 A 被关闭,实体 A 被分离。
-
分离的实体 A 存储在会话中,并且控制器将其返回给客户端。
-
客户端修改接收到的数据,并在另一个 HTTP 请求 b 中提交修改。
-
从会话中提取分离的实体 A,并与客户端提交的数据同步。
-
分离的实体被合并,这意味着 Hibernate 在持久上下文 B 中加载来自数据库(实体 B)的最新数据,并更新它以镜像分离的实体 a。
-
合并后,应用可以相应地更新数据库。
只要 HTTP 请求 A 和 b 之间的实体数据没有被修改,这种情况在没有版本化乐观锁定的情况下也能很好地工作。如果发生这种情况,并且是不需要的(例如,因为丢失更新,那么是时候启用版本化乐观锁定了,如下面的Inventory实体(书店的库存):
@Entity
public class Inventory implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Min(value = 0)
@Max(value = 100)
private int quantity;
@Version
private short version;
public short getVersion() {
return version;
}
// getters and setters omitted for brevity
}
为了更新(增加/减少)库存,书店的管理员通过指向控制器端点(这是响应 HTTP 请求 A 的控制器端点)的简单 HTTP GET请求,通过id(在Inventory实例中具体化)加载所需的图书。返回的Inventory通过@SessionAttributes存储在会话中,如下所示:
@Controller
@SessionAttributes({InventoryController.INVENTORY_ATTR})
public class InventoryController {
protected static final String INVENTORY_ATTR = "inventory";
private static final String BINDING_RESULT =
"org.springframework.validation.BindingResult." + INVENTORY_ATTR;
private final InventoryService inventoryService;
public InventoryController(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
@GetMapping("/load/{id}")
public String fetchInventory(@PathVariable Long id, Model model) {
if (!model.containsAttribute(BINDING_RESULT)) {
model.addAttribute(INVENTORY_ATTR,
inventoryService.fetchInventoryById(id));
}
return "index";
}
...
在设置了新的数量(这个标题的新股票)之后,数据通过 HTTP POST请求提交给下面的控制器端点(这是 HTTP 请求 B)。分离的Inventory从 HTTP 会话中加载,并与提交的数据同步。因此,分离的Inventory被更新以反映提交的修改。这是@ModelAttribute和@SessionAttributes的工作。此外,服务方法updateInventory()负责合并实体并将修改传播到数据库。如果与此同时,数据被另一个管理员修改了,那么就会抛出乐观锁定异常。查看处理潜在乐观锁定异常的try-catch块:
...
@PostMapping("/update")
public String updateInventory(
@Validated @ModelAttribute(INVENTORY_ATTR) Inventory inventory, BindingResult bindingResult, RedirectAttributes redirectAttributes, SessionStatus sessionStatus) {
if (!bindingResult.hasErrors()) {
try {
Inventory updatedInventory =
inventoryService.updateInventory(inventory);
redirectAttributes.addFlashAttribute("updatedInventory",
updatedInventory);
} catch (OptimisticLockingFailureException e) {
bindingResult.reject("", "Another user updated the data.
Press the link above to reload it.");
}
}
if (bindingResult.hasErrors()) {
redirectAttributes.addFlashAttribute(BINDING_RESULT,
bindingResult);
return "redirect:load/" + inventory.getId();
}
sessionStatus.setComplete();
return "redirect:success";
}
...
如果库存成功更新,则数据会通过以下控制器端点显示在一个简单的 HTML 页面中:
@GetMapping(value = "/success")
public String success() {
return "success";
}
下面列出了 Spring service 的源代码:
@Service
public class InventoryService {
private final InventoryRepository inventoryRepository;
public InventoryService(InventoryRepository inventoryRepository) {
this.inventoryRepository = inventoryRepository;
}
public Inventory fetchInventoryById(Long id) {
Inventory inventory = inventoryRepository
.findById(id).orElseThrow();
return inventory;
}
public Inventory updateInventory(Inventory inventory) {
return inventoryRepository.save(inventory);
}
}
测试时间
测试的目的是遵循导致乐观锁定异常的场景。更准确地说,目标是获得图 14-25 。
图 14-25
HTTP 长对话和分离的实体
图 14-25 如下图所示:
-
启动两个浏览器(模拟两个客户端)并访问
localhost:8080。 -
在两种浏览器中,单击屏幕上显示的链接。
-
在第一个浏览器中,插入一个新的股票值并点击
Update Inventory(结果将是一个包含修改的新页面)。 -
在第二个浏览器中,插入另一个新股票值,然后单击
Update Inventory。 -
此时,由于第一个客户端已经修改了数据,第二个客户端将会看到图 14-25 中高亮显示的消息;因此,这次没有丢失更新。
GitHub 64 上有源代码。
在 Spring 中,建议避免使用扩展持久性上下文,因为它有陷阱和缺点。但是,如果你决定继续这样做,请注意以下几点:
-
readOnly标志不起作用。这意味着任何修改都将传播到数据库,即使您将事务标记为readOnly。一个解决方案是对所有涉及的物理事务禁用自动刷新,但最后一个需要启用它的事务除外。然而,在扩展的持久性上下文中,只读操作(例如,find()、refresh()、detach()和读取查询)可以在事务之外执行。甚至一些实体变更(例如persist()和merge())也可以在事务之外执行。它们将被排队,直到扩展的持久性上下文加入一个事务。不能在事务之外执行flush()、lock()和更新/删除查询等操作。 -
内存占用:请注意,您获取的每个实体都会增加扩展的持久性上下文,因此会降低脏检查机制的速度。您可以通过显式分离最后一个物理事务中不需要的实体来改善这种情况。
第 135 项:如何增加锁定实体的版本,即使此实体未被修改
考虑几个准备出版一本书的编辑。他们加载每一章并应用特定的修改(格式、语法、缩进等)。).只有在同时另一个人没有保存任何修改的情况下,他们中的每一个才应该被允许保存他们的修改。在这种情况下,应该在考虑修改之前重新加载该章。换句话说,修改应该按顺序应用。
章节由根实体Chapter映射,修改由Modification实体映射。在Modification(子端)和Chapter(父端)之间,有一个单向的@ManyToOne关联,如图 14-26 中的表格所示。
图 14-26
一对多表关系
乐观 _ 强制 _ 增量
为了塑造这个场景,我们可以依靠@Version和OPTIMISTIC_FORCE_INCREMENT锁定策略。他们的力量结合起来可以帮助你增加锁定实体的版本(Chapter),即使这个实体没有被修改。换句话说,每个修改(Modification)被强制传播到父实体(Chapter)乐观锁定版本。
因此,应该将乐观锁定版本添加到根实体中,Chapter:
@Entity
public class Chapter implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@Version
private short version;
...
}
这里列出了Modification实体:
@Entity
public class Modification implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String description;
private String modification;
@ManyToOne(fetch = FetchType.LAZY)
private Chapter chapter;
...
}
编辑器通过 ID 加载章节,使用LockModeType.OPTIMISTIC_FORCE_INCREMENT锁策略。为此,我们必须覆盖ChapterRepository.findById()方法来添加锁定模式,如下所示(默认情况下,findById()不使用锁定):
@Repository
public interface ChapterRepository extends JpaRepository<Chapter, Long> {
@Override
@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
public Optional<Chapter> findById(Long id);
}
此外,让我们考虑以下场景:
-
第一步:编辑器 1 加载第一章。
-
第二步:编辑器 2 也加载第一章。
-
步骤 3:编辑器 2 执行修改并保存它。
-
第 4 步:编辑器 2 强制将此修改传播到第一章乐观锁定版本。编辑器 2 的事务提交。
-
步骤 5:编辑器 1 执行一个修改,并试图保存它。
-
步骤 6:编辑器 1 导致乐观锁定异常,因为与此同时,编辑器 2 添加了一个修改。
您可以使用TransactionTemplate通过两个并发事务来设计这个场景,如下面的代码所示:
@Service
public class BookstoreService {
private static final Logger log
= Logger.getLogger(BookstoreService.class.getName());
private final TransactionTemplate template;
private final ChapterRepository chapterRepository;
private final ModificationRepository modificationRepository;
public BookstoreService(ChapterRepository chapterRepository,
ModificationRepository modificationRepository,
TransactionTemplate template) {
this.chapterRepository = chapterRepository;
this.modificationRepository = modificationRepository;
this.template = template;
}
public void editChapter() {
template.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
log.info("Starting first transaction ...");
Chapter chapter = chapterRepository.findById(1L).orElseThrow();
Modification modification = new Modification();
modification.setDescription("Rewording first paragraph");
modification.setModification("Reword: ... Added: ...");
modification.setChapter(chapter);
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
log.info("Starting second transaction ...");
Chapter chapter
= chapterRepository.findById(1L).orElseThrow();
Modification modification = new Modification();
modification.setDescription(
"Formatting second paragraph");
modification.setModification("Format ...");
modification.setChapter(chapter);
modificationRepository.save(modification);
log.info("Commit second transaction ...");
}
});
log.info("Resuming first transaction ...");
modificationRepository.save(modification);
log.info("Commit first transaction ...");
}
});
log.info("Done!");
}
}
运行上述editChapter()时,Hibernate 生成以下输出:
Starting first transaction ...
-- Editor 1 load chapter 1
SELECT
chapter0_.id AS id1_0_0_,
chapter0_.content AS content2_0_0_,
chapter0_.title AS title3_0_0_,
chapter0_.version AS version4_0_0_
FROM chapter chapter0_
WHERE chapter0_.id = 1
Starting second transaction ...
-- Editor 2 loads chapter 1 as well
SELECT
chapter0_.id AS id1_0_0_,
chapter0_.content AS content2_0_0_,
chapter0_.title AS title3_0_0_,
chapter0_.version AS version4_0_0_
FROM chapter chapter0_
WHERE chapter0_.id = 1
-- Editor 2 perform a modification and persist it
INSERT INTO modification (chapter_id, description, modification)
VALUES (1, "Formatting second paragraph", "Format")
Commit second transaction ...
-- Editor 2 forcibly propagate this modification
-- to chapter 1 Optimistic Locking version
UPDATE chapter
SET version = 1
WHERE id = 1
AND version = 0
Resuming first transaction ...
-- Editor 1 perform a modification and attempts to persist it
INSERT INTO modification (chapter_id, description, modification)
VALUES (1, "Rewording first paragraph", "Reword: ... Added: ...")
-- Editor 1 causes an Optimistic Locking exception since,
-- in the meanwhile, Editor 2 has added a modification
UPDATE chapter
SET version = 1
WHERE id = 1
AND version = 0
-- org.springframework.orm.ObjectOptimisticLockingFailureException
-- Caused by: org.hibernate.StaleObjectStateException
注意突出显示的UPDATE。这是增加version的UPDATE。这个UPDATE是在当前运行的事务结束时针对chapter表触发的。
OPTIMISTIC_FORCE_INCREMENT锁策略通过将子端状态变化传播到父端乐观锁版本,有助于以顺序方式协调这些变化。您可以编排单个子节点(如前所示)或多个子节点的状态变化序列。
完整的应用可在 GitHub 65 上获得。
悲观 _ 强制 _ 增量
当OPTIMISTIC_FORCE_INCREMENT在当前事务结束时递增版本时,PESSIMISTIC_FORCE_INCREMENT立即递增版本。实体版本更新保证在获得行级锁后立即成功。增量发生在实体返回到数据访问层之前。
如果实体以前被加载而没有被锁定,并且PESSIMISTIC_FORCE_INCREMENT版本更新失败,当前运行的事务可以立即回滚。
这一次,我们使用@Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)。我们还添加了一个获取Chapter而不锁定(findByTitle())的查询:
@Repository
public interface ChapterRepository extends JpaRepository<Chapter, Long> {
@Override
@Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
public Optional<Chapter> findById(Long id);
public Chapter findByTitle(String title);
}
此外,让我们考虑以下场景:
-
步骤 1:编辑器 1 加载第一章,但不获取锁(逻辑锁或物理锁)。
-
第二步:编辑器 2 也加载第一章,但是通过
PESSIMISTIC_FORCE_INCREMENT。 -
步骤 3:编辑器 2 获得一个行锁,并立即增加版本。
-
步骤 4:编辑者 2 保存他们的修改(事务被提交)。
-
步骤 5:编辑器 1 试图在步骤 1 中加载的第一章实体上获取一个
PESSIMISTIC_FORCE_INCREMENT。 -
步骤 6:编辑器 1 导致一个乐观锁定异常,因为与此同时,编辑器 2 添加了一个修改,更新了版本。
您可以使用TransactionTemplate通过两个并发事务来设计这个场景,如下面的代码所示:
public void editChapterTestVersion() {
template.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
log.info("Starting first transaction
(no physical or logical lock) ...");
Chapter chapter = chapterRepository.findByTitle("Locking");
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
log.info("Starting second transaction ...");
Chapter chapter
= chapterRepository.findById(1L).orElseThrow();
Modification modification = new Modification();
modification.setDescription(
"Formatting second paragraph");
modification.setModification("Format ...");
modification.setChapter(chapter);
modificationRepository.save(modification);
log.info("Commit second transaction ...");
}
});
log.info("Resuming first transaction ...");
log.info("First transaction attempts to acquire a "
+ "P_F_I on the existing `chapter` entity");
entityManager.lock(chapter,
LockModeType.PESSIMISTIC_FORCE_INCREMENT);
Modification modification = new Modification();
modification.setDescription("Rewording first paragraph");
modification.setModification("Reword: ... Added: ...");
modification.setChapter(chapter);
modificationRepository.save(modification);
log.info("Commit first transaction ...");
}
});
log.info("Done!");
}
当运行上述editChapterTestVersion()时,Hibernate 生成以下输出:
Starting first transaction (no physical or logical lock) ...
-- Editor 1 loads chapter 1 without acquiring any lock (logical of physical)
SELECT
chapter0_.id AS id1_0_,
chapter0_.content AS content2_0_,
chapter0_.title AS title3_0_,
chapter0_.version AS version4_0_
FROM chapter chapter0_
WHERE chapter0_.title = "Locking"
Starting second transaction ...
-- Editor 2 loads chapter 1 as well, but via PESSIMISTIC_FORCE_INCREMENT
SELECT
chapter0_.id AS id1_0_0_,
chapter0_.content AS content2_0_0_,
chapter0_.title AS title3_0_0_,
chapter0_.version AS version4_0_0_
FROM chapter chapter0_
WHERE chapter0_.id = " Locking" FOR UPDATE
-- Editor 2 gets a row-lock and increment the version immediately
UPDATE chapter
SET version = 1
WHERE id = 1
AND version = 0
-- Editor 2 save their modifications
(transaction is committed)
INSERT INTO modification (chapter_id, description, modification)
VALUES (1, " Formatting second paragraph", "Format ...")
Commit second transaction ...
Resuming first transaction ...
First transaction attempts to acquire a PESSIMISTIC_FORCE_INCREMENT on the existing `chapter` entity
-- Editor 1 attempts to acquire a PESSIMISTIC_FORCE_INCREMENT
-- on chapter 1 entity loaded at Step 1
UPDATE chapter
SET version = 1
WHERE id = 1
AND version = 0
-- Editor 1 causes an Optimistic Locking exception since, in the meanwhile,
-- Editor 2 has added a modification, therefore updated the version
-- javax.persistence.OptimisticLockException
-- Caused by: org.hibernate.StaleObjectStateException
注意,即使编辑器 1 加载了第一章而没有被锁定,获取一个PESSIMISTIC_FORCE_INCREMENT的失败也会立即回滚当前事务。
为了获得一个排他锁,Hibernate 将依赖底层的Dialect lock 子句。注意 MySQL 方言——MySQL5Dialect(MyISAM)不支持行级锁,MySQL5InnoDBDialect (InnoDB)通过FOR UPDATE(可以设置超时)获取行级锁,MySQL8Dialect (InnoDB)通过FOR UPDATE NOWAIT获取行级锁。
在 PostgreSQL 中,PostgreSQL95Dialect方言通过FOR UPDATE NOWAIT获取行级锁。
增加实体版本的事务将阻止其他事务获取PESSIMISTIC_FORCE_INCREMENT锁,直到它释放行级物理锁(通过提交或回滚)。在这种情况下,总是依靠NOWAIT或显式短超时来避免死锁(注意,默认超时通常过于宽松,显式设置短超时是一种好的做法)。数据库可以检测并修复死锁(通过终止其中一个事务),但它只能在超时后才能这样做。长超时意味着长时间的繁忙连接,因此会降低性能。此外,锁定太多数据可能会影响可伸缩性。
注意,MySQL 使用REPEATABLE_READ作为默认隔离级别。这意味着获得的锁(显式锁或非显式锁)在事务期间被持有。另一方面,在READ_COMMITTED隔离级别(PostgreSQL 和其他 RDBMS 中的默认值),不需要的锁在STATEMENT完成后被释放。更多详情请点击 66 。
完整的应用可在 GitHub 67 上获得。
项目 136:悲观读/写如何工作
当我们谈论PESSIMISTIC_READ和PESSIMISTIC_WRITE时,我们谈论的是共享锁和排他锁。
共享锁和读锁允许多个进程同时读,不允许写。只要写操作正在进行,排他锁或写锁就不允许读取和写入。共享/读锁的目的是防止其他进程获得独占/写锁。
简而言之,共享/读锁表示:
-
欢迎你在其他读者旁边阅读,但如果你想写,你必须等待锁被释放。
一个独占/写锁表示:
-
抱歉,有人正在写,因此在解锁之前,您无法读写。
您可以通过PESSIMISTIC_READ在 Spring Boot 的查询级别获得一个共享锁,而您可以通过PESSIMISTIC_WRITE获得一个排他锁,就像在下面与Author实体相关联的存储库中一样(您可以通过同样的方式为任何其他查询获得共享/排他锁,例如通过 Spring Data Query Builder 机制或通过@Query定义的查询):
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Override
@Lock(LockModeType.PESSIMISTIC_READ/WRITE)
public Optional<Author> findById(Long id);
}
获取共享锁和排他锁的支持和语法是特定于每个数据库的。此外,即使在同一个数据库中,这些方面也会有所不同,这取决于方言。Hibernate 依靠Dialect来选择合适的语法。
出于测试目的,让我们考虑以下场景,该场景涉及两个并发事务:
-
步骤 1:事务 A 获取 ID 为
1的作者。 -
步骤 2:事务 B 获取同一个作者。
-
第三步:事务 B 更新作者流派。
-
步骤 4:事务 B 提交。
-
步骤 5:事务 A 提交。
在代码中,这个场景可以通过如下所示的TransactionTemplate来实现:
private final TransactionTemplate template;
...
public void pessimisticReadWrite() {
template.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.setTimeout(3); // 3 seconds
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
log.info("Starting first transaction ...");
Author author = authorRepository.findById(1L).orElseThrow();
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
log.info("Starting second transaction ...");
Author author
= authorRepository.findById(1L).orElseThrow();
author.setGenre("Horror");
log.info("Commit second transaction ...");
}
});
log.info("Resuming first transaction ...");
log.info("Commit first transaction ...");
}
});
log.info("Done!");
}
现在,让我们看看这个场景在PESSIMISTIC_READ和PESSIMISTIC_WRITE的上下文中是如何工作的。
悲观 _ 阅读
在PESSIMISTIC_READ上下文中使用这个场景应该会产生以下流程:
-
步骤 1:事务 A 获取 ID 为
1的作者,并获得一个共享锁。 -
步骤 2:事务 B 获取同一个作者并获得一个共享锁。
-
第三步:事务 B 想要更新作者的流派。
-
步骤 4:事务 B 超时,因为只要事务 A 持有共享锁,它就不能获取锁来修改该行。
-
第五步:事务 B 引起一个
QueryTimeoutException。
现在,让我们看看不同的数据库和方言是如何尊重这种流动的。
MySQL 和 MySQL 方言(MyISAM)
当通过MySQL5Dialect运行前面提到的pessimisticReadWrite()时,Hibernate 生成以下输出(注意SELECT语句中LOCK IN SHARE MODE的出现;这是 MySQL 特有的共享锁语法):
Starting first transaction ...
-- Transaction A fetches the author with id 1 and acquire a shared lock
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = 1 LOCK IN SHARE MODE
Starting second transaction ...
-- Transaction B fetches the same author and acquire a shared lock
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = 1 LOCK IN SHARE MODE
Commit second transaction ...
-- Transaction B updates the author's genre successfully
UPDATE author
SET age = 23,
genre = "Horror",
name = "Mark Janel"
WHERE id = 1
Resuming first transaction ...
Commit first transaction ...
Done!
即使获取共享锁的语法存在(LOCK IN SHARE MODE),MyISAM 引擎也不会阻止写入。所以,避开MySQL5Dialect方言。
MySQL 和 MySQL 5 InnoDB dialect/MySQL 8 dialect 方言(InnoDB)
当通过MySQL5InnoDBDialect或MySQL8Dialect运行前述pessimisticReadWrite()时,结果将遵循场景的步骤。因此,使用 InnoDB 引擎会按预期应用锁,并阻止写入(当共享锁处于活动状态时,InnoDB 会阻止其他事务获取该数据的独占/写入锁)。
在句法上,MySQL5InnoDBDialect方言用LOCK IN SHARE MODE,而MySQL8Dialect方言用FOR SHARE。以下输出是特定于MySQL8Dialect的:
Starting first transaction ...
-- Transaction A fetches the author with id 1 and acquire a shared lock
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = 1 FOR SHARE
Starting second transaction ...
-- Transaction B fetches the same author and acquire a shared lock
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = 1 FOR SHARE
Commit second transaction ...
-- Transaction B wants to update the author's genre
-- Transaction B times out since it cannot acquire a lock for modifying
-- this row as long as transaction
A is holding a shared lock on it
UPDATE author
SET age = 23,
genre = "Horror",
name = "Mark Janel"
WHERE id = 1
-- Transaction B causes a QueryTimeoutException
-- org.springframework.dao.QueryTimeoutException
-- Caused by: org.hibernate.QueryTimeoutException
通过MySQL5InnoDBDialect或MySQL8Dialect使用 InnoDB 引擎可以按预期工作。
PostgreSQL 和 PostgreSQL 95 方言
在 PostgreSQL 和PostgreSQL95Dialect的情况下,语法依赖于FOR SHARE来获取共享锁。下面的SELECT就是一个例子:
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = ? FOR SHARE
其他 RDBMS
Oracle 不支持行级共享锁。
SQL Server 通过WITH (HOLDLOCK, ROWLOCK)表提示获取共享锁。
悲观 _ 写
在PESSIMISTIC_WRITE上下文中创建这个场景应该会产生以下流程:
-
步骤 1:事务 A 获取 ID 为
1的作者,并获得一个独占锁。 -
步骤 2:事务 B 希望将 ID 为
1的作者的流派更新为恐怖。它试图获取这个作者并获得一个独占锁。 -
步骤 3:事务 B 超时,因为只要事务 A 持有对该行的独占锁,它就不能获取用于修改该行的锁。
-
第四步:事务 B 引起一个
QueryTimeoutException。
现在,让我们看看不同的数据库和方言是如何尊重这种流动的。
MySQL 和 MySQL5Dialect 方言(MyISAM)
当通过MySQL5Dialect运行上述pessimisticReadWrite()时,Hibernate 产生以下输出。注意在SELECT语句中出现了LOCK IN SHARE MODE。这是 MySQL 特定的共享锁语法:
Starting first transaction ...
-- Transaction A fetches the author with id 1 and acquire an exclusive lock
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = 1 FOR UPDATE
Starting second transaction ...
-- Transaction B wants to update the genre of author
with id 1 to Horror
-- It attempts to fetch this author and to acquire an exclusive lock
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = 1 FOR UPDATE
Commit second transaction ...
-- Transaction B updates the author's genre successfully
UPDATE author
SET age = 23,
genre = "Horror",
name = "Mark Janel"
WHERE id = 1
Resuming first transaction ...
Commit first transaction ...
Done!
--
即使获取排他锁的语法存在(FOR UPDATE),MyISAM 引擎实际上并不获取排他锁。所以,避开MySQL5Dialect方言。
MySQL 和 MySQL 5 InnoDB dialect/MySQL 8 dialect 方言(InnoDB)
当通过MySQL5InnoDBDialect或MySQL8Dialect运行前述pessimisticReadWrite()时,结果将遵循该场景的步骤。因此,使用 InnoDB 引擎会像预期的那样应用锁。
语法上,两种方言都用FOR UPDATE。以下输出为MySQL5InnoDBDialect和MySQL8Dialect所共有:
Starting first transaction ...
-- Transaction A fetches the author with id 1 and acquire an exclusive lock
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = 1 FOR UPDATE
Starting second transaction ...
-- Transaction B wants to update the genre of author with id 1 to Horror
-- It attempts to fetch this author and to acquire an exclusive lock
-- Transaction B times out since it cannot acquire a lock for modifying
-- this row as long as transaction A is holding an exclusive lock on it
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = 1 FOR UPDATE
-- Transaction B causes a QueryTimeoutException
-- org.springframework.dao.QueryTimeoutException
-- Caused by: org.hibernate.QueryTimeoutException
通过MySQL5InnoDBDialect或MySQL8Dialect使用 InnoDB 引擎可以按预期工作。
PostgreSQL 和 PostgreSQL 95 方言
在 PostgreSQL 和PostgreSQL95Dialect的情况下,语法依赖于FOR UPDATE来获取共享锁。下面的SELECT就是一个例子:
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = ? FOR UPDATE
其他 RDBMS
甲骨文通过FOR UPDATE获得排他锁。
SQL Server 通过WITH (UPDLOCK, HOLDLOCK, ROWLOCK)表提示获取排他锁。
完整的应用可在 GitHub 68 上获得。
第 137 项:悲观写入如何处理更新/插入和删除操作
当我们谈论PESSIMISTIC_WRITE时,我们谈论的是独占锁。考虑Author实体和下面的存储库AuthorRepository:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Override
@Lock(LockModeType.PESSIMISTIC_WRITE)
public Optional<Author> findById(Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
public List<Author> findByAgeBetween(int start, int end);
@Modifying
@Query("UPDATE Author SET genre = ?1 WHERE id = ?2")
public void updateGenre(String genre, long id);
}
触发更新
我们想要使用的场景基于前面的存储库,并遵循以下步骤:
-
第一步:事务 A 通过
findById()选择 ID 为1的作者,并获得一个独占锁。该事务将运行 10 秒钟。 -
第二步:事务 A 运行时,事务 B 在两秒后启动,调用
updateGenre()方法更新事务 A 取出的作者流派,事务 B 在 15 秒后超时。
为了查看UPDATE何时被触发,让我们使用两个线程来表示通过TransactionTemplate的两个事务:
public void pessimisticWriteUpdate() throws InterruptedException {
Thread tA = new Thread(() -> {
template.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
log.info("Starting first transaction ...");
Author author = authorRepository.findById(1L).orElseThrow();
try {
log.info("Locking for 10s ...");
Thread.sleep(10000);
log.info("Releasing lock ...");
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
});
log.info("First transaction committed!");
});
Thread tB = new Thread(() -> {
template.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.setTimeout(15); // 15 seconds
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
log.info("Starting second transaction ...");
authorRepository.updateGenre("Horror", 1L);
}
});
log.info("Second transaction committed!");
});
tA.start();
Thread.sleep(2000);
tB.start();
tA.join();
tB.join();
}
调用pessimisticWriteUpdate()显示以下输出:
Starting first transaction ...
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = 1 FOR UPDATE
Locking for 10s ...
Starting second transaction ...
UPDATE author
SET genre = "Horror"
WHERE id = 1
Releasing lock ...
First transaction committed!
Second transaction committed!
事务 B 仅在事务 A 提交后触发更新。换句话说,事务 B 被阻塞,直到它超时或者直到事务 A 释放独占锁。
触发删除
此外,让我们处理一个试图删除锁定行的场景:
-
第一步:事务 A 通过
findById()选择 ID 为1的作者,并获得一个独占锁。该事务将运行 10 秒钟。 -
步骤 2:当事务 A 正在运行时,事务 B 在两秒钟后启动,并调用内置查询方法
findById()删除事务 A 获取的作者,事务 B 在 15 秒后超时。
为了查看DELETE何时被触发,让我们使用两个线程来表示通过TransactionTemplate的两个事务:
public void pessimisticWriteDelete() throws InterruptedException {
Thread tA = new Thread(() -> {
template.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
log.info("Starting first transaction ...");
Author author = authorRepository.findById(1L).orElseThrow();
try {
log.info("Locking for 10s ...");
Thread.sleep(10000);
log.info("Releasing lock ...");
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
});
log.info("First transaction committed!");
});
Thread tB = new Thread(() -> {
template.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.setTimeout(15); // 15 seconds
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
log.info("Starting second transaction ...");
authorRepository.deleteById(1L);
}
});
log.info("Second transaction committed!");
});
tA.start();
Thread.sleep(2000);
tB.start();
tA.join();
tB.join();
}
调用pessimisticWriteDelete()显示以下输出:
Starting first transaction ...
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = 1 FOR UPDATE
Locking for 10s ...
Starting second transaction ...
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = 1
DELETE FROM author
WHERE id = 1
Releasing lock ...
First transaction committed!
Second transaction committed!
只有在事务 A 提交后,事务 B 才会触发删除。换句话说,事务 B 被阻塞,直到它超时或者直到事务 A 释放独占锁。
触发器插入
通常,即使使用排他锁,INSERT语句也是可能的(例如,PostgreSQL)。让我们关注以下场景:
-
第一步:事务 A 通过
findByAgeBetween()选择所有年龄在 40 到 50 之间的作者,并获得一个独占锁。该事务将运行 10 秒钟。 -
步骤 2:当事务 A 运行时,事务 B 在两秒钟后启动,并试图插入一个新的作者。事务 B 在 15 秒后超时。
为了查看INSERT何时被触发,让我们使用两个线程来表示通过TransactionTemplate的两个事务:
public void pessimisticWriteInsert(int isolationLevel)
throws InterruptedException {
Thread tA = new Thread(() -> {
template.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.setIsolationLevel(isolationLevel);
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
log.info("Starting first transaction ...");
List<Author> authors
= authorRepository.findByAgeBetween(40, 50);
try {
log.info("Locking for 10s ...");
Thread.sleep(10000);
log.info("Releasing lock ...");
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
});
log.info("First transaction committed!");
});
Thread tB = new Thread(() -> {
template.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.setTimeout(15); // 15 seconds
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
log.info("Starting second transaction ...");
Author author = new Author();
author.setAge(43);
author.setName("Joel Bornis");
author.setGenre("Anthology");
authorRepository.saveAndFlush(author);
}
});
log.info("Second transaction committed!");
});
tA.start();
Thread.sleep(2000);
tB.start();
tA.join();
tB.join();
}
用 REPEATABLE_READ 触发 MySQL 中的 INSERT
如上所述,即使使用排他锁,INSERT语句通常也是可能的(例如,PostgreSQL)。MySQL 是个例外,对于默认隔离级别REPEATABLE READ,它可以阻止针对一系列锁定条目的INSERT语句。
让我们用一个REPEATABLE_READ隔离级别调用前面提到的pessimisticWriteInsert()服务方法(这是 MySQL 中的默认隔离级别):
pessimisticWriteInsert(TransactionDefinition.ISOLATION_REPEATABLE_READ);
以下输出揭示了该流程:
Starting first transaction ...
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
WHERE author0_.age BETWEEN ? AND ? FOR UPDATE
Locking for 10s ...
Starting second transaction ...
INSERT INTO author (age, genre, name)
VALUES (?, ?, ?)
Releasing lock ...
First transaction committed!
Second transaction committed!
事务 B 仅在事务 A 提交后触发插入。换句话说,事务 B 被阻塞,直到它超时或者直到事务 A 释放独占锁。
用 READ_COMMITTED 触发 MySQL 中的 INSERT
现在,让我们切换到READ_COMMITTED隔离级别:
pessimisticWriteInsert(TransactionDefinition.ISOLATION_READ_COMMITTED);
这一次,输出如下:
Starting first transaction ...
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
WHERE author0_.age BETWEEN ? AND ? FOR UPDATE
Locking for 10s ...
Starting second transaction ...
INSERT INTO author (age, genre, name)
VALUES (?, ?, ?)
Second transaction committed!
Releasing lock ...
First transaction committed!
即使事务 A 持有独占锁,事务 B 也会触发插入。换句话说,事务 B 没有被事务 a 的独占锁阻塞。
完整的应用可在 GitHub 69 上获得。
Footnotes 1https://hibernate.atlassian.net/browse/HHH-10965
2
https://hibernate.atlassian.net/browse/HHH-13280
3
https://hibernate.atlassian.net/browse/HHH-13782
4
hibernate pringb 欧塔斯基 nctthrugh
5
hibernate pringb oojpacallaback
6
hibernate pringb ootsylistine r
7
hibernate pringb ootbilisi zeviaquery 创建 r
8
hibernate pringb ooderivedcounta nddelete
9
10
11
hibernate pringb ootbeuraslus 1
12
hibernate pringb otfetchjoinandq 战争
13
https://jira.spring.io/browse/DATAJPA-307
14
hibernate pringb oosoft deletes
15
hibernate pringb othjackson hibernate ate 5 模块
16
hibernate pringb oostlazzyi nitinopension envew
17
hibernate pringb bootenablelaylia dnotros
18
19
20
hibernate pringb otsubqueryinhe re
21
hibernate pringb oostresult map
22
hibernate pringgb ootcalstorstoredpro 中的洋葱路由值 e
23
https://jira.spring.io/browse/DATAJPA-1092
24
hibernate pringb oocallstoredpro cedrijdbctempla tebeanprperyro wmapper
25
hibernate pringb oocallstoredpro cedrijdbctempla te
26
hibernate pringb oocallstoredpro ceutilization call
27
hibernate pringb oocallstoredpro 黄昏返回 ltSet
28
https://hibernate.atlassian.net/browse/HHH-13215
29
https://github.com/spring-projects/spring-data-examples/tree/master/jpa/deferred
30
31
https://dev.mysql.com/doc/refman/8.0/en/view-updatability.html
32
33
https://dev.mysql.com/doc/refman/8.0/en/view-updatability.html
34
https://dev.mysql.com/doc/refman/8.0/en/view-updatability.html
35
36
hibernate pringb ootbdatabaseview itcheck option
37
hibernate pringb oottaassigning 对话号
38
hibernate pringb ootlenfunction
39
hibernate pringb oodenserkfunc 站
40
41
hibernate pringb ootopongwsperr oup
42
hibernate pringb otsearchviaspec fiction
43
hibernate pringb ootlinspadding
44
hibernate pringb ootb 规范 QueryFetchJoins
45
hibernate pringb ooquery planccach e
46
47
hibernate pringb ootbdynamic update
48
hibernate pringb oostnamequeriev iaannotations
49
hibernate pringb oostnamequerie n property file
50
hibernate pringb oostnamederquisie normxml
51
hibernate pringb booth property expre sessions
52
hibernate pringb oostchildse 副词
53
hibernate pringb bootsaveandmerge
54
hibernate pringb oomysqlskiplock 和
55
hibernate spring ootPostgresSqlSk ipLocked
56
https://vladmihalcea.com/how-do-postgresql-advisory-locks-work/
57
hibernate pringb ootbersonadversonalis onetimstillcking
58
hibernate pringb otretryves 双光子晶体版本 ng
59
hibernate pringb otretryves 双色调 ngTT
60
hibernate spring ootSimulateVersi onless 乐观锁定
61
hibernate pringb otretryvil essoptimistic ococ king
62
hibernate pringb otretryvil essoptimistic icloc kingdom
63
hibernate pringb ootsversionpti misticlocked dettactity
64
hibernate pringb othttplongconversacioned entity
65
hibernate pringb ootsicfor ceinction
66
67
hibernate pringb ootmimistifor 异步
68
69
hibernate pringb oostclo ckdelinspd