Spring-Boot-持久化最佳实践-全-
Spring Boot 持久化最佳实践(全)
一、关联
第一项:如何有效塑造@OneToMany 联想
双向@OneToMany
关联可能是我们的领域模型中遇到最多的关联。基于这种说法,本书在大量的例子中利用了这种联系。
要了解协会效率的超音速指南,请查看 附录 B 。
考虑双向懒惰@OneToMany
关联中涉及的两个实体Author
和Book
。在图 1-1 中,可以看到对应的@OneToMany
表关系。
图 1-1
@OneToMany 表关系
因此,author
表与book
表有一个@OneToMany
关系。一个author
行可以被多个book
行引用。author_id
列通过引用author
表主键的外键来映射这种关系。一本书不能没有作者,因此,author
是父端(@OneToMany
),而book
是子端(@ManyToOne
)。@ManyToOne
关联负责将外键列与持久性上下文(一级缓存)同步。
要获得超快速但有意义的 JPA 基础指南,请参见附录 A 。
根据经验,使用双向@OneToMany
关联而不是单向关联。你很快就会看到,第 2 条解决了单向@OneToMany
的性能损失,并解释了为什么应该避免它。
编写双向@OneToMany
关联的最佳方式将在下面的章节中讨论。
总是从父端级联到子端
从子端级联到父端是一种代码味道和不好的做法,这是一个明确的信号,是时候审查您的领域模型和应用设计了。想想看,一个孩子级联其父母的创造是多么不恰当或不合逻辑!一方面,一个孩子不能没有父母而存在,而另一方面,孩子级联他的父母的创造。这不符合逻辑吧?因此,根据经验,总是从父端级联到子端,如下例所示(这是使用双向关联的最重要的优点之一)。在这种情况下,我们从Author
端级联到Book
端,因此我们在Author
实体中添加级联类型:
@OneToMany(cascade = CascadeType.ALL)
在这种情况下,不要在@ManyToOne
上使用CascadeType.*
,因为实体状态转换应该从父端实体传播到子端实体。
不要忘记在父端设置 mappedBy
mappedBy
属性表示双向关联,必须在父端设置。换句话说,对于双向@OneToMany
关联,在父端将mappedBy
设置为@OneToMany
,在mappedBy
引用的子端添加@ManyToOne
。通过mappedBy
,双向@OneToMany
关联发信号通知它镜像@ManyToOne
子端映射。在这种情况下,我们在下面添加Author
实体:
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author")
在父端设置 orphanRemoval
在父端设置orphanRemoval
可以保证在没有引用的情况下移除子级。换句话说,orphanRemoval
适合于清理那些没有所有者对象的引用就不应该存在的依赖对象。在这种情况下,我们将orphanRemoval
添加到Author
实体中:
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author",
orphanRemoval = true)
保持关联双方的同步
通过添加到父端的辅助方法,可以很容易地使关联的两端保持同步。一般来说,add
Child
()
、remove
Child
()
和remove
Children
()
的方法会做得很好。虽然这可能代表“生存工具包”,但也可以添加更多的辅助方法。只需识别所使用的和涉及同步的操作,并将它们提取为辅助方法。如果您不努力保持关联双方的同步,那么实体状态转换可能会导致意外的行为。在这种情况下,我们将Author
实体添加到以下助手中:
public void addBook(Book book) {
this.books.add(book);
book.setAuthor(this);
}
public void removeBook(Book book) {
book.setAuthor(null);
this.books.remove(book);
}
public void removeBooks() {
Iterator<Book> iterator = this.books.iterator();
while (iterator.hasNext()) {
Book book = iterator.next();
book.setAuthor(null);
iterator.remove();
}
}
重写 equals()和 hashCode()
通过适当地覆盖equals()
和hashCode()
方法,应用在所有实体状态转换中获得相同的结果(这一方面在第 68 项中进行了剖析)。对于@OneToMany
关联,这些方法应该在子端被覆盖。在这种情况下,我们使用自动生成的数据库标识符来覆盖这两个方法。基于自动生成的数据库标识符覆盖equals()
和hashCode()
是一种特殊情况,详见第 68 项。要记住的最重要的一点是,对于自动生成的数据库标识符,equals()
方法应该在执行相等检查之前执行标识符的null
检查,而hashCode()
方法应该返回一个常量值。由于Book
实体在子端,我们强调这两个方面如下:
@Override
public boolean equals(Object obj) {
...
return id != null && id.equals(((Book) obj).id);
}
@Override
public int hashCode() {
return 2021;
}
在关联的两端使用延迟抓取
默认情况下,提取父端实体不会提取子实体。这意味着@OneToMany
被设置为 lazy。另一方面,默认情况下,获取子实体将急切地获取其父端实体。明智的做法是将@ManyToOne
显式设置为 lazy,并仅基于查询依赖于急切获取。更多详情请参见第章 3 。在这种情况下,Book
实体显式地将@ManyToOne
映射为LAZY
:
@ManyToOne(fetch = FetchType.LAZY)
注意 toString()是如何被覆盖的
如果toString()
需要被覆盖,那么确保只涉及从数据库加载实体时获取的基本属性。包含惰性属性或关联将触发单独的 SQL 语句,这些语句获取相应的数据或抛出LazyInitializationException
。例如,如果我们为Author
实体实现了toString()
方法,那么我们不会提到books
集合,我们只提到基本属性(id
、name
、age
和genre
):
@Override
public String toString() {
return "Author{" + "id=" + id + ", name=" + name
+ ", genre=" + genre + ", age=" + age + '}';
}
使用@JoinColumn 指定联接列名称
由所有者实体(Book
)定义的连接列存储 ID 值,并有一个到Author
实体的外键。建议为此列指定所需的名称。这样,在引用它时(例如,在本地查询中),您可以避免潜在的混淆/错误。在这种情况下,我们将@JoinColumn
添加到Book
实体,如下所示:
@JoinColumn(name = "author_id")
作者和书籍示例
将这些先前的指令粘合在一起并用代码表达它们将会产生下面的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)
private List<Book> books = new ArrayList<>();
public void addBook(Book book) {
this.books.add(book);
book.setAuthor(this);
}
public void removeBook(Book book) {
book.setAuthor(null);
this.books.remove(book);
}
public void removeBooks() {
Iterator<Book> iterator = this.books.iterator();
while (iterator.hasNext()) {
Book book = iterator.next();
book.setAuthor(null);
iterator.remove();
}
}
// getters and setters omitted for brevity
@Override
public String toString() {
return "Author{" + "id=" + id + ", name=" + name
+ ", genre=" + genre + ", age=" + age + '}';
}
}
@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")
private Author author;
// getters and setters omitted for brevity
@Override
public boolean equals(Object obj) {
if(obj == null) {
return false;
}
if (this == obj) {
return true;
}
if (getClass() != obj.getClass()) {
return false;
}
return id != null && id.equals(((Book) obj).id);
}
@Override
public int hashCode() {
return 2021;
}
@Override
public String toString() {
return "Book{" + "id=" + id + ", title=" + title
+ ", isbn=" + isbn + '}';
}
}
GitHub 1 上有源代码。
注意删除实体操作,尤其是子实体操作。虽然CascadeType.REMOVE
和orphanRemoval=true
会完成它们的工作,但是它们可能会产生太多的 SQL 语句。依靠批量操作通常是删除大量实体的最佳方式。要批量删除,请考虑项目 52 和项目 53 ,而要查看删除子实体的最佳实践,请考虑项目 6 。
第二条:为什么你应该避免单向的@木偶联想
考虑双向懒惰@OneToMany
关联中涉及的Author
和Book
实体(一个作者写了几本书,每本书只有一个作者)。试图插入一个子实体Book
,将导致一个 SQL INSERT
语句触发book
表(将添加一个子行)。尝试删除一个子实体将导致针对book
表触发一个 SQL DELETE
语句(删除一个子行)。
现在,让我们假设相同的Author
和Book
实体包含在单向@OneToMany
关联映射中,如下所示:
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books = new ArrayList<>();
缺少@ManyToOne
关联会导致一个单独的连接表(author_books
)来管理父子关联,如图 1-2 所示。
图 1-2
@OneToMany 表关系
连接表包含两个外键,因此索引比双向@OneToMany
情况下消耗更多内存。此外,拥有三个表也会影响查询操作。在双向@OneToMany
关联的情况下,读取数据可能需要三个连接,而不是两个。此外,让我们看看INSERT
和DELETE
如何在单向@OneToMany
关联中行动。
让我们假设有一个名叫乔安娜·尼玛尔的作者写了三本书。数据快照如图 1-3 所示。
图 1-3
数据快照(单向@OneToMany)
常规单向@OneToMany
下面的小节处理常规单向@OneToMany
关联中的INSERT
和REMOVE
操作。
请注意,每个场景都从图 1-3 所示的数据快照开始。
坚持一个作者和他们的书
下面显示了从数据快照中持久保存作者和相关书籍的服务方法:
@Transactional
public void insertAuthorWithBooks() {
Author jn = new Author();
jn.setName("Joana Nimar");
jn.setAge(34);
jn.setGenre("History");
Book jn01 = new Book();
jn01.setIsbn("001-JN");
jn01.setTitle("A History of Ancient Prague");
Book jn02 = new Book();
jn02.setIsbn("002-JN");
jn02.setTitle("A People's History");
Book jn03 = new Book();
jn03.setIsbn("003-JN");
jn03.setTitle("World History");
jn.addBook(jn01);
jn.addBook(jn02);
jn.addBook(jn03);
authorRepository.save(jn);
}
检查生成的 SQL INSERT
语句发现,与双向@OneToMany
关联相比,连接表中多了三个INSERT
(对于 n 本书,有 n 个额外的INSERT
):
INSERT INTO author (age, genre, name)
VALUES (?, ?, ?)
Binding:[34, History, Joana Nimar]
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding:[001-JN, A History of Ancient Prague]
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding:[002-JN, A People's History]
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding:[003-JN, World History]
-- additional inserts that are not needed for bidirectional @OneToMany
INSERT INTO author_books (author_id, books_id)
VALUES (?, ?)
Binding:[1, 1]
INSERT INTO author_books (author_id, books_id)
VALUES (?, ?)
Binding:[1, 2]
INSERT INTO author_books (author_id, books_id)
VALUES (?, ?)
Binding:[1, 3]
因此,在这种情况下,单向@OneToMany
关联不如双向@OneToMany
关联有效。接下来的每个场景都使用这个数据快照作为起点。
坚持现有作者的新书
由于乔安娜·尼玛尔刚刚出版了一本新书,我们必须将它添加到book
表中。这一次,服务方法如下所示:
@Transactional
public void insertNewBook() {
Author author = authorRepository.fetchByName("Joana Nimar");
Book book = new Book();
book.setIsbn("004-JN");
book.setTitle("History Details");
author.addBook(book); // use addBook() helper
authorRepository.save(author);
}
调用此方法并关注 SQL INSERT
语句会产生以下输出:
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding:[004-JN, History Details]
-- the following DML statements don't appear in bidirectional @OneToMany
DELETE FROM author_books
WHERE author_id = ?
Binding:[1]
INSERT INTO author_books (author_id, books_id)
VALUES (?, ?)
Binding:[1, 1]
INSERT INTO author_books (author_id, books_id)
VALUES (?, ?)
Binding:[1, 2]
INSERT INTO author_books (author_id, books_id)
VALUES (?, ?)
Binding:[1, 3]
INSERT INTO author_books (author_id, books_id)
VALUES (?, ?)
Binding:[1, 4]
因此,为了插入一本新书,JPA 持久性提供者(Hibernate)从连接表中删除了所有相关的书籍。接下来,它将新书添加到内存中,并再次将结果保存回来。这远非高效,潜在的性能损失也相当明显。
删除最后一本书
删除最后一本书包括获取一个作者的相关List<Book>
并从列表中删除最后一本书,如下所示:
@Transactional
public void deleteLastBook() {
Author author = authorRepository.fetchByName("Joana Nimar");
List<Book> books = author.getBooks();
// use removeBook() helper
author.removeBook(books.get(books.size() - 1));
}
调用deleteLastBook()
显示以下相关 SQL 语句:
DELETE FROM author_books
WHERE author_id = ?
Binding:[1]
INSERT INTO author_books (author_id, books_id)
VALUES (?, ?)
Binding:[1, 1]
INSERT INTO author_books (author_id, books_id)
VALUES (?, ?)
Binding:[1, 2]
-- for bidirectional @OneToMany this is the only needed DML
DELETE FROM book
WHERE id = ?
Binding:[3]
因此,为了删除最后一本书,JPA persistence provider(Hibernate)从连接表中删除所有相关的书,删除内存中的最后一本书,并再次将剩余的书持久化。因此,与双向@OneToMany
关联相比,有几个额外的 DML 语句表示性能损失。关联书籍越多,性能损失越大。
删除第一本书
删除第一本书包括获取一个作者的相关联的List<Book>
,并从列表中删除第一本书,如下所示:
@Transactional
public void deleteFirstBook() {
Author author = authorRepository.fetchByName("Joana Nimar");
List<Book> books = author.getBooks();
author.removeBook(books.get(0));
}
调用deleteFirstBook()
显示以下相关 SQL 语句:
DELETE FROM author_books
WHERE author_id = ?
Binding:[1]
INSERT INTO author_books (author_id, books_id)
VALUES (?, ?)
Binding:[1, 2]
INSERT INTO author_books (author_id, books_id)
VALUES (?, ?)
Binding:[1, 3]
-- for bidirectional @OneToMany this is the only needed DML
DELETE FROM book
WHERE id = ?
Binding:[1]
因此,删除第一本书的行为与删除最后一本书的行为完全相同。
除了由动态数量的附加 SQL 语句导致的性能损失之外,我们还面临由删除和重新插入与连接表的外键列相关联的索引条目导致的性能损失(大多数数据库对外键列使用索引)。当数据库从连接表中删除与父实体关联的所有表行时,它还会删除相应的索引条目。当数据库重新插入连接表时,它也会插入索引条目。
到目前为止,结论是明确的。对于读取、写入和删除数据,单向@OneToMany
关联不如双向@OneToMany
关联高效。
使用@OrderColumn
通过添加@OrderColumn
注释,单向@OneToMany
关联变得有序。换句话说,@OrderColumn
指示 Hibernate 将元素索引(每个集合元素的索引)具体化到连接表的一个单独的数据库列中,以便使用ORDER BY
子句对集合进行排序。在这种情况下,每个集合元素的索引都将存储在连接表的books_order
列中。在代码中:
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@OrderColumn(name = "books_order")
private List<Book> books = new ArrayList<>();
更进一步,让我们看看关联如何与@OrderColumn
一起工作。
坚持作者和书籍
通过insertAuthorWithBooks()
服务方法持久化快照中的作者和相关书籍会触发以下相关的 SQL 语句:
INSERT INTO author (age, genre, name)
VALUES (?, ?, ?)
Binding:[34, History, Joana Nimar]
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding:[001-JN, A History of Ancient Prague]
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding:[002-JN, A People's History]
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding:[003-JN, World History]
-- additional inserts not needed for bidirectional @OneToMany
INSERT INTO author_books (author_id, books_order, books_id)
VALUES (?, ?, ?)
Binding:[1, 0, 1]
INSERT INTO author_books (author_id, books_order, books_id)
VALUES (?, ?, ?)
Binding:[1, 1, 2]
INSERT INTO author
_books (author_id, books_order, books_id)
VALUES (?, ?, ?)
Binding:[1, 2, 3]
看来@OrderColumn
并没有带来什么好处。三个附加的INSERT
语句仍然被触发。
坚持出版现有作者的新书
通过insertNewBook()
服务方法持久化一本新书会触发以下相关的 SQL 语句:
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding:[004-JN, History Details]
-- this is not needed for bidirectional @OneToMany
INSERT INTO author_books (author_id, books_order, books_id)
VALUES (?, ?, ?)
Binding:[1, 3, 4]
有好消息也有坏消息!
好消息是,这一次,Hibernate 没有删除相关的书籍来从内存中添加它们。
坏消息是,与双向@OneToMany
关联相比,连接表中还有一个额外的INSERT
语句。所以,在这种背景下,@OrderColumn
带来了一些好处。
删除最后一本书
通过deleteLastBook()
删除最后一本书会触发以下相关 SQL 语句:
DELETE FROM author_books
WHERE author_id = ?
AND books_order = ?
Binding:[1, 2]
-- for bidirectional @OneToMany this is the only needed DML
DELETE FROM book
WHERE id = ?
Binding:[3]
看来@OrderColumn
在去掉最后一本书的情况下带来了一些好处。JPA persistence provider(Hibernate)并没有删除所有相关的书籍来从内存中添加剩余的书籍。
但是,与双向@OneToMany
关联相比,仍然有一个额外的DELETE
触发连接表。
删除第一本书
通过deleteFirstBook()
删除第一本书会触发以下相关 SQL 语句:
DELETE FROM author_books
WHERE author_id = ?
AND books_order = ?
Binding:[1, 2]
UPDATE author_books
SET books_id = ?
WHERE author_id = ?
AND books_order = ?
Binding:[3, 1, 1]
UPDATE author_books
SET books_id = ?
WHERE author_id = ?
AND books_order = ?
Binding:[2, 1, 0]
-- for bidirectional @OneToMany this is the only needed DML
DELETE FROM book
WHERE id = ?
Binding:[1]
离集合的末尾越远,使用@OrderColumn
的好处就越小。删除第一本书会导致连接表中出现一个DELETE
,后面跟着一串UPDATE
语句,意在保持数据库中集合在内存中的顺序。同样,这是没有效率的。
添加@OrderColumn
可以为移除操作带来一些好处。然而,要移除的元素越靠近获取列表的头部,需要的UPDATE
语句就越多。这会导致性能下降。即使在最好的情况下(从集合的尾部删除一个元素),这种方法也不比双向@OneToMany
关联好。
使用@JoinColumn
现在,让我们看看添加@JoinColumn
是否会带来任何好处:
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "author_id")
private List<Book> books = new ArrayList<>();
添加@JoinColumn
指示 Hibernate】关联能够控制子表外键。换句话说,取消了连接表,表的数量从三个减少到两个,如图 1-4 所示。
图 1-4
添加@JoinColumn 会删除连接表
坚持作者和书籍
通过insertAuthorWithBooks()
服务方法持久化作者和相关书籍会触发以下相关的 SQL 语句:
INSERT INTO author (age, genre, name)
VALUES (?, ?, ?)
Binding:[34, History, Joana Nimar]
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding:[001-JN, A History of Ancient Prague]
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding:[002-JN, A People's History]
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding:[003-JN, World History]
-- additional DML that are not needed in bidirectional @OneToMany
UPDATE book
SET author_id = ?
WHERE id = ?
Binding:[1, 1]
UPDATE book
SET author_id = ?
WHERE id = ?
Binding:[1, 2]
UPDATE book
SET author_id = ?
WHERE id = ?
Binding:[1, 3]
因此,对于每一本插入的书,Hibernate 都会触发一个UPDATE
来设置author_id
值。显然,与双向@OneToMany
关联相比,这增加了性能损失。
坚持出版现有作者的新书
通过insertNewBook()
服务方法持久化一本新书会触发以下相关的 SQL 语句:
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding:[004-JN, History Details]
-- additional DML that is not needed in bidirectional @OneToMany
UPDATE book
SET author_id = ?
WHERE id = ?
Binding:[1, 4]
这并不像常规的单向@OneToMany
关联那样糟糕,但是它仍然需要一个在双向@OneToMany
关联中不需要的UPDATE
语句。
删除最后一本书
通过deleteLastBook()
删除最后一本书会触发以下相关 SQL 语句:
UPDATE book
SET author_id = NULL
WHERE author_id = ?
AND id = ?
Binding:[1, 3]
-- for bidirectional @OneToMany this is the only needed DML
DELETE FROM book
WHERE id = ?
Binding:[3]
JPA 持久性提供者(Hibernate)通过将author_id
设置为null
来将图书与其作者分离。
接下来,由于orhpanRemoval=true
,被解除关联的书被删除。然而,这个额外的UPDATE
对于双向@OneToMany
关联来说是不必要的。
删除第一本书
通过deleteFirstBook()
删除第一本书会触发以下相关的 SQL 语句(这些语句与上一小节中的 SQL 语句相同):
UPDATE book
SET author_id = NULL
WHERE author_id = ?
AND id = ?
Binding:[1, 1]
-- for bidirectional @OneToMany this is the only needed DML
DELETE FROM book
WHERE id = ?
Binding:[1]
那个UPDATE
还在!再次,双向@OneToMany
协会赢得这场比赛。
添加@JoinColumn
可以提供优于常规单向@OneToMany
的好处,但并不比双向@OneToMany
关联好。额外的UPDATE
语句仍然会导致性能下降。
同时加@JoinColumn
和@OrderColumn
还是比不上双向@OneToMany
。此外,使用Set
而不是List
或者双向@OneToMany
和@JoinColumn
(例如@ManyToOne @JoinColumn(name = "author_id", updatable = false, insertable = false)
)仍然比双向@OneToMany
关联的性能差。
根据经验,单向@OneToMany
关联不如双向@OneToMany
或单向@ManyToOne
关联有效。
完整的代码可以在 GitHub 2 上找到。
第三条:单向@ManyToOne 的效率如何
如第 2 项所强调的,单向@OneToMany
关联效率不高,双向@OneToMany
关联更好。但是,单向@ManyToOne
协会的效率如何呢?让我们假设Author
和Book
参与了单向懒惰@ManyToOne
关联。@ManyToOne
关联正好映射到一对多表关系,如图 1-5 所示。
图 1-5
一对多表关系
如您所见,底层外键处于子端控制之下。这对于单向或双向关系是一样的。
在代码中,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;
...
}
@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")
private Author author;
...
}
现在,让我们看看单向@ManyToOne
关联的效率有多高。
给某个作者添加新书
向某个作者添加新书的最有效方式如下例所示(为简洁起见,我们简单地将作者id
硬编码为4
):
@Transactional
public void insertNewBook() {
Author author = authorRepository.getOne(4L);
Book book = new Book();
book.setIsbn("003-JN");
book.setTitle("History Of Present");
book.setAuthor(author);
bookRepository.save(book);
}
这个方法将触发一个单独的INSERT
SQL 语句。author_id
列将由关联的Author
实体的标识符填充:
INSERT INTO book (author_id, isbn, title)
VALUES (?, ?, ?)
Binding:[4, 003-JN, History Of Present]
注意,我们使用了getOne()
方法,该方法通过EntityManager.getReference()
返回一个Author
引用(更多细节可在项目 14 中找到)。引用状态可能是延迟获取的,但是在这个上下文中不需要它。因此,您避免了不必要的SELECT
语句。当然,如果您需要在持久性上下文中实际加载Author
实例,依赖findById()
也是可能的和可取的。显然,这将通过SELECT
语句来实现。
Hibernate 脏检查机制按预期工作(如果你不熟悉 Hibernate 脏检查,那么考虑第 18 项)。换句话说,更新book
将导致代表您触发UPDATE
语句。查看以下代码:
@Transactional
public void insertNewBook() {
Author author = authorRepository.getOne(4L);
Book book = new Book();
book.setIsbn("003-JN");
book.setTitle("History Of Present");
book.setAuthor(author);
bookRepository.save(book);
book.setIsbn("not available");
}
这一次,调用insertNewBook()
将触发一个INSERT
和一个UPDATE
:
INSERT INTO book (author_id, isbn, title)
VALUES (?, ?, ?)
UPDATE book
SET author_id = ?,
isbn = ?,
title = ?
WHERE id = ?
因为 Hibernate 用相关联的Author
实体的标识符填充author_id
列,所以向某个作者添加新书是高效的。
获取作者的所有书籍
您可以通过 JPQL 查询获取作者写的所有书籍,如下所示:
@Transactional(readOnly = true)
@Query("SELECT b FROM Book b WHERE b.author.id = :id")
List<Book> fetchBooksOfAuthorById(Long id);
从服务方法调用fetchBooksOfAuthorById()
非常简单:
public void fetchBooksOfAuthorById() {
List<Book> books = bookRepository.fetchBooksOfAuthorById(4L);
}
触发的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 = ?
修改一本书将利用脏检查机制。换句话说,从这个集合中更新一本书将导致代表您触发一个UPDATE
语句。查看以下代码:
@Transactional
public void fetchBooksOfAuthorById() {
List<Book> books = bookRepository.fetchBooksOfAuthorById(4L);
books.get(0).setIsbn("not available");
}
这一次,调用fetchBooksOfAuthorById()
将触发一个SELECT
和一个UPDATE
:
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 = ?
UPDATE book
SET author_id = ?,
isbn = ?,
title = ?
WHERE id = ?
获取一个作者的所有书籍只需要一个SELECT
;因此,该操作是高效的。Hibernate 不管理获取的集合,但是添加/删除书籍是非常有效和容易完成的。这个话题马上就要谈到了。
给作者的书翻页
只要子记录的数量很少,获取所有的书就可以了。一般来说,获取大型集合肯定是一种糟糕的做法,会导致严重的性能损失。分页的作用如下(只需添加一个Pageable
参数来产生一个经典的 Spring 数据偏移分页):
@Transactional(readOnly = true)
@Query("SELECT b FROM Book b WHERE b.author.id = :id")
Page<Book> fetchPageBooksOfAuthorById(Long id, Pageable pageable);
您可以从服务方法中调用fetchPageBooksOfAuthorById()
,如下例所示(当然,实际上,您不会使用这里所示的硬编码值):
public void fetchPageBooksOfAuthorById() {
Page<Book> books = bookRepository.fetchPageBooksOfAuthorById(4L,
PageRequest.of(0, 2, Sort.by(Sort.Direction.ASC, "title")));
books.get().forEach(System.out::println);
}
该方法触发两个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 = ?
ORDER BY book0_.title ASC LIMIT ?
SELECT
COUNT(book0_.id) AS col_0_0_
FROM book book0_
WHERE book0_.author_id = ?
优化偏移分页可按项 95 和项 96 进行。
与上一节完全一样,Hibernate 不管理获取的集合,但是修改一本书将利用脏检查机制。
获取作者的所有书籍并添加新书
“获取某个作者的所有书籍”一节已经涵盖了这个主题的一半,而“向某个作者添加新书”一节则涵盖了另一半。连接这些部分会产生以下代码:
@Transactional
public void fetchBooksOfAuthorByIdAndAddNewBook() {
List<Book> books = bookRepository.fetchBooksOfAuthorById(4L);
Book book = new Book();
book.setIsbn("004-JN");
book.setTitle("History Facts");
book.setAuthor(books.get(0).getAuthor());
books.add(bookRepository.save(book));
}
触发的 SQL 语句有:
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 = ?
INSERT INTO book (author_id, isbn, title)
VALUES (?, ?, ?)
由于获取一个作者的所有书籍只需要一个SELECT
,向获取的集合中添加一本新书只需要一个INSERT
,所以这个操作是高效的。
获取作者的所有书籍并删除一本书
以下代码获取某个作者的所有书籍,并删除第一本书:
@Transactional
public void fetchBooksOfAuthorByIdAndDeleteFirstBook() {
List<Book> books = bookRepository.fetchBooksOfAuthorById(4L);
bookRepository.delete(books.remove(0));
}
除了众所周知的获取作者所有书籍所需的SELECT
之外,删除发生在单个DELETE
语句中,如下所示:
DELETE FROM book
WHERE id = ?
因为获取一个作者的所有书籍只需要一个SELECT
,而从获取的集合中删除一本书只需要一个DELETE
,所以这个操作是高效的。
看起来单向@ManyToOne
关联非常有效,只要不需要双向@OneToMany
关联就可以使用。再次,尽量避免单向的@OneToMany
联想(见第 2 项)。
完整的应用可在 GitHub 3 上获得。
第四项:如何有效塑造@ManyToMany 协会
这一次,众所周知的Author
和Book
实体参与了一个双向的懒惰@ManyToMany
关联(一个作者写了更多的书,一本书被几个作者写了)。见图 1-6 。
图 1-6
@ManyToMany 表关系
双向@ManyToMany
关联可以从两端导航,因此,两端都可以是父级(父级端)。由于双方都是父母,他们都不会持有外键。在这个关联中,有两个外键存储在一个单独的表中,称为连接表。连接表是隐藏的,它扮演子端的角色。
编写双向@ManyToMany
关联的最佳方式将在以下章节中描述。
选择关系的所有者
使用默认的@ManyToMany
映射需要开发人员选择关系的所有者和mappedBy
方(也就是相反的一方)。只有一端可以是所有者,并且更改仅从该特定端传播到数据库。比如Author
可以当主人,而Book
加一个mappedBy
方。
@ManyToMany(mappedBy = "books")
private Set<Author> authors = new HashSet<>();
总是使用集合而不是列表
特别是如果涉及移除操作,建议依赖Set
并避免List
。正如第 5 项所强调的,Set
的表现要比List
好得多。
private Set<Book> books = new HashSet<>(); // in Author
private Set<Author> authors = new HashSet<>(); // in Book
保持关联双方的同步
通过在您更可能与之交互的一侧添加辅助方法,您可以很容易地使关联的两端保持同步。例如,如果业务逻辑对操纵Author
比对Book
更感兴趣,那么开发人员可以将Author
添加到至少这三个助手中:addBook()
、removeBook()
和removeBooks()
。
避免级联类型。ALL 和 CascadeType。移动
在大多数情况下,级联删除是坏主意。例如,删除一个Author
实体不应该触发Book
删除,因为Book
也可以被其他作者引用(一本书可以由几个作者写)。所以,避开CascadeType.ALL
和CascadeType.REMOVE
,依靠显式的CascadeType.PERSIST
和CascadeType.MERGE
:
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private Set<Book> books = new HashSet<>();
孤儿移除(orphanRemoval
)选项在@OneToOne
和@OneToMany
关系注释上定义,但在@ManyToOne
或@ManyToMany
注释上都没有定义。
设置连接表
显式设置连接表名和列名允许开发人员引用它们而不会混淆。这可以通过@JoinTable
完成,如下例所示:
@JoinTable(name = "author_book",
joinColumns = @JoinColumn(name = "author_id"),
inverseJoinColumns = @JoinColumn(name = "book_id")
)
在关联的两端使用延迟抓取
默认情况下,@ManyToMany
关联是懒惰的。保持这种方式!不要这样做:
@ManyToMany(fetch=FetchType.EAGER)
重写 equals()和 hashCode()
通过适当地覆盖equals()
和hashCode()
方法,应用在所有实体状态转换中获得相同的结果。这方面在第 68 项中有所剖析。对于双向的@ManyToMany
关联,这些方法应该在两端都被覆盖。
注意 toString()是如何被覆盖的
如果toString()
需要被覆盖,只涉及从数据库加载实体时提取的基本属性。涉及惰性属性或关联将触发单独的 SQL 语句来获取相应的数据。
作者和书籍示例
将这些指令粘合在一起并用代码表达它们将会产生下面的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;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(name = "author_book",
joinColumns = @JoinColumn(name = "author_id"),
inverseJoinColumns = @JoinColumn(name = "book_id")
)
private Set<Book> books = new HashSet<>();
public void addBook(Book book) {
this.books.add(book);
book.getAuthors().add(this);
}
public void removeBook(Book book) {
this.books.remove(book);
book.getAuthors().remove(this);
}
public void removeBooks() {
Iterator<Book> iterator = this.books.iterator();
while (iterator.hasNext()) {
Book book = iterator.next();
book.getAuthors().remove(this);
iterator.remove();
}
}
// getters and setters omitted for brevity
@Override
public boolean equals(Object obj) {
if(obj == null) {
return false;
}
if (this == obj) {
return true;
}
if (getClass() != obj.getClass()) {
return false;
}
return id != null && id.equals(((Author) obj).id);
}
@Override
public int hashCode() {
return 2021;
}
@Override
public String toString() {
return "Author{" + "id=" + id + ", name=" + name
+ ", genre=" + genre + ", age=" + age + '}';
}
}
@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;
@ManyToMany(mappedBy = "books")
private Set<Author> authors = new HashSet<>();
// getters and setter omitted for brevity
@Override
public boolean equals(Object obj) {
if(obj == null) {
return false;
}
if (this == obj) {
return true;
}
if (getClass() != obj.getClass()) {
return false;
}
return id != null && id.equals(((Book) obj).id);
}
@Override
public int hashCode() {
return 2021;
}
@Override
public String toString() {
return "Book{" + "id=" + id + ", title=" + title
+ ", isbn=" + isbn + '}';
}
}
GitHub 4 上有源代码。
或者,@ManyToMany
可以用两个双向@OneToMany
关联代替。换句话说,连接表可以映射到一个实体。这带来了几个好处,本文讨论了。
第五条:为什么在@ManyToMany 中 Set 比 List 好
首先,请记住 Hibernate 将@ManyToMany
关系作为两个单向@OneToMany
关联来处理。所有者端和子端(连接表)代表一个单向的@OneToMany
关联。另一方面,非所有者端和子端(连接表)代表另一种单向@OneToMany
关联。每个关联依赖于存储在连接表中的外键。
在该语句的上下文中,实体移除(或重新排序)导致从连接表中删除所有连接条目,并重新插入它们以反映内存内容(当前持久上下文内容)。
使用列表
假设双向惰性@ManyToMany
关联中涉及的Author
和Book
通过java.util.List
进行映射,如下所示(仅列出相关代码):
@Entity
public class AuthorList implements Serializable {
...
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(name = "author_book_list",
joinColumns = @JoinColumn(name = "author_id"),
inverseJoinColumns = @JoinColumn(name = "book_id")
)
private List<BookList> books = new ArrayList<>();
...
}
@Entity
public class BookList implements Serializable {
...
@ManyToMany(mappedBy = "books")
private List<AuthorList> authors = new ArrayList<>();
...
}
此外,考虑图 1-7 中所示的数据快照。
图 1-7
数据快照(双向@ManyToMany)
目标是在某一天移除作者艾丽西娅·汤姆(ID 为1
的作者)写的那本名为的书(ID 为 2 的书)。考虑到代表这个作者的实体是通过一个名为alicia
的变量存储的,而书是通过一个名为oneDay
的变量存储的,所以可以通过removeBook()
进行删除,如下所示:
alicia.removeBook(oneDay);
此删除触发的 SQL 语句有:
DELETE FROM author_book_list
WHERE author_id = ?
Binding: [1]
INSERT INTO author_book_list (author_id, book_id)
VALUES (?, ?)
Binding: [1, 1]
INSERT INTO author_book_list (author_id, book_id)
VALUES (?, ?)
Binding: [1, 3]
因此,删除并不是在一条 SQL 语句中实现的。实际上,它是从从连接表中删除alicia
的所有连接条目开始的。此外,没有被删除的连接条目被重新插入,以反映内存中的内容(持久性上下文)。重新插入的连接条目越多,数据库事务就越长。
使用集合
考虑从List
切换到Set
,如下所示:
@Entity
public class AuthorSet implements Serializable {
...
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(name = "author_book_set",
joinColumns = @JoinColumn(name = "author_id"),
inverseJoinColumns = @JoinColumn(name = "book_id")
)
private Set<BookSet> books = new HashSet<>();
...
}
@Entity
public class BookSet implements Serializable {
...
@ManyToMany(mappedBy = "books")
private Set<AuthorSet> authors = new HashSet<>();
...
}
这一次,调用alicia.removeBook(oneDay)
将触发下面的 SQL DELETE
语句:
DELETE FROM author_book_set
WHERE author_id = ?
AND book_id = ?
Binding: [1, 2]
源代码可以在 GitHub 6 上找到。这要好得多,因为只需要一条DELETE
语句就可以完成这项工作。
当使用@ManyToMany
注释时,总是使用java.util.Set
。不要使用java.util.List
。在其他关联的情况下,使用最适合您情况的一个。如果你选择了List
,不要忘了注意 HHH-58557的问题,这个问题从 Hibernate 5.0.8 开始就被修复了。
保留结果集的顺序
众所周知,java.util.ArrayList
保留了插入元素的顺序(它可以精确控制每个元素在列表中的插入位置),而java.util.HashSet
则不能。换句话说,java.util.ArrayList
有一个预定义的元素输入顺序,而java.util.HashSet
在默认情况下是无序的。
至少有两种方法可以根据 JPA 规范定义的给定列对结果集进行排序:
-
使用
@OrderBy
请求数据库按照给定的列对获取的数据进行排序(在生成的 SQL 查询中附加ORDER BY
子句,以特定的顺序检索实体),并使用 Hibernate 来保持这种顺序。 -
使用
@OrderColumn
通过一个额外的列(在这种情况下,存储在连接表中)对此进行永久排序。
该注释(@OrderBy
)可以与@OneToMany/@ManyToMany
关联和@ElementCollection
一起使用。添加没有显式列的@OrderBy
将导致实体按其主键(ORDER BY author1_.id ASC
)升序排序。按多列排序也是可能的(例如,按年龄降序和按姓名升序排序,@OrderBy("age DESC, name ASC")
)。显然,@OrderBy
也可以和java.util.List
一起使用。
使用@OrderBy
考虑图 1-8 中的数据快照。
图 1-8
数据快照(多对多集和@OrderBy)
有一本由六位作者写的书。目标是通过Book#getAuthors()
按名字降序获取作者。这可以通过在Book
中添加@OrderBy
来实现,如下所示:
@ManyToMany(mappedBy = "books")
@OrderBy("name DESC")
private Set<Author> authors = new HashSet<>();
当getAuthors()
被调用时,@OrderBy
将:
-
将相应的
ORDER BY
子句附加到触发的 SQL。这将指示数据库对提取的数据进行排序。 -
发送 Hibernate 信号以保持顺序。在后台,Hibernate 将通过一个
LinkedHashSet
来保存顺序。
因此,调用getAuthors()
将导致符合@OrderBy
信息的Set
个作者。被触发的 SQL 是下面包含ORDER BY
子句的SELECT
:
SELECT
authors0_.book_id AS book_id2_1_0_,
authors0_.author_id AS author_i1_1_0_,
author1_.id AS id1_0_1_,
author1_.age AS age2_0_1_,
author1_.genre AS genre3_0_1_,
author1_.name AS name4_0_1_
FROM author_book authors0_
INNER JOIN author author1_
ON authors0_.author_id = author1_.id
WHERE authors0_.book_id = ?
ORDER BY author1_.name DESC
显示Set
将输出以下内容(通过Author#toString()
):
Author{id=2, name=Quartis Young, genre=Anthology, age=51},
Author{id=6, name=Qart Pinkil, genre=Anthology, age=56},
Author{id=5, name=Martin Leon, genre=Anthology, age=38},
Author{id=1, name=Mark Janel, genre=Anthology, age=23},
Author{id=4, name=Katy Loin, genre=Anthology, age=56},
Author{id=3, name=Alicia Tom, genre=Anthology, age=38}
GitHub 8 上有源代码。
将@OrderBy
与HashSet
一起使用将保持加载/获取Set
的顺序,但这在整个瞬态中并不一致。如果这是一个问题,为了获得瞬态的一致性,考虑显式地使用LinkedHashSet
而不是HashSet
。因此,为了完全一致,请使用:
@ManyToMany(mappedBy = "books")
@OrderBy("name DESC")
private Set<Author> authors = new LinkedHashSet<>();
第 6 项:为什么以及何时避免删除 CascadeType 的子实体。Remove 和 orphanRemoval = true
首先,我们快速突出一下CascadeType.REMOVE
和orphanRemoval=true
的区别。让我们使用双向懒惰@OneToMany
关联中涉及的Author
和Book
实体,编写如下:
// in Author.java
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();
// in Book.java
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
删除一个Author
实体会自动级联到相关的Book
实体。只要CascadeType.REMOVE
或orphanRemoval=true
存在,就会发生这种情况。换句话说,从这个角度来看,两者的存在都是多余的。
那他们有什么不同?好吧,考虑下面这个用来断开(或分离)一个Book
和它的Author
的助手方法:
public void removeBook(Book book) {
book.setAuthor(null);
this.books.remove(book);
}
或者,断开所有Book
与其Author
的连接:
public void removeBooks() {
Iterator<Book> iterator = this.books.iterator();
while (iterator.hasNext()) {
Book book = iterator.next();
book.setAuthor(null);
iterator.remove();
}
}
在有orphanRemoval=true
的情况下调用removeBook()
方法将导致通过DELETE
语句自动移除book
。在orphanRemoval=false
面前调用它将触发UPDATE
声明。因为断开Book
不是移除操作,所以CascadeType.REMOVE
的存在无关紧要。因此,orphanRemoval=true
对于清理那些没有所有者实体(Author
)的引用就不应该存在的实体(删除悬空引用)很有用。
但是这些设置的效率如何呢?简短的回答是:如果它们必须影响大量的实体,那么效率就不是很高。长答案从删除以下服务方法中的一个作者开始(这个作者有三本相关的书):
@Transactional
public void deleteViaCascadeRemove() {
Author author = authorRepository.findByName("Joana Nimar");
authorRepository.delete(author);
}
删除作者会将删除级联到关联的图书。这是CascadeType.ALL
的效果,包含了CascadeType.REMOVE
。但是,在删除相关书籍之前,它们通过一个SELECT
被加载到持久性上下文中。如果它们已经在持久性上下文中,则不会被加载。如果书籍不存在于持久上下文中,那么CascadeType.REMOVE
将不会生效。此外,有四个DELETE
语句,一个用于删除作者,三个用于删除相关书籍:
DELETE
FROM book
WHERE id=?
Binding:[1]
DELETE
FROM book
WHERE id=?
Binding:[2]
DELETE
FROM book
WHERE id=?
Binding:[4]
DELETE
FROM author
WHERE id=?
Binding:[4]
每本书都有单独的DELETE
陈述。要删除的书籍越多,拥有的DELETE
语句就越多,性能损失就越大。
现在让我们编写一个基于orphanRemoval=true
删除的服务方法。为了变化,这一次,我们将作者和相关书籍加载在同一个SELECT
:
@Transactional
public void deleteViaOrphanRemoval() {
Author author = authorRepository.findByNameWithBooks("Joana Nimar");
author.removeBooks();
authorRepository.delete(author);
}
不幸的是,这种方法将触发与级联删除完全相同的DELETE
语句,因此它倾向于相同的性能损失。
如果您的应用触发了零星的删除,您可以依靠CascadeType.REMOVE
和/或orphanRemoval=true
。这在删除托管实体时尤其有用,因此需要 Hibernate 来管理实体的状态转换。此外,通过这种方法,你可以从父母和孩子的自动乐观锁定机制(如@Version
)中受益。但是,如果您只是在寻找更有效的删除方法(在更少的 DML 语句中),我们将考虑其中的一些方法。当然,每种方法都有自己的权衡。
以下四种方法通过批量操作删除作者和相关书籍。这样,您可以优化和控制触发的DELETE
语句的数量。这些操作非常快,但是它们有三个主要缺点:
-
他们忽略了自动乐观锁定机制(例如,你不能再依赖于
@Version
) -
持久性上下文没有被同步以反映由批量操作执行的修改,这可能导致过时的上下文
-
他们没有利用级联删除(
CascadeType.REMOVE
)或orphanRemoval
如果这些缺点对你很重要,你有两个选择:避免批量操作或者明确地处理这个问题。最困难的部分是为没有加载到持久性上下文中的子进程模拟自动乐观锁定机制的工作。以下示例假设没有启用自动乐观锁定机制。然而,它们通过flushAutomatically = true
和clearAutomatically = true
管理持久性上下文同步问题。不要认为这两个设置总是需要的。它们的用法取决于你想要达到的目的。
删除已经加载到持久性上下文中的作者
让我们来处理这样的情况:在持久性上下文中,只有一个Author
被加载,以及有更多的Author
被加载,但不是所有的。还必须删除相关的书籍(已经或尚未加载到持久性上下文中)。
已经在持久性上下文中加载了一个作者
让我们假设应该被删除的Author
在没有它们的关联Book
的情况下在持久性上下文中被更早地加载。要删除这个Author
和相关的书籍,可以使用作者标识符(author.getId()
)。首先,删除所有与作者相关的书籍:
// add this method in BookRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Book b WHERE b.author.id = ?1")
public int deleteByAuthorIdentifier(Long id);
然后,让我们按作者的标识符删除作者:
// add this method in AuthorRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Author a WHERE a.id = ?1")
public int deleteByIdentifier(Long id);
稍后会解释flushAutomatically = true, clearAutomatically = true
的存在。目前,负责触发删除的服务方法是:
@Transactional
public void deleteViaIdentifiers() {
Author author = authorRepository.findByName("Joana Nimar");
bookRepository.deleteByAuthorIdentifier(author.getId());
authorRepository.deleteByIdentifier(author.getId());
}
调用deleteViaIdentifiers()
会触发以下查询:
DELETE FROM book
WHERE author_id = ?
DELETE FROM author
WHERE id = ?
注意,相关的书籍没有被加载到持久性上下文中,只有两个DELETE
语句被触发。书的数量不影响DELETE
语句的数量。
也可以通过内置的deleteInBatch(Iterable<T> entities)
删除作者:
authorRepository.deleteInBatch(List.of(author));
持久性上下文中加载了更多的作者
让我们假设持久性上下文包含更多应该被删除的Author
。例如,让我们删除所有作为List<Author>
获取的年龄为 34 的Author
(让我们假设有两个年龄为 34 的作者)。尝试按作者标识符删除(如前一种情况)将导致每个作者有一个单独的DELETE
。此外,每个作者的相关书籍将有一个单独的DELETE
。所以这样效率不高。
这一次,让我们依靠两架散装作战。一个由您通过IN
操作符(允许您在一个WHERE
子句中指定多个值)和内置的deleteInBatch(Iterable<T> entities)
定义:
// add this method in BookRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Book b WHERE b.author IN ?1")
public int deleteBulkByAuthors(List<Author> authors);
删除List<Author>
和相关Book
的服务方法如下:
@Transactional
public void deleteViaBulkIn() {
List<Author> authors = authorRepository.findByAge(34);
bookRepository.deleteBulkByAuthors(authors);
authorRepository.deleteInBatch(authors);
}
调用deleteViaBulkIn()
会触发以下查询:
DELETE FROM book
WHERE author_id IN (?, ?)
DELETE FROM author
WHERE id = ?
OR id = ?
注意,相关的书籍没有被加载到持久性上下文中,只有两个DELETE
语句被触发。作者和书籍的数量不影响DELETE
语句的数量。
一位作者和他的相关书籍已经被加载到持久性上下文中
假设Author
(应该被删除的那个)及其关联的Book
已经被加载到持久性上下文中。这一次没有必要定义批量操作,因为内置的deleteInBatch(Iterable<T> entities)
可以为您完成这项工作:
@Transactional
public void deleteViaDeleteInBatch() {
Author author = authorRepository.findByNameWithBooks("Joana Nimar");
bookRepository.deleteInBatch(author.getBooks());
authorRepository.deleteInBatch(List.of(author));
}
这里的主要缺点是内置deleteInBatch(Iterable<T> entities)
的默认行为,默认情况下,它不会刷新或清除持久性上下文。这可能会使持久性上下文处于过时状态。
当然,在前面的方法中,在删除之前不需要刷新任何内容,也不需要清除持久性上下文,因为在删除操作之后,事务会提交。因此,持久性上下文是封闭的。但是,在某些情况下,冲洗和清洁(不一定两者都需要)是必需的。通常,清除操作比刷新操作更需要。例如,下面的方法在删除之前不需要刷新,但是在删除之后需要清除。否则会导致异常:
@Transactional
public void deleteViaDeleteInBatch() {
Author author = authorRepository.findByNameWithBooks("Joana Nimar");
bookRepository.deleteInBatch(author.getBooks());
authorRepository.deleteInBatch(List.of(author));
...
// later on, we forgot that this author was deleted
author.setGenre("Anthology");
}
突出显示的代码将导致以下类型的异常:
org.springframework.orm.ObjectOptimisticLockingFailureException: Object of class [com.bookstore.entity.Author] with identifier [4]: optimistic locking failed; nested exception is org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.bookstore.entity.Author#4]
实际上,修改(setGenre()
的调用)改变了持久上下文中包含的Author
实体,但是这个上下文已经过时了,因为作者已经从数据库中删除了。换句话说,从数据库中删除作者和相关书籍后,它们将继续存在于当前的持久性上下文中。持久性上下文不知道通过deleteInBatch(Iterable<T> entities)
执行的删除。为了确保删除后持久性上下文被清除,您可以覆盖deleteInBatch(Iterable<T> entities)
来添加@Modifying(clearAutomatically = true)
。这样,持久性上下文在删除后会自动清除。如果你在一个用例中也需要一个预先刷新,那么使用@Modifying(flushAutomatically = true, clearAutomatically = true)
或者调用flush()
方法。或者,更好的是,您可以重用deleteViaIdentifiers()
方法,如下所示(我们已经用@Modifying(flushAutomatically = true, clearAutomatically = true)
注释了这个方法):
@Transactional
public void deleteViaIdentifiers() {
Author author = authorRepository.findByNameWithBooks("Joana Nimar");
bookRepository.deleteByAuthorIdentifier(author.getId());
authorRepository.deleteByIdentifier(author.getId());
}
调用deleteViaIdentifiers()
会触发以下查询:
DELETE FROM book
WHERE author_id = ?
DELETE FROM author
WHERE id = ?
书的数量不影响DELETE
语句的数量。
如果持久化上下文管理几个应该被删除的Author
和相关的Book
,那么依赖于deleteViaBulkIn()
。
当应该删除的作者和书籍没有加载到持久性上下文中时删除
如果应该删除的作者及其相关书籍没有加载到持久性上下文中,那么您可以硬编码作者标识符(如果您知道的话),如下面的服务方法所示:
@Transactional
public void deleteViaHardCodedIdentifiers() {
bookRepository.deleteByAuthorIdentifier(4L);
authorRepository.deleteByIdentifier(4L);
}
deleteByAuthorIdentifier()
和deleteByIdentifier()
方法与“一个作者已经被加载到持久性上下文中”一节中的方法相同。触发的查询非常明显:
DELETE FROM book
WHERE author_id = ?
DELETE FROM author
WHERE id = ?
如果有更多作者,您可以使用批量操作删除他们:
// add this method in BookRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Book b WHERE b.author.id IN ?1")
public int deleteBulkByAuthorIdentifier(List<Long> id);
// add this method in AuthorRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Author a WHERE a.id IN ?1")
public int deleteBulkByIdentifier(List<Long> id);
现在,让我们删除两位作者及其相关书籍:
@Transactional
public void deleteViaBulkHardCodedIdentifiers() {
List<Long> authorsIds = Arrays.asList(1L, 4L);
bookRepository.deleteBulkByAuthorIdentifier(authorsIds);
authorRepository.deleteBulkByIdentifier(authorsIds);
}
触发的 SQL 语句如下:
DELETE FROM book
WHERE author_id IN (?, ?)
DELETE FROM author
WHERE id IN (?, ?)
作者和书籍的数量不影响DELETE
语句的数量。由于我们没有在持久性上下文中加载任何东西,flushAutomatically = true, clearAutomatically = true
没有任何作用。
为了避免持久性上下文中过时的实体,不要忘记在执行查询(flushAutomatically = true
)之前刷新EntityManager
,并在执行查询(clearAutomatically = true
)之后清除它。如果您不想/不需要刷新和/或清除,那么请注意如何设法避免持久性上下文中的过时实体。只要您知道自己在做什么,不刷新和/或清除持久性上下文是没有问题的。理想情况下,将 批量 操作隔离在专用的事务性服务方法中。这样,就不需要显式地刷新和清除持久性上下文。当您将 批量 操作与受管实体操作交错时,可能会出现问题。
如果您需要复习 flush 的工作原理,请阅读附录 H 。
删除所有实体最有效的方法是通过内置的deleteAllInBatch()
,它触发一个批量操作。
完整的应用可在 GitHub 9 上获得。
第 7 项:如何通过 JPA 实体图获取关联
Item 39
第 41 项描述了如何通过LEFT``JOIN FETCH
在同一个SELECT
查询中获取与其父节点的关联。这在涉及惰性关联的场景中非常有用,惰性关联应该基于查询急切地获取,以避免惰性加载异常和 N+1 问题。而(LEFT
) JOIN FETCH
住在查询里面,实体图是独立于查询的。因此,查询和实体图可以被重用(例如,查询可以与或不与实体图一起使用,而实体图可以与不同的查询一起使用)。
现在,简而言之,JPA 2.1 中引入了实体图(又名提取计划),它们通过解决延迟加载异常和 N+1 问题来帮助您提高加载实体的性能。开发人员指定实体的相关关联和基本字段,它们应该在单个SELECT
语句中加载。开发人员可以为同一个实体定义多个实体图,并且可以链接任意数量的实体,甚至可以使用子图来创建复杂的获取计划。实体图是全局的,可以跨实体重用(域模型)。要覆盖当前的FetchType
语义,您可以设置两个属性:
-
取数图:这是默认的取数类型,由
javax.persistence.fetchgraph
属性表示。出现在attributeNodes
中的属性被视为FetchType.EAGER
。其余的属性被视为FetchType.LAZY
,不管默认/显式FetchType
。 -
负载图:该抓取类型可以通过
javax.persistence.loadgraph
属性使用。出现在attributeNodes
中的属性被视为FetchType.EAGER
。其余属性根据其指定的或默认的FetchType
进行处理。
实体图可以通过注释(如@NamedEntityGraph
)、通过attributePaths
(特定实体图)、通过调用getEntityGraph()
或createEntityGraph()
方法通过EntityManager
API 来定义。
假设Author
和Book
实体包含在一个双向惰性@OneToMany
关联中。实体图(一个获取图)应该在同一个SELECT
中加载所有的Author
和相关的Book
。同样的事情可以通过JOIN FETCH
得到,但是这次让我们通过实体图来做。
通过@NamedEntityGraph 定义实体图
@NamedEntityGraph
注释出现在实体级别。通过它的元素,开发人员可以为这个实体图指定一个惟一的名称(通过name
元素)和获取实体图时要包含的属性(通过attributeNodes
元素,它包含一个由逗号分隔的@NamedAttributeNode
注释列表;该列表中的每个@NamedAttributeNode
对应于一个应该提取的字段/关联)。属性可以是基本字段和关联。
让我们把实体图放在代码中的Author
实体中:
@Entity
@NamedEntityGraph(
name = "author-books-graph",
attributeNodes = {
@NamedAttributeNode("books")
}
)
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)
private List<Book> books = new ArrayList<>();
// getters and setters omitted for brevity
}
接下来,关注Author
实体的存储库AuthorRepository
。
AuthorRepository
是应该指定实体图形的地方。Spring Data 通过@EntityGraph
注释(该注释的类是org.springframework.data.jpa.repository.EntityGraph
)提供对实体图形的支持。
覆盖查询方法
例如,使用实体图(author-books-graph
)来查找所有Author
,包括相关联的Book
的代码如下(EntityGraph.EntityGraphType.FETCH
是默认的,并指示一个获取图;EntityGraph.EntityGraphType.LOAD
可以指定一个负载图):
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Override
@EntityGraph(value = "author-books-graph",
type = EntityGraph.EntityGraphType.FETCH)
public List<Author> findAll();
}
调用findAll()
方法将导致下面的 SQL 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
注意,生成的查询考虑了通过@EntityGraph
指定的实体图。
使用查询构建器机制
覆盖findAll()
是获取所有实体的一种便捷方式。但是,使用 Spring 数据查询构建器机制通过WHERE
子句过滤提取的数据。例如,您可以获取小于给定年龄的作者的实体图,并按姓名降序排列,如下所示:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@EntityGraph(value = "author-books-graph",
type = EntityGraph.EntityGraphType.FETCH)
public List<Author> findByAgeLessThanOrderByNameDesc(int age);
}
生成的 SQL SELECT
语句如下所示:
SELECT
...
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.age < ?
ORDER BY author0_.name DESC
使用规范
也支持使用Specification
。例如,让我们假设下面的经典Specification
用于生成WHERE age > 45
:
public class AuthorSpecs {
private static final int AGE = 45;
public static Specification<Author> isAgeGt45() {
return (Root<Author> root,
CriteriaQuery<?> query, CriteriaBuilder builder)
-> builder.greaterThan(root.get("age"), AGE);
}
}
让我们用这个Specification
:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long>, JpaSpecificationExecutor<Author> {
@Override
@EntityGraph(value = "author-books-graph",
type = EntityGraph.EntityGraphType.FETCH)
public List<Author> findAll(Specification spec);
}
List<Author> authors = authorRepository.findAll(isAgeGt45());
生成的 SQL SELECT
语句如下:
SELECT
...
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.age > 45
使用@Query 和 JPQL
最后,使用@Query
和 JPQL 也是可以的。
请注意与指定连接提取的实体图一起使用的查询。在这种情况下,获取的关联的所有者必须出现在SELECT
列表中。
查看以下显式 JPQL 查询:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@EntityGraph(value = "author-books-graph",
type = EntityGraph.EntityGraphType.FETCH)
@Query(value="SELECT a FROM Author a WHERE a.age > 20 AND a.age < 40")
public List<Author> fetchAllAgeBetween20And40();
}
SQL SELECT
语句如下:
SELECT
...
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.age > 20 AND author0_.age < 40
注意使用尝试多次急切抓取的实体图(例如,Author
有两个@OneToMany
关联声明为LAZY
并映射到List
,并且都出现在实体图中)。用多个左外连接触发一个SELECT
将会急切地获取多个特定于 Hibernate 的Bag
(一个无序的集合,其中有重复项,但不打算删除重复项),这将导致MultipleBagFetchException
。换句话说,当您使用实体图提示触发一个查询时,如果您尝试多次快速获取,Hibernate 将用一个MultipleBagFetchException
响应。
但是,不要假设MultipleBagFetchException
是实体图特有的,因为这是一个错误的假设。每当您试图触发一个尝试多次快速获取的查询时,它就会出现。这种异常经常在提取实体层次结构中的多个级别时遇到,例如书有章有节有页。
这个问题最流行的解决方案是从Set
切换到List
。虽然这将像预期的那样工作,但它离有效的解决方案还很远,因为合并中间结果集产生的笛卡尔乘积将是巨大的。一般来说,假设您想要获取一些A
实体以及它们的B
和C
关联。并且您有 25 个A
行与 10 个B
行和 20 个C
行相关联。用于获取最终结果的笛卡尔积将有 25 x 10 x 20 行= 5000 行!从性能角度来看,这真的很糟糕!最佳解决方案是一次最多获取一个关联。即使这意味着不止一个查询,它也避免了这个巨大的笛卡尔积。完整的例子,请看这篇由弗拉德·米哈尔恰撰写的精彩文章 10 。
尝试对实体图使用原生查询将导致类型为A native SQL query cannot use EntityGraphs
的 Hibernate 异常。
当实体图被转换成获取相关集合的 SQL JOIN
时,注意使用分页(Pageable
)。在这种情况下,分页发生在内存中,这会导致性能下降。本机查询不能用于实体图。依赖窗口函数( Item 95 )也不是一个选项。除了在WHERE
和HAVING
子句之外编写子查询、执行集合操作(例如UNION
、INTERSECT
、EXCEPT
)、使用数据库特定提示和编写递归查询之外,在 JPQL 中使用窗口函数代表了 JPQL 的五大局限性。
另一方面,如果实体图仅获取不是集合的基本(@Basic
)属性和/或关联,则分页(Pageable
)将由数据库通过LIMIT
或对应方来完成。
完整的应用可在 GitHub 11 上获得。
这里有一个非常重要的方面需要注意。实体图(获取图)通过@NamedAttributeNode
明确指定只加载books
关联。对于获取图形,不管默认/显式FetchType
,其余的属性都应被视为FetchType.LAZY
。那么为什么前面的查询也包含了Author
的基本属性呢?这个问题的答案和解决方案在第 9 项中。参见项目 9 通过实体图(获取和加载图)仅获取所需的基本属性。现在,让我们继续看特设实体图。
即席实体图
可以通过@EntityGraph
注释的attributePaths
元素定义一个特定的实体图。应该在单个SELECT
中加载的实体的相关关联和基本字段被指定为一个列表,由类型为@EntityGraph(attributePaths = {"attr1", "attr2", ...}
的逗号分隔。显然,这个时候,没有必要使用@NamedEntityGraph
。例如,上一节中的实体图可以写成如下形式:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Override
@EntityGraph(attributePaths = {"books"},
type = EntityGraph.EntityGraphType.FETCH)
public List<Author> findAll();
}
调用findAll()
触发与@NamedEntityGraph
相同的 SQL 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
重复将@EntityGraph
与查询构建器机制、Specification
和 JPQL 一起使用的示例,将会产生以下存储库:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long>,
JpaSpecificationExecutor<Author> {
@Override
@EntityGraph(attributePaths = {"books"},
type = EntityGraph.EntityGraphType.FETCH)
public List<Author> findAll();
@EntityGraph(attributePaths = {"books"},
type = EntityGraph.EntityGraphType.FETCH)
public List<Author> findByAgeLessThanOrderByNameDesc(int age);
@Override
@EntityGraph(attributePaths = {"books"},
type = EntityGraph.EntityGraphType.FETCH)
public List<Author> findAll(Specification spec);
@EntityGraph(attributePaths = {"books"},
type = EntityGraph.EntityGraphType.FETCH)
@Query(value="SELECT a FROM Author a WHERE a.age > 20 AND a.age<40")
public List<Author> fetchAllAgeBetween20And40();
}
完整的应用可在 GitHub 12 上获得。
特设实体图是将实体图定义保持在存储库级别并且不使用@NamedEntityGraph
改变实体的一种便捷方式。
通过 EntityManager 定义实体图
要通过EntityManager
直接获得实体图,您需要调用getEntityGraph(String entityGraphName)
方法。接下来,将该方法的返回传递给重载的find()
方法,如下面的代码片段所示:
EntityGraph entityGraph = entityManager
.getEntityGraph("author-books-graph");
Map<String, Object> properties = new HashMap<>();
properties.put("javax.persistence.fetchgraph", entityGraph);
Author author = entityManager.find(Author.class, id, properties);
JPQL 和EntityManager
也可以使用:
EntityGraph entityGraph = entityManager
.getEntityGraph("author-books-graph");
Author author = entityManager.createQuery(
"SELECT a FROM Author a WHERE a.id = :id", Author.class)
.setParameter("id", id)
.setHint("javax.persistence.fetchgraph", entityGraph)
.getSingleResult();
或者通过CriteriaBuilder
和EntityManager
:
EntityGraph entityGraph = entityManager
.getEntityGraph("author-books-graph");
CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
CriteriaQuery<Author> criteriaQuery
= criteriaBuilder.createQuery(Author.class);
Root<Author> author = criteriaQuery.from(Author.class);
criteriaQuery.where(criteriaBuilder.equal(root.<Long>get("id"), id));
TypedQuery<Author> typedQuery = entityManager.createQuery(criteriaQuery);
typedQuery.setHint("javax.persistence.loadgraph", entityGraph);
Author author = typedQuery.getSingleResult();
您可以通过EntityManager#createEntityGraph()
方法创建一个实体图。有关更多详细信息,请阅读文档。
项目 8:如何通过实体子图获取关联
如果您不熟悉实体图,请在此之前阅读第 7 项。
实体图也容易造成性能损失。创建实体的大树(例如,具有子图的子图)或加载不需要的关联(和/或字段)将导致性能损失。想想创建 m x n x p x 类型的笛卡尔乘积有多容易...,很快增长到巨大的价值。
子图允许您构建复杂的实体图。主要地,子图是嵌入到另一个实体图或实体子图中的实体图。让我们看三个实体— Author
、Book
和Publisher
。Author
和Book
实体包含在一个双向惰性@OneToMany
关联中。Publisher
和Book
实体也包含在双向惰性@OneToMany
关联中。在Author
和Publisher
之间没有关联。图 1-9 显示了涉及的表格(author
、book
和publisher
)。
图 1-9
表关系
这个实体图的目标是获取所有相关书籍的作者,以及与这些书籍相关的出版商。为此,让我们使用实体子图。
使用@NamedEntityGraph 和@NamedSubgraph
在Author
实体中,使用@NamedEntityGraph
定义实体图以急切地加载作者和相关书籍,使用@NamedSubgraph
定义实体子图以加载与已加载书籍相关的出版商:
@Entity
@NamedEntityGraph(
name = "author-books-publisher-graph",
attributeNodes = {
@NamedAttributeNode(value = "books", subgraph = "publisher-subgraph")
},
subgraphs = {
@NamedSubgraph(
name = "publisher-subgraph",
attributeNodes = {
@NamedAttributeNode("publisher")
}
)
}
)
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)
private List<Book> books = new ArrayList<>();
// getters and setters omitted for brevity
}
这里列出了Book
中的相关部分:
@Entity
public class Book implements Serializable {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "publisher_id")
private Publisher publisher;
...
}
进一步,让我们使用AuthorRepository
中的实体图:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Override
@EntityGraph(value = "author-books-publisher-graph",
type = EntityGraph.EntityGraphType.FETCH)
public List<Author> findAll();
}
调用findAll()
会触发下面的 SQL SELECT
语句:
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
publisher2_.id AS id1_2_2_,
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_.publisher_id AS publishe5_1_1_,
books1_.title AS title3_1_1_,
books1_.author_id AS author_i4_1_0__,
books1_.id AS id1_1_0__,
publisher2_.company AS company2_2_2_
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
LEFT OUTER JOIN publisher publisher2_
ON books1_.publisher_id = publisher2_.id
虽然这很明显,但是让我们提一下子图可以与查询构建器机制、Specification
和 JPQL 一起使用。例如,下面是 JPQL 使用的子图:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@EntityGraph(value = "author-books-publisher-graph",
type = EntityGraph.EntityGraphType.FETCH)
@Query(value="SELECT a FROM Author a WHERE a.age > 20 AND a.age<40")
public List<Author> fetchAllAgeBetween20And40();
}
调用fetchAllAgeBetween20And40()
会触发下面的 SQL SELECT
语句(注意查询是如何被丰富为实体图的):
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
publisher2_.id AS id1_2_2_,
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_.publisher_id AS publishe5_1_1_,
books1_.title AS title3_1_1_,
books1_.author_id AS author_i4_1_0__,
books1_.id AS id1_1_0__,
publisher2_.company AS company2_2_2_
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
LEFT OUTER JOIN publisher publisher2_
ON books1_.publisher_id = publisher2_.id
WHERE author0_.age > 20
AND author0_.age < 40
注意 JPQL 查询与指定连接提取的实体图一起使用。在这样的 JPQL 查询中,被提取的关联的所有者必须出现在SELECT
列表中。
使用点符号(。)在特定实体图中
子图也可以用在特定的实体图中。请记住,特设实体图允许您将实体图定义保持在存储库级别,并且不使用@NamedEntityGraph
更改实体。
要使用子图,您只需使用点符号(.),如下例所示:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Override
@EntityGraph(attributePaths = {"books.publisher"},
type = EntityGraph.EntityGraphType.FETCH)
public List<Author> findAll();
}
因此,您可以通过books.publisher
路径获取与图书相关的出版商。触发的SELECT
与使用@NamedEntityGraph
和@NamedSubgraph
时相同。
让我们看另一个例子,只是为了熟悉这个想法。让我们定义一个特定的实体图来获取所有出版商和相关书籍,以及与这些书籍相关的作者。这次,实体图在PublisherRepository
中定义如下:
@Repository
@Transactional(readOnly = true)
public interface PublisherRepository
extends JpaRepository<Publisher, Long> {
@Override
@EntityGraph(attributePaths = "books.author"},
type = EntityGraph.EntityGraphType.FETCH)
public List<Publisher> findAll();
}
本次触发的 SQL SELECT
语句如下:
SELECT
publisher0_.id AS id1_2_0_,
books1_.id AS id1_1_1_,
author2_.id AS id1_0_2_,
publisher0_.company AS company2_2_0_,
books1_.author_id AS author_i4_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.publisher_id AS publishe5_1_1_,
books1_.title AS title3_1_1_,
books1_.publisher_id AS publishe5_1_0__,
books1_.id AS id1_1_0__,
author2_.age AS age2_0_2_,
author2_.genre AS genre3_0_2_,
author2_.name AS name4_0_2_
FROM publisher publisher0_
LEFT OUTER JOIN book books1_
ON publisher0_.id = books1_.publisher_id
LEFT OUTER JOIN author author2_
ON books1_.author_id = author2_.id
特设子图可以与 Spring 数据查询构建器机制、Specification
和 JPQL 一起使用。例如,这里是上面与 JPQL 一起使用的特殊子图:
@Repository
@Transactional(readOnly = true)
public interface PublisherRepository
extends JpaRepository<Publisher, Long> {
@EntityGraph(attributePaths = {"books.author"},
type = EntityGraph.EntityGraphType.FETCH)
@Query("SELECT p FROM Publisher p WHERE p.id > 1 AND p.id < 3")
public List<Publisher> fetchAllIdBetween1And3();
}
调用fetchAllIdBetween1And3()
会触发下面的 SQL SELECT
语句(注意查询是如何被丰富为实体图的):
SELECT
publisher0_.id AS id1_2_0_,
books1_.id AS id1_1_1_,
author2_.id AS id1_0_2_,
publisher0_.company AS company2_2_0_,
books1_.author_id AS author_i4_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.publisher_id AS publishe5_1_1_,
books1_.title AS title3_1_1_,
books1_.publisher_id AS publishe5_1_0__,
books1_.id AS id1_1_0__,
author2_.age AS age2_0_2_,
author2_.genre AS genre3_0_2_,
author2_.name AS name4_0_2_
FROM publisher publisher0_
LEFT OUTER JOIN book books1_
ON publisher0_.id = books1_.publisher_id
LEFT OUTER JOIN author author2_
ON books1_.author_id = author2_.id
WHERE publisher0_.id > 1
AND publisher0_.id < 3
完整的应用可在 GitHub 13 上获得。
通过 EntityManager 定义实体子图
您可以通过EntityManager
和EntityGraph.addSubgraph(String attributeName)
方法直接构建一个实体子图,如下面的代码片段所示:
EntityGraph<Author> entityGraph = entityManager.createEntityGraph(Author.class);
Subgraph<Book> bookGraph = entityGraph.addSubgraph("books");
bookGraph.addAttributeNodes("publisher");
Map<String, Object> properties = new HashMap<>();
properties.put("javax.persistence.fetchgraph", entityGraph);
Author author = entityManager.find(Author.class, id, properties);
项目 9:如何处理实体图和基本属性
当 Hibernate JPA 出现时,使用实体图只获取实体的一些基本属性(不是全部)需要一个折衷的解决方案,它基于:
-
启用 Hibernate 字节码增强
-
用
@Basic(fetch = FetchType.LAZY)
标注不应该是实体图一部分的基本属性
主要的缺点在于,这些基本属性是由所有其他查询(例如findById()
)缓慢获取的,而不仅仅是由使用实体图的查询获取的,而且最有可能的是,您不希望出现这种行为。所以慎用!
遵照 JPA 规范,实体图可以通过两个属性——?? 和 ??——覆盖当前的 ?? 语义。根据所使用的属性,实体图可以是获取图或加载图。在获取图形的情况下,attributeNodes
中出现的属性被视为FetchType.EAGER
。不管默认/显式FetchType
如何,其余属性都被视为FetchType.LAZY
。在负载图的情况下,attributeNodes
中的属性被视为FetchType.EAGER
。其余属性根据其指定或默认FetchType
进行处理。
也就是说,让我们假设Author
和Book
实体包含在一个双向惰性@OneToMany
关联中。此外,在Author
实体中,让我们定义一个实体图来加载作者和相关书籍的名称。不需要加载作者的年龄和流派,所以实体图中不指定age
和genre
基本字段:
@Entity
@NamedEntityGraph(
name = "author-books-graph",
attributeNodes = {
@NamedAttributeNode("name"),
@NamedAttributeNode("books")
}
)
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)
private List<Book> books = new ArrayList<>();
// getters and setters omitted for brevity
}
让我们用AuthorRepository
中的这个实体图。要将两者放在同一个存储库中,可以通过查询构建器机制使用两种方法。它产生几乎相同的名为findByAgeGreaterThanAndGenre()
和findByGenreAndAgeGreaterThan()
的 SQL 语句:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@EntityGraph(value = "author-books-graph",
type = EntityGraph.EntityGraphType.FETCH)
public List<Author> findByAgeGreaterThanAndGenre(int age, String genre);
@EntityGraph(value = "author-books-graph",
type = EntityGraph.EntityGraphType.LOAD)
public List<Author> findByGenreAndAgeGreaterThan(String genre, int age);
}
调用findByAgeGreaterThanAndGenre()
会触发下面的 SQL 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_.age > ?
AND author0_.genre = ?
注意,即使age
和genre
不是获取图的一部分,它们也已经在查询中被获取。让我们通过findByGenreAndAgeGreaterThan()
试试负载图:
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 = ?
AND author0_.age > ?
这次age
和genre
的出现是正常的。但是这些属性(age
和genre
)在获取图的情况下也会被加载,即使它们没有通过@NamedAttributeNode
被明确指定。
默认情况下,属性用@Basic
标注,这依赖于默认的获取策略。默认的获取策略是FetchType.EAGER
。基于这一陈述,一个折衷的解决方案包括用@Basic(fetch = FetchType.LAZY)
在获取图中标注不应该获取的基本属性,如下所示:
...
@Basic(fetch = FetchType.LAZY)
private String genre;
@Basic(fetch = FetchType.LAZY)
private int age;
...
但是再次执行获取和加载图揭示了完全相同的查询。这意味着 JPA 规范不适用于具有基本(@Basic
)属性的 Hibernate。只要没有启用字节码增强,获取图和加载图都会忽略这些设置。在 Maven 中,添加以下插件:
<plugin>
<groupId>org.hibernate.orm.tooling</groupId>
<artifactId>hibernate-enhance-maven-plugin</artifactId>
<version>${hibernate.version}</version>
<executions>
<execution>
<configuration>
<failOnError>true</failOnError>
<enableLazyInitialization>true</enableLazyInitialization>
</configuration>
<goals>
<goal>enhance</goal>
</goals>
</execution>
</executions>
</plugin>
最后,执行获取图将显示预期的SELECT
:
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
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_.age > ?
AND author0_.genre = ?
执行负载图也将显示预期的SELECT
:
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
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 = ?
AND author0_.age > ?
完整的应用可在 GitHub 14 上获得。
第 10 项:如何通过特定于 Hibernate 的@Where 注释过滤关联
只有在JOIN FETCH WHERE
( 第 39 项)或@NamedEntityGraph
( 第 7 项和第 8 项)不适合你的情况下,才依靠@Where
的方法。
@Where
注释使用起来很简单,并且可以通过在查询中附加一个WHERE
子句来过滤提取的关联。
让我们使用双向惰性@OneToMany
关联中涉及的Author
和Book
实体。目标是延迟获取以下内容:
-
所有书籍
-
所有低于 20 美元的书
-
所有超过 20 美元的书
为了过滤更便宜/更贵的书,Author
实体如下依赖于@Where
:
@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)
private List<Book> books = new ArrayList<>();
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
@Where(clause = "price <= 20")
private List<Book> cheapBooks = new ArrayList<>();
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
@Where(clause = "price > 20")
private List<Book> restOfBooks = new ArrayList<>();
...
}
此外,让我们编写将触发三个查询的三个服务方法:
@Transactional(readOnly = true)
public void fetchAuthorWithAllBooks() {
Author author = authorRepository.findById(1L).orElseThrow();
List<Book> books = author.getBooks();
System.out.println(books);
}
@Transactional(readOnly = true)
public void fetchAuthorWithCheapBooks() {
Author author = authorRepository.findById(1L).orElseThrow();
List<Book> books = author.getCheapBooks();
System.out.println(books);
}
@Transactional(readOnly = true)
public void fetchAuthorWithRestOfBooks() {
Author author = authorRepository.findById(1L).orElseThrow();
List<Book> books = author.getRestOfBooks();
System.out.println(books);
}
调用fetchAuthorWithCheapBooks()
触发下面的 SQL 语句,该语句获取低于 20 美元的书籍:
SELECT
cheapbooks0_.author_id AS author_i5_1_0_,
cheapbooks0_.id AS id1_1_0_,
cheapbooks0_.id AS id1_1_1_,
cheapbooks0_.author_id AS author_i5_1_1_,
cheapbooks0_.isbn AS isbn2_1_1_,
cheapbooks0_.price AS price3_1_1_,
cheapbooks0_.title AS title4_1_1_
FROM book cheapbooks0_
WHERE (cheapbooks0_.price <= 20)
AND cheapbooks0_.author_id = ?
Hibernate 添加了WHERE
子句,指示数据库通过price <= 20
过滤书籍。
调用fetchAuthorWithRestOfBooks()
会追加WHERE
子句,按照price > 20
过滤书籍:
SELECT
restofbook0_.author_id AS author_i5_1_0_,
restofbook0_.id AS id1_1_0_,
restofbook0_.id AS id1_1_1_,
restofbook0_.author_id AS author_i5_1_1_,
restofbook0_.isbn AS isbn2_1_1_,
restofbook0_.price AS price3_1_1_,
restofbook0_.title AS title4_1_1_
FROM book restofbook0_
WHERE (restofbook0_.price > 20)
AND restofbook0_.author_id = ?
完整的应用可在 GitHub 15 上获得。
注意,这些查询以一种懒惰的方式获取书籍。换句话说,这些是在单独的SELECT
中获取作者后触发的额外的SELECT
查询。只要你不想在同一个SELECT
中获取作者和相关书籍,这是没问题的。在这种情况下,应该避免从LAZY
切换到EAGER
。所以依靠JOIN FETCH WHERE
至少从两个方面来说要好很多:
-
它获取与作者在同一个
SELECT
中的相关书籍 -
它允许我们将给定的价格作为查询绑定参数传递
尽管如此,@Where
在很多情况下还是有用的。例如,它可以用于软删除实现(项 109 )。
第 11 项:如何通过@MapsId 优化单向/双向@OneToOne
让我们使用一个@OneToOne
关联中涉及的Author
和Book
实体。图 1-10 中有对应的一一对应的表关系。
图 1-10
一对一的表关系
在关系数据库(RDBMS)中,一对一的关联涉及通过唯一外键“链接”的父端和子端。在 JPA 中,这种关联通过@OneToOne
注释进行映射,关联可以是单向的,也可以是双向的。
在这种背景下,为什么@MapsId
在单向和双向@OneToOne
关联中如此重要?好吧,让我们使用一个常规的映射,从性能的角度突出缺点。因此,我们把重点放在单向的@OneToOne
联想上。
常规单向@一对一
Author
是一对一关联的父端,而Book
是子端。这里列出了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
}
@OneToOne
注释被添加到子端,如下所示:
@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;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
// getters and setters omitted for brevity
}
@JoinColumn
注释用于定制外键列的名称。
单向@OneToOne
控制相关的外键。换句话说,关系的拥有方控制外键。您从如下服务方法调用setAuthor()
(不要在生产中使用orElseThrow()
;这里只是用来快速的从返回的Optional
中打开Author
:
@Transactional
public void newBookOfAuthor() {
Author author = authorRepository.findById(1L).orElseThrow();
Book book = new Book();
book.setTitle("A History of Ancient Prague");
book.setIsbn("001-JN");
book.setAuthor(author);
bookRepository.save(book);
}
调用newBookOfAuthor()
将在book
表中产生以下INSERT
语句:
INSERT INTO book (author_id, isbn, title)
VALUES (?, ?, ?)
Binding:[1, 001-JN, A History of Ancient Prague]
因此,JPA 持久性提供者(Hibernate)已经用author
标识符填充了外键列(author_id
)的值。
到目前为止一切看起来都很好!然而,当这样一个关联的父端需要获取关联的子节点时,它需要触发一个 JPQL 查询,因为子实体标识符是未知的。查看下面的 JPQL 查询:
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT b FROM Book b WHERE b.author = ?1")
public Book fetchBookByAuthor(Author author);
}
并且,服务方法如下:
@Transactional(readOnly = true)
public Book fetchBookByAuthor() {
Author author = authorRepository.findById(1L).orElseThrow();
return bookRepository.fetchBookByAuthor(author);
}
调用fetchBookByAuthor()
将产生以下 SQL 语句:
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 = ?
Binding:[1] Extracted:[1, 1, 001-JN, A History of Ancient Prague]
如果父端也经常/总是需要子端,那么触发新的查询可能会降低性能。
如果应用使用二级缓存来存储Author
和Book
的话,突出显示的性能损失会变得更严重。虽然Author
和Book
存储在二级缓存中,但是提取相关的子对象仍然需要通过此处列出的 JPQL 查询进行数据库往返。假设父节点知道子节点的标识符,它可以如下利用二级缓存(不要把你的注意力给orElseThrow()
;只是为了快速解决返回的Optional
):
Author author = authorRepository.findById(1L).orElseThrow();
Book book = bookRepository.findById(author.getId()).orElseThrow();
但是,由于子标识符未知,所以不能使用该代码。
其他(不是更好的)解决方法是依赖查询缓存或@NaturalId
。
常规双向@一对一
让我们使用双向@OneToOne
关联中涉及的Author
和Book
实体。换句话说,父端依赖于mappedBy
如下(子端保持不变):
@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;
@OneToOne(mappedBy = "author", cascade = CascadeType.ALL,
fetch = FetchType.LAZY)
private Book book;
// getters and setters omitted for brevity
}
双向@OneToOne
的主要缺点可以通过提取父对象Author
来观察,如下所示:
Author author = authorRepository.findById(1L).orElseThrow();
即使这是一个LAZY
关联,获取Author
也会触发下面的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
book0_.id AS id1_1_0_,
book0_.author_id AS author_i4_1_0_,
book0_.isbn AS isbn2_1_0_,
book0_.title AS title3_1_0_
FROM book book0_
WHERE book0_.author_id = ?
除了父实体,Hibernate 还提取了子实体。显然,如果应用只需要父节点,那么获取子节点只是浪费资源,这会降低性能。
第二个查询是由父端困境引起的。如果不获取子实体,JPA 持久提供者(Hibernate)就无法知道是否应该将子引用分配给null
或Object
(具体对象或代理对象)。在这种情况下,通过optional=false
元素向@OneToOne
添加非空性意识没有帮助。
一个解决方法是依赖字节码增强和父端的@LazyToOne(LazyToOneOption.NO_PROXY)
。或者,更好的是,依靠单向的@OneToOne
和@MapsId
。
@MapsId 拯救@OneToOne
@MapsId
是一个 JPA 2.0 注释,可以应用于@ManyToOne
和单向(或双向)@OneToOne
关联。通过这个注释,book
表的主键也可以是引用author
表主键的外键。author
和book
表共享主键(子表与父表共享主键),如图 1-11 所示。
图 1-11
@MapsId 和@OneToOne 共享密钥
您将@MapsId
添加到子实体,如下所示:
@Entity
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private Long id;
private String title;
private String isbn;
@MapsId
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
// getters and setters omitted for brevity
}
检查Book
实体的标识符。不需要生成它(@GeneratedValue
不存在),因为这个标识符正是author
关联的标识符。Book
标识符由 Hibernate 为您设置。
@JoinColumn
注释用于定制主键列的名称。
父实体非常简单,因为不需要双向的@OneToOne
(如果这是您最初拥有的)。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
}
现在,您可以通过如下服务方法持久化一个Book
(考虑突出显示的注释):
@Transactional
public void newBookOfAuthor() {
Author author = authorRepository.findById(1L).orElseThrow();
Book book = new Book();
book.setTitle("A History of Ancient Prague");
book.setIsbn("001-JN");
// this will set the id of the book as the id of the author
book.setAuthor(author);
bookRepository.save(book);
}
调用newBookOfAuthor()
揭示了下面的INSERT
语句(这是调用save()
方法的效果):
INSERT INTO book (isbn, title, author_id)
VALUES (?, ?, ?)
Binding:[001-JN, A History of Ancient Prague, 1]
注意author_id
被设置为author
标识符。这意味着父表和子表共享同一个主键。
此外,开发者可以通过Author
标识符获取Book
,如下所示(由于标识符在Author
和Book
之间共享,开发者可以依靠author.getId()
来指定Book
标识符):
@Transactional(readOnly = true)
public Book fetchBookByAuthorId() {
Author author = authorRepository.findById(1L).orElseThrow();
return bookRepository.findById(author.getId()).orElseThrow();
}
使用@MapsId
有很多好处,如下所示:
-
如果
Book
存在于二级缓存中,它将被相应地提取(不需要额外的数据库往返)。这是常规单向@OneToOne
的主要缺点。 -
获取
Author
也不会自动触发获取Book
的不必要的额外查询。这是常规双向@OneToOne
的主要缺点。 -
共享主键减少了内存占用(不需要索引主键和外键)。
完整的代码可以在 GitHub 16 上找到。
第 12 项:如何验证只有一个关联是非空的
考虑一下Review
实体。它定义了与Book
、Article
和Magazine
的三种@ManyToOne
关系:
@Entity
public class Review implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
private Book book;
@ManyToOne(fetch = FetchType.LAZY)
private Article article;
@ManyToOne(fetch = FetchType.LAZY)
private Magazine magazine;
// getters and setters omitted for brevity
}
在这种情况下,评论可以与一本书、一本杂志或一篇文章相关联。通过 Bean 验证 17 可以在应用级别实现这个约束。首先定义一个将在类级别添加到Review
实体的注释:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {JustOneOfManyValidator.class})
public @interface JustOneOfMany {
String message() default "A review can be associated with either
a book, a magazine or an article";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
在 Bean 验证文档之后,@JustOneOfMany
注释由以下验证提供支持:
public class JustOneOfManyValidator
implements ConstraintValidator<JustOneOfMany, Review> {
@Override
public boolean isValid(Review review, ConstraintValidatorContext ctx) {
return Stream.of(
review.getBook(), review.getArticle(), review.getMagazine())
.filter(Objects::nonNull)
.count() == 1;
}
}
最后,只需将类级别的@JustOneOfMany
注释添加到Review
实体中:
@Entity
@JustOneOfMany
public class Review implements Serializable {
...
}
测试时间
数据库已经包含一个Book
、一个Article
和一个Magazine
。下面的服务方法将成功保存一个Book
的Review
:
@Transactional
public void persistReviewOk() {
Review review = new Review();
review.setContent("This is a book review ...");
review.setBook(bookRepository.findById(1L).get());
reviewRepository.save(review);
}
另一方面,下面的服务方法将不会成功地持久化一个Review
。它将无法通过通过@JustOneOfMany
指定的验证,因为代码试图将这个审查设置为Article
和Magazine
:
@Transactional
public void persistReviewWrong() {
Review review = new Review();
review.setContent("This is an article and magazine review ...");
review.setArticle(articleRepository.findById(1L).get());
// this will fail validation
review.setMagazine(magazineRepository.findById(1L).get());
reviewRepository.save(review);
}
尽管如此,请注意本机查询可以绕过这种应用级验证。如果您知道这种情况是可能的,那么您也必须在数据库级别添加这种验证。在 MySQL 中,这可以通过一个TRIGGER
来完成,如下所示:
CREATE TRIGGER Just_One_Of_Many
BEFORE INSERT ON review
FOR EACH ROW
BEGIN
IF (NEW.article_id IS NOT NULL AND NEW.magazine_id IS NOT NULL)
OR (NEW.article_id IS NOT NULL AND NEW.book_id IS NOT NULL)
OR (NEW.book_id IS NOT NULL AND NEW.magazine_id IS NOT NULL) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='A review can be associated with either
a book, a magazine or an article';
END IF;
END;
完整的应用可在 GitHub 18 上获得。
二、实体
项目 13:如何在实体中采用流畅的 API 风格
考虑Author
和Book
实体,它们涉及到一个双向懒惰@OneToMany
关联,如图 2-1 所示。
图 2-1
@OneToMany 表关系
通常,您可以用Book
s 创建一个Author
,如下所示(例如,一个作者有两本书):
Author author = new Author();
author.setName("Joana Nimar");
author.setAge(34);
author.setGenre("History");
Book book1 = new Book();
book1.setTitle("A History of Ancient Prague");
book1.setIsbn("001-JN");
Book book2 = new Book();
book2.setTitle("A People's History");
book2.setIsbn("002-JN");
// addBook() is a helper method defined in Author class
author.addBook(book1);
author.addBook(book2);
您还可以用至少两种方式流畅地编写这个代码片段。
流畅风格主要是为了可读性和创造一种代码流畅的感觉。
通过实体设置器的流畅风格
让我们通过实体设置器让员工流畅地工作。通常,实体设置器方法返回void
。您可以更改实体设置器以返回this
而不是void
,如下所示(对于 helper 方法也应该这样做):
@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)
private List<Book> books = new ArrayList<>();
public Author addBook(Book book) {
this.books.add(book);
book.setAuthor(this);
return this;
}
public Author removeBook(Book book) {
book.setAuthor(null);
this.books.remove(book);
return this;
}
public Author setId(Long id) {
this.id = id;
return this;
}
public Author setName(String name) {
this.name = name;
return this;
}
public Author setGenre(String genre) {
this.genre = genre;
return this;
}
public Author setAge(int age) {
this.age = age;
return this;
}
public Author setBooks(List<Book> books) {
this.books = books;
return this;
}
// getters 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")
private Author author;
public Book setId(Long id) {
this.id = id;
return this;
}
public Book setTitle(String title) {
this.title = title;
return this;
}
public Book setIsbn(String isbn) {
this.isbn = isbn;
return this;
}
public Book setAuthor(Author author) {
this.author = author;
return this;
}
// getters omitted for brevity
}
设置器返回的是this
而不是void
,因此它们可以以流畅的方式链接,如下所示:
Author author = new Author()
.setName("Joana Nimar")
.setAge(34)
.setGenre("History")
.addBook(new Book()
.setTitle("A History of Ancient Prague")
.setIsbn("001-JN"))
.addBook(new Book()
.setTitle("A People's History")
.setIsbn("002-JN"));
GitHub 1 上有源代码。
流畅风格通过附加方法
您还可以通过其他方法实现流畅风格的方法,而不是改变实体设置器,如下所示:
@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)
private List<Book> books = new ArrayList<>();
public Author addBook(Book book) {
this.books.add(book);
book.setAuthor(this);
return this;
}
public Author removeBook(Book book) {
book.setAuthor(null);
this.books.remove(book);
return this;
}
public Author id(Long id) {
this.id = id;
return this;
}
public Author name(String name) {
this.name = name;
return this;
}
public Author genre(String genre) {
this.genre = genre;
return this;
}
public Author age(int age) {
this.age = age;
return this;
}
public Author books(List<Book> books) {
this.books = books;
return this;
}
// 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")
private Author author;
public Book id(Long id) {
this.id = id;
return this;
}
public Book title(String title) {
this.title = title;
return this;
}
public Book isbn(String isbn) {
this.isbn = isbn;
return this;
}
public Book author(Author author) {
this.author = author;
return this;
}
// getters and setters omitted for brevity
}
这一次,这些额外的方法可以用在流畅风格的方法中,如下面的代码片段所示:
Author author = new Author()
.name("Joana Nimar")
.age(34)
.genre("History")
.addBook(new Book()
.title("A History of Ancient Prague")
.isbn("001-JN"))
.addBook(new Book()
.title("A People's History")
.isbn("002-JN"));
GitHub 2 上有源代码。
第 14 项:如何通过特定于 Hibernate 的代理填充子级父级关联
您可以通过 Spring 内置的查询方法findById()
或getOne()
按标识符获取实体。在findById()
方法后面,Spring 使用EntityManager#find()
,在getOne()
方法后面,Spring 使用EntityManager#getReference()
。
调用findById()
从持久化上下文、二级缓存或数据库返回实体(这是尝试查找指定实体的严格顺序)。因此,返回的实体与声明的实体映射的类型相同。
另一方面,调用getOne()
将返回特定于 Hibernate 的代理对象。这不是实际的实体类型。当子实体可以通过对其父实体的引用持久化时,特定于 Hibernate 的代理会很有用(@ManyToOne
或@OneToOne
惰性关联)。在这种情况下,从数据库中获取父实体(执行相应的SELECT
语句)会降低性能,而且只是一个无意义的动作,因为 Hibernate 可以为未初始化的代理设置底层的外键值。
让我们通过@ManyToOne
协会将这一声明付诸实践。这个关联是一个常见的 JPA 关联,它精确地映射到一对多表关系。因此,考虑一下Author
和Book
实体包含在一个单向惰性@ManyToOne
关联中。在下面的例子中,Author
实体代表父端,而Book
是子端。该关系中涉及的author
和book
表如图 2-2 所示。
图 2-2
一对多表关系
考虑一下,在author
表中,有一个 ID 为1
的作者。现在,让我们为这个条目创建一个Book
。
使用 findById()
依赖findById()
可能会导致下面的代码(当然生产中不要用orElseThrow()
;这里,orElseThrow()
只是从返回的Optional
中提取值的快捷方式:
@Transactional
public void addBookToAuthor() {
Author author = authorRepository.findById(1L).orElseThrow();
Book book = new Book();
book.setIsbn("001-MJ");
book.setTitle("The Canterbury Anthology");
book.setAuthor(author);
bookRepository.save(book);
}
调用addBookToAuthor()
会触发以下 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 = ?
INSERT INTO book (author_id, isbn, title)
VALUES (?, ?, ?)
首先,通过findById()
触发一个SELECT
查询。这个SELECT
从数据库中获取作者。接下来,INSERT
语句通过设置外键author_id
保存新书。
使用 getOne()
依赖getOne()
可能会导致以下代码:
@Transactional
public void addBookToAuthor() {
Author proxy = authorRepository.getOne(1L);
Book book = new Book();
book.setIsbn("001-MJ");
book.setTitle("The Canterbury Anthology");
book.setAuthor(proxy);
bookRepository.save(book);
}
因为 Hibernate 可以设置未初始化代理的底层外键值,所以这段代码会触发一条INSERT
语句:
INSERT INTO book (author_id, isbn, title)
VALUES (?, ?, ?)
显然,这比使用findById()
要好。
完整的代码可以在 GitHub 3 上找到。
第 15 项:如何在持久层使用 Java 8 可选
此项目的目标是确定在持久层中使用 Java 8 可选 API 的最佳实践。为了在示例中展示这些实践,我们使用双向惰性@OneToMany
关联中涉及的众所周知的Author
和Book
实体。
编码的黄金法则是,使用事物的最佳方式是为了它们被创建和测试的目的而利用它们。Java 8 Optional
也不例外。Java 8 Optional
的目的由 Java 的语言架构师 Brian Goetz 明确定义:
- Optional 旨在为库方法返回类型提供一种有限的机制,在这种情况下需要一种清晰的方式来表示“没有结果”,而使用 null 表示这种情况极有可能导致错误。
记住这句话,让我们把它应用到持久层。
实体中可选
Optional
可用于实体。更准确地说,Optional
应该用在实体的某些 getter 中(例如,倾向于返回null
的 getter)。对于Author
实体,Optional
可用于name
和genre
对应的 getters,而对于Book
实体,Optional
可用于title
、isbn
和author
,如下:
@Entity
public class Author implements Serializable {
...
public Optional<String> getName() {
return Optional.ofNullable(name);
}
public Optional<String> getGenre() {
return Optional.ofNullable(genre);
}
...
}
@Entity
public class Book implements Serializable {
...
public Optional<String> getTitle() {
return Optional.ofNullable(title);
}
public Optional<String> getIsbn() {
return Optional.ofNullable(isbn);
}
public Optional<Author> getAuthor() {
return Optional.ofNullable(author);
}
...
}
请勿将Optional
用于:
-
实体字段(
Optional
不是Serializable
) -
构造函数和 setter 参数
-
返回基本类型和集合的 Getters
-
特定于主键的 Getters
在存储库中可选
Optional
可用于储存库。更准确地说,Optional
可以用来包装查询的结果集。Spring 已经自带了返回Optional
的内置方法,比如findById()
和findOne()
。下面的代码片段使用了findById()
方法:
Optional<Author> author = authorRepository.findById(1L);
此外,您可以编写返回Optional
的查询,如以下两个示例所示:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
Optional<Author> findByName(String name);
}
@Repository
@Transactional(readOnly = true)
public interface BookRepository extends JpaRepository<Book, Long> {
Optional<Book> findByTitle(String title);
}
不要假设Optional
只与查询构建器机制一起工作。它也适用于 JPQL 和本地查询。以下查询完全没问题:
@Query("SELECT a FROM Author a WHERE a.name=?1")
Optional<Author> fetchByName(String name);
@Query("SELECT a.genre FROM Author a WHERE a.name=?1")
Optional<String> fetchGenreByName(String name);
@Query(value="SELECT a.genre FROM author a WHERE a.name=?1",
nativeQuery=true)
Optional<String> fetchGenreByNameNative(String name);
GitHub 4 上有源代码。
第 16 项:如何编写不可变的实体
不可变实体必须遵守以下约定:
-
必须用
@Immutable(org.hibernate.annotations.Immutable)
标注 -
它不得包含任何类型的关联(
@ElementCollection
、@OneToOne
、@OneToMany
、@ManyToOne
或@ManyToMany
) -
hibernate.cache.use_reference_entries
配置属性必须设置为true
不可变实体作为实体引用存储在二级高速缓存中,而不是作为分解状态。这将防止从实体的拆卸状态重建实体的性能损失(创建一个新的实体实例并用拆卸状态填充它)。
这里,不可变实体将被存储在二级高速缓存中:
@Entity
@Immutable
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY, region = "Author")
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private Long id;
private String name;
private String genre;
private int age;
// getters and setters omitted for brevity
}
本书附带的代码提供了一个完整的解决方案,它依赖于二级缓存的 EhCache 实现。
现在,让我们对这个实体应用 CRUD 操作:
-
创建一个新的
Author
:下面的方法创建一个新的Author
,并把它保存在数据库中。此外,这个Author
将通过直写策略存储在二级缓存中(关于二级缓存的更多细节,参见附录 I ): -
获取已创建的
Author
:下一个方法从二级缓存中获取已创建的Author
,不命中数据库:
public void newAuthor() {
Author author = new Author();
author.setId(1L);
author.setName("Joana Nimar");
author.setGenre("History");
author.setAge(34);
authorRepository.save(author);
}
- 更新
Author
:由于Author
是不可变的(它不能被修改),所以这个操作将不起作用。这不会导致任何错误,只会被忽略:
public void fetchAuthor() {
Author author = authorRepository.findById(1L).orElseThrow();
System.out.println(author);
}
- 删除
Author
:该操作将从二级缓存中提取实体,并将其从两个位置(二级缓存和数据库)删除:
@Transactional
public void updateAuthor() {
Author author = authorRepository.findById(1L).orElseThrow();
author.setAge(45);
}
public void deleteAuthor() {
authorRepository.deleteById(1L);
}
不可变类的实体被自动加载为只读的实体。
完整的代码可以在 GitHub 5 上找到。
第 17 项:如何克隆实体
克隆实体不是一项日常任务,但有时这是避免从头开始创建实体的最简单的方法。有许多众所周知的克隆技术,如手动克隆、通过clone()
克隆、通过复制构造器克隆、使用克隆库克隆、通过串行化克隆和通过 JSON 克隆。
在实体的情况下,你很少需要使用深度克隆,但是如果这是你所需要的,那么克隆 6 库会非常有用。大多数情况下,您只需要复制属性的子集。在这种情况下,复制构造函数提供了对克隆内容的完全控制。
让我们以双向懒惰的@ManyToMany
关联中涉及的Author
和Book
实体为例。为了简洁起见,让我们使用图 2-3 (一个有两本书的作者)中的数据快照。
图 2-3
数据快照
克隆父对象并关联书籍
让我们假设 Mark Janel 不是这两本书的唯一作者(我的选集和 999 选集)。因此,您需要添加合著者。合著者与马克·詹妮尔有着相同的流派和书籍,但却有着不同的年龄和名字。一种解决方案是克隆 Mark Janel 实体,并使用克隆(新实体)来创建合著者。
假设合著者的名字是法雷尔·特里奥普,他是 54,你可以期望从图 2-4 中获得数据快照。
图 2-4
克隆父项并关联书籍
为了完成这项任务,您需要关注于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;
@ManyToMany(...)
private Set<Book> books = new HashSet<>();
private Author() {
}
public Author(Author author) {
this.genre = author.getGenre();
// associate books
books.addAll(author.getBooks());
}
...
}
Hibernate 内部需要private
构造函数。克隆一个Author
需要public
复制构造器。更准确地说,您只克隆了genre
属性。此外,最初的Author
实体( Mark Janel )引用的所有Book
实体将与新的共同作者实体( Farell Tliop )相关联。
服务方法可以通过初始的Author
实体(马克·詹妮尔)创建共同作者实体(法雷尔·特里奥),如下所示:
@Transactional
public void cloneAuthor() {
Author author = authorRepository.fetchByName("Mark Janel");
Author authorClone = new Author(author);
authorClone.setAge(54);
authorClone.setName("Farell Tliop");
authorRepository.save(authorClone);
}
被触发的 SQL 语句——除了通过fetchByName()
触发的SELECT JOIN FETCH
之外——用于提取马克·詹内尔和相关书籍的是预期的INSERT
语句:
INSERT INTO author (age, genre, name)
VALUES (?, ?, ?)
Binding: [54, Anthology, Farell Tliop]
INSERT INTO author_book (author_id, book_id)
VALUES (?, ?)
Binding: [2, 1]
INSERT INTO author_book (author_id, book_id)
VALUES (?, ?)
Binding: [2, 2]
注意,这个例子使用了Set#addAll()
方法,而不是传统的addBook()
助手。这样做是为了避免由book.getAuthors().add(this)
触发的额外的SELECT
语句:
public void addBook(Book book) {
this.books.add(book);
book.getAuthors().add(this);
}
例如,如果将books.addAll(author.getBooks())
替换为:
for (Book book : author.getBooks()) {
addBook((book));
}
然后,每本书都有一个额外的SELECT
。换句话说,合著者和书籍之间的关联双方是同步的。例如,如果在保存合著者之前在 service-method 中运行以下代码片段:
authorClone.getBooks().forEach(
b -> System.out.println(b.getAuthors()));
你会得到:
[
Author{id=1, name=Mark Janel, genre=Anthology, age=23},
Author{id=null, name=Farell Tliop, genre=Anthology, age=54}
]
[
Author{id=1, name=Mark Janel, genre=Anthology, age=23},
Author{id=null, name=Farell Tliop, genre=Anthology, age=54}
]
您可以看到作者和合著者 id 是null
,因为它们没有保存在数据库中,并且您使用的是IDENTITY
生成器。另一方面,如果您运行相同的代码片段,依靠Set#addAll()
,您将获得:
[
Author{id=1, name=Mark Janel, genre=Anthology, age=23}
]
[
Author{id=1, name=Mark Janel, genre=Anthology, age=23}
]
这一次,合著者是不可见的,因为您没有在书籍上设置它(您没有同步关联的这一侧)。因为Set#addAll()
帮助您避免额外的SELECT
语句,并且在克隆一个实体后,您可能会立即将它保存在数据库中,这应该不是一个问题。
克隆父对象和书籍
这一次,假设您想要克隆Author
( 马克·詹妮尔)和相关书籍。因此,你应该期待类似图 2-5 的东西。
图 2-5
克隆父对象和书籍
要克隆Book
,您需要在Book
实体中添加适当的构造函数,如下所示:
@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;
private Book() {
}
public Book(Book book) {
this.title = book.getTitle();
this.isbn = book.getIsbn();
}
...
}
Hibernate 内部需要private
构造函数。public
复制构造器克隆了Book
。这个例子克隆了Book
的所有属性。
此外,您应该提供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;
@ManyToMany(...)
private Set<Book> books = new HashSet<>();
private Author() {
}
public Author(Author author) {
this.genre = author.getGenre();
// clone books
for (Book book : author.getBooks()) {
addBook(new Book(book));
}
}
public void addBook(Book book) {
this.books.add(book);
book.getAuthors().add(this);
}
...
}
服务方法保持不变:
@Transactional
public void cloneAuthor() {
Author author = authorRepository.fetchByName("Mark Janel");
Author authorClone = new Author(author);
authorClone.setAge(54);
authorClone.setName("Farell Tliop");
authorRepository.save(authorClone);
}
被触发的 SQL 语句——除了通过fetchByName()
触发的SELECT JOIN FETCH
之外——用于提取马克·詹内尔和相关书籍的是预期的INSERT
语句:
INSERT INTO author (age, genre, name)
VALUES (?, ?, ?)
Binding: [54, Anthology, Farell Tliop]
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding: [001, My Anthology]
INSERT INTO book (isbn, title)
VALUES (?, ?)
Binding: [002, 999 Anthology]
INSERT INTO author_book (author_id, book_id)
VALUES (?, ?)
Binding: [2, 1]
INSERT INTO author_book (author_id, book_id)
VALUES (?, ?)
Binding: [2, 2]
加入这些案例
通过使用一个boolean
参数来重塑Author
的复制构造函数,您可以很容易地从 service-method 中决定这两种情况(克隆父节点并关联图书或克隆父节点并关联图书),如下所示:
public Author(Author author, boolean cloneChildren) {
this.genre = author.getGenre();
if (!cloneChildren) {
// associate books
books.addAll(author.getBooks());
} else {
// clone each book
for (Book book : author.getBooks()) {
addBook(new Book(book));
}
}
}
完整的应用可以在 GitHub 7 中找到。
第 18 项:为什么以及如何激活脏追踪
Dirty Checking
是一种 Hibernate 机制,专门用于在刷新时检测托管实体,这些实体自从被加载到当前持久性上下文中以来已经被修改过。然后,它代表应用(数据访问层)触发相应的 SQL UPDATE
语句。请注意,Hibernate 会扫描所有受管实体,即使受管实体中只有一个属性发生了变化。
在 Hibernate 5 之前,脏检查机制依赖于 Java 反射 API 来检查每个托管实体的每个属性。从性能的角度来看,只要实体的数量相对较少,这种方法就是“无害的”。对于大量受管实体,这种方法可能会导致性能损失。
从 Hibernate 5 开始,脏检查机制依赖于脏跟踪机制,这是一个实体跟踪其自身属性变化的能力。脏跟踪机制会带来更好的性能,其好处是显而易见的,尤其是当实体的数量非常大时。为了工作,脏跟踪机制需要将 Hibernate 字节码增强进程添加到应用中。此外,开发人员必须通过特定的标志配置启用脏跟踪机制:
<plugin>
<groupId>org.hibernate.orm.tooling</groupId>
<artifactId>hibernate-enhance-maven-plugin</artifactId>
<version>${hibernate.version}</version>
<executions>
<execution>
<configuration>
<failOnError>true</failOnError>
<enableDirtyTracking>true</enableDirtyTracking>
</configuration>
<goals>
<goal>enhance</goal>
</goals>
</execution>
</executions>
</plugin>
一般来说,字节码增强是为了某些目的而检测 Java 类的字节码的过程。Hibernate 字节码增强是一个通常发生在构建时的过程;因此,它不会影响应用的运行时(没有运行时性能损失,但当然在构建期间会有开销)。但是,它可以设置为在运行时或部署时发生。
您可以通过添加相应的 Maven 或 Gradle 插件(Ant 也受支持)来为您的应用添加字节码增强。
一旦添加了字节码增强插件,所有实体类的字节码都会被插装。这个过程被称为插装,它包括向代码添加一组服务于所选配置所需的指令(例如,您需要插装实体的代码以进行脏跟踪;通过这种手段,实体能够跟踪它的哪些属性已经改变)。在刷新时,Hibernate 将要求每个实体报告任何变化,而不是依赖状态差异计算。
您可以通过enableDirtyTracking
配置启用脏跟踪。
尽管如此,仍然推荐使用瘦持久性上下文。 水合状态 (实体快照)仍然保存在持久化上下文中。
要检查脏跟踪是否被激活,只需反编译实体类的源代码并搜索以下代码:
@Transient
private transient DirtyTracker $$_hibernate_tracker;
$$_hibernate_tracker
用于登记实体修改。在刷新期间,Hibernate 调用一个名为$$_hibernate_hasDirtyAttributes()
的方法。该方法将脏属性作为一个String[]
返回。
或者,只需检查日志中的消息,如下所示:
INFO: Enhancing [com.bookstore.entity.Author] as Entity
Successfully enhanced class [D:\...\com\bookstore\entity\Author.class]
Hibernate 字节码增强服务于三种主要机制(对于每种机制,Hibernate 将在字节码中加入适当的插装指令):
-
脏污痕迹(包含在此项中):
enableDirtyTracking
-
属性惰性初始化(第 23 项 ):
enableLazyInitialization
-
关联管理(在双向关联的情况下自动两侧同步):
enableAssociationManagement
完整的代码可以在 GitHub 8 上找到。
第 19 项:如何将布尔值映射为是/否
考虑一个遗留数据库,它有一个表author
,该表使用以下数据定义语言(DDL):
CREATE TABLE author (
id bigint(20) NOT NULL AUTO_INCREMENT,
age int(11) NOT NULL,
best_selling varchar(3) NOT NULL,
genre varchar(255) DEFAULT NULL,
name varchar(255) DEFAULT NULL,
PRIMARY KEY (id)
);
注意best_selling
栏。该列存储两个可能的值,是或否,表示作者是否是畅销书作者。此外,让我们假设这个模式不能被修改(例如,它是遗留的,您不能修改它),并且best_selling
列应该被映射到一个Boolean
值。
显然,将相应的实体属性声明为Boolean
是必要的,但并不充分:
@Entity
public class Author implements Serializable {
...
@NotNull
private Boolean bestSelling;
...
public Boolean isBestSelling() {
return bestSelling;
}
public void setBestSelling(Boolean bestSelling) {
this.bestSelling = bestSelling;
}
}
此时,Hibernate 将尝试映射这个Boolean
,如下表所示:
Java 类型
|
<->-冬眠类型
|
JDBC 式
|
| --- | --- | --- |
| boolean/Boolean
| BooleanType
| BIT
|
| boolean/Boolean
| NumericBooleanType
| INTEGER (e.g, 0 or 1)
|
| boolean/Boolean
| YesNoType
| CHAR (e.g., N/n or Y/y)
|
| boolean/Boolean
| TrueFalseType
| CHAR (e.g., F/f or T/t)
|
因此,这些映射都不匹配VARCHAR(3)
。一个优雅的解决方案是编写一个定制的转换器,Hibernate 将把它应用于所有的 CRUD 操作。这可以通过实现AttributeConverter
接口并覆盖它的两个方法来实现:
@Converter(autoApply = true)
public class BooleanConverter
implements AttributeConverter<Boolean, String> {
@Override
public String convertToDatabaseColumn(Boolean attr) {
return attr == null ? "No" : "Yes";
}
@Override
public Boolean convertToEntityAttribute(String dbData) {
return !"No".equals(dbData);
}
}
convertToDatabaseColumn()
从Boolean
转换为String
,而convertToEntityAttribute()
从String
转换为Boolean
。
这个转换器用@Converter(autoApply = true)
标注,这意味着这个转换器将用于被转换类型的所有属性(Boolean
)。要指定属性,只需删除autoApply
或将其设置为false
,并在属性级别添加@Converter
,如下所示:
@Convert(converter = BooleanConverter.class)
private Boolean bestSelling;
请注意,AttributeConverter
不能应用于用@Enumerated
注释的属性。
完整的应用可在 GitHub 9 上获得。
第 20 项:从聚合根发布域事件的最佳方式
由 Spring 存储库管理的实体被称为聚合根。在域驱动设计(DDD)中,聚合根可以发布事件或域事件。从 Spring Data Ingalls 发行版开始,通过聚合根(实体)发布这样的事件变得容易多了。
Spring Data 附带了一个@DomainEvents
注释,可以用在聚合根的方法上,使发布尽可能简单。用@DomainEvents
注释的方法被 Spring 数据识别,并且每当使用适当的存储库保存实体时,该方法被自动调用。此外,除了@DomainEvents
注释之外,Spring Data 还提供了@AfterDomainEventsPublication
注释来指示在发布后应该自动调用来清除事件的方法。在代码中,这通常如下所示:
class MyAggregateRoot {
@DomainEvents
Collection<Object> domainEvents() {
// return events you want to get published here
}
@AfterDomainEventsPublication
void callbackMethod() {
// potentially clean up domain events list
}
}
但是 Spring Data Commons 附带了一个方便的模板基类(AbstractAggregateRoot
),它帮助注册域事件并使用@DomainEvents
和@AfterDomainEventsPublication
隐含的发布机制。通过调用AbstractAggregateRoot#registerEvent()
方法来注册事件。如果您调用 Spring 数据仓库的 save 方法之一(例如save()
)并在发布后清除它,注册的域事件就会被发布。
让我们看一个依赖于AbstractAggregateRoot
及其registerEvent()
方法的示例应用。有两个实体——Book
和BookReview
——参与了一个双向懒惰@OneToMany
协会。新的书评以CHECK
状态保存到数据库,并发布CheckReviewEvent
。该事件负责检查复习语法、内容等。,并将审查状态从CHECK
切换到ACCEPT
或REJECT
。然后,它在数据库中传播新的状态。所以,这个事件是在保存处于CHECK
状态的书评之前注册的,并且在您调用BookReviewRepository.save()
方法之后自动发布。发布后,事件被清除。
让我们从聚合器根开始,BookReview
:
@Entity
public class BookReview extends AbstractAggregateRoot<BookReview>
implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
private String email;
@Enumerated(EnumType.STRING)
private ReviewStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id")
private Book book;
public void registerReviewEvent() {
registerEvent(new CheckReviewEvent(this));
}
// getters, setters, etc omitted for brevity
}
BookReview
扩展AbstractAggregateRoot
并公开registerReviewEvent()
方法,通过AbstractAggregateRoot#registerEvent()
注册域事件。在保存书评之前,调用registerReviewEvent()
方法来注册事件(CheckReviewEvent
):
@Service
public class BookstoreService {
private final static String RESPONSE
= "We will check your review and get back to you with an email ASAP :)";
private final BookRepository bookRepository;
private final BookReviewRepository bookReviewRepository;
...
@Transactional
public String postReview(BookReview bookReview) {
Book book = bookRepository.getOne(1L);
bookReview.setBook(book);
bookReview.registerReviewEvent();
bookReviewRepository.save(bookReview);
return RESPONSE;
}
}
在调用了save()
方法并且提交了事务之后,事件被发布。这里列出了CheckReviewEvent
(它传递了bookReview
实例,但是您也可以通过编写适当的构造函数只传递所需的属性):
public class CheckReviewEvent {
private final BookReview bookReview;
public CheckReviewEvent(BookReview bookReview) {
this.bookReview = bookReview;
}
public BookReview getBookReview() {
return bookReview;
}
}
最后,您需要事件处理程序,其实现如下:
@Service
public class CheckReviewEventHandler {
public final BookReviewRepository bookReviewRepository;
...
@TransactionalEventListener
public void handleCheckReviewEvent(CheckReviewEvent event) {
BookReview bookReview = event.getBookReview();
logger.info(() -> "Starting checking of review: "
+ bookReview.getId());
try {
// simulate a check out of review grammar, content, acceptance
// policies, reviewer email, etc via artificial delay of 40s for
// demonstration purposes
String content = bookReview.getContent(); // check content
String email = bookReview.getEmail(); // validate email
Thread.sleep(40000);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
// log exception
}
if (new Random().nextBoolean()) {
bookReview.setStatus(ReviewStatus.ACCEPT);
logger.info(() -> "Book review " + bookReview.getId()
+ " was accepted ...");
} else {
bookReview.setStatus(ReviewStatus.REJECT);
logger.info(() -> "Book review " + bookReview.getId()
+ " was rejected ...");
}
bookReviewRepository.save(bookReview);
logger.info(() -> "Checking review " + bookReview.getId() + " done!");
}
}
我们模拟检查评论语法、内容、接受政策、评论者电子邮件等。出于演示目的,通过 40 秒(Thread.sleep(40000);
)的人工延迟。审阅检查完成后,审阅状态会在数据库中更新。
同步执行
事件处理程序用@TransactionalEventListener
标注。事件处理程序可以通过phase
元素显式绑定到发布事件的事务阶段。通常,在事务成功完成后处理事件(TransactionPhase.AFTER_COMMIT
)。AFTER_COMMIT
是@TransactionalEventListener
的默认设置,可以进一步定制为BEFORE_COMMIT
或AFTER_COMPLETION
(事务已完成,无论成功与否)或AFTER_ROLLBACK
(事务已回滚)。AFTER_COMMIT
和AFTER_ROLLBACK
是AFTER_COMPLETION
的专门化。
如果没有事务正在运行,用@TransactionalEventListener
标注的方法将不会被执行,除非有一个名为fallbackExecution
的参数被设置为true
。
由于我们依赖于AFTER_COMMIT
并且没有为handleCheckReviewEvent()
指定明确的事务上下文,我们可能期望审查检查(通过Thread.sleep()
模拟)将在事务之外运行。此外,我们期待一个由save()
方法(bookReviewRepository.save(bookReview);
)调用引起的UPDATE
。这个UPDATE
应该被包装在一个新的事务中。但是如果您分析应用日志,您会发现这与现实相差甚远(这只是输出的相关部分):
Creating new transaction with name [...BookstoreService.postReview]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(719882002<open>)] for JPA transaction
begin
insert into book_review (book_id, content, email, status) values (?, ?, ?, ?)
Committing JPA transaction on EntityManager [SessionImpl(719882002<open>)]
committing
// The application flow entered in handleCheckReviewEvent()
Starting checking of review: 1
HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)
Found thread-bound EntityManager [SessionImpl(719882002<open>)] for JPA transaction
Participating in existing transaction
Checking review 1 done!
Closing JPA EntityManager [SessionImpl(719882002<open>)] after transaction
这里需要注意几件事。首先,事务在调用postReview()
时开始,并在运行handleCheckReviewEvent()
事件处理程序的代码之前提交。这是正常的,因为您指示 Spring 在事务提交后执行handleCheckReviewEvent()
(AFTER_COMMIT
)。但是提交事务并不意味着事务性资源已经被释放。事务性资源仍然是可访问的。正如您所看到的,连接池中没有返回连接(HikariCP 报告一个活动连接,active=1
)并且关联的持久性上下文仍然是打开的。例如,触发一个bookReviewRepository.findById
( book_reivew_id )将从当前持久上下文中获取BookReview
!
第二,没有执行UPDATE
语句!书评状态未传播到数据库。发生这种情况是因为事务已经提交。此时,数据访问代码仍将参与原始事务,但不会有提交(不会有写操作传播到数据库)。这正是这段代码bookReviewRepository.save(bookReview);
将要发生的事情。
你很容易得出结论,我们正处于非常不愉快的境地。有一个长时间运行的事务(由于通过Thread.sleep()
模拟的长流程),最终没有更新书评状态。你可能认为切换到AFTER_COMPLETION
(或AFTER_ROLLBACK
)会在执行handleCheckReviewEvent()
之前返回连接池中的连接,在handleCheckReviewEvent()
级别添加@Transactional
会触发预期的UPDATE
语句。但是下面这些都无济于事。结果将完全相同:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void handleCheckReviewEvent(CheckReviewEvent event) {
...
}
@Transactional
public void handleCheckReviewEvent(CheckReviewEvent event) {
...
}
@Transactional
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void handleCheckReviewEvent(CheckReviewEvent event) {
...
}
要解决这种情况,您必须通过Propagation.REQUIRES_NEW
明确要求为handleCheckReviewEvent()
创建一个新事务,如下所示:
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleCheckReviewEvent(CheckReviewEvent event) {
...
}
将更改(写操作)传播到事件处理程序中的数据库(用@TransactionalEventListener
标注的方法)需要一个显式的新事务(Propagation.REQUIRES_NEW
)。但是一定要阅读下面的讨论,因为从性能的角度来看,这不是没有代价的。
让我们再次检查应用日志:
Creating new transaction with name [...BookstoreService.postReview]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(514524928<open>)] for JPA transaction
begin
insert into book_review (book_id, content, email, status) values (?, ?, ?, ?)
Committing JPA transaction on EntityManager [SessionImpl(514524928<open>)]
committing
// The application flow entered in handleCheckReviewEvent()
Suspending current transaction, creating new transaction with name [com.bookstore.event.CheckReviewEventHandler.handleCheckReviewEvent]
Opened new EntityManager [SessionImpl(1879180026<open>)] for JPA transaction
begin
HikariPool-1 - Pool stats (total=10, active=2, idle=8, waiting=0)
Found thread-bound EntityManager [SessionImpl(1879180026<open>)] for JPA transaction
Participating in existing transaction
select bookreview0_.id as id1_1_0_, ... where bookreview0_.id=?
Committing JPA transaction on EntityManager [SessionImpl(1879180026<open>)]
committing
update book_review set book_id=?, content=?, email=?, status=? where id=?
Closing JPA EntityManager [SessionImpl(1879180026<open>)] after transaction
Resuming suspended transaction after completion of inner transaction
Closing JPA EntityManager [SessionImpl(514524928<open>)] after transaction
这一次,事务在您调用postReview()
时开始,并在应用流到达handleCheckReviewEvent()
时暂停。新的事务和新的持久上下文被创建并被进一步使用。触发预期的UPDATE
,更新数据库中的书评状态。在此期间,两个数据库连接是活动的(一个用于挂起的事务,一个用于当前事务)。该事务提交,附加的数据库连接返回到连接池。此外,暂停的事务被恢复并关闭。最后,调用postReview()
时打开的连接被返回到连接池中。显然,这里唯一的好处是触发了UPDATE
,但是性能损失很大。这使两个数据库连接长时间保持活动状态。所以,两个长时间运行的事务!要解决这种情况,您可以切换到BEFORE_COMMIT
并移除@Transactional
:
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleCheckReviewEvent(CheckReviewEvent event) {
...
}
这一次,事务在您调用postReview()
时开始,并在运行事件处理程序(handleCheckReviewEvent()
)结束时提交。所以,书评状态的UPDATE
就是在这个事务上下文中触发的。现在,您有了一个长时间运行的事务,并且针对数据库执行了UPDATE
。数据库连接在您调用postReview()
时打开,在执行handleCheckReviewEvent()
结束时关闭。除了这个长时间运行的事务所代表的性能损失之外,您还必须记住,使用BEFORE_COMMIT
并不总是适应这种场景。如果您确实需要在继续之前提交事务,这不是一个选项。
或者,您仍然可以依赖于AFTER_COMMIT
并延迟通过Propagation.REQUIRES_NEW
请求的事务的连接获取。这可以按照第 60 项中的方法完成。所以,在application.properties
中,你需要禁用auto-commit
:
spring.datasource.hikari.auto-commit=false
spring.jpa.properties.hibernate.connection.provider_disables_autocommit=true
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleCheckReviewEvent(CheckReviewEvent event) {
...
}
让我们来看看应用日志:
// The application flow entered in handleCheckReviewEvent()
Suspending current transaction, creating new transaction with name [com.bookstore.event.CheckReviewEventHandler.handleCheckReviewEvent]
Opened new EntityManager [SessionImpl(1879180026<open>)] for JPA transaction
begin
HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)
Found thread-bound EntityManager [SessionImpl(1879180026<open>)] for JPA transaction
Participating in existing transaction
select bookreview0_.id as id1_1_0_, ... where bookreview0_.id=?
Committing JPA transaction on EntityManager [SessionImpl(1879180026<open>)]
committing
update book_review set book_id=?, content=?, email=?, status=? where id=?
Closing JPA EntityManager [SessionImpl(1879180026<open>)] after transaction
Resuming suspended transaction after completion of inner transaction
Closing JPA EntityManager [SessionImpl(514524928<open>)] after transaction
这一次,通过Propagation.REQUIRES_NEW
要求的事务被延迟,直到您调用bookReviewRepository.save(bookReview);
。这意味着检查书评的漫长过程将打开一个数据库连接,而不是两个。这样好一点了,但还是不能接受。
异步执行
到目前为止,我们还不能说可以忽略相关的性能损失。这意味着我们需要努力进一步优化这些代码。由于图书审核检查过程非常耗时,因此在此过程结束之前,没有必要阻止审核者。正如您在postReview()
方法中看到的,在保存书评和注册事件之后,我们返回一个字符串响应作为,We will check your review and get back to you with an email ASAP :)
。该实现依赖于同步执行,因此您需要在事件处理程序完成执行后发送这个字符串响应。显然,由于在图书评论检查过程中,评论者被阻止,所以响应较晚。
最好是在事件处理程序开始执行之前立即返回字符串响应,而带有决定的电子邮件可以稍后发送。默认情况下,事件处理程序在调用方线程中执行。因此,是时候让异步执行为事件处理程序分配不同的线程了。在 Spring Boot,您可以通过@EnableAsync
启用异步功能。接下来,用@Async
注释事件处理程序:
@Async
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleCheckReviewEvent(CheckReviewEvent event) {
...
}
是时候再次查看应用日志了:
Creating new transaction with name [...BookstoreService.postReview]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(1691206416<open>)] for JPA transaction
begin
insert into book_review (book_id, content, email, status) values (?, ?, ?, ?)
Committing JPA transaction on EntityManager [SessionImpl(1691206416<open>)]
...
Closing JPA EntityManager [SessionImpl(1691206416<open>)] after transaction
Creating new transaction with name [...CheckReviewEventHandler.handleCheckReviewEvent]: PROPAGATION_REQUIRES_NEW,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(1272552918<open>)] for JPA transaction
// since the execution is asynchronous the exact moment in time when the
// string response is sent may slightly vary
Response: We will check your review and get back to you with an email ASAP :)
begin
Starting checking of review: 1
HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)
Found thread-bound EntityManager [SessionImpl(1272552918<open>)] for JPA transaction
Participating in existing transaction
select bookreview0_.id as id1_1_0_, ... where bookreview0_.id=?
Checking review 1 done!
Committing JPA transaction on EntityManager [SessionImpl(1272552918<open>)]
...
这一次,应用日志显示您已经消除了长时间运行的事务。当postReview()
调用被提交和关闭时,事务开始(附加的数据库连接在连接池中返回), string-response 被立即发送给检查者。事件处理程序的执行是异步的,需要一个新的线程和一个新的事务。数据库连接的获取被延迟到真正需要的时候(这时应该更新书评状态)。因此,图书检查不会免费保持任何数据库连接活动/繁忙。
一般来说,大多数应用依赖连接池来重用物理数据库连接,而一个数据库服务器只能服务有限数量的这种连接。这意味着执行长时间运行的事务将使连接长时间处于忙碌状态,这将影响可伸缩性。这不符合 MVCC(多版本并发控制)。为了有一个愉快的连接池和数据库服务器,最好有短的数据库事务。在域事件的上下文中,您至少应该注意以下几点,以避免重大的性能损失。
异步执行期间:
-
如果您需要执行任何非常适合异步执行的任务,请使用带有
AFTER_COMPLETION
(或其专门化)的异步事件处理程序。 -
如果这些任务不涉及数据库操作(读/写),那么不要在事件处理程序方法级别使用
@Transactional
(不要启动新的事务)。 -
如果这些任务涉及数据库读和/或写操作,那么使用
Propagation.REQUIRES_NEW
并将数据库连接获取延迟到需要时(在数据库连接打开后,避免耗时的任务)。 -
如果这些任务只涉及数据库读取操作,那么用
@Transactional(readOnly=true, Propagation.REQUIRES_NEW)
注释事件处理程序方法。 -
如果这些任务涉及数据库写操作,那么用
@Transactional(Propagation.REQUIRES_NEW)
注释事件处理程序方法。 -
避免在
BEFORE_COMMIT
阶段执行异步任务,因为您无法保证这些任务会在生产者的事务提交之前完成。 -
根据您的场景,您可能需要拦截事件处理程序线程的完成。
同步执行时:
-
考虑异步执行(包括它的特定缺点)。
-
只有当事件处理程序不耗时并且需要数据库写操作时,才使用
BEFORE_COMMIT
(当然,如果在提交之前执行事件处理程序代码适合您的场景)。显然,您仍然可以读取当前的持久性上下文(它是打开的)并触发只读数据库操作。 -
仅当事件处理程序不耗时且不需要数据库写操作时,才使用
AFTER_COMPLETION
(或其专门化)(尽量避免在同步执行中使用Propagation.REQUIRES_NEW
)。尽管如此,您仍然可以读取当前的持久性上下文(它是打开的)并触发只读数据库操作。 -
在使用
BEFORE_COMMIT
的情况下,在事件处理程序中执行的数据库操作的失败将回滚整个事务(取决于您的场景,这可能是好的,也可能是不好的)。
Spring 域事件对于简化事件基础设施非常有用,但是要注意以下几点:
-
域事件只适用于 Spring 数据仓库。
-
只有当我们显式调用一个保存方法(例如
save()
)时,域事件才会按预期发布。 -
如果发布事件时发生异常,则不会通知侦听器(事件处理程序)。因此,事件将会丢失。
在应用中使用域事件之前,建议评估一下使用 JPA 回调(第 104 条)、观察者设计模式、特定于 Hibernate 的@Formula
( 第 77 条)或者其他方法是否也能很好地工作。
完整的应用可在 GitHub 10 上获得。
三、抓取
第 21 项:如何使用直接抓取
当实体的标识符是已知的并且它的惰性关联不会在当前持久化上下文中导航时,直接获取或通过 ID 获取是获取实体的优选方式。
默认情况下,直接取数会根据默认或指定的FetchType
加载实体。重要的是要记住,默认情况下,JPA @OneToMany
和@ManyToMany
关联被认为是LAZY
,而@OneToOne
和@ManyToOne
关联被认为是EAGER
。
因此,通过 ID 获取具有EAGER
关联的实体将会在持久性上下文中加载该关联,即使不需要,这会导致性能损失。另一方面,获取一个具有LAZY
关联的实体并在当前持久化上下文中访问这个关联也会导致加载它的额外查询——也会导致性能损失。
最好的方法是保留所有的关联LAZY
,依靠手动抓取策略(参见第 39 项、第 41 项和第 43 项)来加载这些关联。只有当您不打算访问当前持久性上下文中的LAZY
关联时,才依赖直接获取。
现在,让我们看看几种通过 ID 获取实体的方法。考虑下面的Author
实体:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int age;
private String name;
private String genre;
// getters and setters omitted for brevity
}
下面三个例子的目的是使用直接抓取来加载 ID 为1
的实体。
通过 Spring 数据直接获取
您可以通过内置的findById()
方法直接获取 Spring 数据。该方法获取 ID 作为参数,并返回一个包装相应实体的Optional
。在代码中,findById()
的用法如下:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {}
Optional<Author> author = authorRepository.findById(1L);
加载这个Author
的 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 = ?
在幕后,findById()
使用了EntityManager.find()
方法。
通过 EntityManager 获取
可以通过@PersistenceContext
注入EntityManager
。有了EntityManager
,剩下的就是调用find()
方法了。该方法遵循 Spring 数据样式,并返回一个Optional
:
@PersistenceContext
private EntityManager entityManager;
@Override
public Optional<T> find(Class<T> clazz, ID id) {
if (id == null) {
throw new IllegalArgumentException("ID cannot be null");
}
return Optional.ofNullable(entityManager.find(clazz, id));
}
加载这个Author
的 SQL SELECT
语句与findById()
相同:
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 = ?
通过特定于 Hibernate 的会话获取
要使用 Hibernate 特有的Session.get()
方法通过 ID 获取数据,您需要从EntityManager
中解开Session
。以下方法执行此展开并返回一个Optional
:
@PersistenceContext
private EntityManager entityManager;
@Override
public Optional<T> findViaSession(Class<T> clazz, ID id) {
if (id == null) {
throw new IllegalArgumentException("ID cannot be null");
}
Session session = entityManager.unwrap(Session.class);
return Optional.ofNullable(session.get(clazz, id));
}
加载此Author
的 SQL SELECT
语句与findById()
和EntityManager
的情况相同:
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 = ?
完整的应用可在 GitHub 1 上获得。
JPA 持久性提供者(Hibernate)通过findById()
、find()
和get()
获取具有给定 ID 的实体,按以下顺序搜索:
-
当前的持久性上下文(如果没有找到,转到下一步)
-
二级缓存(如果没有找到,转到下一步)
-
数据库
搜索的顺序是严格的。
直接读取和会话级可重复读取
这一节详细介绍了第一个要点(在当前持久性上下文中搜索)。为什么 Hibernate 首先检查持久化上下文来查找具有给定 ID 的实体?答案是 Hibernate 保证了会话级的可重复读取。这意味着第一次获取的实体被缓存在持久性上下文中(一级缓存)。同一实体的后续获取(通过直接获取或显式实体查询(JPQL/HQL))是从持久性上下文中完成的。换句话说,会话级可重复读取防止在并发写入情况下丢失更新。
请看下面的例子,它将这三种直接获取技术归入一个事务性服务方法:
@Transactional(readOnly=true)
public void directFetching() {
// direct fetching via Spring Data
Optional<Author> resultSD = authorRepository.findById(1L);
System.out.println("Direct fetching via Spring Data: "
+ resultSD.get());
// direct fetching via EntityManager
Optional<Author> resultEM = dao.find(Author.class, 1L);
System.out.println("Direct fetching via EntityManager: "
+ resultEM.get());
// direct fetching via Session
Optional<Author> resultHS = dao.findViaSession(Author.class, 1L);
System.out.println("Direct fetching via Session: "
+ resultHS.get());
}
将执行多少条SELECT
语句?如果你回答了一个,你就答对了!有一单SELECT
由authorRepository.findById(1L)
呼叫引起。返回的作者缓存在持久性上下文中。随后的调用——dao.find(Author.class, 1L)
和dao.findViaSession(Author.class, 1L)
——从持久性上下文中获取同一个作者实例,而不会触及底层数据库。
现在,让我们假设我们使用显式 JPQL 查询,如下例所示。首先,我们编写显式的 JPQL,通过 ID 获取作者(我们使用Optional
只是为了保持趋势,但它与本主题无关):
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a WHERE a.id = ?1")
public Optional<Author> fetchById(long id);
}
接下来,让我们看看下面的服务方法:
@Transactional(readOnly=true)
public void directFetching() {
// direct fetching via Spring Data
Optional<Author> resultSD = authorRepository.findById(1L);
System.out.println("Direct fetching via Spring Data: "
+ resultSD.get());
// direct fetching via EntityManager
Optional<Author> resultJPQL = authorRepository.fetchById(1L);
System.out.println("Explicit JPQL: "
+ resultJPQL.get());
}
将执行多少条SELECT
语句?如果你回答了两个,你就对了:
-- triggered by authorRepository.findById(1L)
-- the returned author is loaded in the Persistence Context
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 = ?
-- identical SELECT triggered by authorRepository.fetchById(1L)
-- the returned data snapshot is ignored and
-- the returned author is from Persistence Context
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 = ?
当持久性上下文为空时,第一个SELECT
由authorRepository.findById(1L)
调用引起。第二个SELECT
命中数据库,因为除非我们使用二级缓存,否则任何显式查询都将针对数据库执行。因此,我们的显式SELECT
并不是这个规则的例外。调用authorRepository.fetchById(1L)
返回的作者是来自当前加载的数据库快照,还是来自我们调用authorRepository.findById(1L)
时加载的持久化上下文?由于持久性上下文保证了会话级的可重复读取,Hibernate 忽略了通过 JPQL 加载的数据库快照,并返回持久性上下文中已经存在的作者。
从性能角度来看,建议使用findById()
、find()
或get()
而不是显式的 JPQL/SQL 来按 ID 获取实体。这样,如果实体存在于当前的持久化上下文中,就不会触发针对数据库的SELECT
,也不会忽略任何数据快照。
虽然乍一看,这种行为可能不是那么明显,但是我们可以通过一个简单的测试来揭示它,这个测试使用了两个通过 Spring TransactionTemplate
API 形成的并发事务。考虑以下作者:
INSERT INTO author (age, name, genre, id)
VALUES (23, "Mark Janel", "Anthology", 1);
和以下服务方法:
private final AuthorRepository authorRepository;
private final TransactionTemplate template;
...
public void process() {
template.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.setIsolationLevel(Isolation.READ_COMMITTED.value());
// Transaction A
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
Author authorA1 = authorRepository.findById(1L).orElseThrow();
System.out.println("Author A1: " + authorA1.getName() + "\n");
// Transaction B
template.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
Author authorB = authorRepository
.findById(1L).orElseThrow();
authorB.setName("Alicia Tom");
System.out.println("Author B: "
+ authorB.getName() + "\n");
}
});
// Direct fetching via findById(), find() and get()
// doesn't trigger a SELECT
// It loads the author directly from Persistence Context
Author authorA2 = authorRepository.findById(1L).orElseThrow();
System.out.println("\nAuthor A2: " + authorA2.getName() + "\n");
// JPQL entity queries take advantage of
// session-level repeatable reads
// The data snapshot returned by the triggered SELECT is ignored
Author authorViaJpql = authorRepository.fetchByIdJpql(1L);
System.out.println("Author via JPQL: "
+ authorViaJpql.getName() + "\n");
// SQL entity queries take advantage of
// session-level repeatable reads
// The data snapshot returned by the triggered SELECT is ignored
Author authorViaSql = authorRepository.fetchByIdSql(1L);
System.out.println("Author via SQL: "
+ authorViaSql.getName() + "\n");
// JPQL query projections always load the latest database state
String nameViaJpql = authorRepository.fetchNameByIdJpql(1L);
System.out.println("Author name via JPQL: " + nameViaJpql + "\n");
// SQL query projections always load the latest database state
String nameViaSql = authorRepository.fetchNameByIdSql(1L);
System.out.println("Author name via SQL: " + nameViaSql + "\n");
}
});
}
有很多代码,但非常简单。首先,我们针对 MySQL 运行这段代码,MySQL 依赖REPEATABLE_READ
作为默认隔离级别(关于 Spring 事务隔离级别的更多细节可以在附录 F 中找到)。我们需要切换到READ_COMMITTED
隔离级别,以便强调 Hibernate 会话级可重复读取是如何在不交错REPEATABLE_READ
隔离级别的情况下工作的。我们还通过设置PROPAGATION_REQUIRES_NEW
来确保第二个事务(事务 B)不参与到事务 A 的上下文中(关于 Spring 事务传播的更多细节可以在附录 G 中找到)。
此外,我们启动事务 A(和持久性上下文 A)。在这个事务上下文中,我们调用findById()
来获取 ID 为1
的作者。因此,这个作者通过适当的SELECT
查询被加载到持久化上下文 A 中。
接下来,我们让事务 A 保持原样,并启动事务 B(和持久性上下文 B)。在事务 B 的上下文中,我们通过适当的SELECT
加载作者的 ID1
,并执行名称更新(马克·詹内尔变成了艾丽西娅·汤姆)。相应的UPDATE
在刷新时针对数据库执行,就在事务 B 提交之前。所以现在,在底层数据库中,ID 为1
的作者的名字是 Alicia Tom 。
现在,我们回到事务 A(和持久性上下文 A)并触发一系列查询,如下所示:
-
首先,我们调用
findById()
来获取 ID 为1
的作者。作者直接从持久化上下文 A 返回(没有任何SELECT
,名字是马克·詹妮尔。因此,会话级可重复读取按预期工作。 -
其次,我们执行以下显式 JPQL 查询(
fetchByIdJpql()
):
@Query("SELECT a FROM Author a WHERE a.id = ?1")
public Author fetchByIdJpql(long id);
被触发的SELECT
返回的数据快照被忽略,返回的作者是来自持久上下文 A 的作者(马克·詹内尔)。同样,会话级可重复读取按预期工作。
- 接下来,我们执行以下显式本机 SQL 查询(
fetchByIdSql()
):
@Query(value = "SELECT * FROM author WHERE id = ?1",
nativeQuery = true)
public Author fetchByIdSql(long id);
同样,被触发的SELECT
返回的数据快照被忽略,返回的作者是来自持久上下文 A 的作者(马克·詹内尔)。会话级可重复读取按预期工作。
到目前为止,我们可以得出结论,对于通过 JPQL 或原生 SQL 表达的实体查询,Hibernate 会话级可重复读取可以像预期的那样工作。接下来,让我们看看这是如何与 SQL 查询投影一起工作的。
- 我们执行下面的 JPQL 查询投影(
fetchNameByIdJpql()
):
@Query("SELECT a.name FROM Author a WHERE a.id = ?1")
public String fetchNameByIdJpql(long id);
这一次,被触发的SELECT
返回的数据快照没有被忽略。返回的作者有名字艾丽西娅·汤姆。因此,会话级可重复读取在这种情况下不起作用。
- 最后,我们执行下面的本地 SQL 查询投影(
fetchNameByIdSql()
):
@Query(value = "SELECT name FROM author WHERE id = ?1",
nativeQuery = true)
public String fetchNameByIdSql(long id);
同样,由触发的SELECT
返回的数据快照不会被忽略。返回的作者有名字艾丽西娅·汤姆。因此,会话级可重复读取不起作用。
到目前为止,我们可以得出结论,Hibernate 会话级可重复读取不适用于通过 JPQL 或原生 SQL 表达的 SQL 查询投影。这类查询总是加载最新的数据库状态。
然而,如果我们将事务隔离级别切换回REPEATABLE_READ
,那么 SQL 查询投影将返回作者马克·詹纳。这是因为,顾名思义,REPEATABLE_READ
隔离级别声明一个事务在多次读取中读取相同的结果。换句话说,REPEATABLE_READ
隔离级别防止了 SQL 不可重复读取异常(附录 E )。例如,多次从数据库中读取一条记录的事务在每次读取时都会获得相同的结果(附录 F )。
不要混淆 Hibernate 会话级可重复读取和REPEATABLE_READ
事务隔离级别。
好的,还有两个方面需要考虑:
Hibernate 提供了即时可用的会话级可重复读取。但是有时您会希望从数据库中加载最新的状态。在这种情况下,您可以调用EntityManager#refresh()
方法(因为 Spring 数据没有公开这个方法,您可以扩展JpaRepository
来添加它)。
不要将 Hibernate 会话级可重复读取与应用级可重复读取混淆,后者通常在对话跨越多个请求时使用(第 134 项)。Hibernate 保证了会话级的可重复读取,并提供了对应用级可重复读取的支持。更准确地说,持久性上下文保证了会话级的可重复读取,并且您可以通过分离的实体或扩展的持久性上下文来塑造应用级的可重复读取。应用级可重复读取应该得到应用级并发控制策略的帮助,比如乐观锁定,以避免更新丢失(参见附录 E )。
完整的应用可在 GitHub 2 上获得。
通过 ID 直接提取多个实体
有时,您需要通过 ID 加载多个实体。在这种情况下,通过 ID 加载实体的最快方法将依赖于使用IN
操作符的查询。Spring Data 提供了现成的findAllById()
方法。它将 id 的一个Iterable
作为参数,并返回实体的一个List
(Book
是实体,BookRepository
是该实体的经典 Spring 存储库):
List<Book> books = bookRepository.findAllById(List.of(1L, 2L, 5L));
通过 JPQL 可以获得相同的结果(相同的触发 SQL ),如下所示:
@Query("SELECT b FROM Book b WHERE b.id IN ?1")
List<Book> fetchByMultipleIds(List<Long> ids);
将IN
子句与支持执行计划缓存的数据库结合使用可以进一步优化,如第 122 项所示。
使用Specification
也是一种选择。看看下面的例子:
List<Book> books = bookRepository.findAll(
new InIdsSpecification(List.of(1L, 2L, 5L)));
其中InIdsSpecification
是:
public class InIdsSpecification implements Specification<Book> {
private final List<Long> ids;
public InIdsSpecification(List<Long> ids) {
this.ids = ids;
}
@Override
public Predicate toPredicate(Root<Book> root,
CriteriaQuery<?> cquery, CriteriaBuilder cbuilder) {
return root.in(ids);
// or
// Expression<String> expression = root.get("id");
// return expression.in(ids);
}
}
这三种方法都触发相同的 SQL SELECT
,并受益于会话级可重复读取。完整的应用可在 GitHub 3 上获得。
另一种方法是依赖 Hibernate 特有的MultiIdentifierLoadAccess
接口。在它的优点中,这个接口允许您通过 ID 批量加载多个实体(withBatchSize()
),并指定在执行数据库查询之前是否应该检查持久化上下文(默认情况下不检查,但是可以通过enableSessionCheck()
启用)。由于MultiIdentifierLoadAccess
是一个特定于 Hibernate 的 API,我们需要将其塑造成 Spring Boot 风格。GitHub 4 上有完整的应用。
第 22 项:每当您计划在未来的持久性上下文中将更改传播到数据库时,为什么要使用只读实体
考虑通过几个属性作为id
、name
、age
和genre
形成作者简介的Author
实体。该场景要求您加载一个Author
概要文件,编辑该概要文件(例如,修改年龄),并将其保存回数据库。您不会在单个事务中这样做(持久性上下文)。您可以在两个不同的事务中完成,如下所示。
以读写模式加载作者
由于应该修改Author
实体,您可能认为应该以读写模式加载它,如下所示:
@Transactional
public Author fetchAuthorReadWriteMode() {
Author author = authorRepository.findByName("Joana Nimar");
return author;
}
注意,获取的作者在方法(事务)中没有被修改。它被获取并返回,因此当前的持久化上下文在任何修改之前被关闭,返回的author
被分离。让我们看看在持久性上下文中有什么。
提取读写实体后的持久性上下文:
Total number of managed entities: 1
Total number of collection entries: 0
EntityKey[com.bookstore.entity.Author#4]:
Author{id=4, age=34, name=Joana Nimar, genre=History}
Entity name: com.bookstore.entity.Author
Status: MANAGED
State: [34, History, Joana Nimar]
请注意突出显示的内容。实体的状态为MANAGED
,并且还存在水合状态。换句话说,这种方法至少有两个缺点:
-
Hibernate 准备好将实体更改传播到数据库(即使我们在当前的持久化上下文中没有修改),所以它在内存中保持水合状态。
-
在刷新时,Hibernate 将扫描这个实体的修改,这次扫描也将包括这个实体。
性能损失反映在内存和 CPU 上。存储不需要的水合状态会消耗内存,而在刷新时扫描实体并由垃圾收集器收集会消耗 CPU 资源。最好通过以只读模式获取实体来避免这些缺点。
以只读模式加载作者
由于在当前持久化上下文中没有修改Author
实体,所以它可以以只读模式加载,如下所示:
@Transactional(readOnly = true)
public Author fetchAuthorReadOnlyMode() {
Author author = authorRepository.findByName("Joana Nimar");
return author;
}
此方法(事务)加载的实体是只读实体。不要将只读实体与 DTO(投影)混淆。只读实体只能被修改,因此修改将在将来的持久性上下文中传播到数据库。DTO(投影)永远不会加载到持久性上下文中,它适用于永远不会被修改的数据。
让我们来看看这种情况下的持久性上下文内容。
提取只读实体后的持久性上下文:
Total number of managed entities: 1
Total number of collection entries: 0
EntityKey[com.bookstore.entity.Author#4]:
Author{id=4, age=34, name=Joana Nimar, genre=History}
Entity name: com.bookstore.entity.Author
Status: READ_ONLY
State: null
这次状态是READ_ONLY
,水合状态被丢弃。此外,没有自动冲洗时间,也没有应用脏检查。这比以读写模式获取实体要好得多。我们不会为了存储水合状态而消耗内存,也不会因为不必要的动作而消耗 CPU。
更新作者
在获取和返回实体(在读写或只读模式下)后,它被分离。此外,我们可以对其进行修改和合并:
// modify the read-only entity in detached state
Author authorRO = bookstoreService.fetchAuthorReadOnlyMode();
authorRO.setAge(authorRO.getAge() + 1);
bookstoreService.updateAuthor(authorRO);
// merge the entity
@Transactional
public void updateAuthor(Author author) {
// behind the scene it calls EntityManager#merge()
authorRepository.save(author);
}
author
不是当前的持久上下文,这是一个合并操作。因此,这个动作在一个SELECT
和一个UPDATE
中被具体化。
此外,合并后的实体由 Hibernate 管理。完整的代码可以在 GitHub 5 上找到。
请注意,这里展示的案例使用了每个请求的持久性上下文习惯用法。持久性上下文被绑定到单个物理数据库事务和单个逻辑@Transactional
的生命周期。如果您选择使用扩展的持久性上下文,那么实现将由其他规则控制。然而,在 Spring 中使用扩展持久性上下文是相当具有挑战性的。如果你不完全确定你理解它,最好避免它。
本文介绍的场景在 web 应用中很常见,被称为 HTTP 长对话。通常,在 web 应用中,这种场景需要两个或更多的 HTTP 请求。特别是在这种情况下,第一个请求将加载作者配置文件,而第二个请求将推送配置文件更改。在 HTTP 请求之间是作者思考的时间。这在第 134 项中有详细说明。
第 23 项:如何通过 Hibernate 字节码增强来延迟加载实体属性
假设应用包含下面的Author
实体。该实体映射一个作者简档:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private Long id;
@Lob
private byte[] avatar;
private int age;
private String name;
private String genre;
...
// getters and setters omitted for brevity
}
启用属性的延迟加载
诸如实体标识符(id
)、name
、age
或genre
之类的属性将在每次实体加载时被急切地获取。但是avatar
应该被延迟获取,只有当它被应用代码访问时。因此,avatar
列不应该出现在被触发来获取Author
的 SQL 中。
在图 3-1 中,可以看到author
表的avatar
栏。
图 3-1
头像要装懒
默认情况下,一个实体的属性被急切地加载(在同一个查询中一次全部加载),所以即使应用不需要/不要求,也会加载avatar
。
avatar
代表一张图片;因此,这是一个潜在的大量字节数据(例如,在图 3-1 中,头像占用了 5086 字节)。在每次实体加载时加载头像而不使用它是一种性能损失,应该消除。
这个问题的解决方案依赖于属性延迟加载。
属性延迟加载对于存储大量数据的列类型非常有用,如CLOB
、BLOB
、VARBINARY
等。—或者用于需要加载的细节。
要使用属性延迟加载,您需要遵循一些步骤。第一步是为 Maven 添加 Hibernate 字节码增强插件。接下来,通过enableLazyInitialization
配置启用惰性初始化,指示 Hibernate 用正确的指令来插装实体类的字节码(如果您想看看添加的指令,那么只需反编译插装的实体类)。对于 Maven,将pom.xml
添加到字节码增强插件的<plugins>
部分,如下所示:
<plugin>
<groupId>org.hibernate.orm.tooling</groupId>
<artifactId>hibernate-enhance-maven-plugin</artifactId>
<version>${hibernate.version}</version>
<executions>
<execution>
<configuration>
<failOnError>true</failOnError>
<enableLazyInitialization>true</enableLazyInitialization>
</configuration>
<goals>
<goal>enhance</goal>
</goals>
</execution>
</executions>
</plugin>
Hibernate 字节码增强发生在构建时;因此,它不会增加运行时的开销。如果不像这里所示的那样添加字节码增强,属性 lazy loading 将不起作用。
第二步包括注释应该用@Basic(fetch = FetchType.LAZY
惰性加载的实体属性。对于Author
实体,将avatar
属性注释如下:
@Lob
@Basic(fetch = FetchType.LAZY)
private byte[] avatar;
默认情况下,属性用@Basic
标注,这依赖于默认的获取策略。默认的获取策略是FetchType.EAGER
。
此外,可以为Author
实体编写一个经典的 Spring 存储库。最后,出于好玩,添加一个查询来获取所有大于或等于给定年龄的作者:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Transactional(readOnly=true)
List<Author> findByAgeGreaterThanEqual(int age);
}
下面的服务方法将加载所有超过给定年龄的作者。不会加载avatar
属性:
public List<Author> fetchAuthorsByAgeGreaterThanEqual(int age) {
List<Author> authors = authorRepository.findByAgeGreaterThanEqual(age);
return authors;
}
调用这个方法将显示一个只获取id
、name
、age
和genre
的 SQL:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre4_0_,
author0_.name AS name5_0_
FROM author author0_
WHERE author0_.age >= ?
从返回的作者列表中选取一个作者id
并将其传递给下面的方法,也将获取avatar
属性。对getAvatar()
方法的显式调用将触发一个二级 SQL 来加载虚拟角色的字节:
@Transactional(readOnly = true)
public byte[] fetchAuthorAvatarViaId(long id) {
Author author = authorRepository.findById(id).orElseThrow();
return author.getAvatar(); // lazy loading of 'avatar'
}
用给定的id
获取作者是在两个SELECT
语句中完成的。第一个SELECT
取id
、age
、name
和genre
,第二个SELECT
取avatar
:
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre4_0_0_,
author0_.name AS name5_0_0_
FROM author author0_
WHERE author0_.id = ?
SELECT
author_.avatar AS avatar3_0_
FROM author author_
WHERE author_.id = ?
试图在会话上下文之外(持久性上下文之外)获取惰性属性(例如,avatar
)将导致LazyInitializationException
。
属性延迟加载和 N+1
N+1 表示由于触发的 SQL 语句(查询)比所需/预期的多而导致的性能损失。换句话说,执行不必要的数据库往返会消耗 CPU、RAM 内存、数据库连接等资源。大多数情况下,直到您检查(计数/断言)被触发的 SQL 语句的数量时,N+1 才被检测到。
额外的和不必要的 SQL 语句越多,应用就越慢。
考虑以下方法:
@Transactional(readOnly = true)
public List<Author> fetchAuthorsDetailsByAgeGreaterThanEqual(int age) {
List<Author> authors = authorRepository.findByAgeGreaterThanEqual(age);
// don't do this since this is a N+1 case
authors.forEach(a -> {
a.getAvatar();
});
return authors;
}
通过调用findByAgeGreaterThanEqual()
触发的查询获取一个比给定年龄更老的作者列表(这是 N+1 中的 1)。循环作者列表并为每个作者调用getAvatar()
会导致与作者数量相等的额外查询。换句话说,由于头像是被延迟获取的,调用getAvatar()
将为每个作者触发一个 SQL SELECT
(这是 N+1 中的 N)。对于两个作者,我们有以下三个 SQL 语句(最后两个查询是获取化身所需的附加查询):
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre4_0_,
author0_.name AS name5_0_
FROM author author0_
WHERE author0_.age >= ?
SELECT
author_.avatar AS avatar3_0_
FROM author author_
WHERE author_.id = ?
SELECT
author_.avatar AS avatar3_0_
FROM author author_
WHERE author_.id = ?
通过采用子实体技术(参见第 24 条)或者通过触发一个 SQL SELECT
来显式加载 DTO 中的延迟获取属性,可以避免 N+1 的性能损失。例如,下面的查询将触发单个SELECT
来获取年龄大于给定年龄的作者的姓名和头像作为 DTO (Spring projection):
public interface AuthorDto {
public String getName();
public byte[] getAvatar();
}
@Transactional(readOnly = true)
@Query("SELECT a.name AS name, a.avatar AS avatar
FROM Author a WHERE a.age >= ?1")
List<AuthorDto> findDtoByAgeGreaterThanEqual(int age);
GitHub 6 上有源代码。
属性延迟加载和延迟初始化异常
在 Spring Boot 应用中启用属性延迟加载最终会导致特定于该上下文的延迟初始化异常。通常,当开发人员在视图中禁用 Open Session(在 Spring Boot 默认情况下是启用的)时,就会发生这种情况。让我们处理一个典型的场景。
默认情况下,View 中的 Open Session 强制当前的持久性上下文保持打开,而 Jackson 强制惰性加载属性的初始化(一般来说,View 层触发代理初始化)。例如,如果启用了 Open Session in View,并且应用从 REST 控制器端点返回一个List<Author>
,那么视图(Jackson 序列化 JSON 响应)也将强制初始化avatar
属性。OSIV 将提供当前活动的Session
,因此不会出现懒惰初始化问题。
即使这是另一个话题,考虑以下问题:通过 REST API 公开实体是否明智?我建议你读一读索本·让桑的这篇文章 7 。
显然,这违背了应用的目标。解决方案包括通过在application.properties
中设置以下内容来禁用 OSIV:
spring.jpa.open-in-view=false
但这导致了一个例外。这一次,当 Jackson 试图将List<Author>
序列化为 JSON(这是应用的客户端通过控制器端点接收的数据)时,将没有活动的Session
可用。
最有可能的例外如下:
Could not write JSON: Unable to perform requested lazy initialization [com.bookstore.entity.Author.avatar] - no session and settings disallow loading outside the Session;
因此,Jackson 在没有 Hibernate 会话的情况下强制初始化惰性加载的属性,这导致了惰性初始化异常。另一方面,此时没有活动的 Hibernate 会话也没什么问题。
至少有两种方法可以解决这个问题,并且仍然可以利用属性延迟加载。
为延迟加载的属性设置显式默认值
一种快速的方法是为延迟加载的属性显式设置默认值。如果 Jackson 发现惰性加载的属性已经用值初始化了,那么它不会尝试初始化它们。考虑以下方法:
@Transactional(readOnly = true)
public Author fetchAuthor(long id) {
Author author = authorRepository.findById(id).orElseThrow();
if (author.getAge() < 40) {
author.getAvatar();
} else {
author.setAvatar(null);
}
return author;
}
该方法通过id
获取一个作者,如果获取的作者小于 40 岁,它通过二级查询加载化身。否则,用null
初始化avatar
属性。这一次,Jackson 序列化不会导致任何问题,但是客户端收到的 JSON 可能如下所示:
{
"id": 1,
"avatar": null,
"age": 43,
"name": "Martin Ticher",
"genre": "Horror"
}
现在,根据实现的特性,您可能希望将化身序列化为null
,或者指示 Jackson 不要序列化具有默认值的属性(例如,对于对象为null
,对于原始整数为0
,等等)。).最常见的是,应用应该避免avatar
的序列化;因此,设置@JsonInclude(Include.NON_DEFAULT)
是实体级需要的设置。在这个设置中,Jackson 将跳过任何具有默认值的属性的序列化(根据您的情况,也可以使用其他值的Include
,比如Include.NON_EMPTY):
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
...
@Entity
@JsonInclude(Include.NON_DEFAULT)
public class Author implements Serializable {
...
}
这一次,生成的 JSON 不包含avatar
:
{
"id": 1,
"age": 43,
"name": "Martin Ticher",
"genre": "Horror"
}
为延迟加载的属性设置显式默认值可以防止视图触发它们的延迟加载。从这个角度来看,启用或禁用 OSIV 并不重要,因为不会使用Session
。然而,Session
仍然是开放的,消耗资源,所以建议禁用 OSIV。
GitHub 8 上有源代码。
提供自定义杰克逊过滤器
或者,可以通过自定义过滤器通知 Jackson 哪些应该序列化,哪些不应该序列化。在这种情况下,杰克森应该连载id
、age
、name
、genre
,而不连载avatar
。
假设下面的服务方法,它简单地获取超过给定年龄的作者,而不获取他们的头像:
public List<Author> fetchAuthorsByAgeGreaterThanEqual(int age) {
List<Author> authors = authorRepository.findByAgeGreaterThanEqual(age);
return authors;
}
有几种方法可以编写和配置杰克逊过滤器。
一种方法是从用@JsonFilter
注释实体开始,如下所示(引号之间的文本作为这个过滤器的标识符,用于以后引用它):
@Entity
@JsonFilter("AuthorId")
public class Author implements Serializable {
...
}
通过AuthorId
识别的过滤器在BookstoreController
中实现,如下(重要部分被突出显示;注意应该被序列化并传递给filterOutAllExcept()
方法的属性列表):
@Controller
public class BookstoreController {
private final SimpleFilterProvider filterProvider;
private final BookstoreService bookstoreService;
public BookstoreController(BookstoreService bookstoreService) {
this.bookstoreService = bookstoreService;
filterProvider = new SimpleFilterProvider().addFilter("AuthorId",
SimpleBeanPropertyFilter.filterOutAllExcept(
"id", "name", "age", "genre"));
filterProvider.setFailOnUnknownId(false);
}
...
}
过滤器在 REST 端点中的使用如下:
@GetMapping("/authors/{age}")
public MappingJacksonValue fetchAuthorsByAgeGreaterThanEqual(
@PathVariable int age) throws JsonProcessingException {
List<Author> authors = bookstoreService.
fetchAuthorsByAgeGreaterThanEqual(age);
MappingJacksonValue wrapper = new MappingJacksonValue(authors);
wrapper.setFilters(filterProvider);
return wrapper;
}
返回的MappingJacksonValue
可以序列化,如下面的 JSON 所示:
{
"id": 1,
"age": 43,
"name": "Martin Ticher",
"genre": "Horror"
}
这看起来不错,但是应用还必须涵盖获取avatar
属性的情况。否则,Jackson 将抛出类型为Cannot resolve PropertyFilter with id 'AuthorId'
的异常。当获取avatar
时,它也应该被序列化。因此,筛选器应该序列化所有属性。作为默认行为,过滤器可以被全局配置(在应用级别)以用于序列化Author
实体的所有属性:
@Configuration
public class WebConfig extends WebMvcConfigurationSupport {
@Override
protected void extendMessageConverters(
List<HttpMessageConverter<?>> converters) {
for(HttpMessageConverter<?> converter: converters) {
if(converter instanceof MappingJackson2HttpMessageConverter) {
ObjectMapper mapper = ((MappingJackson2HttpMessageConverter)
converter).getObjectMapper();
mapper.setFilterProvider(
new SimpleFilterProvider().addFilter("AuthorId",
SimpleBeanPropertyFilter.serializeAll()));
}
}
}
}
将返回一个List<Author>
的 REST 端点将依赖这个过滤器来序列化Author
的所有属性,包括avatar
.
Jackson 为 JSON 处理器提供了一个附加模块,用于处理 Hibernate 数据类型,特别是延迟加载方面( Item 110 )。该模块由工件 idjackson-datatype-hibernate5
标识。不幸的是,到目前为止,这个模块对延迟加载的属性没有影响。它负责懒惰的加载关联。
GitHub 9 上有源代码。
第 24 项:如何通过子实体惰性加载实体属性
假设应用包含下面的Author
实体。该实体映射一个作者简档:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private Long id;
@Lob
private byte[] avatar;
private int age;
private String name;
private String genre;
...
// getters and setters omitted for brevity
}
该项目显示了对项目 23 的替代方案;因此,目标是急切地加载id
、age
、name
和genre
,并懒洋洋地引导avatar
(仅按需)。这种方法基于将Author
实体分割成子实体,如图 3-2 所示。
图 3-2
通过子实体的属性延迟加载
图 3-2 中间的类是基类(这不是实体,在数据库中没有表)BaseAuthor
,用@MappedSuperclass
标注。该注释标记一个类,其映射信息应用于从该类继承的实体。因此,BaseAuthor
应该托管那些被急切加载的属性(id
、age
、name
和genre
)。BaseAuthor
的每个子类都是继承这些属性的实体;因此,载入子类也将载入这些属性:
@MappedSuperclass
public class BaseAuthor implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private Long id;
private int age;
private String name;
private String genre;
// getters and setters omitted for brevity
}
AuthorShallow
是BaseAuthor
的子实体。这个子实体继承了超类的属性。因此,应该急切地加载所有属性。通过@Table
注释将这个子实体显式映射到author
表是很重要的:
@Entity
@Table(name = "author")
public class AuthorShallow extends BaseAuthor {
}
AuthorDeep
也是BaseAuthor
的子实体。这个子实体继承了超类的属性,并定义了avatar
。通过@Table
显式映射该子实体,avatar
也位于author
表中,如下所示:
@Entity
@Table(name = "author")
public class AuthorDeep extends BaseAuthor {
@Lob
private byte[] avatar;
public byte[] getAvatar() {
return avatar;
}
public void setAvatar(byte[] avatar) {
this.avatar = avatar;
}
}
如果子实体没有通过@Table
显式映射到同一个表,那么属性将位于不同的表中。此外,继承的属性将被复制。例如,如果没有@Table(name = "author")
,id
,name
,age
和genre
将会在一个名为author_shallow
的表和一个名为author_deep
的表中着陆。另一方面,avatar
将只落在author_deep
工作台。显然,这样不好。
此时,AuthorShallow
允许急切地获取id
、age
、name
和genre
,而AuthorDeep
允许获取这四个属性加上avatar
。总之,avatar
可以按需加载。
下一步很简单。只需为这两个子实体提供经典的 Spring 存储库,如下所示:
@Repository
public interface AuthorShallowRepository
extends JpaRepository<AuthorShallow, Long> {
}
@Repository
public interface AuthorDeepRepository
extends JpaRepository<AuthorDeep, Long> {
}
从AuthorShallowRepository
调用findAll()
将触发下面的 SQL(注意avatar
没有被加载):
SELECT
authorshal0_.id AS id1_0_,
authorshal0_.age AS age2_0_,
authorshal0_.genre AS genre3_0_,
authorshal0_.name AS name4_0_
FROM author authorshal0_
从AuthorDeepRepository
调用findAll()
将触发下面的 SQL(注意avatar
已经加载):
SELECT
authordeep0_.id AS id1_0_,
authordeep0_.age AS age2_0_,
authordeep0_.genre AS genre3_0_,
authordeep0_.name AS name4_0_,
authordeep0_.avatar AS avatar5_0_
FROM author authordeep0_
此时,一个结论开始形成。Hibernate 支持延迟加载属性(参见第 23 项),但是这需要字节码增强,并且需要处理 View 中的 Open Session 和 Jackson 序列化问题。另一方面,使用子实体可能是更好的选择,因为它不需要字节码增强,也不会遇到这些问题。
GitHub 10 上有源代码。
第 25 项:如何通过春季项目获得 DTO
从数据库获取数据会在内存中产生该数据的副本(通常称为结果集或 JDBC 结果集)。保存获取的结果集的内存区域被称为持久性上下文或一级缓存,或简称为缓存。默认情况下,Hibernate 以读写模式运行。这意味着获取的结果集作为Object[]
(更准确地说,作为特定于 Hibernate 的EntityEntry
实例)存储在持久性上下文中,在 Hibernate 术语中称为混合状态,以及从这种混合状态构建的实体。水合状态服务于脏检查机制(在刷新时,Hibernate 将实体与水合状态进行比较,以发现潜在的更改/修改,并代表您触发UPDATE
语句)、无版本乐观锁定机制(用于构建WHERE
子句)和二级缓存(缓存的条目是从分解的水合状态构建的,或者更准确地说,是从 Hibernate 特定的CacheEntry
实例构建的,该实例是从第一次分解的水合状态构建的)。
换句话说,在获取操作之后,获取的结果集位于数据库之外的内存中。应用通过实体(因此,通过 Java 对象)访问/管理这些数据,为了方便这个上下文,Hibernate 应用了几种特定的技术,将获取的 raw 数据(JDBC 结果集)转换成水合状态(这个过程称为水合,并进一步转换成可管理的表示(实体)。
如果没有修改数据的计划,这是不以读写模式获取数据的一个很好的理由。在这样的场景下,读写数据会白白消耗内存和 CPU 资源。这给应用增加了严重的性能损失。或者,如果您需要只读实体,那么切换到只读模式(例如,在 Spring 中,使用readOnly
元素,@Transactional(readOnly=true)
)。这将指示 Hibernate 从内存中丢弃水合状态。此外,没有自动冲洗时间,也没有脏检查。只有实体保留在持久性上下文中。因此,这将节省存储器和 CPU 资源(例如,CPU 周期)。只读实体仍然意味着您计划在不久的将来的某个时候修改它们(例如,您不计划在当前的持久性上下文中修改它们,但是它们将在分离状态下被修改,并在以后合并到另一个持久性上下文中)。如果您不打算修改数据,这是不要以只读模式获取数据的一个很好的理由。但是,这里有一个例外,您可以考虑将只读实体作为镜像实体(包含所有列)的 dto 的替代。
根据经验,如果您需要的只是不会被修改的只读数据,那么使用数据传输对象(DTO)将只读数据表示为 Java 对象。大多数时候,dto 只包含实体属性的一个子集,这样可以避免获取不必要的数据(列)。不要忘记,除了跳过不需要的列之外,您应该考虑通过LIMIT
或它的对应物来限制获取的行数。
出于各种原因,有些人会告诉您只获取实体来使用转换器/映射器创建 dto。在决定之前,考虑阅读弗拉德·米哈尔恰的推文 11 ,它也反对这种反模式。Vlad 说:“不要提取实体,只使用映射器来创建 dto。这是非常低效的,但我一直看到这种反模式得到推广。”
DTO 和春天的预测有着本质上相同的目的。Martin Folwer 将 DTO 定义为“在进程间携带数据以减少方法调用次数的对象”。在执行层面,DTO 和春季的预测是不一样的。DTO 依赖于带有构造函数和 getter/setter 的类,而 Spring 投影依赖于接口和自动生成的代理。然而,Spring 也可以依赖于类,其结果被称为 DTO 投影。
假设我们有下面的Author
实体。该实体映射一个作者简档:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int age;
private String name;
private String genre;
// getters and setters omitted for brevity
}
目标是只获取具有相同genre
的两位作者的name
和age
。这一次,应用依赖于弹簧投影。
Spring projection 可能以 Java 接口开始,该接口只包含应该从数据库获取的列的 getters(例如,name
和age
)。
这种类型的弹簧投影被称为基于接口的封闭投影(在这种投影中定义的方法与实体属性的名称完全匹配):
public interface AuthorNameAge {
String getName();
int getAge();
}
在幕后,Spring 为每个实体对象生成投影接口的代理实例。此外,对代理的调用会自动转发到该对象。
投影接口也可以声明为存储库接口的内部接口。它可以声明为static
或非static
,如下例所示:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Transactional(readOnly = true)
List<AuthorNameAge> findFirst2ByGenre(String genre);
public static interface AuthorNameAge {
String getName();
int getAge();
}
}
完整的应用可在 GitHub 12 上获得。
在这个投影中,只获取两个作者的正确查询是(利用 Spring 数据查询构建器机制或依赖 JPQL 或原生 SQL):
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
List<AuthorNameAge> findFirst2ByGenre(String genre);
}
注意,这个查询返回的是一个List<AuthorNameAge>
而不是一个List<Author>
。
为给定流派调用此方法将触发以下 SQL:
SELECT
author0_.name AS col_0_0_,
author0_.age AS col_1_0_
FROM author author0_
WHERE author0_.genre=?
LIMIT ?
提取的数据可以通过投影获取器进行操作,如下例所示:
List<AuthorNameAge> authors = ...;
for (AuthorNameAge author : authors) {
System.out.println("Author name: " + author.getName()
+ " | Age: " + author.getAge());
}
GitHub 13 上有源代码。
使用投影并不局限于使用 Spring 数据存储库基础设施中内置的查询构建器机制。通过 JPQL 或原生 SQL 查询获取投影也是一种选择。例如,前面的查询可以通过本机 SQL 查询编写,如下所示:
@Query(value = "SELECT a.name, a.age FROM author a
WHERE a.genre=?1 LIMIT 2", nativeQuery=true)
当列名与实体属性名不一致时,只需依靠 SQL AS
关键字来定义相应的别名。例如,如果name
属性被映射到author_name
列,而age
属性被映射到author_age
列,那么本地 SQL 查询将如下所示:
@Query(value = "SELECT a.author_name AS name, a.author_age AS age
FROM author a WHERE a.genre=?1 LIMIT 2",
nativeQuery=true)
如果没有必要使用LIMIT
那么就依赖 JPQL。在 GitHub 14 上,有一个使用 JPQL 和 Spring 投影的例子。
JPA 命名的(本地)查询可以与 Spring 投影结合使用
如果您不熟悉在 Spring Boot 应用中使用命名(本地)查询,那么我建议您推迟阅读本节,直到您阅读了 Item 127 。
假设您的项目中有一组命名查询,并且您想要利用 Spring Projection。下面是完成这项任务的一个例子。首先,使用@NamedQuery
和@NamedNativeQuery
注释定义两个命名查询及其本地副本。第一个查询Author.fetchName
,表示到List<String>
的标量映射,而第二个查询Author.fetchNameAndAge
,表示到List<AuthorNameAge>
的弹簧投影映射:
@NamedQuery(
name = "Author.fetchName",
query = "SELECT a.name FROM Author a"
)
@NamedQuery(
name = "Author.fetchNameAndAge",
query = "SELECT a.age AS age, a.name AS name FROM Author a"
)
@Entity
public class Author implements Serializable {
...
}
@NamedNativeQuery(
name = "Author.fetchName",
query = "SELECT name FROM author"
)
@NamedNativeQuery(
name = "Author.fetchNameAndAge",
query = "SELECT age, name FROM author"
)
@Entity
public class Author implements Serializable {
...
}
或者,您可以通过一个jpa-named-queries.properties
文件定义相同的查询(这是在非本地的命名查询中利用动态排序(Sort
)的推荐方式)和在Pageable
(在命名查询和命名本地查询中)中定义Sort
:
# Find the names of authors
Author.fetchName=SELECT a.name FROM Author a
# Find the names and ages of authors
Author.fetchNameAndAge=SELECT a.age AS age, a.name AS name FROM Author a
和他们的本土对手:
# Find the names of authors
Author.fetchName=SELECT name FROM author
# Find the names and ages of authors
Author.fetchNameAndAge=SELECT age, name FROM author
或者,您可以通过orm.xml
文件定义相同的查询(注意,这种方法与使用@NamedQuery
和@NamedNativeQuery
有相同的缺点):
<!-- Find the names of authors -->
<named-query name="Author.fetchName">
<query>SELECT a.name FROM Author a</query>
</named-query>
<!-- Find the names and ages of authors -->
<named-query name="Author.fetchNameAndAge">
<query>SELECT a.age AS age, a.name AS name FROM Author a</query>
</named-query>
和他们的本土对手:
<!-- Find the names of authors -->
<named-native-query name="Author.fetchName">
<query>SELECT name FROM author</query>
</named-native-query>
<!-- Find the names and ages of authors -->
<named-native-query name="Author.fetchNameAndAge">
<query>SELECT age, name FROM author</query>
</named-native-query>
无论您喜欢哪种方法,AuthorRepository
都是一样的:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
// Scalar Mapping
List<String> fetchName();
// Spring projection
List<AuthorNameAge> fetchNameAndAge();
}
或者本地的对应物:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
// Scalar Mapping
@Query(nativeQuery = true)
List<String> fetchName();
// Spring projection
@Query(nativeQuery = true)
List<AuthorNameAge> fetchNameAndAge();
}
仅此而已!Spring Boot 会自动为你做剩下的事情。根据提供命名(本地)查询的方式,您可以从以下应用中进行选择:
-
如何通过
@NamedQuery
和弹簧投影 15 使用 JPA 命名查询 -
如何通过
@NamedNativeQuery
和 Spring projection16使用 JPA 命名的本地查询 -
如何通过属性文件和 Spring projection17使用 JPA 命名查询
-
如何通过属性文件和 Spring projection18使用 JPA 命名的本地查询
-
如何通过
orm.xml
文件和弹簧投影 19 使用 JPA 命名查询 -
如何通过
orm.xml
文件和 Spring projection20使用 JPA 命名的原生查询
基于类别的投影
除了基于接口的投影,Spring 还支持基于类的投影。这一次,您编写了一个类,而不是一个接口。例如,AuthorNameAge
接口变成了下面的AuthorNameAge
类:
public class AuthorNameAge {
private String name;
private int age;
public AuthorNameAge(String name, int age) {
this.name = name;
this.age = age;
}
// getters, setters, equals() and hashCode() omitted for brevity
}
如您所见,构造函数的参数名必须与实体属性相匹配。
请注意,基于接口的投影可以嵌套,而基于类的投影则不能。
完整的应用可在 GitHub 21 上获得。
如何重复使用弹簧投影
这一次,考虑我们已经丰富了Author
实体以包含以下属性:id
、name
、genre
、age
、email
、address
和rating
。或者,一般来说,一个拥有大量属性的实体。当一个实体有大量属性时,我们可能需要一组只读查询来获取不同的属性子集。例如,一个只读查询可能需要获取age
、name
、genre
、email
和address
,而另一个查询可能需要获取age name
和genre
,还有一个查询可能只需要获取name
和email
。
为了满足这三个查询,我们可以定义三个基于接口的弹簧闭合投影。这不太实际。例如,稍后,我们可能还需要一个只读查询来获取name
和address
。按照这个逻辑,我们还需要再定义一个弹簧投影。更实际的做法是定义一个单一的 Spring 投影,用于针对作者执行的所有只读查询。
为了完成这个任务,我们定义了一个 Spring projection,它包含一些 getters 来满足最繁重的查询(在本例中,查询获取了age
、name
、genre
、email
和address
):
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
public interface AuthorDto {
public Integer getAge();
public String getName();
public String getGenre();
public String getEmail();
public String getAddress();
}
投影用@JsonInclude(JsonInclude.Include.NON_DEFAULT)
标注。这是为了避免序列化null
值(在当前查询中没有提取的值)。这将指示 Jackson 序列化机制从生成的 JSON 中跳过null
值。
现在,我们可以依靠 Spring 数据查询构建器机制来生成获取age
、name
、genre
、email
和address
的查询,如下所示:
List<AuthorDto> findBy();
或者,您可以编写如下 JPQL:
@Query("SELECT a.age AS age, a.name AS name, a.genre AS genre, "
+ "a.email AS email, a.address AS address FROM Author a")
List<AuthorDto> fetchAll();
调用fetchAll()
并将结果表示为 JSON 将产生以下结果:
[
{
"genre":"Anthology",
"age":23,
"email":"markj@gmail.com",
"name":"Mark Janel",
"address":"mark's address"
},
...
]
此外,您可以在查询中重用AuthorDto
投影,只获取age
、name
和genre
:
@Query("SELECT a.age AS age, a.name AS name, a.genre AS genre FROM Author a")
List<AuthorDto> fetchAgeNameGenre();
调用fetchAgeNameGenre()
并将结果表示为 JSON 将产生如下结果:
[
{
"genre":"Anthology",
"age":23,
"name":"Mark Janel"
},
...
]
或者,您可以将AuthorDto
投影重新用于只获取name
和email
的查询:
@Query("SELECT a.name AS name, a.email AS email FROM Author a")
List<AuthorDto> fetchNameEmail();
调用fetchNameEmail()
并将结果表示为 JSON 将产生如下结果:
[
{
"email":"markj@gmail.com",
"name":"Mark Janel"
},
...
]
完整的应用可在 GitHub 22 上获得。
如何使用动态弹簧投影
考虑上一节中的Author
实体,它具有以下属性:id
、name
、genre
、age
、email
、address
和rating
。此外,考虑此图元的两个弹簧投影,定义如下:
public interface AuthorGenreDto {
public String getGenre();
}
public interface AuthorNameEmailDto {
public String getName();
public String getEmail();
}
通过编写三个查询,您可以通过相同的查询方法获取实体类型、AuthorGenreDto
类型和AuthorNameEmailDto
类型,如下所示:
Author findByName(String name);
AuthorGenreDto findByName(String name);
AuthorNameEmailDto findByName(String name);
您实际上编写了相同的查询方法来返回不同的类型。这有点麻烦,Spring 通过动态的 ?? 预测来处理这种情况。您可以通过使用Class
参数声明一个查询方法来应用动态预测,如下所示:
<T> T findByName(String name, Class<T> type);
这里还有两个例子:
<T> List<T> findByGenre(String genre, Class<T> type);
@Query("SELECT a FROM Author a WHERE a.name=?1 AND a.age=?2")
<T> T findByNameAndAge(String name, int age, Class<T> type);
这一次,根据您期望返回的类型,您可以如下调用findByName()
:
Author author = authorRepository.findByName(
"Joana Nimar", Author.class);
AuthorGenreDto author = authorRepository.findByName(
"Joana Nimar", AuthorGenreDto.class);
AuthorNameEmailDto author = authorRepository.findByName(
"Joana Nimar", AuthorNameEmailDto.class);
完整的应用可在 GitHub 23 上获得。
第 26 项:如何在弹簧投影中添加图元
如果您不熟悉弹簧投影,那么可以考虑在继续之前阅读上一条。
通常,弹簧投影(DTO)用于获取只读数据。但是在有些情况下,应用需要获取 Spring 投影中的一个实体。对于这种情况,本例中突出显示了需要遵循的步骤。
物化联想
考虑双向惰性@OneToMany
关联中涉及的Author
和Book
实体。
弹簧投影应该映射Author
实体,并且从Book
实体,只映射title
属性。基于上一项,弹簧投影接口可以编写如下:
public interface BookstoreDto {
public Author getAuthor();
public String getTitle();
}
获取数据是通过 JPQL 在下面的存储库中完成的(获取的数据存放在一个List<BookstoreDto>
):
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a AS author, b.title AS title
FROM Author a JOIN a.books b")
List<BookstoreDto> fetchAll();
}
调用此方法将触发以下 SQL:
SELECT
author0_.id AS col_0_0_,
books1_.title AS col_1_0_,
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 book books1_
ON author0_.id = books1_.author_id
下面的服务方法在读写事务中调用fetchAll()
。注意,获取的Author
实例由 Hibernate 管理,潜在的更改将通过脏检查机制传播到数据库(Hibernate 将代表您触发UPDATE
语句):
@Transactional
public List<BookstoreDto> fetchAuthors() {
List<BookstoreDto> dto = authorRepository.fetchAll();
// the fetched Author are managed by Hibernate
// the following line of code will trigger an UPDATE
dto.get(0).getAuthor().setGenre("Poetry");
return dto;
}
将获取的数据显示到控制台非常简单:
List<BookstoreDto> authors = ...;
authors.forEach(a -> System.out.println(a.getAuthor()
+ ", Title: " + a.getTitle()));
GitHub 24 上有源代码。
非物化关联
这一次,考虑在Author
和Book
实体之间没有具体化的关联。然而,如图 3-3 所示,两个实体共享一个genre
属性。
图 3-3
没有物化关联
该属性可用于连接Author
和Book
,并在同一个弹簧投影BookstoreDto
中获取数据。这一次,JPQL 使用genre
属性来连接这两个表,如下所示:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a AS author, b.title AS title FROM Author a
JOIN Book b ON a.genre=b.genre ORDER BY a.id")
List<BookstoreDto> fetchAll();
}
调用fetchAll()
将触发下面的 SQL:
SELECT
author0_.id AS col_0_0_,
book1_.title AS col_1_0_,
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 book book1_
ON (author0_.genre = book1_.genre)
ORDER BY author0_.id
下面的服务方法在读写事务中调用fetchAll()
。请注意,提取的Author
是托管的,Hibernate 会将这些Author
的修改传播到数据库:
@Transactional
public List<BookstoreDto> fetchAuthors() {
List<BookstoreDto> dto = authorRepository.fetchAll();
// the fetched Author are managed by Hibernate
// the following line of code will trigger an UPDATE
dto.get(0).getAuthor().setAge(47);
return dto;
}
将获取的数据显示到控制台非常简单:
List<BookstoreDto> authors = ...;
authors.forEach(a -> System.out.println(a.getAuthor()
+ ", Title: " + a.getTitle()));
GitHub 25 上有源代码。
项目 27:如何用属于/不属于实体的虚拟属性来丰富 Spring 投影
在继续之前,考虑阅读第 25 项。
Spring 投影可以用属于或不属于域模型的虚拟属性来丰富。通常,当它们不是域模型的一部分时,它们是在运行时通过 SpEL 表达式计算的。
一个基于接口的 Spring 投影被称为基于接口的开放投影,它包含的方法在域模型中具有不匹配的名称,并且在运行时计算返回。
例如,下面的弹簧投影包含三个虚拟属性(years
、rank
和books
):
public interface AuthorNameAge {
String getName();
@Value("#{target.age}")
String years();
@Value("#{ T(java.lang.Math).random() * 10000 }")
int rank();
@Value("5")
String books();
}
在 Spring 投影中,AuthorNameAge
依赖于@Value
和 Spring SpEL 来指向来自域模型的支持属性(在这种情况下,域模型属性age
通过虚拟属性属性years
公开)。此外,使用@Value
和 Spring SpEL 通过两个在域模型中不匹配的虚拟属性(在本例中是rank
和books
)来丰富结果。
Spring 存储库非常简单,它包含一个查询,获取比给定年龄更老的作者name
和age
:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a.name AS name, a.age AS age
FROM Author a WHERE a.age >= ?1")
List<AuthorNameAge> fetchByAge(int age);
}
针对给定年龄调用fetchByAge()
将触发以下 SQL:
SELECT
author0_.name AS col_0_0_,
author0_.age AS col_1_0_
FROM author author0_
WHERE author0_.age >= ?
打印提取的数据使用years()
代替age
、rank()
和books()
:
List<AuthorNameAge> authors = ...;
for (AuthorNameAge author : authors) {
System.out.println("Author name: " + author.getName()
+ " | Age: " + author.years()
+ " | Rank: " + author.rank()
+ " | Books: " + author.books());
}
控制台的输出是(从数据库中获取作者的姓名和年龄):
Author name: Olivia Goy | Age: 43 | Rank: 3435 | Books: 5
Author name: Quartis Young | Age: 51 | Rank: 2371 | Books: 5
Author name: Katy Loin | Age: 56 | Rank: 2826 | Books: 5
GitHub 26 上有源代码。
第 28 项:如何有效地获取包含一对一关联的弹簧投影
假设Author
和Book
再次涉及双向懒惰@OneToMany
关联。您想获取一个只读的结果集,其中包含每本书的title
和作者的name
和genre
。这样一个只读的结果集是 DTO 的完美候选,而且,在春天,获取这个 DTO 的主要方式涉及到春天的投影。
让我们使用图 3-4 所示的数据快照。
图 3-4
数据快照
使用嵌套闭合投影
图书title
取自book
表,而作者name
和genre
取自author
表。这意味着您可以编写基于接口的嵌套弹簧闭合投影,如下所示:
public interface BookDto {
public String getTitle();
public AuthorDto getAuthor();
interface AuthorDto {
public String getName();
public String getGenre();
}
}
现在您所需要的就是适当的查询来填充这个 Spring 投影。最快的方法依赖于 Spring 数据查询构建器机制,如下所示:
@Repository
@Transactional(readOnly=true)
public interface BookRepository extends JpaRepository<Book, Long> {
List<BookDto> findBy();
}
从实现的角度来看,这真的很快!但是,这种方法有效吗?让我们把结果集看作一个 JSON 表示(假设这是由一个 REST 控制器端点返回的):
[
{
"title":"A History of Ancient Prague",
"author":{
"genre":"History",
"name":"Joana Nimar"
}
},
{
"title":"A People's History",
"author":{
"genre":"History",
"name":"Joana Nimar"
}
},
...
]
是的,起作用了!但是有效率吗?如果不检查触发的 SQL 和持久性上下文内容,您可能会认为这种方法很棒。但是生成的SELECT
获取的数据比需要的多(例如,您不需要作者的id
和age
):
SELECT
book0_.title AS col_0_0_,
author1_.id AS col_1_0_,
author1_.id AS id1_0_,
author1_.age AS age2_0_,
author1_.genre AS genre3_0_,
author1_.name AS name4_0_
FROM book book0_
LEFT OUTER JOIN author author1_
ON book0_.author_id = author1_.id
很明显,这个查询获取作者的所有属性(实体的属性越多,获取的不需要的数据就越多)。此外,如果您检查持久性上下文内容,您会注意到它包含三个处于READ_ONLY
状态的条目,并且它们中没有一个处于水合状态(水合状态被丢弃,因为这个事务被标记为readOnly
):
以下是持久性上下文内容:
Total number of managed entities: 3
Total number of collection entries: 3
EntityKey[com.bookstore.entity.Author#1]:
Author{id=1, name=Mark Janel, genre=Anthology, age=23}
EntityKey[com.bookstore.entity.Author#2]:
Author{id=2, name=Olivia Goy, genre=Horror, age=43}
EntityKey[com.bookstore.entity.Author#4]:
Author{id=4, name=Joana Nimar, genre=History, age=34}
从数据库到投影的结果集部分通过持久化上下文。作者也被提取为只读实体。一般而言,数据量可能会影响性能(例如,相对大量的不需要的提取列和/或相对大量的提取行)。但是因为我们处于只读模式,所以在持久化上下文中没有水合状态,也不会对作者执行脏检查。然而,垃圾收集器需要在持久性上下文关闭后收集这些实例。
编写显式 JPQL 会产生与通过查询构建器机制生成的查询相同的输出):
@Repository
@Transactional(readOnly=true)
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT b.title AS title, a AS author "
+ "FROM Book b LEFT JOIN b.author a")
// or as a INNER JOIN
// @Query("SELECT b.title AS title, b.author AS author
FROM Book b")
List<BookDto> findByViaQuery();
}
使用简单的封闭投影
依赖嵌套弹簧投影会导致性能下降。如何使用一个简单的弹簧闭合投影来获取原始数据,如下所示:
public interface SimpleBookDto {
public String getTitle(); // of book
public String getName(); // of author
public String getGenre(); // of author
}
这一次,查询构建器机制无法帮助您。你可以写一个LEFT JOIN
如下:
@Repository
@Transactional(readOnly=true)
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT b.title AS title, a.name AS name, a.genre AS genre "
+ "FROM Book b LEFT JOIN b.author a")
List<SimpleBookDto> findByViaQuerySimpleDto();
}
这一次,结果集的 JSON 表示如下:
[
{
"title":"A History of Ancient Prague",
"genre":"History",
"name":"Joana Nimar"
},
{
"title":"A People's History",
"genre":"History",
"name":"Joana Nimar"
},
...
]
书籍和作者的数据是混杂的。根据具体情况,这种输出可以被接受(如本例所示)也可以不被接受。但是效率有多高呢?让我们看看触发的 SQL:
SELECT
book0_.title AS col_0_0_,
author1_.name AS col_1_0_,
author1_.genre AS col_2_0_
FROM book book0_
LEFT OUTER JOIN author author1_
ON book0_.author_id = author1_.id
该查询看起来与预期的完全一样。请注意,该查询只获取请求的列。此外,持久性上下文是空的。以下是持久性上下文内容:
Total number of managed entities: 0
Total number of collection entries: 0
从性能角度来看,这种方法比依赖嵌套弹簧投影要好。SQL 只获取所请求的列,并绕过持久性上下文。缺点是在数据表示中( raw 数据),它没有维护父子实体的树结构。在某些情况下,这不是问题;在其他情况下,它是。您必须根据需要处理这些数据(在服务器端或客户端)。当不需要进一步处理时,您甚至可以放弃投影并返回List<Object[]>
:
@Query("SELECT b.title AS title, a.name AS name, a.genre AS genre "
+ "FROM Book b LEFT JOIN b.author a")
List<Object[]> findByViaQueryArrayOfObjects();
使用简单的开放投影
只要你不在乎维护数据结构(父子实体的树形结构),依靠一个简单的 Spring 闭投影是没问题的。如果这是一个问题,你可以依靠一个简单的弹簧打开投影。请记住第 27 条中的内容,开放投影允许您在域模型中使用不匹配的名称定义方法,并在运行时计算回报。本质上,开放投影支持虚拟属性。
这一次,我们按如下方式编写弹簧打开投影:
public interface VirtualBookDto {
public String getTitle(); // of book
@Value("#{@authorMapper.buildAuthorDto(target.name, target.genre)}")
AuthorClassDto getAuthor();
}
突出显示的 SpEL 表达式引用 bean AuthorMapper
,该 bean 调用buildAuthorDto()
方法并转发投影name
和genre
作为方法参数。因此,在运行时,应该使用作者的name
和genre
来创建这里列出的AuthorClassDto
的实例:
public class AuthorClassDto {
private String genre;
private String name;
// getters, setters, equals() and hashCode() omitted for brevity
}
这项工作由名为AuthorMapper
的助手类完成,如下所示:
@Component
public class AuthorMapper {
public AuthorClassDto buildAuthorDto(String genre, String name) {
AuthorClassDto authorClassDto = new AuthorClassDto();
authorClassDto.setName(name);
authorClassDto.setGenre(genre);
return authorClassDto;
}
}
这种实现的效率如何?值得努力吗?触发的 SQL 从以下 JPQL 中获得:
@Repository
@Transactional(readOnly=true)
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT b.title AS title, a.name AS name, a.genre AS genre "
+ "FROM Book b LEFT JOIN b.author a")
List<VirtualBookDto> findByViaQueryVirtualDto();
}
SQL 看起来和预期的完全一样:
SELECT
book0_.title AS col_0_0_,
author1_.name AS col_1_0_,
author1_.genre AS col_2_0_
FROM book book0_
LEFT OUTER JOIN author author1_
ON book0_.author_id = author1_.id
持久性上下文没有被改动,如图所示。:
Total number of managed entities: 0
Total number of collection entries: 0
JSON 表示维护数据结构:
[
{
"title":"A History of Ancient Prague",
"author":{
"genre":"Joana Nimar",
"name":"History"
}
},
{
"title":"A People's History",
"author":{
"genre":"Joana Nimar",
"name":"History"
}
},
...
]
即使这比前面的方法需要更多的工作,依靠一个简单的 Spring open 投影也能保持数据结构。不幸的是,从图 3-5 中可以看出,这种方法的时间性能趋势更差。
图 3-5 所示的时间-性能趋势图将这四种方法与 100、500 和 1,000 名各有五本书的作者进行了比较。看起来获取原始数据是最快的方法,而使用开放投影是最慢的。
图 3-5
抓取@ManyToOne 关联为 DTO
图 3-5 中的时间-性能趋势图是针对 MySQL 在具有以下特征的 Windows 7 机器上获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。
完整的代码可以在 GitHub 27 上找到。
第 29 项:为什么要关注包含相关系列的春季预测
假设Author
和Book
参与了一个双向懒惰的@OneToMany
关联。您想获取每个作者的name
和genre
,以及所有相关书籍的title
。因为您需要一个包含来自 author 和 book 表的列子集的只读结果集,所以让我们尝试使用一个弹簧投影(DTO)。
让我们使用图 3-6 所示的数据快照。
图 3-6
数据快照
使用嵌套弹簧闭合投影
书籍title
取自book
表,而作者name
和genre
取自author
表。这意味着您可以编写一个基于接口的、嵌套的 Spring closed 投影,如下所示(这种方法非常诱人,因为它很简单):
public interface AuthorDto {
public String getName();
public String getGenre();
public List<BookDto> getBooks();
interface BookDto {
public String getTitle();
}
}
注意,书名被映射为一个List<BookDto>
。因此,调用AuthorDto#getBooks()
应该会返回一个只包含书名的List<BookDto>
。
使用查询构建器机制
从实现的角度来看,填充投影的最快方法依赖于查询构建器机制,如下所示:
@Repository
@Transactional(readOnly=true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
List<AuthorDto> findBy();
}
这种方法有效吗?让我们把结果集看作一个 JSON 表示(假设这是由一个 REST 控制器端点返回的):
[
{
"genre":"Anthology",
"books":[
{
"title":"The Beatles Anthology"
}
],
"name":"Mark Janel"
},
{
"genre":"Horror",
"books":[
{
"title":"Carrie"
},
{
"title":"Nightmare Of A Day"
}
],
"name":"Olivia Goy"
},
{
"genre":"Anthology",
"books":[
],
"name":"Quartis Young"
},
{
"genre":"History",
"books":[
{
"title":"A History of Ancient Prague"
},
{
"title":"A People's History"
},
{
"title":"History Now"
}
],
"name":"Joana Nimar"
}
]
结果看起来很完美!因此,您已经使用了一个 Spring 投影和一个通过查询构建器机制生成的查询来获取一个只读结果集。这样有效率吗?您是否触发了单个SELECT
查询?你已经设法绕过持久性上下文了吗?不不不。
检查触发的 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_
-- for each author there is an additional SELECT
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 = ?
这个解决方案触发了五个SELECT
语句!很明显,这是一个 N+1 问题。Author
和Book
之间的关联是惰性的,Spring 需要获取作者和相关书籍作为实体,以便用请求的数据填充投影。持久性上下文内容也证实了这一点。
持久性上下文包含 10 个实体(其中 4 个是集合条目),状态为READ_ONLY
,没有水合状态。
持久性上下文内容:
Total number of managed entities: 10
Total number of collection entries: 4
EntityKey[com.bookstore.entity.Book#1]:
Book{id=1, title=A History of Ancient Prague, isbn=001-JN}
EntityKey[com.bookstore.entity.Book#3]:
Book{id=3, title=History Now, isbn=003-JN}
EntityKey[com.bookstore.entity.Book#2]:
Book{id=2, title=A People's History, isbn=002-JN}
EntityKey[com.bookstore.entity.Book#5]:
Book{id=5, title=Carrie, isbn=001-OG}
EntityKey[com.bookstore.entity.Book#4]:
Book{id=4, title=The Beatles Anthology, isbn=001-MJ}
EntityKey[com.bookstore.entity.Book#6]:
Book{id=6, title=Nightmare Of A Day, isbn=002-OG}
EntityKey[com.bookstore.entity.Author#1]:
Author{id=1, name=Mark Janel, genre=Anthology, age=23}
EntityKey[com.bookstore.entity.Author#2]:
Author{id=2, name=Olivia Goy, genre=Horror, age=43}
EntityKey[com.bookstore.entity.Author#3]:
Author{id=3, name=Quartis Young, genre=Anthology, age=51}
EntityKey[com.bookstore.entity.Author#4]:
Author{id=4, name=Joana Nimar, genre=History, age=34}
除了 N+1 问题之外,也不能忽略持久性上下文。所以,这种做法确实不好,应该避免。
使用显式 JPQL
您可以通过放弃查询构建器机制并采用显式 JPQL 来稍微改善一下情况,如下所示:
@Repository
@Transactional(readOnly=true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a.name AS name, a.genre AS genre, b AS books "
+ "FROM Author a INNER JOIN a.books b")
List<AuthorDto> findByViaQuery();
}
这次触发的是单个SELECT
。根据 JPQL,书籍已完全加载,不仅仅是书名:
SELECT
author0_.name AS col_0_0_,
author0_.genre AS col_1_0_,
books1_.id AS col_2_0_,
books1_.id AS id1_1_,
books1_.author_id AS author_i4_1_,
books1_.isbn AS isbn2_1_,
books1_.title AS title3_1_
FROM author author0_
INNER JOIN book books1_
ON author0_.id = books1_.author_id
此外,持久性上下文由处于READ_ONLY
状态的类型为Book
的六个实体(没有集合条目)填充,并且没有水合状态(这一次,在持久性上下文中加载了更少的数据)。
持久性上下文内容:
Total number of managed entities: 6
Total number of collection entries: 0
EntityKey[com.bookstore.entity.Book#3]:
Book{id=3, title=History Now, isbn=003-JN}
EntityKey[com.bookstore.entity.Book#2]:
Book{id=2, title=A People's History, isbn=002-JN}
EntityKey[com.bookstore.entity.Book#5]:
Book{id=5, title=Carrie, isbn=001-OG}
EntityKey[com.bookstore.entity.Book#4]:
Book{id=4, title=The Beatles Anthology, isbn=001-MJ}
EntityKey[com.bookstore.entity.Book#6]:
Book{id=6, title=Nightmare Of A Day, isbn=002-OG}
EntityKey[com.bookstore.entity.Book#1]:
Book{id=1, title=A History of Ancient Prague, isbn=001-JN}
而且,我们丢失了数据结构(父子实体的树形结构),每个标题都包装在自己的List
:
[
{
"genre":"History",
"books":[
{
"title":"A History of Ancient Prague"
}
],
"name":"Joana Nimar"
},
{
"genre":"History",
"books":[
{
"title":"A People's History"
}
],
"name":"Joana Nimar"
},
{
"genre":"History",
"books":[
{
"title":"History Now"
}
],
"name":"Joana Nimar"
},
{
"genre":"Anthology",
"books":[
{
"title":"The Beatles Anthology"
}
],
"name":"Mark Janel"
},
...
]
作为一个小小的调整,您可以从嵌套投影中移除List
,如下所示:
public interface AuthorDto {
public String getName();
public String getGenre();
public BookDto getBooks();
interface BookDto {
public String getTitle();
}
}
这不会创建List
s,但是会很混乱。
使用 JPA 连接提取
正如 Item 39 所强调的,JOIN FETCH
能够使用一个 SQL SELECT
来初始化相关的集合以及它们的父对象。因此,您可以编写如下查询:
@Repository
@Transactional(readOnly=true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a JOIN FETCH a.books")
Set<AuthorDto> findByJoinFetch();
}
注意,这个例子使用了Set
而不是List
来避免重复。在这种情况下,添加 SQL DISTINCT
子句不起作用。如果你添加一个ORDER BY
子句(例如ORDER BY a.name ASC
,在幕后,Hibernate 使用一个LinkedHashSet
。因此,项目的顺序也得以保留。
调用findByJoinFetch()
触发下面的SELECT
(注意author
和book
之间的INNER JOIN
):
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_
INNER JOIN book books1_
ON author0_.id = books1_.author_id
这次触发的是单个SELECT
。根据这个 SQL,作者和书籍被完全加载,而不仅仅是名称、流派和书名。让我们检查一下持久性上下文(我们有九个处于READ_ONLY
状态且没有水合状态的实体,其中三个是集合条目)。这并不奇怪,因为根据其含义,JOIN FETCH
获取实体,并与@Transactional(readOnly=true)
结合,这导致只读实体。因此,Set<AuthorDto>
是通过持久性上下文从这些实体中获得的。持久性上下文内容:
Total number of managed entities: 9
Total number of collection entries: 3
EntityKey[com.bookstore.entity.Book#3]:
Book{id=3, title=History Now, isbn=003-JN}
EntityKey[com.bookstore.entity.Book#2]:
Book{id=2, title=A People's History, isbn=002-JN}
EntityKey[com.bookstore.entity.Book#5]:
Book{id=5, title=Carrie, isbn=001-OG}
EntityKey[com.bookstore.entity.Book#4]:
Book{id=4, title=The Beatles Anthology, isbn=001-MJ}
EntityKey[com.bookstore.entity.Book#6]:
Book{id=6, title=Nightmare Of A Day, isbn=002-OG}
EntityKey[com.bookstore.entity.Book#1]:
Book{id=1, title=A History of Ancient Prague, isbn=001-JN}
EntityKey[com.bookstore.entity.Author#1]:
Author{id=1, name=Mark Janel, genre=Anthology, age=23}
EntityKey[com.bookstore.entity.Author#2]:
Author{id=2, name=Olivia Goy, genre=Horror, age=43}
EntityKey[com.bookstore.entity.Author#4]:
Author{id=4, name=Joana Nimar, genre=History, age=34}
这一次,我们将数据保存为父子实体的树结构。以 JSON 的形式提取数据输出预期的结果,没有重复:
[
{
"genre":"Anthology",
"books":[
{
"title":"The Beatles Anthology"
}
],
"name":"Mark Janel"
},
{
"genre":"Horror",
"books":[
{
"title":"Carrie"
},
{
"title":"Nightmare Of A Day"
}
],
"name":"Olivia Goy"
},
{
"genre":"History",
"books":[
{
"title":"A History of Ancient Prague"
},
{
"title":"A People's History"
},
{
"title":"History Now"
}
],
"name":"Joana Nimar"
}
]
正如您所看到的,JOIN FETCH
维护了父子实体的树结构,但是与显式 JPQL 相比,它将更多不需要的数据带入了持久性上下文。这将如何影响整体性能取决于提取了多少不需要的数据,以及您对垃圾收集器的压力如何,垃圾收集器必须在持久性上下文被释放后清理这些对象。
使用简单的封闭投影
嵌套的弹簧投影容易造成性能损失。使用简单的弹簧闭合投影如何,如下所示:
public interface SimpleAuthorDto {
public String getName(); // of author
public String getGenre(); // of author
public String getTitle(); // of book
}
和一个 JPQL,如下所示:
@Repository
@Transactional(readOnly=true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a.name AS name, a.genre AS genre, b.title AS title "
+ "FROM Author a INNER JOIN a.books b")
List<SimpleAuthorDto> findByViaQuerySimpleDto();
}
这一次,只有一个SELECT
只获取请求的数据:
SELECT
author0_.name AS col_0_0_,
author0_.genre AS col_1_0_,
books1_.title AS col_2_0_
FROM author author0_
INNER JOIN book books1_
ON author0_.id = books1_.author_id
持久性上下文被绕过。持久性上下文内容:
Total number of managed entities: 0
Total number of collection entries: 0
但是,正如下面的 JSON 所揭示的,数据结构完全丢失了(这是原始数据):
[
{
"genre":"History",
"title":"A History of Ancient Prague",
"name":"Joana Nimar"
},
{
"genre":"History",
"title":"A People's History",
"name":"Joana Nimar"
},
{
"genre":"History",
"title":"History Now",
"name":"Joana Nimar"
},
...
]
虽然这种方法只获取需要的数据,不涉及持久性上下文,但在数据表示级别上,它受到了严重的影响。在某些情况下,这不是问题,而在其他情况下则是问题。您必须处理这些数据,以便根据需要对其进行调整(在服务器端或客户端)。当不需要进一步处理时,您甚至可以放下投影返回List<Object[]>
:
@Query("SELECT a.name AS name, a.genre AS genre, b.title AS title "
+ "FROM Author a INNER JOIN a.books b")
List<Object[]> findByViaArrayOfObjects();
DTO 的改造清单
四、批量
第 46 项:如何批量插入 Spring Boot 风格
批处理是一种能够对INSERT
、UPDATE
和DELETE
语句进行分组的机制,因此,它显著减少了数据库/网络往返次数。往返次数越少,性能越好。
批处理是避免由大量独立的数据库/网络往返(代表数据库中的插入、删除或更新)所导致的性能损失的完美解决方案。例如,在没有批处理的情况下,1,000 次插入需要 1,000 次单独的往返,而采用批处理且批处理大小为 30 将导致 34 次单独的往返。插入越多,批处理就越有用。
启用批处理并准备 JDBC URL
在 Spring Boot + Hibernate +(本例中为 MySQL)应用中启用批量插入支持从application.properties
中的几个设置开始,接下来将讨论。
设置批量大小
批量大小可以通过spring.jpa.properties.hibernate.jdbc.batch_size
属性设置。推荐值范围在 5 到 30 之间。默认值可以通过Dialect.DEFAULT_BATCH_SIZE
获取。将批量大小设置为 30 可以按如下方式完成:
spring.jpa.properties.hibernate.jdbc.batch_size=30
不要混淆hibernate.jdbc.batch_size
和hibernate.jdbc.fetch_size
。后者用于设置 JDBC Statement.setFetchSize()
,如第 45 项所述。根据经验,对于 Hibernate(导航整个结果集)和在单次数据库往返中获取整个结果集的数据库,不推荐使用hibernate.jdbc.fetch_size
。所以在使用 MySQL 或者 PostgreSQL 的时候要避免。但是对于支持在多次数据库往返中获取结果集的数据库(如 Oracle)来说,这可能很有用。
MySQL 的批处理优化
对于 MySQL,有几个属性可以用来优化批处理性能。首先,有 JDBC URL 优化标志属性,rewriteBatchedStatements
(这可以在 PostgreSQL 以及项目 55 中使用)。启用此属性后,SQL 语句将被重写到单个字符串缓冲区中,并发送到对数据库的单个请求中。否则,批处理语句(例如,INSERT
s)如下所示:
insert into author (age, genre, name, id) values (828, 'Genre_810', 'Name_810', 810)
insert into author (age, genre, name, id) values (829, 'Genre_811', 'Name_811', 811)
...
使用此设置,这些 SQL 语句将重写如下:
insert into author (age, genre, name, id) values (828, 'Genre_810', 'Name_810', 810),(829, 'Genre_811', 'Name_811', 811),...
另一个 JDBC URL 优化标志属性是cachePrepStmts
。该属性支持缓存,并与prepStmtCacheSize
、prepStmtCacheSqlLimit
等配合使用。如果没有此设置,缓存将被禁用。
最后,JDBC URL 优化标志属性useServerPrepStmts
用于启用服务器端准备好的语句(这可能会导致显著的性能提升)。
MySQL 支持客户端(默认情况下启用)和服务器端(默认情况下禁用)预处理语句。
使用客户端准备语句时,SQL 语句在发送到服务器执行之前在客户端准备好。通过用文字值替换占位符来准备 SQL 语句。在每次执行时,客户机通过一个COM_QUERY
命令发送一个准备执行的完整 SQL 语句。
设置useServerPrepStmts=true
时,启用服务器准备语句。这一次,SQL 查询文本只通过一个COM_STMT_PREPARE
命令从客户机发送到服务器一次。服务器准备查询并将结果(例如,占位符)发送给客户端。此外,在每次执行时,客户端将通过一个COM_STMT_EXECUTE
命令向服务器发送仅用于替代占位符的文字值。此时,SQL 被执行。
大多数连接池(例如,Apache DBCP、Vibur 和 C3P0)将跨连接缓存准备好的语句。换句话说,对同一语句字符串的连续调用将使用同一个PreparedStatement
实例。因此,相同的PreparedStatement
被跨连接使用(被使用并返回到池的连接)以避免在服务器端准备相同的字符串。其他连接池不支持连接池级别的预准备语句缓存,而倾向于利用 JDBC 驱动程序的缓存功能(例如,HikariCP 1 )。
MySQL 驱动程序提供了客户端语句缓存,默认情况下是禁用的。可通过 JDBC 选项cachePrepStmts=true
启用。一旦启用,MySQL 将为客户机和服务器准备的语句提供缓存。您可以通过以下查询获得当前缓存状态的快照:
SHOW GLOBAL STATUS LIKE '%stmt%';
这将返回如下所示的表格:
| **变量名称** | **值** | | `com_stmt_execute` | `...` | | `com_stmt_prepare` | `...` | | `prepared_stmt_count` | `...` | | `...` | `...` |请注意,较旧的 MySQL 版本不允许同时激活重写和服务器端准备语句。为了确保这些陈述仍然有效,请检查您正在使用的连接器/J 的注释。
进行这些设置后,会出现以下 JDBC URL:
jdbc:mysql://localhost:3306/bookstoredb?
cachePrepStmts=true
&useServerPrepStmts=true
&rewriteBatchedStatements=true
对于其他 RDBMS,只需移除/替换特定于 MySQL 的设置。
根据经验,如果不需要二级缓存,那么确保通过spring.jpa.properties.hibernate.cache.use_second_level_cache=false
将其禁用。
为批处理插入准备实体
接下来,准备批处理插入中涉及的实体。设置分配的发生器,因为 HibernateIDENTITY
发生器将导致批处理插入被禁用。Author
实体如下:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private Long id;
private String name;
private String genre;
private int age;
// getters and setters omitted for brevity
}
不要补充这个:
@GeneratedValue(strategy = GenerationType.IDENTITY)
对于 Hibernate IDENTITY
生成器(例如 MySQL AUTO_INCREMENT
和 PostgreSQL ( BIG
) SERIAL
),Hibernate 只对INSERT
禁用 JDBC 批处理(作为替代,开发者可以依赖 JOOQ,它在这种情况下也支持批处理)。
另一方面,GenerationType.AUTO
和 UUID 可用于插入批处理:
@Entity
public class Author implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
...
}
然而,简而言之,应该避免 UUID 标识符。在标题为“通用唯一标识符(UUID)怎么样?”的章节中的第 74 项中可以获得更多详细信息。
识别并避免内置的 saveAll(Iterable 实体)缺陷
Spring 自带saveAll(Iterable<S> entities)
方法。虽然这种方法对于保存相对较小的Iterable
非常方便,但是当您处理批处理时,尤其是处理大量的实体时,您需要注意几个方面:
-
开发人员无法控制当前事务中持久上下文的刷新和清除:
saveAll(Iterable<S> entities)
方法将在事务提交前导致一次刷新;因此,在准备 JDBC 批处理的过程中,实体是在当前持久性上下文中累积的。对于大量的实体(大的Iterable
),这会“淹没”持久性上下文,导致性能下降(例如,刷新变得缓慢)或者甚至特定于内存的错误。解决方案是将数据分块,用一个大小等于批处理大小的Iterable
调用saveAll()
。这样,每个Iterable
都在一个单独的事务和持久性上下文中运行。您不会冒淹没持久性上下文的风险,并且在失败的情况下,回滚不会影响之前的提交。此外,您避免了不利于 MVCC(多版本并发控制 2 )和影响可伸缩性的长时间运行的事务。然而,更好的方法是在刷新-清除周期中重用持久性上下文,在开始-提交周期中重用相同的事务(您将在下一节中这样做)。 -
开发者不能靠
persist()
代替merge()
: 在幕后,saveAll(Iterable<S> entities)
方法调用内置的save(S s)
方法,后者调用EntityManager#merge()
。这意味着,在触发INSERT
之前,JPA 持久性提供者将触发SELECT
,触发的SELECT
越多,性能损失就越大。需要每个被触发的SELECT
来确保数据库还没有包含与要插入的记录具有相同主键的记录(在这种情况下,Hibernate 将触发一个UPDATE
而不是一个INSERT
)。调用persist()
而不是merge()
将仅触发INSERT
s。然而,向实体添加一个@Version
属性将防止这些额外的SELECT
s 在批处理之前被触发。 -
saveAll()
方法返回一个包含持久化实体的List<S>
:对于每个Iterable
,saveAll()
创建一个添加持久化实体的列表。如果你不需要这个列表,那么它就是免费的。例如,如果批量处理 1000 个实体,批量大小为 30,那么将创建 34 个列表。如果你不需要这些List
对象,你只是白给垃圾收集器增加了更多的工作。
**通过saveAll(Iterable<S> entities)
批量插入的例子可以在 GitHub 3 找到。接下来,让我们来谈谈一种能给你更多控制权的方法。
定制实现是一条可行之路
通过编写批处理的自定义实现,您可以控制和调整该过程。您向客户端公开了一个利用多种优化的saveInBatch(Iterable<S> entities)
方法。这个定制实现可以依赖于EntityManager
,并且有几个主要目标:
-
在每个批处理后提交数据库事务
-
用
persist()
代替merge()
-
不要求实体中存在
@Version
以避免额外的SELECT
-
不要返回持久化实体的
List
-
通过名为
saveInBatch(Iterable<S>)
的方法以 Spring 风格公开批处理
在我们继续之前,让我们强调一下批处理插入的最佳实践。
推荐的批量大小在 5 到 30 之间。
提交每个批处理的数据库事务(这将把当前批处理刷新到数据库)。这样,您可以避免长时间运行的事务(这不利于 MVCC 并影响可伸缩性),并且在失败的情况下,回滚不会影响之前的提交。在开始新的批处理之前,再次开始事务并清除实体管理器。这将防止托管实体的累积和可能的内存错误,内存错误是由缓慢刷新导致的性能损失。在开始提交周期中重用事务,在清除周期中重用实体管理器。
然而,如果您决定只在最后提交事务,那么在事务内部,在每一批之后显式地刷新和清除记录。通过这种方式,持久性上下文可以释放一些内存,防止内存耗尽和缓慢刷新。注意长时间运行的事务的代码。
撰写批处理存储合同
实现从包含所需方法的非存储库接口开始。这个界面用@NoRepositoryBean
标注:
@NoRepositoryBean
public interface BatchRepository<T, ID extends Serializable>
extends JpaRepository<T, ID> {
<S extends T> void saveInBatch(Iterable<S> entitles);
}
编写批处理存储实现
接下来,您可以扩展SimpleJpaRepository
存储库基类并实现BatchRepository
。通过扩展SimpleJpaRepository
,您可以通过添加所需的方法来自定义基本存储库。主要是,您扩展了特定于持久性技术的存储库基类,并使用这个扩展作为存储库代理的定制基类。请注意,您将事务传播设置为NEVER
,因为您不想让 Spring 启动一个潜在的长时间运行的事务(有关 Spring 事务传播的高超指南,请查看附录 G ):
@Transactional(propagation = Propagation.NEVER)
public class BatchRepositoryImpl<T, ID extends Serializable>
extends SimpleJpaRepository<T, ID> implements BatchRepository<T, ID> {
...
@Override
public <S extends T> void saveInBatch(Iterable<S> entities) {
BatchExecutor batchExecutor
= SpringContext.getBean(BatchExecutor.class);
batchExecutor.saveInBatch(entities);
}
...
}
这个扩展有助于以 Spring 风格公开批处理插入实现。批处理发生在名为BatchExecutor
的 Spring 组件中。虽然 GitHub 4 上提供了完整的代码,但是下面的方法(BatchExecutor.saveInBatch()
)显示了实现(注意,它从EntityManagerFactory
获得EntityManager
,并控制事务的开始-提交周期):
@Component
public class BatchExecutor<T> {
private static final Logger logger =
Logger.getLogger(BatchExecutor.class.getName());
@Value("${spring.jpa.properties.hibernate.jdbc.batch_size}")
private int batchSize;
private final EntityManagerFactory entityManagerFactory;
public BatchExecutor(EntityManagerFactory entityManagerFactory) {
this.entityManagerFactory = entityManagerFactory;
}
public <S extends T> void saveInBatch(Iterable<S> entities) {
EntityManager entityManager
= entityManagerFactory.createEntityManager();
EntityTransaction entityTransaction = entityManager.getTransaction();
try {
entityTransaction.begin();
int i = 0;
for (S entity : entities) {
if (i % batchSize == 0 && i > 0) {
logger.log(Level.INFO,
"Flushing the EntityManager
containing {0} entities ...", batchSize);
entityTransaction.commit();
entityTransaction.begin();
entityManager.clear();
}
entityManager.persist(entity);
i++;
}
logger.log(Level.INFO,
"Flushing the remaining entities ...");
entityTransaction.commit();
} catch (RuntimeException e) {
if (entityTransaction.isActive()) {
entityTransaction.rollback();
}
throw e;
} finally {
entityManager.close();
}
}
}
将 BatchRepositoryImpl 设置为基类
接下来,您需要指示 Spring 依赖这个定制的存储库基类。在 Java 配置中,这可以通过repositoryBaseClass
属性来完成:
@SpringBootApplication
@EnableJpaRepositories(
repositoryBaseClass = BatchRepositoryImpl.class)
public class MainApplication {
...
}
测试时间
考虑使用 Spring Boot 风格的实现。首先,为Author
实体定义一个经典存储库(这次,扩展BatchRepository
):
@Repository
public interface AuthorRepository extends BatchRepository<Author, Long> {
}
此外,在服务中注入这个存储库,并如下调用saveInBatch()
:
public void batchAuthors() {
List<Author> authors = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
Author author = new Author();
author.setId((long) i + 1);
author.setName("Name_" + i);
author.setGenre("Genre_" + i);
author.setAge(18 + i);
authors.add(author);
}
authorRepository.saveInBatch(authors);
}
可能的输出将显示 1000 个作者在 34 个批次和 34 次刷新中被处理(如果您需要刷新如何工作,请参见附录 H )。见图 4-1 。
图 4-1
批量插入
根据经验,总是要确保应用(数据访问层)确实在使用批处理,并且是按预期使用的。由于批处理可能会被无声地禁用或没有正确地优化,所以不要认为它可以正常工作。最好还是依靠工具(如DataSource-Proxy
;参见第 83 项,能够记录批量大小并统计执行的语句。
GitHub 5 上有源代码。如果您只想在批处理过程结束时提交,但仍想利用每次批处理后的刷新和清除,请考虑使用此代码 6 。
您可能还想检查:
-
批量插入通过
EntityManager
和一个刀层 7 -
通过
JpaContext
和EntityManager
和 8 和批量插入
第 47 项:如何优化父子关系的批量插入
为了熟悉分批插入,在继续之前考虑阅读项目 46 。
考虑一下Author
和Book
实体之间的@OneToMany
关联。由于级联持久存储(或全部),保存作者也保存了他们的书。如果作者和书籍的数量非常多,您可以使用批量插入技术来提高性能。
默认情况下,这将导致批处理每个作者和每个作者的图书。例如,考虑 40 个作者,每个人写了五本书。将这些数据插入数据库需要 240 次插入(40 位作者和 200 本书)。批量为 15 的这些插页应产生 17 个 JDBC 批次。为什么是 17?答案即将揭晓。
如果不使用对插入进行排序,以下 SQL 语句将按此顺序分批分组(突出显示的插入是为了直观地区分每个作者):
insert into author (age, genre, name, id) values (?, ?, ?, ?)
insert into book (author_id, isbn, title, id) values (?, ?, ?, ?)
-- 4 more
insert into author (age, genre, name, id) values (?, ?, ?, ?)
insert into book (author_id, isbn, title, id) values (?, ?, ?, ?)
-- 4 more
...
因此,有一个针对author
表的插入,后面跟着五个针对book
表的插入。因为有 40 个作者,所以重复 40 次。最终统计出 80 个 JDBC 批次,如图 4-2 所示。
图 4-2
不排序插入的批量插入(包括关联)
为什么是 80 批 JDBC?答案在于批处理是如何工作的。更准确地说,一个 JDBC 批处理只能针对一个表。当目标是另一个表时,当前批处理结束,并创建一个新的表。在这种情况下,定位到author
表会创建一个批处理,而定位到book
表会创建另一个批处理。第一批只分组一个插入,而第二批分组五个插入。所以,有 40 x 2 批。
订购插页
这个问题的解决依赖于对插页进行排序。这可以通过在以下设置中添加application.properties
来实现:
spring.jpa.properties.hibernate.order_inserts=true
这一次,插页的顺序如下:
insert into author (age, genre, name, id) values (?, ?, ?, ?)
-- 14 more
insert into book (author_id, isbn, title, id) values (?, ?, ?, ?)
-- 74 more (15 x 5)
...
第一批将目标为author
工作台的 15 个插入物分组。以下五个批次中的每一个批次都以book
工作台为目标将 15 个插入物分组。所以,到目前为止有六批。另外六个将涵盖下一组的 15 个作者。所以,12 批。最后 10 个作者被分组到新的一批中;所以,至今有 13 个。最后 10 位作者写了 50 本书,这导致了另外四批。总共是 17 个 JDBC 批次,如图 4-3 所示。
图 4-3
带有有序插入的批量插入(包括关联)
图 4-4 所示的时间-性能趋势图揭示了订购插件可以带来实质性的好处。这里,我们将作者的数量从 5 增加到 500,同时保持每个作者的书籍数量等于 5。
图 4-4
批量插入,包括有无排序的关联
这张时间性能趋势图是在具有以下特征的 Windows 7 机器上针对 MySQL 获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。
GitHub 9 上有源代码。或者,如果您想在单个事务中运行批处理,请查看这个 GitHub10应用。
第 48 项:如何在会话级别控制批量大小
可通过spring.jpa.properties.hibernate.jdbc.batch_size
在application.properties
中设置应用级批量。换句话说,所有 Hibernate 会话都使用相同的批处理大小。但是,从 Hibernate 5.2 开始,您可以在会话级设置批处理大小。这允许您拥有不同批处理大小的 Hibernate 会话。
您可以通过Session.setJdbcBatchSize()
方法在会话级别设置批处理大小。在 Spring Boot,访问Session
意味着通过EntityManager#unwrap()
从当前的EntityManager
中打开它。
以下代码片段显示了在批处理插入的情况下,在会话级别设置批处理大小所需的所有部分:
private static final int BATCH_SIZE = 30;
private EntityManager entityManager = ...;
Session session = entityManager.unwrap(Session.class);
session.setJdbcBatchSize(BATCH_SIZE);
...
int i = 0;
for (S entity: entities) {
if (i % session.getJdbcBatchSize() == 0 && i > 0) {
...
}
}
...
GitHub 11 上有源代码。或者,如果您想在单个事务中运行批处理,那么请查看这个 GitHub 12 应用。
第 49 项:如何分叉连接 JDBC 配料
大多数数据库支持批量插入数百万条记录。在决定在应用级别进行批处理/批量处理之前,建议查看一下您的数据库供应商提供了哪些选项。例如,MySQL 提供了LOAD DATA INFILE
,这是一个高度优化的特性,可以以很高的速度将数据从 CSV/TSV 文件直接插入到表格中。
前面的内容已经涵盖了通过批处理持久化实体的几个方面。但是在有些情况下,实体是不需要的,你必须使用 JDBC 普通批处理。例如,假设您有一个文件(citylots.json
),其中包含关于 JSON 中城市地块的信息。您需要通过一个INSERT
类型的语句(占位符是文件中的一行)将这个文件传输到一个数据库表(lots
):
INSERT INTO lots (lot) VALUES (?)
在 Spring Boot,JDBC 配料可以通过JdbcTemplate
轻松完成;更准确地说是通过JdbcTemplate.batchUpdate()
方法。该方法的一个特点是将一个BatchPreparedStatementSetter
实例作为第二个参数,这对于设置通过String
作为第一个参数传递的PreparedStatement
的文字值很有用。本质上,batchUpdate()
在单个PreparedStatement
上发布多个 update 语句,使用批量更新和一个BatchPreparedStatementSetter
来设置值。
以下组件表示使用batchUpdate()
的 JDBC 批处理实现:
@Component
public class JoiningComponent {
private static final String SQL_INSERT
= "INSERT INTO lots (lot) VALUES (?)";
private final JdbcTemplate jdbcTemplate;
public JoiningComponent(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void executeBatch(List<String> jsonList) {
jdbcTemplate.batchUpdate(SQL_INSERT,
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement pStmt, int i)
throws SQLException {
String jsonLine = jsonList.get(i);
pStmt.setString(1, jsonLine);
}
@Override
public int getBatchSize() {
return jsonList.size();
}
});
}
}
动作发生在executeBatch()
方法中。接收到的jsonList
被迭代,并且对于每个项目,相应地准备PreparedStatement
(在setValues()
中)并且发布更新。
只要jsonList
不是很大,这种实现就很好。citylots.json
文件有 200,000+行,因此实现需要一个长事务来迭代 200,000+项的列表并发出 200,000+更新。批量大小为 30 时,有 6,600+批要执行。即使有批处理支持,顺序执行 6,600 多个批处理也需要大量时间。
分叉连接批处理
在这种情况下,与其按顺序执行批处理,不如并发执行。Java 提供了几种可以使用的方法,比如Executors
、fork/join 框架、CompletableFuture
等等。在这种情况下,让我们使用 fork/join 框架。
虽然剖析 fork/join 框架已经超出了本书的范围,但是本节将快速强调几个方面:
-
fork/join 框架意味着接受一个大任务(通常,“大”意味着大量数据),并递归地将它分割(fork)成可以并行执行的较小任务(子任务)。最后,在所有子任务完成后,它们的结果被组合(连接)成一个结果。
-
在 API 术语中,可以通过
java.util.concurrent.ForkJoinPool
创建一个 fork/join。 -
一个
ForkJoinPool
对象操纵任务。在ForkJoinPool
中执行的任务的基本类型是ForkJoinTask<V>
。有三种类型的任务,但我们对RecursiveAction
感兴趣,它是针对返回void
的任务。 -
任务的逻辑发生在名为
compute()
的abstract
方法中。 -
向
ForkJoinPool
提交任务可以通过很多方法完成,但是我们对invokeAll()
感兴趣。该方法用于派生一组任务(例如,一个集合)。 -
通常,可用处理器(核心)的数量决定了 fork/join 并行性的级别。
基于这几点,您可以使用 fork/join 框架将一个 200,000 多项的列表派生到最多 30 项的子任务中(30 是一个批处理的大小,在application.properties
中表示为一个配置属性)。此外,JoiningComponent.executeBatch()
方法将执行每个子任务(批处理):
@Component
@Scope("prototype")
public class ForkingComponent extends RecursiveAction {
@Value("${jdbc.batch.size}")
private int batchSize;
@Autowired
private JoiningComponent joiningComponent;
@Autowired
private ApplicationContext applicationContext;
private final List<String> jsonList;
public ForkingComponent(List<String> jsonList) {
this.jsonList = jsonList;
}
@Override
public void compute() {
if (jsonList.size() > batchSize) {
ForkJoinTask.invokeAll(createSubtasks());
} else {
joiningComponent.executeBatch(jsonList);
}
}
private List<ForkingComponent> createSubtasks() {
List<ForkingComponent> subtasks = new ArrayList<>();
int size = jsonList.size();
List<String> jsonListOne = jsonList.subList(0, (size + 1) / 2);
List<String> jsonListTwo = jsonList.subList((size + 1) / 2, size);
subtasks.add(applicationContext.getBean(
ForkingComponent.class, new ArrayList<>(jsonListOne)));
subtasks.add(applicationContext.getBean(
ForkingComponent.class, new ArrayList<>(jsonListTwo)));
return subtasks;
}
}
最后,你需要通过ForkJoinPool
启动一切:
public static final int NUMBER_OF_CORES =
Runtime.getRuntime().availableProcessors();
public static final ForkJoinPool forkJoinPool = new
ForkJoinPool(NUMBER_OF_CORES);
// fetch 200000+ lines from file
List<String> allLines = Files.readAllLines(Path.of(fileName));
private void forkjoin(List<String> lines) {
ForkingComponent forkingComponent
= applicationContext.getBean(ForkingComponent.class, lines);
forkJoinPool.invoke(forkingComponent);
}
每个批处理都将在自己的事务/连接中运行,因此您需要确保连接池(例如 HikariCP)可以提供必要数量的连接,以避免 fork/join 线程之间的争用。通常,可用处理器(核心)的数量决定了 fork/join 并行性级别(这不是一个规则;你需要对它进行基准测试)。因此,连接数应该等于或大于将执行批处理的 fork/join 线程数。例如,如果您有八个核心,那么如果您想避免空闲的 fork/join 线程,连接池必须提供至少八个连接。对于 HikariCP,您可以设置 10 个连接:
spring.datasource.hikari.maximumPoolSize=10
spring.datasource.hikari.minimumIdle=10
图 4-5 显示了分别使用一个线程、四个线程、八个线程批处理 1000、10000 和 25000 个项目时的时间性能趋势,批处理大小为 30。很明显,使用并发批处理可以大大加快进程。当然,对于特定的作业,要调优并找到线程数、连接数、批处理大小、子任务大小等的最佳值。,可以进一步优化这个实现。
图 4-5
分叉/连接和 JDBC 批量插入
图 4-5 中显示的时间-性能趋势图是在具有以下特征的 Windows 7 机器上针对 MySQL 获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。
完整的应用可在 GitHub 13 上获得。
对于复杂的批处理场景,依靠专用工具是明智的。比如春季批次 14 项目可以适当选择。
第 50 项:通过 CompletableFuture 批处理实体
这篇文章使用了“定制实现是必由之路”一节中第 46 篇文章中的代码库,所以在阅读这篇文章之前,请考虑熟悉它。
当你需要加速实体批处理过程时,考虑并发执行批处理,而不是顺序执行,就像在项目 46 中一样。Java 有几种方法,比如Executors
、fork/join 框架、CompletableFuture
等等。你可以像在 Item 49 中一样轻松地使用 fork/join 框架,但是为了方便起见,这次让我们使用CompletableFuture
API。
虽然剖析CompletableFuture
API 已经超出了本书的范围,但是下面的列表快速突出了几个方面:
-
CompletableFuture
作为Future
API 的增强,在 JDK 8 中增加。 -
CompletableFuture
提供了一个可靠的异步 API,它以大量的方法实现。 -
从这些方法中,我们感兴趣的是
CompletableFuture.allOf()
。该方法允许您异步执行一系列任务,并等待它们完成。在这种情况下,任务是插入批处理。 -
你需要的另一个方法是
CompletableFuture.runAsync()
。该方法可以异步运行任务,并且不返回结果。在这种情况下,任务是执行单个批处理的事务。如果您需要返回一个结果,那么您可以简单地使用supplyAsync()
方法。
请记住,在第 49 项中,您创建了BatchExecutor
,它在开始-提交周期中重用相同的事务。这个时候需要并发批处理,所以单个事务是不够的。换句话说,每批需要一个事务/连接。这可以通过TransactionTemplate
进行整形。这里列出了改装后的BatchExecutor
:
@Component
public class BatchExecutor<T> {
private static final Logger logger =
Logger.getLogger(BatchExecutor.class.getName());
@Value("${spring.jpa.properties.hibernate.jdbc.batch_size}")
private int batchSize;
private final TransactionTemplate txTemplate;
private final EntityManager entityManager;
private static final ExecutorService executor
= Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() - 1);
public BatchExecutor(TransactionTemplate txTemplate,
EntityManager entityManager) {
this.txTemplate = txTemplate;
this.entityManager = entityManager;
}
public <S extends T> void saveInBatch(List<S> entities)
throws InterruptedException, ExecutionException {
txTemplate.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
final AtomicInteger count = new AtomicInteger();
CompletableFuture[] futures = entities.stream()
.collect(Collectors.groupingBy(
c -> count.getAndIncrement() / batchSize))
.values()
.stream()
.map(this::executeBatch)
.toArray(CompletableFuture[]::new);
CompletableFuture<Void> run = CompletableFuture.allOf(futures);
run.get();
}
public <S extends T> CompletableFuture<Void> executeBatch(List<S> list) {
return CompletableFuture.runAsync(() -> {
txTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
for (S entity : list) {
entityManager.persist(entity);
}
}
});
}, executor);
}
}
注意,我们使用批处理大小将初始列表分块到一个数组CompletableFuture
中。虽然这里使用的分块技术相当慢,但它非常容易编写。然而,许多其他解决方案是可用的,正如你在应用 15 中看到的。
另外,注意我们使用了一个自定义的ExecutorService
。这对于控制并行度非常有用,但是您也可以跳过它。如果跳过它,那么异步任务将在从全局ForkJoinPool.commonPool()
获得的线程中执行。
最后,对于 HikariCP 连接池,您可以设置 10 个连接,如下所示(这将很容易容纳用于批处理的 8 个线程):
spring.datasource.hikari.maximumPoolSize=10
spring.datasource.hikari.minimumIdle=10
图 4-6 显示了分别使用一个线程、四个线程和八个线程批处理 1000、5000 和 10000 个实体的性能趋势,批处理大小为 30。很明显,使用并发批处理可以大大加快进程。当然,对于一个特定的作业,调整并找到线程数、连接数、批处理大小、子任务大小等的最佳值。,可以进一步优化这个实现。
图 4-6
CompletableFuture 和 JPA 批量插入
图 4-6 中的时间-性能趋势图是在具有以下特征的 Windows 7 机器上针对 MySQL 获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。
完整的应用可在 GitHub 16 上获得。
对于复杂的批处理场景,依靠专用工具是明智的。比如春季批次 17 项目都可以是不错的选择。
第 51 项:如何有效地批量更新
批量更新是一个设置问题。首先,MySQL 的 JDBC URL 可以像在第 46 项中那样准备:
jdbc:mysql://localhost:3306/bookstoredb?
cachePrepStmts=true
&useServerPrepStmts=true
&rewriteBatchedStatements=true
对于其他 RDBMS,您只需删除特定于 MySQL 的设置。
您也可以通过spring.jpa.properties.hibernate.jdbc.batch_size
设置批量大小。
接下来,需要考虑的主要有两个方面。
版本化实体
如果应该更新的实体是版本化的(包含一个用@Version
注释的属性,用于防止丢失更新,那么确保设置了以下属性:
spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true
spring.jpa.properties.hibernate.jdbc.batch_versioned_data
应该在 Hibernate 5 之前显式设置。从 Hibernate 5 开始,默认情况下启用该设置。
父子关系的批量更新
当更新影响具有级联全部/持续的父子关系时,建议通过以下设置对更新进行排序:
spring.jpa.properties.hibernate.order_updates=true
如果您不订购更新,应用将容易出现 Item 47 中描述的问题。快速提醒一下,JDBC 批处理只能针对一个表。当目标是另一个表时,当前批处理结束,并创建一个新的表。
本书附带的源代码包含两个应用。一个用于不涉及关联的批量更新(GitHub 18 ),另一个涉及关联(GitHub 19 )。两个应用都使用众所周知的实体,Author
和Book
。
批量更新
批量操作(删除和更新)对于修改一组记录也很有用。批量操作速度很快,但它们有三个主要缺点:
-
批量更新(和删除)可能会使持久性上下文处于过时状态(您可以在更新/删除之前刷新持久性上下文,然后在更新/删除之后关闭/清除它,以避免由潜在的未刷新或过时的实体造成的问题,从而防止这个问题)。
-
批量更新(和删除)不会受益于自动乐观锁定(例如,
@Version
被忽略)。因此,不会阻止丢失更新。然而,其他查询可能会受益于乐观锁定机制。因此,建议通过显式递增版本(如果有)来通知这些更新。 -
批量删除不能利用级联删除(
CascadeType.REMOVE
)或orphanRemoval
。
也就是说,让我们假设Author
和Book
参与了一个双向懒惰的@OneToMany
关联。Author
持久字段是id
、name
、genre
、age
、version
和books
。Book
持久字段是id
、title
、isbn
、version
和author
。现在,让我们更新!
让我们通过将他们的age
增加 1 来更新所有的作者,并通过将他们的isbn
s 设置为 None 来更新书籍。不需要在持久性上下文中加载作者和书籍来执行这些更新。您可以触发两个批量操作,如下所示(注意查询是如何显式递增version
):
// add this query in AuthorRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(value = "UPDATE Author a SET a.age = a.age + 1,
a.version = a.version + 1")
public int updateInBulk();
// add this query in BookRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(value = "UPDATE Book b SET b.isbn='None',
b.version=b.version + 1")
public int updateInBulk();
并且服务方法触发更新:
@Transactional
public void updateAuthorsAndBooks() {
authorRepository.updateInBulk();
bookRepository.updateInBulk();
}
触发的 SQL 语句有:
UPDATE author
SET age = age + 1,
version = version + 1
UPDATE book
SET isbn = 'None',
version = version + 1
批量操作也可以用于实体。让我们假设持久性上下文包含所有比 40 更老的作者的Author
和相关联的Book
。这一次,批量操作可以写成:
// add this query in AuthorRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(value = "UPDATE Author a SET a.age = a.age + 1,
a.version = a.version + 1 WHERE a IN ?1")
public int updateInBulk(List<Author> authors);
// add this query in BookRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(value = "UPDATE Book b SET b.isbn='None',
b.version = b.version + 1 WHERE b.author IN ?1")
public int updateInBulk(List<Author> authors);
并且服务方法触发更新:
@Transactional
public void updateAuthorsGtAgeAndBooks() {
List<Author> authors = authorRepository.findGtGivenAge(40);
authorRepository.updateInBulk(authors);
bookRepository.updateInBulk(authors);
}
触发的 SQL 语句如下:
UPDATE author
SET age = age + 1,
version = version + 1
WHERE id IN (?, ?, ?, ..., ?)
UPDATE book
SET isbn = 'None',
version = version + 1
WHERE author_id IN (?, ?, ..., ?)
完整的应用可在 GitHub 20 上获得。
第 52 项:如何有效地批量删除(无关联)
要批量删除 MySQL,您可以准备 JDBC URL,如 Item 46 中所述:
jdbc:mysql://localhost:3306/bookstoredb?
cachePrepStmts=true
&useServerPrepStmts=true
&rewriteBatchedStatements=true
对于其他 RDBMS,只需删除 MySQL 特有的设置即可。
通过spring.jpa.properties.hibernate.jdbc.batch_size
设置批量大小(例如,设置为 30)。对于版本化的实体,将spring.jpa.properties.hibernate.jdbc.batch_versioned_data
设置为true
。
批处理删除可以通过几种方式有效地完成。要决定哪种方法最适合,了解批处理会影响关联以及会删除多少数据是很重要的。此项处理不影响关联的批处理删除。
考虑图 4-7 中的Author
实体。
图 4-7
作者实体表
Spring Boot 公开了一堆可以用来删除记录的方法。此外,这些方法中的每一种都用于删除 100 个作者。让我们从触发批量操作的两个方法开始— deleteAllInBatch()
和deleteInBatch(Iterable<T> entities)
。
一般来说,请注意,批量操作比批处理更快,并且可以使用索引,但是它们不会受益于级联机制(例如,CascadeType.ALL
被忽略)或自动应用级乐观锁定机制(例如,@Version
被忽略)。他们对实体的修改不会自动反映在持久性上下文中。
通过内置的 deleteAllInBatch()方法删除
您可以通过经典的 Spring 存储库(AuthorRepository
)从服务方法中轻松调用内置的deleteAllInBatch()
方法,如下所示:
public void deleteAuthorsViaDeleteAllInBatch() {
authorRepository.deleteAllInBatch();
}
deleteAllInBatch()
生成的 SQL 语句如下:
DELETE FROM author
在DataSource-Proxy
(这个库在第 83 项中介绍过)的上下文中添加这个 SQL 会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:21, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["delete from author"]
Params:[()]
不使用批处理,但是已经删除了author
表中的所有记录。
即使不使用批处理,这也是从数据库中删除所有记录的非常有效的方法。它需要一次数据库往返。然而,deleteAllInBatch()
并不受益于自动应用级乐观锁定机制(如果该机制被启用以防止丢失更新(例如,通过@Version
)),而是依赖于Query
的executeUpdate()
来触发批量操作。这些操作比批处理要快,但是 Hibernate 不知道删除了哪些实体。因此,持久性上下文不会相应地自动更新/同步。为了避免过时的持久性上下文,您需要决定是否需要在删除之前触发刷新操作,并在删除之后丢弃(清除或关闭)持久性上下文。例如,deleteAuthorsViaDeleteAllInBatch()
并不要求任何显式的冲或清。在删除之前,没有要刷新的内容,而在删除之后,持久性上下文会自动关闭。
通过内置的 deleteInBatch(Iterable 实体)删除
deleteInBatch(Iterable<T> entities)
方法也可以触发批量删除。您可以通过经典的 Spring 存储库(AuthorRepository
)从服务方法中轻松调用内置的deleteInBatch(Iterable<T> entities)
方法,如下所示(删除所有小于 60 的作者):
@Transactional
public void deleteAuthorsViaDeleteInBatch() {
List<Author> authors = authorRepository.findByAgeLessThan(60);
authorRepository.deleteInBatch(authors);
}
这次,deleteInBatch(Iterable<T> entities)
生成的 SQL 语句如下:
DELETE FROM author
WHERE id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
...
将该 SQL 添加到DataSource-Proxy
的上下文中会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:27, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["delete from author where id=? or id=? or id=? ...]
Params:[(1,12,23, ...)]
不使用批处理。Spring Boot 只是使用OR
操作符将相应的id
链接到WHERE
子句下。
与deleteAllInBatch()
完全一样,该方法通过Query
的executeUpdate()
触发批量操作。
不要用deleteInBatch(Iterable<T> entities)
删除所有记录。对于这种情况,使用deleteAllInBatch()
。如果您使用这种方法来删除一组满足给定过滤标准的记录,您就没有自动应用级乐观锁定机制的好处(防止丢失更新)。虽然这种方法非常快,但是请记住,如果生成的DELETE
语句超过了最大的可接受大小/长度(例如,get 一个StackOverflowError
),也很容易导致问题。通常,可接受的最大大小是很大的,但是因为您使用了批处理,所以要删除的数据量也可能很大。
与deleteAllInBatch()
的情况完全一样,由您来决定是否在删除之前,您必须刷新任何未刷新的实体,而在删除之后,您必须丢弃(关闭或清除)持久性上下文。例如,deleteAuthorsViaDeleteInBatch()
不需要任何显式的刷新或清除。在删除之前,没有要刷新的内容,而在删除之后,持久性上下文会自动关闭。
如果您对生成的查询的大小有疑问,您可以考虑几个备选方案。例如,您可以依靠IN
操作符来编写自己的批量操作,如下所示(这将导致类型为IN (?, ..., ?)
的查询:
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Author a WHERE a IN ?1")
public int deleteInBulk(List<Author> authors);
一些 RDBMS(例如,SQL Sever)在内部从IN
转换为OR
,而另一些则没有(例如,MySQL)。就性能而言,IN
和OR
非常相似,但是最好针对特定的 RDBMS 进行基准测试(例如,在 MySQL 中,IN
应该比OR
性能更好)。此外,在 MySQL 8 中,我依靠IN
来管理 500,000 次删除而没有问题,而OR
在用于 10,000 次删除时导致了StackOverflowError
。
另一种方法是将获取的结果集分块以适应deleteInBatch(Iterable<T> entities)
。例如,这可以通过函数式编程风格快速完成,如下所示(如果您需要优化分块过程,请考虑这个应用 21 ):
@Transactional
public void deleteAuthorsViaDeleteInBatch() {
List<Author> authors = authorRepository.findByAgeLessThan(60);
final AtomicInteger count = new AtomicInteger();
Collection<List<Author>> chunks = authors.parallelStream()
.collect(Collectors.groupingBy(c -> count.getAndIncrement() / size))
.values();
chunks.forEach(authorRepository::deleteInBatch);
}
显然,这种方法的缺点是内存中的数据重复。它也没有受益于自动乐观锁定机制(它不能防止丢失更新)。但是分块数据可以通过 fork-join、CompletableFuture
或任何其他特定的 API 利用删除的并行化。您可以为每个事务传递一个数据 chuck,并以并发方式运行多个事务。例如,在 Item 49 中,您看到了如何并行化批处理插入。
或者,您可以分块获取结果集,并为每个获取的块调用deleteInBatch(Iterable<T> entities)
。在这种情况下,缺点表现为每个块的额外SELECT
和没有丢失更新预防。
通过内置的 deleteAll()方法删除
你可以通过一个经典的 Spring 存储库(AuthorRepository
)从一个服务方法中轻松调用内置的deleteAll(Iterable<? extends T> entities)
方法,如下所示(删除所有小于 60 的作者):
@Transactional
public void deleteAuthorsViaDeleteAll() {
List<Author> authors = authorRepository.findByAgeLessThan(60);
authorRepository.deleteAll(authors);
}
这次,deleteAll(Iterable<? extends T> entities)
生成的 SQL 语句如下:
DELETE FROM author
WHERE id = ?
AND version = ?
在DataSource-Proxy
(这个库在第 83 项中有介绍)的上下文中添加这个 SQL 会显示以下输出(查看突出显示的部分):
Name:DATA_SOURCE_PROXY, Connection:6, Time:1116, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:30
Query:["delete from author where id=? and version=?"]
Params:[(2,0),(3,0),(6,0),(11,0),(13,0),(15,0),(17,0) ...]
最后,按预期使用批处理!它受益于自动乐观锁定机制,因此也防止更新丢失。
幕后,deleteAll(Iterable<? extends T> entities
、delete(T entity)
依靠EntityManager.remove()
。因此,持久性上下文会相应地更新。换句话说,Hibernate 将每个实体的生命周期状态从管理的转变为移除的。
您可以通过无参数调用deleteAll()
通过批处理删除所有记录。这个方法在后台调用findAll()
。
通过内置的删除(T 实体)方法删除
在幕后,deleteAll(Iterable<? extends T> entities)
方法依赖于内置的delete(T entity)
方法。没有参数的deleteAll()
方法调用findAll()
,在循环结果集时,它为每个元素调用delete(T entity)
。另一方面,deleteAll(Iterable<? extends T> entities)
循环实体并为每个元素调用delete(T entity)
。
你可以通过 Spring repository ( AuthorRepository
)从服务方法中轻松调用内置的delete(T entity)
方法,如下所示(删除所有小于 60 的作者):
@Transactional
public void deleteAuthorsViaDelete() {
List<Author> authors = authorRepository.findByAgeLessThan(60);
authors.forEach(authorRepository::delete);
}
这次,delete(T entity)
生成的 SQL 语句如下:
DELETE FROM author
WHERE id = ? AND version = ?
在DataSource-Proxy
的上下文中添加该 SQL 会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:1116, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:30
Query:["delete from author where id=? and version=?"]
Params:[(2,0),(3,0),(6,0),(11,0),(13,0),(15,0),(17,0) ...]
正如所料,输出类似于使用deleteAll(Iterable<? extends T> entities)
。
总之,deleteAllInBatch()
和deleteInBatch(Iterable<T> entities)
不使用删除批处理。因此,不需要执行特定于启用批处理的设置。它们触发批量操作,这些操作不会受益于自动乐观锁定机制(如果启用了该机制,例如通过@Version
,以防止丢失更新),并且持久性上下文不会与数据库同步。建议在删除前刷新持久性上下文,并在删除后清除/关闭它,以避免任何未刷新或过时的实体造成的问题。如果开发者使用deleteAll()
或deleteAll(Iterable<? extends T> entities)
方法或delete(T entity)
方法,则采用批处理。只要所有的记录都要被删除,最好的方法就是使用deleteAllInBatch()
。在deleteInBatch(Iterable<T> entities)
和deleteAll()
、deleteAll(Iterable<? extends T> entities)
/ delete(T entity)
之间做出选择是基于所有这些考虑做出的决定。
GitHub 22 上有源代码。
第 53 项:如何有效地批量删除(带关联)
要对 MySQL 进行批量删除,可以准备好 JDBC URL,如 Item 46 所示:
jdbc:mysql://localhost:3306/bookstoredb?
cachePrepStmts=true
&useServerPrepStmts=true
&rewriteBatchedStatements=true
对于其他 RDBMS,您只需删除特定于 MySQL 的设置。
通过spring.jpa.properties.hibernate.jdbc.batch_size
设置批量大小(例如,设置为 30)。对于版本化的实体,将spring.jpa.properties.hibernate.jdbc.batch_versioned_data
设置为true
。
考虑惰性双向@OneToMany
关联中涉及的Author
和Book
实体,如图 4-8 所示。
图 4-8
@OneToMany 表关系
删除作者也应该删除相关的书籍。例如,删除所有作者应该会自动删除所有书籍。
依赖 orphanRemoval = true
默认情况下,orphanRemoval
设置为false
。您可以启用它来指示 JPA 持久性提供者删除父实体中不再引用的子实体。
不要混淆orphanRemoval
和CascadeType.REMOVE
。他们不一样!虽然orphanRemoval
负责自动移除一个被解除关联的实体实例,但是CascadeType.REMOVE
并不采取行动,因为解除关联不是一个移除操作。
这里列出了重要的Author
代码:
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();
接下来,让我们考虑一下 Spring Boot 删除功能。
通过内置的 deleteAllInBatch()方法删除
您可以通过经典的 Spring 存储库(AuthorRepository
)从服务方法中轻松调用内置的deleteAllInBatch()
方法,如下所示:
public void deleteAuthorsAndBooksViaDeleteAllInBatch() {
authorRepository.deleteAllInBatch();
}
deleteAllInBatch()
生成的 SQL 语句是:
DELETE FROM author
在DataSource-Proxy
的上下文中添加该 SQL(该库在第 83 项中有介绍)会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:21, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["delete from author"]
Params:[()]
批处理没有被使用,也没有受益于自动乐观锁定机制(防止丢失更新,但是author
表中的所有记录都已被删除。然而,book
表中的记录并没有被删除。因此,不出所料,deleteAllInBatch()
没有使用orphanRemoval
或级联。它只是通过Query
的executeUpdate()
触发一个批量删除,并且持久性上下文不与数据库同步。使用它删除所有书籍的唯一方法是显式调用它,如下所示:
@Transactional
public void deleteAuthorsAndBooksViaDeleteAllInBatch() {
authorRepository.deleteAllInBatch();
bookRepository.deleteAllInBatch();
}
即使不使用批处理和丢失更新预防机制,并且持久性上下文不与数据库同步,这也是从数据库中删除所有记录的非常有效的方法。由您来决定是否刷新(在删除之前)和关闭/清除(在删除之后)持久性上下文,以避免由任何未刷新或过时的实体造成的问题。
通过内置的 deleteInBatch(Iterable 实体)删除
deleteInBatch(Iterable<T> entities)
是另一种可以触发批量删除的方法。您可以通过经典的 Spring 知识库(AuthorRepository
)从服务方法中轻松调用内置的deleteInBatch(Iterable<T> entities)
方法,如下所示(删除所有小于 60 的作者及其书籍):
@Transactional
public void deleteAuthorsAndBooksViaDeleteInBatch() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authorRepository.deleteInBatch(authors);
}
这次,deleteInBatch(Iterable<T> entities)
生成的 SQL 语句如下:
DELETE FROM author
WHERE id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
...
在DataSource-Proxy
的上下文中添加该 SQL 会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:27, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["delete from author where id=? or id=? or id=? ...]
Params:[(1,12,23, ...)]
同样,不使用批处理和丢失更新防止机制,但是所有小于 60 的作者都已被删除。然而,book
表中的相关记录并没有被删除。因此,deleteInBatch(Iterable<T> entities)
没有利用orphanRemoval
或级联。它只是通过Query
的executeUpdate()
触发一个批量删除,并且持久性上下文不与数据库同步。使用它删除所有书籍的唯一方法是显式调用它,如下所示:
@Transactional
public void deleteAuthorsAndBooksViaDeleteInBatch() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authorRepository.deleteInBatch(authors);
authors.forEach(a -> bookRepository.deleteInBatch(a.getBooks()));
}
这一次,每删除一个作者,就会多一个DELETE
来删除关联的书籍。这是一个 N+1 问题。添加的 Ns 越多,效率就越低。最终,您可以通过将所有作者的书加入到一个列表中并将该列表传递给deleteInBatch(Iterable<T> entities)
)来解决这个 N+1 问题:
DELETE FROM book
WHERE id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
此外,请记住,如果生成的DELETE
语句超过了可接受的最大大小,这种方法很容易导致问题。关于这一点的更多细节在第 52 项中。
通过内置的 deleteAll(Iterable extends T>实体)和 delete(T 实体)方法删除
你可以通过一个经典的 Spring 存储库(AuthorRepository
)从一个服务方法中轻松调用内置的deleteAll(Iterable<? extends T> entities)
方法,如下所示(删除所有小于 60 的作者和相关书籍):
@Transactional
public void deleteAuthorsAndBooksViaDeleteAll() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authorRepository.deleteAll(authors);
}
同样的事情可以通过delete(T entity)
完成,如下所示:
@Transactional
public void deleteAuthorsAndBooksViaDeleteAll() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authors.forEach(authorRepository::delete);
}
这两种方法都产生相同的 SQL 语句(请注意通过查询中出现的version
起作用的乐观锁定机制):
DELETE FROM book
WHERE id = ?
AND version = ?
-- since each author has written 5 books, there will be 4 more DELETEs here
DELETE FROM author
WHERE id = ?
AND version = ?
这些 SQL 语句对每个应该删除的作者重复。在DataSource-Proxy
的上下文中添加这些 SQL 语句会显示以下输出(检查突出显示的部分并记住,对于每个被删除的作者,有两个批处理):
Name:DATA_SOURCE_PROXY, Connection:6, Time:270, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:5
Query:["delete from book where id=? and version=?"]
Params:[(1,0),(2,0),(3,0),(4,0),(5,0)]
Name:DATA_SOURCE_PROXY, Connection:6, Time:41, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:1
Query:["delete from author where id=? and version=?"]
Params:[(1,0)]
最后,使用批处理,但不是很优化。使用批处理是因为CascadeType.ALL
,其中包含了CascadeType.REMOVE
。为了确保每个Book
从管理到移除的状态转换,每个Book
都有一个DELETE
语句。但是批处理已经将这些DELETE
语句分组到一个批处理中。
尽管如此,问题还是表现在批次的数量上。没有对DELETE
语句进行排序,这导致了比该任务所需更多的批处理。请记住,一个批处理只能针对一个表。将book
和author
表作为目标会产生下面的语句:删除 10 个作者,每个人有 5 本书,需要 10 x 2 个批处理。你需要 20 个批次,因为每个作者在他自己的批次中被删除,而他的五本书在另一个批次中被删除。以下方法将优化批次数量。
首先,代码:
@Transactional
public void deleteAuthorsAndBooksViaDelete() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authors.forEach(Author::removeBooks);
authorRepository.flush();
// or, authorRepository.deleteAll(authors);
authors.forEach(authorRepository::delete);
}
看看这些粗线。代码通过助手方法removeBooks()
将所有的Book
从它们对应的Author
中分离出来,如下所示(该方法在Author
中):
public void removeBooks() {
Iterator<Book> iterator = this.books.iterator();
while (iterator.hasNext()) {
Book book = iterator.next();
book.setAuthor(null);
iterator.remove();
}
}
此外,代码显式(手动)刷新持久性上下文。orphanRemoval=true
该进场了。由于此设置,所有取消关联的书籍都将被删除。生成的DELETE
语句是批处理的(如果orphanRemoval
设置为false
,将执行一堆更新而不是删除)。最后,代码通过deleteAll(Iterable<? extends T> entities)
或delete(T entity)
方法删除所有的Author
。因为所有的Book
都是分离的,所以Author
删除也将利用批处理。
与之前的方法相比,这一次的批次数量要少得多。请记住,当删除 10 个作者和相关书籍时,需要 20 个批次。依靠这种方法只会产生三个批次。
首先执行删除所有相关书籍的批处理(因为每个作者有五本书,所以有 10 个作者 x 5 本书记录要删除):
Name:DATA_SOURCE_PROXY, Connection:6, Time:1071, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:30
Query:["delete from book where id=? and version=?"]
Params:[(1,0),(2,0),(3,0),(4,0),(5,0),(6,0),(7,0),(8,0), ... ,(30,0)]
Name:DATA_SOURCE_PROXY, Connection:6, Time:602, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:20
Query:["delete from book where id=? and version=?"]
Params:[(31,0),(32,0),(33,0),(34,0),(35,0),(36,0), ... ,(50,0)]
此外,执行删除 10 个作者的批处理:
Name:DATA_SOURCE_PROXY, Connection:6, Time:432, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:10
Query:["delete from author where id=? and version=?"]
Params:[(1,0),(2,0),(3,0),(4,0),(5,0),(6,0),(7,0),(8,0),(9,0),(10,0)]
GitHub 23 上有源代码。
依靠 SQL,依靠删除级联
ON DELETE CASCADE
是使用 SQL 级联删除的 SQL 指令。
ON DELETE CASCADE
是一个特定于数据库的操作,在删除父行时删除数据库中的子行。您可以通过 Hibernate 特有的@OnDelete
注释添加这个指令,如下所示:
@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE},
mappedBy = "author", orphanRemoval = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private List<Book> books = new ArrayList<>();
请注意,层叠(CascadeType
)效果减少为PERSIST
和MERGE
。此外,orphanRemoval
被设置为false
(或者,简单地删除它,因为false
是默认的)。这意味着这种方法不涉及 JPA 实体状态传播或实体移除事件。这种方法依赖于数据库自动操作,因此,持久性上下文不会相应地同步。让我们看看通过每个内置的删除机制会发生什么。
@OnDelete
的出现将如下改变author
表:
ALTER TABLE book
ADD CONSTRAINT fkklnrv3weler2ftkweewlky958
FOREIGN KEY (author_id) REFERENCES author (id)
ON DELETE CASCADE
在 MySQL 的情况下,如果spring.jpa.properties.hibernate.dialect
被设置为使用 InnoDB 引擎,则ON DELETE CASCADE
被考虑如下:
spring.jpa.properties.hibernate.dialect= org.hibernate.dialect.MySQL5InnoDBDialect
或者,对于 MySQL 8:
org.hibernate.dialect.MySQL8Dialect
通过内置的 deleteAllInBatch()方法删除
您可以通过经典的 Spring 存储库(AuthorRepository
)从服务方法中轻松调用内置的deleteAllInBatch()
方法,如下所示:
public void deleteAuthorsAndBooksViaDeleteAllInBatch() {
authorRepository.deleteAllInBatch();
}
deleteAllInBatch()
生成的 SQL 语句如下:
DELETE FROM author
在DataSource-Proxy
的上下文中添加该 SQL 会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:21, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["delete from author"]
Params:[()]
不使用批处理,丢失更新不会被阻止,但是被触发的批量操作将触发数据库级联删除。因此,book
表中的行也被删除。当从author
和book
表中删除所有行时,这是一种非常有效的方法。
通过内置的 deleteInBatch(Iterable 实体)删除
你可以通过一个经典的 Spring 存储库(AuthorRepository
)从一个服务方法中轻松调用内置的deleteInBatch(Iterable<T> entities)
方法,如下所示(删除所有小于 60 的作者和他们的书):
@Transactional
public void deleteAuthorsAndBooksViaDeleteInBatch() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authorRepository.deleteInBatch(authors);
}
这次,deleteInBatch(Iterable<T> entities)
生成的 SQL 语句如下:
DELETE FROM author
WHERE id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
...
在DataSource-Proxy
的上下文中添加该 SQL 会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:27, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["delete from author where id=? or id=? or id=? ...]
Params:[(1,12,23, ...)]
不使用批处理,不阻止丢失更新,但是触发的批量操作将触发数据库级联删除。来自book
表的相关行也被删除。这是一种非常有效的方法。唯一需要注意的是避免超过查询最大接受大小的DELETE
字符串语句。
通过内置的 deleteAll(Iterable extends T>实体)和 delete(T 实体)方法删除
你可以通过一个经典的 Spring 存储库(AuthorRepository
)从一个服务方法中轻松调用内置的deleteAll(Iterable<? extends T> entities)
方法,如下所示(删除所有小于 60 的作者和相关书籍):
@Transactional
public void deleteAuthorsAndBooksViaDeleteAll() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authorRepository.deleteAll(authors);
}
同样的事情可以通过delete(T entity)
完成,如下所示:
@Transactional
public void deleteAuthorsAndBooksViaDelete() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authors.forEach(authorRepository::delete);
}
这两种方法产生相同的 SQL 语句:
DELETE FROM author
WHERE id = ?
AND version = ?
-- this DELETE is generated for each author that should be deleted
这些 SQL 语句对每个应该删除的作者重复。在DataSource-Proxy
的上下文中添加这些 SQL 语句会显示以下输出(查看突出显示的部分):
Name:DATA_SOURCE_PROXY, Connection:6, Time:35, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:6
Query:["delete from author where id=? and version=?"]
Params:[(5,0),(6,0),(7,0),(8,0),(9,0),(10,0)]
使用批处理,并通过乐观锁定机制防止Author
的更新丢失!此外,删除作者将触发数据库级联删除。来自book
表的相关行也被删除。这一次,实体状态转换和数据库自动操作混合在一起。因此,持久性上下文是部分同步的。同样,这是非常高效的。
GitHub 24 上有源代码。
第 54 项:如何批量提取关联
第 39 项描述了如何通过JOIN FETCH
获取同一个查询中与其父查询的关联(尤其是集合)。此外, Item 7 描述了 JPA 2.1 @NamedEntityGraph
的强大功能,可用于避免 N+1 问题和解决惰性加载问题,而 Item 43 通过 SQL JOIN
处理抓取关联。
Hibernate 允许您通过 Hibernate 特有的@BatchSize
注释批量获取关联。然而,在考虑@BatchSize
之前,建议评估前面提到的方法。掌握所有这些方法将有助于你做出明智的决定。
现在,让我们继续看@BatchSize
,让我们来看一个通过例子学习的技巧。考虑双向懒惰@OneToMany
关联中涉及的Author
和Book
实体。图 4-9 显示了一个数据快照,有助于跟踪和更好地理解查询的结果集。
图 4-9
数据快照
集合级别的@BatchSize
查看Author
实体源代码:
@Entity
public class Author implements Serializable {
...
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
@BatchSize(size = 3)
private List<Book> books = new ArrayList<>();
...
}
与books
相关联的集合被标注为@BatchSize(size = 3)
。这意味着 Hibernate 应该一次为三个Author
实体初始化books
集合。在检查 SQL 语句之前,让我们考虑下面的服务方法,它在集合级利用了@BatchSize
:
@Transactional(readOnly = true)
public void displayAuthorsAndBooks() {
List<Author> authors = authorRepository.findAll();
for (Author author : authors) {
System.out.println("Author: " + author.getName());
System.out.println("No of books: "
+ author.getBooks().size() + ", " + author.getBooks());
}
}
这个方法通过一个SELECT
查询获取所有的Author
实体。此外,调用第一个Author
实体的getBooks()
方法将触发另一个SELECT
查询,该查询初始化前一个SELECT
查询返回的前三个Author
实体的集合。这是@BatchSize
在集合级的效果。
因此,首先SELECT
获取所有的Author
:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
第一个Author
调用getBooks()
将触发以下SELECT
:
SELECT
books0_.author_id AS author_i4_1_1_,
books0_.id AS id1_1_1_,
books0_.id AS id1_1_0_,
books0_.author_id AS author_i4_1_0_,
books0_.isbn AS isbn2_1_0_,
books0_.title AS title3_1_0_
FROM book books0_
WHERE books0_.author_id IN (?, ?, ?)
Hibernate 使用一个IN
子句有效地引用三个实体作者的标识符(这是批处理的大小)。输出如下所示:
Author: Mark Janel
No of books: 1, [Book{id=4, title=The Beatles Anthology, isbn=001-MJ}]
Author: Olivia Goy
No of books: 2, [Book{id=5, title=Carrie, isbn=001-OG}, Book{id=6,
title=House Of Pain, isbn=002-OG}]
Author: Quartis Young
No of books: 0, []
到达第四个作者,乔安娜·尼玛尔将需要一个新的SELECT
用于下一批Book
s。这个SELECT
的结果集如下:
Author: Joana Nimar
No of books: 3, [Book{id=1, title=A History of Ancient Prague, isbn=001-JN}, Book{id=2, title=A People's History, isbn=002-JN}, Book{id=3, title=History Day, isbn=003-JN}]
Author: Alicia Tom
No of books: 1, [Book{id=7, title=Anthology 2000, isbn=001-WT}]
Author: Katy Loin
No of books: 0, []
到达最后一个作者沃斯巨魔,将需要一个新的SELECT
。没有足够的数据来填充另一批Book
;因此,不需要IN
条款:
SELECT
books0_.author_id AS author_i4_1_1_,
books0_.id AS id1_1_1_,
books0_.id AS id1_1_0_,
books0_.author_id AS author_i4_1_0_,
books0_.isbn AS isbn2_1_0_,
books0_.title AS title3_1_0_
FROM book books0_
WHERE books0_.author_id = ?
输出如下所示:
No of books: 0, []
确保不要误解@BatchSize
在集合级的工作方式。不要认为集合级别的大小为 n 的@BatchSize
将在集合中加载 n 个项目(例如,书籍)。它加载了 n 个集合。Hibernate 不能截断集合(当我们讨论JOIN FETCH
的分页时,会在第 97 项中解决这个问题)。
类/实体级别的@BatchSize
查看Author
实体源代码:
@Entity
@BatchSize(size = 3)
public class Author implements Serializable {
...
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();
...
}
用@BatchSize(size = 3)
对Author
实体进行了注释。这意味着当获取一本书时,Hibernate 应该初始化三个引用的authors
。换句话说,如果我们遍历所有的书,并且在没有@BatchSize
存在的情况下对每本书调用getAuthor()
,那么 Hibernate 将执行四个SELECT
语句来检索被代理的所有者(有七本书,但是其中一些书有相同的作者,因此某些SELECT
语句将命中持久上下文而不是数据库)。在Author
实体级存在@BatchSize
的情况下执行相同的操作,将导致两个SELECT
语句。
在检查一些 SQL 语句之前,让我们考虑下面的服务方法,它在实体级利用了@BatchSize
:
@Transactional(readOnly = true)
public void displayBooksAndAuthors() {
List<Book> books = bookRepository.findAll();
for (Book book : books) {
System.out.println("Book: " + book.getTitle());
System.out.println("Author: " + book.getAuthor());
}
}
这个方法通过一个SELECT
查询获取所有的Book
实体。此外,调用第一个Book
实体的getAuthor()
方法将触发另一个SELECT
查询,该查询初始化前一个SELECT
查询返回的前三个Book
实体的关联。这就是@BatchSize
在实体层面的效果。
因此,第一个SELECT
获取所有的Book
:
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_
此外,调用第一个Book
的getAuthor(
将触发下面的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 IN (?, ?, ?)
Hibernate 使用一个IN
子句有效地引用三个实体作者的标识符(这是批处理的大小)。输出如下所示:
Book: A History of Ancient Prague
Author: Author{id=4, name=Joana Nimar, genre=History, age=34}
Book: A People's History
Author: Author{id=4, name=Joana Nimar, genre=History, age=34}
Book: History Day
Author: Author{id=4, name=Joana Nimar, genre=History, age=34}
Book: The Beatles Anthology
Author: Author{id=1, name=Mark Janel, genre=Anthology, age=23}
Book: Carrie
Author: Author{id=2, name=Olivia Goy, genre=Horror, age=43}
Book: House Of Pain
Author: Author{id=2, name=Olivia Goy, genre=Horror, age=43}
到达下一本书, 选集】2000 ,将需要一个新的SELECT
用于下一批Author
s。没有足够的数据来填充另一批Author
,因此不需要IN
子句:
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 = ?
输出如下所示:
Book: Anthology 2000
Author: Author{id=5, name=Alicia Tom, genre=Anthology, age=38}
一般来说,注意在大小为 n 的集合级别使用@BatchSize
一次将初始化多达 n 个惰性集合。另一方面,在实体级使用大小为 n 的@BatchSize
将一次初始化多达 n 个惰性实体代理。
显然,批量加载关联的实体比一个一个地加载要好(这样,可以避免潜在的 N+1 问题)。然而,在使用@BatchSize
之前,准备好反对在您的特定情况下使用 SQL JOIN
、JPA JOIN FETCH
或实体图的论据。
完整的应用可在 GitHub 25 上获得。
第 55 项:为什么在通过 Hibernate 批处理插入时避免 PostgreSQL (BIG)SERIAL
在 PostgreSQL 中,使用GenerationType.IDENTITY
将禁用 Hibernate 插入批处理。(BIG
) SERIAL
的行为“几乎”像 MySQL 的AUTO_INCREMENT
。换句话说,当使用插入批处理时,要避免以下情况:
@Entity
public class Author implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
}
PostgreSQL 的(BIG
) SERIAL
是一个用于模拟标识列的语法糖表达式。在这个仿真的背后,PostgreSQL 使用了一个数据库序列。
这个问题的一个解决方案是依靠GenerationType.AUTO
。在 PostgreSQL 中,GenerationType.AUTO
设置会拾取SEQUENCE
生成器;因此,批处理插入将按预期工作。以下代码工作正常:
@Entity
public class Author implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
...
}
优化标识符提取过程
这一次,批处理插入机制工作正常,但是对于每次插入,Hibernate 必须在一次单独的数据库往返中获取它的标识符。如果在一个批处理中有 30 个插入,则需要 30 次数据库往返来获取 30 个标识符,如下例所示:
select nextval ('hibernate_sequence')
select nextval ('hibernate_sequence')
-- 28 more
...
insert into author (age, genre, name, id) values (?, ?, ?, ?)
insert into author (age, genre, name, id) values (?, ?, ?, ?)
...
通常,当插入数量相当大时(例如,10,000 个插入),使用批处理插入。对于 10,000 次插入,有 10,000 次额外的数据库往返,这意味着性能损失。您可以通过 hi/lo 算法消除这种性能损失,该算法可以生成内存中的标识符( Item 66 )。更好的是伟大的 pooled 或 pooled-lo 算法(第 67 项)。您可以采用高/低算法,如下所示:
@Entity
public class Author implements Serializable {
@Id
@GeneratedValue(
strategy = GenerationType.SEQUENCE,
generator = "hilo"
)
@GenericGenerator(
name = "hilo",
strategy = "org.hibernate.id.enhanced.SequenceStyleGenerator",
parameters = {
@Parameter(name = "sequence_name", value = "hilo_sequence"),
@Parameter(name = "initial_value", value = "1"),
@Parameter(name = "increment_size", value = "1000"),
@Parameter(name = "optimizer", value = "hilo")
}
)
private Long id;
...
}
这一次,1,000 的增量意味着 hi/lo 可以生成 1,000 个内存中标识符。因此,对于 10,000 次插入,只需要 10 次数据库往返来获取标识符。显然,您可以通过调整increment_size
来进一步优化这一点。
通过重写批处理插入优化批处理
Item 46 介绍了针对 MySQL 的reWriteBatchedInserts
优化,并表示这种优化也可以用于 PostgreSQL。启用该属性后,SQL 语句将被重写到单个字符串缓冲区中,并在单个请求中发送到数据库。
在依赖 HikariCP 的 Spring Boot 应用中,您可以通过application.properties
设置reWriteBatchedInserts
:
spring.datasource.hikari.data-source-properties.reWriteBatchedInserts=true
该设置也可以通过编程实现:
PGSimpleDataSource ds = ...;
ds.setReWriteBatchedInserts(true);
完整的应用可在 GitHub 26 上获得。
**五、集合
第 56 项:如何联接获取@ElementCollection 集合
特别是当定义一个单向的一对多关联到一个Basic
类型(例如String
)或者Embeddable
类型时,JPA 有一个简单的解决方案,就是@ElementCollection
。这些类型被映射到一个单独的表中,可以通过@CollectionTable
进行定制。假设一个网上书店购物车通过ShoppingCart
实体映射,可嵌入的Book
通过@ElementCollection
映射,如图 5-1 所示。
图 5-1
@ElementCollection 表关系
相关部分是@ElementCollection
映射:
@Entity
public class ShoppingCart implements Serializable {
...
@ElementCollection(fetch = FetchType.LAZY) // lazy is default
@CollectionTable(name = "shopping_cart_books",
joinColumns = @JoinColumn(name = "shopping_cart_id"))
private List<Book> books = new ArrayList<>();
...
}
默认情况下,books
是延迟加载的。有时,建模某些功能需求可能需要程序急切地获取books
属性。显然,在实体级切换到FechType.EAGER
是必须避免的代码气味。
解决方案来自于JOIN FETCH
,它可以像用于关联一样用于@ElementCollection
。换句话说,下面两个 JPQL 查询使用JOIN FETCH
在获取ShoppingCart
的同一个SELECT
中获取books
:
@Repository
public interface ShoppingCartRepository
extends JpaRepository<ShoppingCart, Long> {
@Query(value = "SELECT p FROM ShoppingCart p JOIN FETCH p.books")
ShoppingCart fetchShoppingCart();
@Query(value = "SELECT p FROM ShoppingCart p
JOIN FETCH p.books b WHERE b.price > ?1")
ShoppingCart fetchShoppingCartByPrice(int price);
}
调用fetchShoppingCart()
将触发下面的 SQL:
SELECT
shoppingca0_.id AS id1_1_,
shoppingca0_.owner AS owner2_1_,
books1_.shopping_cart_id AS shopping1_0_0__,
books1_.genre AS genre2_0_0__,
books1_.isbn AS isbn3_0_0__,
books1_.price AS price4_0_0__,
books1_.title AS title5_0_0__
FROM shopping_cart shoppingca0_
INNER JOIN shopping_cart_books books1_
ON shoppingca0_.id = books1_.shopping_cart_id
调用fetchShoppingCartByPrice()
将触发下面的 SQL:
SELECT
shoppingca0_.id AS id1_1_,
shoppingca0_.owner AS owner2_1_,
books1_.shopping_cart_id AS shopping1_0_0__,
books1_.genre AS genre2_0_0__,
books1_.isbn AS isbn3_0_0__,
books1_.price AS price4_0_0__,
books1_.title AS title5_0_0__
FROM shopping_cart shoppingca0_
INNER JOIN shopping_cart_books books1_
ON shoppingca0_.id = books1_.shopping_cart_id
WHERE books1_.price > ?
GitHub 1 上有源代码。
第 57 项:如何 DTO 一个@ElementCollection
此项假设网上书店购物车通过ShoppingCart
实体映射,可嵌入的Book
通过@ElementCollection
映射,如图 5-2 所示。
图 5-2
@ElementCollection 表关系
相关部分是@ElementCollection
映射:
@Entity
public class ShoppingCart implements Serializable {
...
@ElementCollection(fetch = FetchType.LAZY) // lazy is default
@CollectionTable(name = "shopping_cart_books",
joinColumns = @JoinColumn(name = "shopping_cart_id"))
private List<Book> books = new ArrayList<>();
...
}
此外,目标是获取一个只读数据的结果集,其中包含来自shopping_cart
的owner
,以及来自shopping_cart_books
的title
和price
(收集表)。因为它是只读数据,一个JOIN
和 DTO 将完成这项工作。由于JOIN
和弹簧投影对于@ElementCollection
工作良好,解决方案依赖于以下投影:
public interface ShoppingCartDto {
public String getOwner();
public String getTitle();
public int getPrice();
}
该投影可以在存储库中进一步使用,如下所示:
@Repository
public interface ShoppingCartRepository
extends JpaRepository<ShoppingCart, Long> {
@Query(value = "SELECT a.owner AS owner, b.title AS title,
b.price AS price FROM ShoppingCart a JOIN a.books b")
List<ShoppingCartDto> fetchShoppingCart();
@Query(value = "SELECT a.owner AS owner, b.title AS title,
b.price AS price FROM ShoppingCart a JOIN a.books b
WHERE b.price > ?1")
List<ShoppingCartDto> fetchShoppingCartByPrice(int price);
}
调用fetchShoppingCart()
将触发下面的 SQL(注意只选择了owner
、title
和price
):
SELECT
shoppingca0_.owner AS col_0_0_,
books1_.title AS col_1_0_,
books1_.price AS col_2_0_
FROM shopping_cart shoppingca0_
INNER JOIN shopping_cart_books books1_
ON shoppingca0_.id = books1_.shopping_cart_id
调用fetchShoppingCartByPrice()
将触发下面的 SQL:
SELECT
shoppingca0_.owner AS col_0_0_,
books1_.title AS col_1_0_,
books1_.price AS col_2_0_
FROM shopping_cart shoppingca0_
INNER JOIN shopping_cart_books books1_
ON shoppingca0_.id = books1_.shopping_cart_id
WHERE books1_.price > ?
注意@ElementCollection
不是实体关联类型,即使你可能这么认为。主要是,你会在下一项看到,@ElementCollection
充当单向@OneToMany
( 项 2 )。因此,它遭受同样的性能损失。最佳实践建议您使用@ElementCollection
来表示基本类型(例如,整数或字符串)或可嵌入类型,而不是实体类。
GitHub 2 上有源代码。
第 58 项:为什么以及何时将@OrderColumn 与@ElementCollection 一起使用
这个项目假设一个在线书店购物车是通过ShoppingCart
实体映射的,可嵌入的Book
是通过@ElementCollection
映射的,如下面的代码所示:
@Entity
public class ShoppingCart implements Serializable {
...
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String owner;
@ElementCollection
@CollectionTable(name = "shopping_cart_books",
joinColumns = @JoinColumn(name = "shopping_cart_id"))
@Column(name="title")
private List<String> books = new ArrayList<>();
// getters and setters omitted for brevity
}
该实体通过两个表进行映射(shopping_cart
和shopping_cart_books
)。图 5-3 表示数据的快照(基本上,有一个购物车,里面有三本书)。
图 5-3
数据快照(@ElementCollection)
该实体的存储库包含一个通过所有者名称获取ShoppingCart
的查询:
@Repository
@Transactional(readOnly = true)
public interface ShoppingCartRepository
extends JpaRepository<ShoppingCart, Long> {
ShoppingCart findByOwner(String owner);
}
此外,应用运行几个查询(三个INSERT
和三个DELETE
)来:
-
将一本书添加到当前购物车的开头
-
将一本书添加到当前购物车的末尾
-
将一本书添加到当前购物车的中间
-
从购物车中取出第一本书
-
从购物车中取出最后一本书
-
从推车中取出中间的书
以下每个场景都从图 5-3 中的数据快照开始。
为了将一本新书添加到当前购物车中(INSERT
a book),Hibernate 需要删除shopping_cart_books
中的所有内容,然后重新插入值,包括新书。例如,下面的方法将在books
的开头添加一本新书:
@Transactional
public void addToTheBeginning() {
ShoppingCart cart = shoppingCartRepository.findByOwner("Mark Juno");
cart.getBooks().add(0, "Modern history");
}
调用此方法将产生以下 SQL 语句套件。第一,把所有的书都删掉;第二,它们被重新插入,包括新书:
DELETE FROM shopping_cart_books
WHERE shopping_cart_id = ?
Binding: [1]
INSERT INTO shopping_cart_books (shopping_cart_id, title)
VALUES (?, ?)
Binding: [1, Modern history]
INSERT INTO shopping_cart_books (shopping_cart_id, title)
VALUES (?, ?)
Binding: [1, A History of Ancient Prague]
INSERT INTO shopping_cart_books (shopping_cart_id, title)
VALUES (?, ?)
Binding: [1, Carrie]
INSERT INTO shopping_cart_books (shopping_cart_id, title)
VALUES (?, ?)
Binding: [1, The Beatles Anthology]
每个INSERT
必须从@CollectionTable
中删除Entity
的所有记录,然后重新插入。
类似地,以下在末尾和中间插入一本书的尝试将产生一串 SQL 语句,如前所述:
@Transactional
public void addToTheEnd() {
ShoppingCart cart = shoppingCartRepository.findByOwner("Mark Juno");
cart.getBooks().add("The last day");
}
@Transactional
public void addInTheMiddle() {
ShoppingCart cart = shoppingCartRepository.findByOwner("Mark Juno");
cart.getBooks().add(cart.getBooks().size() / 2, "Middle man");
}
从books
中删除一本书也没有效率。与INSERT
的情况一样,每次删除都需要删除shopping_cart_books
中的所有内容,然后重新插入所有值。例如,以下方法将删除第一本书:
@Transactional
public void removeFirst() {
ShoppingCart cart = shoppingCartRepository.findByOwner("Mark Juno");
cart.getBooks().remove(0);
}
调用此方法将产生以下 SQL 语句套件。第一,把所有的书都删掉;二是全部重新插入,删除的书除外:
DELETE FROM shopping_cart_books
WHERE shopping_cart_id = ?
Binding: [1]
INSERT INTO shopping_cart_books (shopping_cart_id, title)
VALUES (?, ?)
Binding: [1, Carrie]
INSERT INTO shopping_cart_books (shopping_cart_id, title)
VALUES (?, ?)
Binding: [1, The Beatles Anthology]
每个DELETE
必须从@CollectionTable
中删除Entity
的所有记录,然后重新插入。
类似地,以下从末尾和中间删除一本书的尝试将产生一串 SQL 语句,如您之前所见:
@Transactional
public void removeLast() {
ShoppingCart cart = shoppingCartRepository.findByOwner("Mark Juno");
cart.getBooks().remove(cart.getBooks().size() - 1);
}
@Transactional
public void removeMiddle() {
ShoppingCart cart = shoppingCartRepository.findByOwner("Mark Juno");
cart.getBooks().remove(cart.getBooks().size() / 2);
}
需要频繁更新的集合会导致明显的性能损失。最好依靠显式的一对多关联。另一方面,需要很少(或不需要)更新的集合是@ElementCollection
的一个很好的候选,因为它不代表外键方。
GitHub 3 上有源代码。
通过@OrderColumn 优化@ElementCollection
一个@OrderColumn
可以用来在任何集合映射上定义一个顺序List
。将@OrderColumn
添加到@ElementCollection
是在某些INSERT
和DELETE
中反映的优化。相关代码修改如下:
@Entity
public class ShoppingCart implements Serializable {
...
@ElementCollection
@OrderColumn(name = "index_no")
@CollectionTable(name = "shopping_cart_books",
joinColumns = @JoinColumn(name = "shopping_cart_id"))
@Column(name="title")
private List<String> books = new ArrayList<>();
...
}
@OrderColumn
的存在反映在shopping_cart_books
表中的一个新列(index_no
)中,如图 5-4 所示。
图 5-4
数据快照(@ElementCollection 和@OrderColumn)
因此,为了惟一地标识每一行,@OrderColumn
在目标表中被映射为一个新列。现在,让我们看看@OrderColumn
如何优化@ElementCollection
。以下每个场景都从图 5-4 所示的数据快照开始。
将一本书添加到当前购物车的开头
将一本书(现代史)添加到当前购物车的开头将触发以下 SQL 语句(在每个 SQL 语句下是一个绑定参数列表):
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [Modern History, 1, 0]
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [A History of Ancient Prague, 1, 1]
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [Carrie, 1, 2]
INSERT INTO shopping_cart_books (shopping_cart_id, index_no, title)
VALUES (?, ?, ?)
Binding: [1, 3, The Beatles Anthology]
将一本新书添加到books
(在索引 0 处)的开头会将现有书籍向下推一个位置。这发生在内存中,并通过一组UPDATE
语句刷新到数据库中。每个现有的行都有一个相应的UPDATE
语句。最后,在这些更新完成后,最后一本书通过一个INSERT
语句被重新插入。图 5-5 为插近代史书前后的shopping_cart_books
台(左侧)。
图 5-5
在开头插入(@ElementCollection 和@OrderColumn)
没有@OrderColumn
,应用触发了五条 SQL 语句(一条DELETE
和四条INSERT
)。通过@OrderColumn
,应用触发了四条 SQL 语句(三条UPDATE
和一条INSERT
)。
将一本书添加到当前购物车的末尾
将一本书(最后一天)添加到当前购物车的末尾将触发以下 SQL 语句:
INSERT INTO shopping_cart_books (shopping_cart_id, index_no, title)
VALUES (?, ?, ?)
Binding: [1, 3, The Last Day]
添加到集合的末尾不会影响它的顺序;因此,单个INSERT
就可以完成这项工作。这比没有@OrderColumn
的情况好多了。
没有@OrderColumn
,应用触发了五条 SQL 语句(一条DELETE
和四条INSERT
)。通过@OrderColumn
,应用触发了一个INSERT
语句。
将一本书添加到当前购物车的中间
将一本书(中间人)添加到当前购物车的中间将触发以下 SQL 语句:
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [Middle Man, 1, 1]
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [Carrie, 1, 2]
INSERT INTO shopping_cart_books (shopping_cart_id, index_no, title)
VALUES (?, ?, ?)
Binding: [1, 3, The Beatles Anthology]
将一本新书添加到books
的中间会将位于集合中间和结尾之间的所有现有书籍向下推一个位置。这发生在内存中,并通过一组UPDATE
语句刷新到数据库中。每一行都有一个相应的UPDATE
语句。最后,最后一本书通过一个INSERT
语句被重新插入。图 5-6 为插入中间人书之前(左侧)和之后(右侧)的shopping_cart_books
工作台。
图 5-6
在中间插入(@ElementCollection 和@OrderColumn)
没有@OrderColumn
,应用触发了五条 SQL 语句(一条DELETE
和四条INSERT
)。通过@OrderColumn
,应用触发了三个 SQL 语句(两个UPDATE
和一个INSERT
)。
从当前购物车中移除第一本书
从当前购物车中删除第一本书( A History of 古布拉格)将触发以下 SQL 语句:
DELETE FROM shopping_cart_books
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [1, 2]
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [The Beatles Anthology, 1, 1]
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [Carrie, 1, 0]
从books
(索引 0 处)移除第一本书会将所有现有的书上移一个位置。这发生在内存中,并通过一组在通过DELETE
语句删除最后一行后触发的UPDATE
语句刷新到数据库中。图 5-7 为删除古布拉格历史书前后的shopping_cart_books
表(左侧)。
图 5-7
移除第一本书(@ElementCollection 和@OrderColumn)
没有@OrderColumn
,应用触发了三个 SQL 语句(一个DELETE
和两个INSERT
)。使用@OrderColumn
,应用也触发了三条 SQL 语句(一条DELETE
和两条UPDATE
)。
从当前购物车中移除最后一本书
从当前购物车中删除最后一本书( The Beatles 选集)将触发以下 SQL 语句:
DELETE FROM shopping_cart_books
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [1, 2]
从收藏的末尾删除一本书不会影响它的顺序;因此,单个DELETE
就可以完成这项工作。这比没有@OrderColumn
的情况好多了。
没有@OrderColumn
,应用触发了三个 SQL 语句(一个DELETE
和两个INSERT
)。通过@OrderColumn
,应用触发了一个DELETE
语句。
从当前购物车中取出一本书
从当前购物车中间移除一本书( Carrie )将触发以下 SQL 语句:
DELETE FROM shopping_cart_books
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [1, 2]
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [The Beatles Anthology, 1, 1]
从books
的中间移除一本书会将位于集合的中间和末端之间的现有书向上推一个位置。这发生在内存中,并通过一个DELETE
和一组UPDATE
语句刷新到数据库中。首先,删除最后一行。第二,位于表的末端和中间的每一行都被更新。图 5-8 为移除载体书之前(左侧)和之后(右侧)的shopping_cart_books
工作台。
图 5-8
从中间移除(@ElementCollection 和@OrderColumn)
没有@OrderColumn
,应用触发了三个 SQL 语句(一个DELETE
和两个INSERT
)。使用@OrderColumn
,应用也触发了两条 SQL 语句(一条DELETE
和一条UPDATE
)。
最后的结论是,当操作发生在集合末尾附近时,@OrderColumn
可以减轻一些性能损失(例如,在集合末尾添加/删除)。位于添加/移除条目之前的所有元素基本上保持不变,因此如果应用影响靠近集合尾部的行,性能损失可以忽略。
根据经验,当数据变化很少,并且添加新实体的目的只是映射外键侧时,元素集合是一个合适的选择。否则,一对多关联是更好的选择。
注意,单向@OneToMany
和@ManyToMany
以及双向@ManyToMany
与@ElementCollection
属于同一把伞。
GitHub 4 上有源代码。
第 59 项:如何合并实体集合
此项说明了合并实体集合的一种好方法。
首先,假设Author
和Book
参与了一个双向懒惰的@OneToMany
关联。领域模型如图 5-9 所示。
图 5-9
双向@一对一关系
在代码中,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;
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();
public void addBook(Book book) {
this.books.add(book);
book.setAuthor(this);
}
public void removeBook(Book book) {
book.setAuthor(null);
this.books.remove(book);
}
// getters and setters omitted for brevity
}
并且Book
实体看起来如下:
@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")
private Author author;
// getters and setters omitted for brevity
@Override
public boolean equals(Object obj) {
if(obj == null) {
return false;
}
if (this == obj) {
return true;
}
if (getClass() != obj.getClass()) {
return false;
}
return id != null && id.equals(((Book) obj).id);
}
@Override
public int hashCode() {
return 2021;
}
}
数据库已经填充了图 5-10 中的实体。
图 5-10
数据快照(合并前)
现在,让我们获取与给定的Author
记录相关联的Book
个实体中的List
(例如, Joana Nimar )。通过一个JOIN
可以很容易地获取Author
和一个关联的Book
,如下所示:
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
@Query(value = "SELECT b FROM Book b JOIN b.author a WHERE a.name = ?1")
List<Book> booksOfAuthor(String name);
}
调用booksOfAuthor("Joana Nimar")
将触发以下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_
INNER JOIN author author1_
ON book0_.author_id = author1_.id
WHERE author1_.name = ?
这个SELECT
返回的List<Book>
包含三本书。
此时,List<Book>
处于脱离状态;因此,让我们将它存储在一个名为detachedBooks
的变量中:
Book{id=1, title=A History of Ancient Prague, isbn=001-JN}
Book{id=2, title=A People's History, isbn=002-JN}
Book{id=4, title=Carrie, isbn=007-JN}
接下来,让我们对该集合执行以下修改(因为该集合处于分离状态,所以修改不会自动传播到数据库):
-
将第一本书的书名从古布拉格史更新为古罗马史:
-
取出第二本书:
detachedBooks.get(0).setTitle("A History of Ancient Rome");
- 添加新书(100 分钟内的历史):
detachedBooks.remove(1);
Book book = new Book();
book.setTitle("History In 100 Minutes");
book.setIsbn("005-JN");
detachedBooks.add(book);
显示修改后的detachedBooks
集合会显示以下内容(查看最后一本新书,它有一个null
id):
Book{id=1, title=A History of Ancient Rome, isbn=001-JN}
Book{id=4, title=Carrie, isbn=007-JN}
Book{id=null, title=History In 100 Minutes, isbn=005-JN}
合并分离的集合
这一项的最后一步是使用尽可能少的数据库往返次数来合并分离的集合。首先,开发者必须获取Author
和关联的Book
。这可以通过JOIN FETCH
轻松完成:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query(value="SELECT a FROM Author a JOIN FETCH a.books
WHERE a.name = ?1")
Author authorAndBooks(String name);
}
调用authorAndBooks()
触发下面的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_
INNER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.name = ?
考虑返回的Author
存储在名为author
的变量中。
接下来,让我们将detachedBooks
设置为author
!首先,让我们快速消除不好的方法。
混合管理的和分离的实体是一种导致错误的糟糕组合。所以,试图以author.setBooks(detachedBooks)
的身份去做某件事,根本行不通。另一方面,分离作者,设置detachedBooks
,然后合并作者也可以,但是会导致合并过程产生额外的SELECT
查询。这个额外的SELECT
可以通过使用手动合并来避免。
手动合并需要三个步骤:
-
移除传入集合中不再存在的现有数据库行(
detachedBooks
)。首先,过滤掉不在detachedBooks
的author
的书。第二,每一本在detachedBooks
找不到的author
书都要撤下如下: -
更新在传入集合中找到的现有数据库行(
detachedBooks
)。首先过滤新书(newBooks
)。这些书在detachedBooks
有,但在author
书里没有。第二步,过滤detachedBooks
,得到在detachedBooks
而不在newBooks
的书籍。这些是应该更新的书籍,如下所示:
List<Book> booksToRemove = author.getBooks().stream()
.filter(b -> !detachedBooks.contains(b))
.collect(Collectors.toList());
booksToRemove.forEach(b -> author.removeBook(b));
- 最后,添加在传入集合中找到的、在当前结果集中找不到的行(
newBooks
):
List<Book> newBooks = detachedBooks.stream()
.filter(b -> !author.getBooks().contains(b))
.collect(Collectors.toList());
detachedBooks.stream()
.filter(b -> !newBooks.contains(b))
.forEach((b) -> {
b.setAuthor(author);
Book mergedBook = bookRepository.save(b);
author.getBooks().set(
author.getBooks().indexOf(mergedBook), mergedBook);
});
newBooks.forEach(b -> author.addBook(b));
将这三个步骤粘合在一个服务方法中会产生以下结果:
@Transactional
public void updateBooksOfAuthor(String name, List<Book> detachedBooks) {
Author author = authorRepository.authorAndBooks(name);
// Remove the existing database rows that are no
// longer found in the incoming collection (detachedBooks)
List<Book> booksToRemove = author.getBooks().stream()
.filter(b -> !detachedBooks.contains(b))
.collect(Collectors.toList());
booksToRemove .forEach(b -> author.removeBook(b));
// Update the existing database rows which can be found
// in the incoming collection (detachedBooks)
List<Book> newBooks = detachedBooks.stream()
.filter(b -> !author.getBooks().contains(b))
.collect(Collectors.toList());
detachedBooks.stream()
.filter(b -> !newBooks.contains(b))
.forEach((b) -> {
b.setAuthor(author);
Book mergedBook = bookRepository.save(b);
author.getBooks().set(
author.getBooks().indexOf(mergedBook), mergedBook);
});
// Add the rows found in the incoming collection,
// which cannot be found in the current database snapshot
newBooks.forEach(b -> author.addBook(b));
}
测试时间
调用updateBooksOfAuthor()
可以如下进行:
updateBooksOfAuthor("Joana Nimar", detachedBooks);
除了获取作者和相关书籍的SELECT
之外,触发的 SQL 语句有:
INSERT INTO book (author_id, isbn, title)
VALUES (?, ?, ?)
Binding: [2, 005-JN, History In 100 Minutes]
UPDATE book
SET author_id = ?,
isbn = ?,
title = ?
WHERE id = ?
Binding: [2, 001-JN, A History of Ancient Rome, 1]
DELETE FROM book
WHERE id = ?
Binding: [2]
图 5-11 显示了数据的当前快照。
图 5-11
数据快照(合并后)
搞定了。完整的代码可以在 GitHub 5 上找到。
你可能会认为这个案例是 ?? 的一个角落案例。获取子实体集合并独立于关联的父实体使用它们不是一项日常任务。更常见的是获取父实体和相关联的子实体集合,修改处于分离状态的集合,并合并父实体。在这种情况下,将使用CascadeType.ALL
,产生的 SQL 语句与您预期的完全一样。
六、连接和事务
第 60 项:如何将连接获取延迟到真正需要的时候
从 Hibernate 5.2.10 开始,数据库连接获取可以推迟到真正需要的时候。
关于 Spring 事务传播的高超指南,请查看附录 G 。
在资源本地(单个数据源)的情况下,Hibernate 将在事务开始后立即获取 JDBC 事务的数据库连接(例如,在 Spring 中,用@Transactional
标注的方法在被调用后立即获取数据库连接)。
在资源本地中,因为 Hibernate 需要检查 JDBC Connection
自动提交状态,所以会立即获得一个数据库连接。如果这是true
,那么 Hibernate 将禁用它。
实际上,在当前事务的第一个 JDBC 语句被触发之前,数据库连接对应用是无用的;如果在第一个 JDBC 语句之前有许多或/和耗时的任务,在这段时间内保持数据库连接不使用会导致性能下降,这会产生很大的影响。
为了防止这种性能损失,您可以通知 Hibernate 您禁用了自动提交,因此不需要检查。为此,请遵循以下两个步骤:
-
关闭自动提交。例如,检查类型为
setAutoCommit(boolean commit)
的方法的池连接,并将其设置为false
,例如HikariConfiguartion#setAutoCommit(false)
。 -
将特定于 Hibernate 的属性
hibernate.connection.provider_disables_autocommit
设置为true
默认情况下,Spring Boot 依赖 HikariCP,您可以通过spring.datasource.hikari.auto-commit
属性关闭application.properties
中的自动提交。因此,需要将以下两个设置添加到application.properties
:
spring.datasource.hikari.auto-commit=false
spring.jpa.properties.hibernate.connection.provider_disables_autocommit=true
根据经验,对于资源本地 JPA 事务,配置连接池(例如 HikariCP)来禁用自动提交并将hibernate.connection.provider_disables_autocommit
设置为true
总是一个好的做法。所以,在你所有的应用中使用资源本地吧!
注意不要将hibernate.connection.provider_disables_autocommit
设置为true
,然后忘记禁用自动提交模式!Hibernate 也不会禁用!这意味着每个 SQL 语句都将在自动提交模式下执行,没有工作单元事务可用。
要查看连接获取是如何延迟的,请考虑以下方法,该方法旨在隔离从 HikariCP 连接池中获取连接时的主时隙。考虑阅读这个方法的注释,因为它们解释了正在发生的事情:
@Transactional
public void doTimeConsumingTask() throws InterruptedException {
System.out.println("Waiting for a time-consuming
task that doesn't need a database connection ...");
// we use a sleep of 40 seconds just to capture HikariCP logging status
// which take place at every 30 seconds - this will reveal if
// the connection was opened (acquired from the connection pool) or not
Thread.sleep(40000);
System.out.println("Done, now query the database ...");
System.out.println("The database connection should be acquired now ...");
Author author = authorRepository.findById(1L).get();
// at this point, the connection should be open
Thread.sleep(40000);
author.setAge(44);
}
在不延迟连接获取的情况下调用该方法将显示如图 6-1 所示的输出(连接被立即获取并保持打开,直到第一个 SQL 被触发)。
图 6-1
立即获取连接
在启用连接获取的情况下调用相同的方法将会发现,就在第一个 SQL 被触发之前获取了连接。同时,这个连接可以被另一个线程使用,如图 6-2 所示。
图 6-2
延迟连接获取
GitHub 1 上有源代码。
第 61 项:@Transactional(readOnly=true)的实际工作原理
考虑带有id age
、name
和genre
字段的Author
实体。
接下来,使用传统的AuthorRepository
和BookstoreService
实体,您可以通过genre
快速加载第一个Author
,如下所示:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
Author findFirstByGenre(String genre);
}
@Service
public class BookstoreService {
public void fetchAuthor() {
Author author = authorRepository.findFirstByGenre("Anthology");
}
}
但是,这里缺少了什么吗?!是的,没有事务上下文。findFirstByGenre()
方法必须包装在事务上下文中;所以,你要考虑@Transactional
。
通过@Transactional
,您明确划分了数据库事务边界,并确保一个数据库连接将用于整个数据库事务持续时间。所有 SQL 语句都将使用这个隔离连接,并且都将在相同的持久性上下文范围内运行。
一般来说,JPA 不会对读操作强制执行事务(它只是通过抛出一个有意义的异常来对写操作强制执行事务),但这意味着:
-
您允许自动提交模式控制数据访问的行为(此行为可能会因 JDBC 驱动程序、数据库以及连接池的实现和设置而有所不同)。
-
一般来说,如果自动提交被设置为
true
,那么每个 SQL 语句将必须在单独的物理数据库事务中执行,这可能意味着每个语句有不同的连接(例如,在不支持每线程连接的环境中,具有两个SELECT
语句的方法需要两个物理数据库事务和两个单独的数据库连接)。每个 SQL 语句在执行后都会自动提交。 -
显式设置事务隔离级别可能会导致意外行为。
-
将 auto-commit 设置为
true
只有在您执行一条只读 SQL 语句时才有意义(就像我们上面做的那样),但是它不会带来任何显著的好处。因此,即使在这种情况下,最好还是依赖显式(声明性)事务。根据经验,使用显式(声明性)事务,甚至是只读语句(例如
SELECT
)来定义适当的事务上下文。非事务上下文指的是没有明确事务边界的上下文,不是指的是没有物理数据库事务的上下文。所有数据库语句都在物理数据库事务的上下文中执行。通过省略显式事务边界(事务上下文、begin/commit/rollback ),您至少会使应用面临以下对性能有影响的缺点: -
默认情况下,Hibernate 无论如何都会关闭
autocommit
模式(autocommit=false
),并打开一个 JDBC 事务。SQL 语句在这个 JDBC 事务内部运行,然后 Hibernate 关闭连接。但是它不关闭事务,事务保持未提交状态(保持挂起状态),这允许数据库供应商实现或连接池采取行动。(JDBC 规范没有为未决事务强加某种行为。例如,MySQL 在 Oracle 提交事务时回滚事务。)您不应该冒这个风险,因为根据经验,您总是必须通过提交或回滚来确保事务结束。 -
在许多小事务的情况下(在有许多并发请求的应用中很常见),为每个 SQL 语句启动和结束一个物理数据库事务意味着性能开销。
-
在非事务上下文中运行的方法很容易被开发人员为了写数据而修改。(在类/方法级别通过
@Transactional(readOnly=true)
拥有一个事务上下文充当团队成员的标志,表明不应该向该方法添加任何写操作,并且如果该标志被忽略,则阻止写操作。) -
您无法从底层数据访问层的 Spring 优化中受益(例如,flush mode 被设置为
MANUAL
,因此脏检查被跳过)。 -
您无法从针对只读事务的特定于数据库的优化中受益。
-
您没有遵循默认情况下用
@Transactional(readOnly=true)
注释的只读 Spring 内置查询方法。 -
从 Hibernate 5.2.10 开始,您可以延迟连接获取(第 60 项),这需要禁用
autocommit
。 -
没有对一组只读 SQL 语句的 ACID 支持。
意识到这些缺点(这个列表并不详尽)应该有助于您明智地在非事务性上下文和用于只读语句的经典数据库 ACID 事务之间做出决定。
好的,那么应该添加@Transactional
,但是readOnly
应该设置为false
(默认)还是true
?根据该设置,实体以读写模式或只读模式加载。除了读写模式和只读模式之间的明显区别之外,另一个主要区别发生在 Hibernate 底层。Hibernate 通过所谓的水合状态或加载状态来完成在持久性上下文中加载实体。水合是将取出的数据库结果集物化为一个Object[]
的过程。实体在持久性上下文中被具体化。接下来会发生什么取决于读取模式:
-
读写模式:在这种模式下,实体及其水合状态在持久性上下文中都是可用的。它们在持久性上下文生命周期内(直到持久性上下文关闭)或者直到实体被分离时都是可用的。水合状态是脏检查机制、无版本乐观锁定机制和二级高速缓存所需要的。污垢检查机制利用了冲洗时的水合状态(如果您需要复习一下冲洗是如何工作的,请参考附录 H )。它只是将当前实体的状态与相应的水合状态进行比较,如果它们不相同,Hibernate 就会触发适当的
UPDATE
语句。无版本乐观锁定机制利用水合状态来构建过滤谓词的WHERE
子句。二级高速缓存通过分解的水合状态表示高速缓存条目。在读写模式下,实体具有MANAGED
状态。 -
只读模式:在这种模式下,水合状态被从内存中丢弃,只有实体被保存在持久性上下文中(这些是只读实体)。显然,这意味着自动脏检查和无版本乐观锁定机制被禁用。在只读模式下,实体具有
READ_ONLY
状态。此外,没有自动冲洗,因为 Spring Boot 将冲洗模式设置为MANUAL
。只有当 Spring 版本是 5.1 或更高版本并且您使用了
@Transactional(readOnly=true)
时,只读模式才会以这种方式运行。或者,如果通过@QueryHint
、Session.setDefaultReadOnly(true)
或org.hibernate.readOnly
设置只读模式,JPA 查询提示如下:
// @QueryHint in repository at query-level
@QueryHints(value = {
@QueryHint(
name = org.hibernate.jpa.QueryHints.HINT_READONLY, value = "true")
})
// setDefaultReadOnly
Session session = entityManager.unwrap(Session.class);
session.setDefaultReadOnly(true);
// JPA hint
List<Foo> foo = entityManager.createQuery("", Foo.class)
.setHint(QueryHints.HINT_READONLY, true).getResultList();
在 5.1 之前的版本中,Spring 不会将只读模式传播到 Hibernate。因此,水合状态保留在持久上下文的记忆中。Spring 仅设置FlushType.MANUAL
,因此自动脏检查机制不会采取行动,因为没有自动冲洗时间。在内存中保持水合状态会带来性能损失(垃圾收集器必须收集这些数据)。这是至少升级到 Spring 5.1 的充分理由。
此外,让我们尝试两种读取模式,看看持久性上下文揭示了什么。下面的代码是针对 Spring Boot 2.1.4 运行的,它需要 Spring Framework 5.1.x。为了检查持久性上下文,将使用下面的 helper 方法(该方法将当前持久性上下文作为org.hibernate.engine.spi.PersistenceContext
的实例返回):
private org.hibernate.engine.spi.PersistenceContext
getPersistenceContext() {
SharedSessionContractImplementor sharedSession = entityManager.unwrap(
SharedSessionContractImplementor.class
);
return sharedSession.getPersistenceContext();
}
使用PersistenceContext
允许您探索它的 API 并检查持久性上下文内容。例如,让我们显示以下信息:
-
当前阶段(这只是在检查持久性上下文时标记时隙的字符串)
-
通过
toString()
提取的实体 -
如果持久性上下文只包含非只读实体
-
实体状态(
org.hibernate.engine.spi.Status
) -
实体的水合/负载状态
让我们将这些信息分组到一个帮助器方法中:
private void displayInformation(String phase, Author author) {
System.out.println("Phase:" + phase);
System.out.println("Entity: " + author);
org.hibernate.engine.spi.PersistenceContext
persistenceContext = getPersistenceContext();
System.out.println("Has only non read entities : "
+ persistenceContext.hasNonReadOnlyEntities());
EntityEntry entityEntry = persistenceContext.getEntry(author);
Object[] loadedState = entityEntry.getLoadedState();
Status status = entityEntry.getStatus();
System.out.println("Entity entry : " + entityEntry);
System.out.println("Status: " + status);
System.out.println("Loaded state: " + Arrays.toString(loadedState));
}
此外,将readOnly
设置为false
并运行以下服务方法(在以下示例中,我们出于测试目的强制刷新,但手动刷新是一种代码气味,应该避免):
@Transactional
public void fetchAuthorReadWriteMode() {
Author author = authorRepository.findFirstByGenre("Anthology");
displayInformation("After Fetch", author);
author.setAge(40);
displayInformation("After Update Entity", author);
// force flush - triggering manual flush is
// a code smell and should be avoided
// in this case, by default, flush will take
// place before transaction commit
authorRepository.flush();
displayInformation("After Flush", author);
}
调用fetchAuthorReadWriteMode()
会触发一个SELECT
和一个UPDATE
语句。输出如下所示:
-------------------------------------
Phase:After Fetch
Entity: Author{id=1, age=23, name=Mark Janel, genre=Anthology}
-------------------------------------
Has only non read entities : true
Entity entry : EntityEntrycom.bookstore.entity.Author#1
Status:MANAGED
Loaded state: [23, Anthology, Mark Janel]
-------------------------------------
Phase:After Update Entity
Entity: Author{id=1, age=40, name=Mark Janel, genre=Anthology}
-------------------------------------
Has only non read entities : true
Entity entry : EntityEntrycom.bookstore.entity.Author#1
Status:MANAGED
Loaded state: [23, Anthology, Mark Janel]
Hibernate: update author set age=?, genre=?, name=? where id=?
-------------------------------------
Phase:After Flush
// this flush was manually forced for the sake of testing
// by default, the flush will take place before transaction commits
Entity: Author{id=1, age=40, name=Mark Janel, genre=Anthology}
-------------------------------------
Has only non read entities : true
Entity entry : EntityEntrycom.bookstore.entity.Author#1
Status:MANAGED
Loaded state: [40, Anthology, Mark Janel]
对该输出的解释很简单。水合/加载状态保存在持久上下文中,脏检查机制在刷新时使用它来更新作者(代表您触发一个UPDATE
)。提取的实体状态为MANAGED
。
此外,将readOnly
设置为true
并运行以下服务方法:
@Transactional(readOnly = true)
public void fetchAuthorReadOnlyMode() {
...
}
调用fetchAuthorReadOnlyMode()
会触发一个单独的SELECT
语句。输出如下所示:
-------------------------------------
Phase:After Fetch
Entity: Author{id=1, age=23, name=Mark Janel, genre=Anthology}
-------------------------------------
Has only non read entities : false
Entity entry : EntityEntrycom.bookstore.entity.Author#1
Status:READ_ONLY
Loaded state: null
-------------------------------------
Phase:After Update Entity
Entity: Author{id=1, age=40, name=Mark Janel, genre=Anthology}
-------------------------------------
Has only non read entities : false
Entity entry : EntityEntrycom.bookstore.entity.Author#1
Status:READ_ONLY
Loaded state: null
-------------------------------------
Phase:After Flush
// be default, for readOnly=true, there is no flush
// this flush was manually forced for the sake of testing
Entity: Author{id=1, age=40, name=Mark Janel, genre=Anthology}
-------------------------------------
Has only non read entities : false
Entity entry : EntityEntrycom.bookstore.entity.Author#1
Status:READ_ONLY
Loaded state: null
这一次,取出Author
实体后,水合/加载状态立即被丢弃(是null
)。提取的实体处于READ_ONLY
状态,自动刷新被禁用。即使通过显式调用flush()
强制刷新,也不会使用脏检查机制,因为它被禁用(不会触发UPDATE
)。
为只读数据设置readOnly=true
是一个很好的性能优化,因为水合/负载状态被丢弃。这允许 Spring 优化底层的数据访问层操作。然而,如果您不打算修改只读数据,那么通过 DTO (Spring projection)获取这些数据仍然是一个更好的方法。
考虑以下弹簧投影:
public interface AuthorDto {
public String getName();
public int getAge();
}
和下面的查询:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
AuthorDto findTopByGenre(String genre);
}
调用findTopByGenre()
并检查持久化上下文发现持久化上下文为空:
@Transactional
public void fetchAuthorDtoReadWriteMode() {
AuthorDto authorDto = authorRepository.findTopByGenre("Anthology");
org.hibernate.engine.spi.PersistenceContext
persistenceContext = getPersistenceContext();
System.out.println("No of managed entities : "
+ persistenceContext.getNumberOfManagedEntities());
}
@Transactional(readOnly = true)
public void fetchAuthorDtoReadOnlyMode() {
AuthorDto authorDto = authorRepository.findTopByGenre("Anthology");
org.hibernate.engine.spi.PersistenceContext
persistenceContext = getPersistenceContext();
System.out.println("No of managed entities : "
+ persistenceContext.getNumberOfManagedEntities());
}
两种服务方法返回相同的结果:
No of managed entities : 0
完整的应用可在 GitHub 2 上获得。作为奖励,您可以在这个应用中获得一个事务 ID(在 MySQL 中只有读写事务获得一个 ID)。 3
项目 62:为什么 Spring 忽略@Transactional
考虑以下简单的服务:
@Service
public class BookstoreService {
private static final Logger log =
Logger.getLogger(BookstoreService.class.getName());
private final AuthorRepository authorRepository;
public BookstoreService(AuthorRepository authorRepository) {
this.authorRepository = authorRepository;
}
public void mainAuthor() {
Author author = new Author();
persistAuthor(author);
notifyAuthor(author);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
private long persistAuthor(Author author) {
authorRepository.save(author);
return authorRepository.count();
}
private void notifyAuthor(Author author) {
log.info(() -> "Saving author: " + author);
}
}
调用mainAuthor()
方法将创建一个新的作者,持久化作者(通过persistAuthor()
),并通知他们帐户已经创建(通过notifyAuthor()
)。如您所见,persistAuthor()
方法用@Transactional
进行了注释,并且需要一个新的事务(REQUIRES_NEW
)。因此,当调用persistAuthor()
时,Spring Boot 应该启动一个新的事务,并在其中运行save()
和count()
查询方法。为了检查这个假设,让我们记录这些事务细节(添加application.properties
):
logging.level.ROOT=INFO
logging.level.org.springframework.orm.jpa=DEBUG
logging.level.org.springframework.transaction=DEBUG
# for Hibernate only
logging.level.org.hibernate.engine.transaction.internal.TransactionImpl=DEBUG
运行代码会输出以下相关行:
Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(343534938<open>)] for JPA transaction
insert into author (age, genre, name) values (?, ?, ?)
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(343534938<open>)]
Closing JPA EntityManager [SessionImpl(343534938<open>)] after transaction
Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.count]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
Opened new EntityManager [SessionImpl(940130302<open>)] for JPA transaction
select count(*) as col_0_0_ from author author0_
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(940130302<open>)]
Closing JPA EntityManager [SessionImpl(940130302<open>)] after transaction
没有将persistAuthor()
方法作为工作单元运行的事务。save()
和count()
方法在不同的事务中运行。为什么@Transactional
被忽略了?
为什么@Transactional
被忽略了?主要有两个原因:
-
@Transactional
被添加到private
、protected
或package-protected
方法中。 -
@Transactional
被添加到一个方法中,该方法定义在与它被调用的位置相同的类中。因此,根据经验,
@Transactional
只对public
方法有效,并且该方法应该被添加到一个不同于它被调用的类中。
根据这个技巧,persistAuthor()
方法可以被移动到一个助手服务中,并被标记为public
:
@Service
public class HelperService {
private final AuthorRepository authorRepository;
public HelperService(AuthorRepository authorRepository) {
this.authorRepository = authorRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long persistAuthor(Author author) {
authorRepository.save(author);
return authorRepository.count();
}
}
从BookstoreService
开始调用,如下所示:
@Service
public class BookstoreService {
private static final Logger log =
Logger.getLogger(BookstoreService.class.getName());
private final HelperService helperService;
public BookstoreService(HelperService helperService) {
this.helperService = helperService;
}
public void mainAuthor() {
Author author = new Author();
helperService.persistAuthor(author);
notifyAuthor(author);
}
private void notifyAuthor(Author author) {
log.info(() -> "Saving author: " + author);
}
}
这一次,运行代码会输出以下相关行:
Creating new transaction with name [com.bookstore.service.HelperService.persistAuthor]: PROPAGATION_REQUIRES_NEW,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(1973372401<open>)] for JPA transaction
Participating in existing transaction
insert into author (age, genre, name) values (?, ?, ?)
Participating in existing transaction
select count(*) as col_0_0_ from author author0_
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(1973372401<open>)]
Closing JPA EntityManager [SessionImpl(1973372401<open>)] after transaction
最后,事情按预期进行。@Transactional
没有被忽略。
完整的应用可在 GitHub 4 上获得。
第 63 项:如何设置和检查事务超时和到期回滚是否正常工作
Spring 支持几种显式设置事务超时的方法。最流行的方法依赖于@Transactional
注释的超时元素,如下面简单的服务方法所示:
@Transactional(timeout = 10)
public void newAuthor() {
Author author = new Author();
author.setAge(23);
author.setGenre("Anthology");
author.setName("Mark Janel");
authorRepository.saveAndFlush(author);
System.out.println("The end!");
}
在此方法中,事务超时设置为 10 秒。显然,这个简单的插入不会花这么长时间来导致事务过期。那么,你怎么知道它有效呢?一个天真的尝试将偷偷放入一个值大于事务超时的Thread.sleep()
:
@Transactional(timeout = 10)
public void newAuthor() {
Author author = new Author();
author.setAge(23);
author.setGenre("Anthology");
author.setName("Mark Janel");
authorRepository.saveAndFlush(author);
Thread.sleep(15000); // 15 seconds
System.out.println("The end!");
}
由于当前线程将事务提交延迟了 15 秒,事务在 10 秒后超时,因此您可能会看到特定于超时的异常和事务回滚。但是,这不会像预期的那样起作用;相反,事务将在 15 秒后提交。
另一种尝试可能依赖于两个并发事务。事务 A 持有排他锁的时间可以长到足以导致事务 B 超时。这是可行的,但是有一个更简单的方法。
只需将一个 SQL 查询偷偷放入使用特定于 RDBMS 的 SQL SLEEP
函数的事务服务方法中。大多数 RDBMS 都带有一种SLEEP
函数的味道。比如 MySQL 用的是SLEEP(n)
,PostgreSQL 用的是PG_SLEEP(n)
。一个SLEEP
函数将当前语句暂停一段指定的时间(SLEEP()
和PG_SLEEP()
的持续时间以秒为单位),这将暂停事务。如果它暂停事务的时间超过了事务超时时间,则事务应该过期并回滚。
以下存储库定义了一个基于SLEEP()
的查询,该查询将当前事务延迟 15 秒,而超时设置为 10 秒:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query(value = "SELECT SLEEP(15)", nativeQuery = true)
public void sleepQuery();
}
因此,通过在事务中隐藏这个查询,事务应该被延迟指定的时间:
@Transactional(timeout = 10)
public void newAuthor() {
Author author = new Author();
author.setAge(23);
author.setGenre("Anthology");
author.setName("Mark Janel");
authorRepository.saveAndFlush(author);
authorRepository.sleepQuery();
System.out.println("The end!");
}
调用newAuthor()
将运行 10 秒钟,并抛出以下特定于超时的异常:
org.springframework.dao.QueryTimeoutException
Caused by: org.hibernate.QueryTimeoutException
设置事务和查询超时
依靠@Transactional
的timeout
元素是在方法级或类级设置事务超时的一种非常方便的方式。您也可以通过application-properties
中的spring.transaction.default-timeout
属性显式设置全局超时,如下所示(您可以通过@Transactional
注释的timeout
元素覆盖全局设置):
spring.transaction.default-timeout=10
您可以通过两个提示在查询级别设置超时:
-
通过一个
org.hibernate.timeout
Hibernate 特有的提示,相当于来自org.hibernate.query.Query
的setTimeout()
(超时以秒为单位指定): -
通过
javax.persistence.query.timeout
JPA 提示,相当于来自org.hibernate.query.Query
的setTimeout()
(超时以毫秒指定):
@QueryHints({
@QueryHint(name = "org.hibernate.timeout", value = "10")
})
@Query(value = "SELECT SLEEP(15)", nativeQuery = true)
public void sleepQuery();
@QueryHints({
@QueryHint(name = "javax.persistence.query.timeout", value = "10000")
})
@Query(value = "SELECT SLEEP(15)", nativeQuery = true)
public void sleepQuery();
最后,如果您使用的是TransactionTemplate
,那么可以通过TransactionTemplate.setTimeout(int n)
设置超时,单位是秒。
检查事务是否已回滚
事务超时后,应该回滚。您可以在数据库级别、通过特定工具或在应用日志中检查这一点。首先,启用application.properties
中的事务日志,如下所示:
logging.level.ROOT=INFO
logging.level.org.springframework.orm.jpa=DEBUG
logging.level.org.springframework.transaction=DEBUG
现在,过期的事务将记录如下所示的内容:
Creating new transaction with name ...
Opened new EntityManager [SessionImpl(1559444773<open>)] for JPA transaction
...
At this point the transaction times out !!!
...
Statement cancelled due to timeout or client request
Initiating transaction rollback
Rolling back JPA transaction on EntityManager [SessionImpl(1559444773<open>)]
Closing JPA EntityManager [SessionImpl(1559444773<open>)] after transaction
完整的应用可在 GitHub 5 上获得。
第 64 项:为什么以及如何在存储库接口中使用@Transactional
在数据访问层处理事务的方式是决定超音速应用和勉强工作的应用的关键因素之一。
一般来说,数据库的速度由事务吞吐量给出,表示为每秒的事务数量。这意味着数据库是为了容纳大量短期事务而不是长期运行的事务而构建的。遵循本文中介绍的技术,通过努力获得短事务来增强数据访问层。
定义查询方法(只读和读写查询方法)的第一步是定义一个特定于域类的存储库接口。该接口必须扩展Repository
并被类型化为域类和 ID 类型。通常,你会扩展CrudRepository
、JpaRepository
或PagingAndSortingRepository
。此外,在这个定制界面中,您列出了查询方法。
例如,考虑Author
实体及其简单的存储库接口:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
}
有人建议开发人员只在服务(@Service
)上使用@Transactional
,避免将其添加到存储库接口中。但是,从性能的角度来看,这是生产中应该遵循的好建议吗?或者,您是否应该更加灵活,考虑在接口库中也使用@Transactional
?有些声音甚至会鼓励你只在服务类级别添加@Transactional
,或者更糟,在控制器类级别添加。很明显,这样的建议没有考虑长时间运行事务的缓解和/或针对小型应用。当然,遵循这个建议可能会加快开发曲线,并为大多数开发人员级别快速创建一个舒适的开发环境。
让我们看看这些事务是如何工作的,并根据放置@Transactional
注释的位置来看看所涉及的性能损失。让我们从一个被公式化为问题的神话开始。
默认情况下,接口存储库中列出的查询方法在事务上下文中运行吗?
作为快速剩余,非事务上下文是指没有显式事务边界的上下文,不是指没有物理数据库事务的上下文。所有数据库语句都是在物理数据库事务的上下文中触发的。通过省略明确的事务边界,您将应用暴露在一系列性能损失中,详见第 61 条。简而言之,建议对只读查询也使用显式事务。
现在,让我们通过将 JPQL SELECT
写入AuthorRepository
来尝试回答本节标题中的问题:
@Query("SELECT a FROM Author a WHERE a.name = ?1")
public Author fetchByName(String name);
现在,服务方法可以调用这个查询方法。注意,服务方法没有声明显式的事务上下文。这样做是为了看看 Spring 是否会为您提供事务上下文(实际上,开发人员忘记添加@Transactional(readOnly = true
):
public void callFetchByNameMethod() {
Author author = authorRepository.fetchByName("Joana Nimar");
System.out.println(author);
}
通过简单地检查应用日志中的事务流( Item 85 ,我们注意到没有可用的事务上下文,因此 Spring 没有提供默认的事务上下文。此外,它通过如下消息标记这种行为:Don't need to create transaction for [
...fetchByName
]: This method isn't transactional.
但是,通过 Spring 数据查询构建器机制生成的查询怎么样呢?好吧,考虑一下AuthorRepository
中的下一个查询方法:
public Author findByName(String name);
让我们从一个恰当的服务方法来称呼它:
public void callFindByNameMethod() {
Author author = authorRepository.findByName("Joana Nimar");
System.out.println(author);
}
同样,检查应用日志会发现没有默认的事务上下文。
最后,让我们添加一个查询方法来修改AuthorRepository
的数据:
@Modifying
@Query("DELETE FROM Author a WHERE a.genre <> ?1")
public int deleteByNeGenre(String genre);
和服务方法:
public void callDeleteByNeGenreMethod() {
int result = authorRepository.deleteByNeGenre("Anthology");
System.out.println(result);
}
这一次,您不需要检查应用日志。service-method 将抛出一个有意义的异常,如下所示:
Caused by: org.springframework.dao.InvalidDataAccessApiUsageException: Executing an update/delete query;
nested exception is javax.persistence.TransactionRequiredException: Executing an update/delete query
Caused by: javax.persistence.TransactionRequiredException: Executing an update/delete query
总之,Spring 没有为用户定义的查询方法提供默认的事务上下文。另一方面,内置的查询方法(如save()
、findById()
、delete()
等)。)没有这个问题。它们继承自扩展的内置存储库接口(例如JpaRepository
),并带有默认的事务上下文。
让我们快速调用内置findById()
来看看这方面:
public void callFindByIdMethod() {
Author author = authorRepository.findById(1L).orElseThrow();
System.out.println(author);
}
应用日志显示,在这种情况下,Spring 会自动提供一个事务上下文:
Creating new transaction with name [...SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
Opened new EntityManager [SessionImpl(854671988<open>)] for JPA transaction
begin
Exposing JPA transaction as JDBC [...HibernateJpaDialect$HibernateConnectionHandle@280099a0]
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=?
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(854671988<open>)]
committing
Closing JPA EntityManager [SessionImpl(854671988<open>)] after transaction
这个例子触发了一个SELECT
语句。现在,让我们通过setGenre()
更新选择的作者:
public void callFindByIdMethodAndUpdate() {
Author author = authorRepository.findById(1L).orElseThrow();
author.setGenre("History");
authorRepository.save(author);
}
这一次,应用日志显示,这段代码需要两个单独的物理事务(两次数据库往返)来容纳通过findById()
触发的SELECT
,以及通过save()
触发的SELECT
和UPDATE
。在这个方法执行之后,findById()
使用的持久上下文被关闭。因此,save()
方法需要另一个持久上下文。为了更新作者,Hibernate 需要合并分离的author
。基本上,它通过一个 prior SELECT
将作者加载到这个持久性上下文中。显然,如果并发事务对相关数据执行修改,这两个SELECT
语句可能会返回不同的结果集,但这可以通过版本化乐观锁定来消除,以防止丢失更新。让我们检查一下应用日志:
Creating new transaction with name [...SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
Opened new EntityManager [SessionImpl(1403088342<open>)] for JPA transaction
begin
Exposing JPA transaction as JDBC [...HibernateJpaDialect$HibernateConnectionHandle@51fa09c7]
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=?
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(1403088342<open>)]
committing
Closing JPA EntityManager [SessionImpl(1403088342<open>)] after transaction
Creating new transaction with name [...SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(94617220<open>)] for JPA transaction
begin
Exposing JPA transaction as JDBC [...HibernateJpaDialect$HibernateConnectionHandle@4850d66b]
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=?
Committing JPA transaction on EntityManager [SessionImpl(94617220<open>)]
committing
update author set age=?, genre=?, name=? where id=?
Closing JPA EntityManager [SessionImpl(94617220<open>)] after transaction
换句话说,Spring 已经自动为findById()
和save()
方法提供了事务上下文,但是它没有为callFindByIdMethodAndUpdate()
服务方法提供事务上下文。在缺点中,这个服务方法没有利用 ACID 属性作为工作单元,需要两个物理事务和数据库往返,并且触发三个 SQL 语句而不是两个。
大多数时候,您会实现一个包含查询方法调用的服务方法,并假设触发的 SQL 语句将作为具有 ACID 属性的事务中的一个工作单元运行。显然,这个假设并不能验证前面的情况。
在同一个服务方法中调用fetchByName()
和deleteByNeGenre()
怎么样,如下所示:
public void callFetchByNameAndDeleteByNeGenreMethods() {
Author author = authorRepository.fetchByName("Joana Nimar");
authorRepository.deleteByNeGenre(author.getGenre());
}
由于AuthorRepository
没有为查询方法提供事务上下文,deleteByNeGenre()
将导致一个javax.persistence.TransactionRequiredException
异常。因此,这一次,代码不会在非事务上下文中静默运行。
好,那么我要做的就是在服务方法级别添加@Transactional,对吗?
为了提供明确的事务上下文,您可以在服务方法级别添加@Transactional
。这样,在这个事务上下文的边界中运行的 SQL 语句将利用 ACID 属性作为工作单元。比如,我们把@Transactional
加到callFetchByNameMethod()
上:
@Transactional(readOnly = true)
public void callFetchByNameMethod() {
Author author = authorRepository.fetchByName("Joana Nimar");
System.out.println(author);
}
这一次,应用日志确认了事务上下文的存在:
Creating new transaction with name [...BookstoreService.callFetchByNameMethod]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
Opened new EntityManager [SessionImpl(2012237082<open>)] for JPA transaction
begin
Exposing JPA transaction as JDBC [...HibernateJpaDialect$HibernateConnectionHandle@7d3815f7]
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=?
Author{id=4, age=34, name=Joana Nimar, genre=History}
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(2012237082<open>)]
committing
Closing JPA EntityManager [SessionImpl(2012237082<open>)] after transaction
酷!现在,您可以通过在事务上下文的保护伞下连接多个逻辑相关的 SQL 语句来定义一个工作单元,并利用 ACID 属性。例如,你可以重写callFindByIdMethodAndUpdate()
,如下所示:
@Transactional
public void callFindByIdMethodAndUpdate() {
Author author = authorRepository.findById(1L).orElseThrow();
author.setGenre("History");
}
这次是单个事务(单个数据库往返),两个 SQL 语句(一个SELECT
和一个UPDATE
,不需要显式调用save()
(见第 107 项)。
callFindByIdMethodAndUpdate()
也利用了酸的特性。以下是日志:
Creating new transaction with name [...BookstoreService.callFindByIdMethodAndUpdate]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(1115708094<open>)] for JPA transaction
begin
Exposing JPA transaction as JDBC [...HibernateJpaDialect$HibernateConnectionHandle@78ea700f]
Found thread-bound EntityManager [SessionImpl(1115708094<open>)] for JPA transaction
Participating in existing 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=?
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(1115708094<open>)]
committing
update author set age=?, genre=?, name=? where id=?
Closing JPA EntityManager [SessionImpl(1115708094<open>)] after transaction
最后,让我们在显式事务上下文中调用callFetchByNameAndDeleteByNeGenreMethods()
方法:
@Transactional
public void callFetchByNameAndDeleteByNeGenreMethods() {
Author author = authorRepository.fetchByName("Joana Nimar");
authorRepository.deleteByNeGenre(author.getGenre());
if (new Random().nextBoolean()) {
throw new RuntimeException("Some DAO exception occurred!");
}
}
现在,请注意,在触发了SELECT
(通过fetchByName()
)和DELETE
(通过deleteByNeGenre()
)之后,我们模拟了一个随机异常,该异常应该会导致事务回滚。这揭示了事务的原子性。因此,如果发生异常,应用日志将显示以下内容:
Creating new transaction with name [...BookstoreService.callFetchByNameAndDeleteByNeGenreMethods]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(654609843<open>)] for JPA transaction
begin
Exposing JPA transaction as JDBC [...HibernateJpaDialect$HibernateConnectionHandle@7f94541b]
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=?
delete from author where genre<>?
Initiating transaction rollback
Rolling back JPA transaction on EntityManager [SessionImpl(654609843<open>)]
rolling back
Closing JPA EntityManager [SessionImpl(654609843<open>)] after transaction
Caused by: java.lang.RuntimeException: Some DAO exception occurred!
好了,看起来在服务方法级别添加@Transactional
可以解决所有问题。该解决方案具有可用于服务方法的事务上下文,并且利用了 ACID 属性。
但是,总的来说,这种方法就足够了吗?
为了回答这个问题,让我们来解决下面的服务方法:
@Transactional(readOnly = true)
public void longRunningServiceMethod() {
System.out.println("Service-method start ...");
System.out.println("Sleeping before triggering SQL
to simulate a long running code ...");
Thread.sleep(40000);
Author author = authorRepository.fetchByName("Joana Nimar");
System.out.println(author);
System.out.println("Service-method done ...");
}
注意,只是为了测试,我们使用了 40 秒的长睡眠。当我们讨论长时间运行的事务和短时间运行的事务时,我们应该用毫秒来讨论它们。例如,图 6-3 显示了五个长时间运行的事务。
图 6-3
web 事务的时间示例
在服务方法的末尾,您调用fetchByName()
查询方法。因此,服务方法用@Transactional(readOnly = true)
进行了注释,以明确定义事务上下文的边界。查看应用日志:
Creating new transaction with name [...BookstoreService.longRunningServiceMethod]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
Opened new EntityManager [SessionImpl(1884806106<open>)] for JPA transaction
begin
Exposing JPA transaction as JDBC [...HibernateJpaDialect$HibernateConnectionHandle@63ad5fe7]
Service-method start ...
Sleeping before triggering SQL to simulate a long running code ...
HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)
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=?
Author{id=4, age=34, name=Joana Nimar, genre=History}
Service-method done ...
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(1884806106<open>)]
committing
Closing JPA EntityManager [SessionImpl(1884806106<open>)] after transaction
HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)
那么,这里发生了什么?在开始运行longRunningServiceMethod()
方法代码之前,Spring 立即启动事务并获取数据库连接。数据库连接会立即打开,并可以使用了。但是我们不会马上使用它,我们只是让它一直开着!我们在调用fetchByName()
之前运行一些其他任务(通过Thread.sleep()
模拟),这是在第一次与数据库连接交互之前。同时,数据库连接保持打开并链接到事务(查看 HikariCP 日志,active=1
)。最后,事务被提交,数据库连接被释放回连接池。这个场景代表一个长时间运行的事务,可能会影响可伸缩性,并且不利于 MVCC(多版本并发控制)。这个问题的主要原因是因为我们已经用@Transactional
注释了服务方法。但是,如果我们删除这个@Transactional
,那么fetchByName()
将在事务上下文之外运行!嗯!
我知道!让我们在存储库接口中移动@Transactional!
解决方案包括将@Transactional
移动到存储库接口,如下所示:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a WHERE a.name = ?1")
public Author fetchByName(String name);
}
或者,像这样(当然,这里显示的缺点是,如果我们有更多的只读查询方法,那么我们需要重复@Transactional(readOnly = true)
注释):
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Transactional(readOnly = true)
@Query("SELECT a FROM Author a WHERE a.name = ?1")
public Author fetchByName(String name);
}
服务方法不包含@Transactional
:
public void longRunningServiceMethod() {
// remains unchanged
}
这一次,应用日志揭示了预期的结果:
Service-method start ...
Sleeping before triggering SQL to simulate a long running code ...
HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)
Creating new transaction with name [...SimpleJpaRepository.fetchByName]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
Opened new EntityManager [SessionImpl(508317658<open>)] for JPA transaction
begin
Exposing JPA transaction as JDBC [...HibernateJpaDialect$HibernateConnectionHandle@3ba1f56e]
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=?
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(508317658<open>)]
committing
Closing JPA EntityManager [SessionImpl(508317658<open>)] after transaction
Author{id=4, age=34, name=Joana Nimar, genre=History}
Service-method done ...
因此,这一次,事务只包装通过 query-method 触发的 SQL SELECT
语句。由于这导致了一个短期事务,很明显这是应该走的路。
但是如果我想在服务方法中调用更多的查询方法呢?我掉酸了吗?
前面的场景按预期运行,因为我们在longRunningServiceMethod()
服务方法中调用了单个查询方法。然而,您很可能需要调用几个查询方法,这些方法产生一组定义逻辑事务的 SQL 语句。例如,在通过名字(fetchByName()
)获取一个作者之后,您可能想要删除所有与这个作者有不同流派的作者(deleteByNeGenre()
)。在没有用@Transactional
注释的服务方法中调用这两个查询方法将会丢失这个工作单元的 ACID 属性。因此,您也需要在服务方法中添加@Transactional
。
首先,让我们看看塑造存储库接口的最佳方式,AuthorRepository
。你应该听从奥利弗·德罗特博姆的建议:
- 因此,我们推荐使用
@Transactional(readOnly = true)
作为查询方法,您可以轻松地将注释添加到您的存储库接口。确保您将普通的@Transactional
添加到您可能已经在该接口中声明或重新修饰的操作方法中。
此外,Oliver 被问到:“所以简而言之,我应该在添加/编辑/删除查询中使用@Transactional
,在所有 DAO 方法的SELECT
查询中使用@Transaction(readOnly = true)
?”奥利弗回答如下:
- 正是。最简单的方法是在界面上使用
@Transactional(readOnly = true)
(因为它通常包含大部分查找器方法),并用普通的@Transactional
覆盖每个修改查询方法的设置。在SimpleJpaRepository
实际上就是这么做的。
所以,我们应该有:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a WHERE a.name = ?1")
public Author fetchByName(String name);
@Transactional
@Modifying
@Query("DELETE FROM Author a WHERE a.genre <> ?1")
public int deleteByNeGenre(String genre);
}
因此,我们通过用@Transactional(readOnly = true)
注释存储库接口来确保所有查询方法都在只读事务上下文中运行。此外,对于可以修改数据的查询方法,我们通过添加不带readOnly
标志的@Transactional
来切换到允许数据修改的事务上下文。主要是,我们在这里所做的正是 Spring Data 为其内置查询方法所做的。
此外,服务方法用@Transactional
标注,因为我们将触发一个SELECT
和一个UPDATE
:
@Transactional
public void longRunningServiceMethod() {
System.out.println("Service-method start ...");
System.out.println("Sleeping before triggering SQL
to simulate a long running code ...");
Thread.sleep(40000);
Author author = authorRepository.fetchByName("Joana Nimar");
authorRepository.deleteByNeGenre(author.getGenre());
System.out.println("Service-method done ...");
}
现在让我们来看看应用日志:
Creating new transaction with name [...BookstoreService.longRunningServiceMethod]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(138303640<open>)] for JPA transaction
begin
Exposing JPA transaction as JDBC [...HibernateJpaDialect$HibernateConnectionHandle@7c4a03a]
Service-method start ...
Sleeping before triggering SQL to simulate a long running code ...
HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)
Found thread-bound EntityManager [SessionImpl(138303640<open>)] for JPA transaction
Participating in existing 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_.name=?
Found thread-bound EntityManager [SessionImpl(138303640<open>)] for JPA transaction
Participating in existing transaction
delete from author where genre<>?
Service-method done ...
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(138303640<open>)]
committing
Closing JPA EntityManager [SessionImpl(138303640<open>)] after transaction
HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)
检查以下突出显示的输出:
Found thread-bound EntityManager [SessionImpl(138303640<open>)] for JPA transaction
Participating in existing transaction
这一次,每个被调用的查询方法(fetchByName()
和deleteByNeGenre()
)都参与到您调用longRunningServiceMethod()
服务方法时打开的现有事务中。所以,不要混淆,不要认为来自存储库接口的@Transactional
注释会启动新的事务或者消耗新的数据库连接。Spring 将自动邀请被调用的查询方法参与现有的事务。一切都像魔咒一样管用!Spring 依赖于其事务传播机制,详见附录 G 。更准确地说,在默认模式下,Spring 应用特定于默认事务传播机制Propagation.REQUIRED
的事务传播规则。当然,如果您显式地设置了另一个事务传播机制(参见附录 G ,那么您必须在相应的上下文中评估您的事务流。
好的,但是现在我们回到了一个长时间运行的事务!在这种情况下,我们应该重构代码并重新设计实现,以获得更短的事务。或者,如果我们使用 Hibernate 5.2.10+,我们可以延迟数据库连接获取。基于第 60 项,我们可以通过以下两个设置来延迟连接获取(建议在资源-本地(针对单个数据源)中始终使用这些设置):
spring.datasource.hikari.auto-commit=false
spring.jpa.properties.hibernate.connection.provider_disables_autocommit=true
现在,数据库连接获取被延迟,直到第一条 SQL 语句被执行:
Creating new transaction with name [...BookstoreService.longRunningServiceMethod]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Opened new EntityManager [SessionImpl(138303640<open>)] for JPA transaction
begin
Exposing JPA transaction as JDBC [...HibernateJpaDialect$HibernateConnectionHandle@7c4a03a]
Service-method start ...
Sleeping before triggering SQL to simulate a long running code ...
HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)
Found thread-bound EntityManager [SessionImpl(138303640<open>)] for JPA transaction
Participating in existing 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_.name=?
Found thread-bound EntityManager [SessionImpl(138303640<open>)] for JPA transaction
Participating in existing transaction
delete from author where genre<>?
Service-method done ...
Initiating transaction commit
Committing JPA transaction on EntityManager [SessionImpl(138303640<open>)]
committing
Closing JPA EntityManager [SessionImpl(138303640<open>)] after transaction
HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)
请注意,在调用第一个 query-method 之前,HikariCP 报告 0 个活动连接。因此,我们耗时的任务(通过Thread.sleep()
模拟)在没有保持数据库连接打开的情况下执行。然而,在获得连接之后,它将保持打开状态,直到服务方法执行结束(直到事务完成)。这是额外关注服务方法设计以避免任何长时间运行任务的一个强有力的理由。
作为一个经验法则,努力避免那些与繁重的业务逻辑交错的事务,这些业务逻辑不会通过查询方法调用与数据库进行交互。这可能导致长时间运行的事务和复杂的服务方法变得耗时,并且难以理解、调试、重构和审查。几乎总是有更好的解决方案,只是要花时间去发现它们。
涵盖长期运行方法案例的完整代码可以在 GitHub 6 上找到。
因此,如果我延迟连接获取,那么我就可以在存储库接口中避免@Transactional?
如果可以,升级到 Hibernate 5.2.10+,执行第 60 项的设置,延迟连接获取。然后,在大多数情况下,您只能在服务级别使用@Transactional
,而不能在存储库接口中使用。但是这意味着您仍然容易忘记将@Transactional(readOnly=true)
添加到包含只读数据库操作的服务方法中( Item 61 )。现在,让我们看看两种情况,如果您也将@Transactional
添加到存储库接口,它们会生成更短的事务。
案例 1
考虑下面的存储库和BookstoreService
中的两个服务方法:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a WHERE a.name = ?1")
public Author fetchByName(String name);
}
@Service
public class BookstoreService {
public void displayAuthor() {
Author author = fetchAuthor();
System.out.println(author);
}
@Transactional(readOnly = true)
public Author fetchAuthor() {
return authorRepository.fetchByName("Joana Nimar");
}
}
该代码属于第 62 项的范围。换句话说,@Transactional
被添加到一个方法中,该方法定义在调用它的同一个类中,Spring 将忽略它。但是,如果我们遵循最佳实践并在存储库接口中声明@Transactional(readOnly=true)
,那么一切都会完美地工作:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a WHERE a.name = ?1")
public Author fetchByName(String name);
}
@Service
public class BookstoreService {
public void displayAuthor() {
Author author = fetchAuthor();
System.out.println(author);
}
public Author fetchAuthor() {
return authorRepository.fetchByName("Joana Nimar");
}
}
或者,您可以使用两种服务,如第 62 项中所示。
案例 2
考虑BookstoreService
中的以下存储库和服务方法:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a WHERE a.name = ?1")
public Author fetchByName(String name);
}
@Service
public class BookstoreService {
@Transactional(readOnly = true)
public Royalty computeRoyalties() {
Author author = authorRepository.fetchByName("Joana Nimar");
// computing royalties is a slow task
// that requires interaction with other services
// (e.g., revenue and financial services)
return royalties;
}
}
在这种情况下,延迟连接获取不会带来显著的好处。我们马上给fetchByName()
打电话;因此,数据库连接是立即获得的。在执行了fetchByName()
查询方法之后,数据库连接保持打开,直到版税计算完毕。
但是,如果我们准备了如下的AuthorRepository
:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a WHERE a.name = ?1")
public Author fetchByName(String name);
}
那么就不需要用@Transactional(readOnly = true)
来注释服务方法,事务将只封装fetchByName()
的执行,而版税在事务之外计算:
@Service
public class BookstoreService {
public Royalty computeRoyalties() {
Author author = authorRepository.fetchByName("Joana Nimar");
// computing royalties is a slow task
// that requires interaction with other services
// (e.g., revenue and financial services)
return royalties;
}
}
或者,您可以将computeRoyalties()
分成两个方法,如下所示:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a WHERE a.name = ?1")
public Author fetchByName(String name);
}
@Service
public class BookstoreService {
public Royalty computeRoyalties() {
Author author = fetchAuthorByName("Joana Nimar");
// computing royalties is a slow task
// that requires interaction with other services
// (e.g., revenue and financial services)
return royalties;
}
@Transactional(readOnly = true)
public Author fetchAuthorByName(String name) {
return authorRepository.fetchByName(name);
}
}
但是现在我们回到案例 1。
三个简单而常见的场景
让我们来解决三个简单而常见的容易把事情搞砸的场景。
回滚不与数据库交互的代码引发的异常服务方法
考虑以下服务方法:
public void foo() {
// call a query-method that triggers DML statements (e.g., save())
// follows tasks that don't interact with the database but
// are prone to throw RuntimeException
}
这个服务方法应该用@Transactional
注释吗?如果不与数据库交互的突出显示的代码通过RuntimeException
失败,那么当前事务应该被回滚。第一个想法是将这个服务方法标注为@Transactional
。这种情况对于使用@Transactional(rollbackFor = Exception.class)
的检查的异常也很常见。
但是,在决定将@Transactional
添加到服务方法之前,最好三思而行。也许有另一种解决方法。例如,也许您可以在不影响行为的情况下更改任务的顺序:
public void foo() {
// follows tasks that don't interact with the database but
// are prone to throw RuntimeException
// call a query-method that triggers DML statements (e.g., save())
}
现在不需要用@Transactional
来注释这个服务方法。如果不与数据库交互的任务抛出一个RuntimeException
,那么save()
根本不会被调用,这样就省了一次数据库往返。
此外,如果这些任务很耗时,那么它们不会影响为save()
方法打开的事务的持续时间。在最坏的情况下,我们不能改变任务的顺序,而且这些任务非常耗时。更糟糕的是,这可能是应用中被频繁调用的方法。在这些情况下,service-method 将导致长时间运行的事务。在这种情况下,您必须重新设计您的解决方案,以避免用@Transactional
注释服务方法(例如,显式捕捉异常并通过显式 DML 语句提供手动回滚,或者将服务方法重构为几个服务方法,以减轻长时间运行的事务)。
级联和@事务
考虑双向懒惰关联中涉及的Foo
和Buzz
。持久化一个Foo
会将持久化操作级联到关联的Buzz
。以及以下服务方法:
public void fooAndBuzz() {
Foo foo = new Foo();
Buzz buzz1 = new Buzz();
Buzz buzz2 = new Buzz();
foo.addBuzz(buzz1);
foo.addBuzz(buzz2);
fooRepository.save(foo);
}
我们只调用了一次save()
,但是它将触发三个INSERT
语句。那么,我们应该用@Transactional
来注释这个方法以提供 ACID 属性吗?答案是否定的!我们不应该用@Transactional
来注释这个服务方法,因为触发持久化与Foo
关联的Buzz
实例的INSERT
语句是通过CascadeType.ALL
/ PERSIST
级联的结果。所有三个INSERT
语句都在同一个事务的上下文中执行。如果这些INSERT
语句中的任何一个失败,事务将自动回滚。
选择➤修改➤保存和交叉存取的长期运行任务
还记得之前的callFindByIdMethodAndUpdate()
吗?
public void callFindByIdMethodAndUpdate() {
Author author = authorRepository.findById(1L).orElseThrow();
author.setGenre("History");
authorRepository.save(author);
}
让我们将这个方法抽象如下:
public void callSelectModifyAndSave () {
Foo foo = fooRepository.findBy...(...);
foo.setFooProperty(...);
fooRepository.save(foo);
}
前面,我们用@Transactional
注释了这种方法,以划分事务边界。好处之一,我们说会有两个 SQL 语句(SELECT
和UPDATE
)而不是三个(SELECT
、SELECT
和UPDATE
),我们省去了一次数据库往返,不需要显式调用save()
:
@Transactional
public void callSelectModifyAndSave () {
Foo foo = fooRepository.findBy...(...);
foo.setFooProperty(...);
}
然而,这种方法在下面的情况下有用吗?
@Transactional
public void callSelectModifyAndSave() {
Foo foo = fooRepository.findBy...(...);
// long-running task using foo data
foo.setFooProperty(...);
}
如果我们在SELECT
和UPDATE
之间偷偷放一个长时间运行的任务,那么我们会导致一个长时间运行的事务。例如,我们可能需要选择一本书,使用选择的数据生成该书的 PDF 版本(这是一个长期运行的任务),并更新该书的可用格式。如果我们选择像上面那样做(这是一种非常常见的情况),那么我们就有了一个长时间运行的事务,因为该事务也将包含长时间运行的任务。
在这种情况下,最好去掉@Transactional
,允许两个短事务被一个长时间运行的任务和一个额外的SELECT
分开:
public void callSelectModifyAndSave() {
Foo foo = fooRepository.findBy...(...);
// long-running task using foo data
foo.setFooProperty(...);
fooRepository.save(foo);
}
通常,当像这里这样涉及一个长时间运行的任务时,我们必须考虑所选数据可能会被SELECT
和UPDATE
之间的另一个事务( lost update )修改。这可能发生在两种情况下——一个长时间运行的事务或两个被长时间运行的任务分隔开的短事务。在这两种情况下,我们都可以依靠版本化的乐观锁定和重试机制( Item 131 )。由于这个方法没有用@Transactional
标注,我们可以应用@Retry
(注意@Retry
不应该应用于用@Transactional
标注的方法——细节在项 131 中解释):
@Retry(times = 10, on = OptimisticLockingFailureException.class)
public void callSelectModifyAndSave() {
Foo foo = fooRepository.findBy...(...);
// long-running task using foo data
foo.setFooProperty(...);
fooRepository.save(foo);
}
搞定了。这比单个长时间运行的事务好得多。
为了获得最佳的基于 ACID 的事务上下文来减轻主要的性能损失,特别是长时间运行的事务,建议遵循以下准则:
准备您的存储库接口:
-
用
@Transactional(readOnly=true)
注释存储库接口。 -
对于修改数据/生成 DML 的查询方法(如
INSERT
、UPDATE
和DELETE
,用@Transactional
覆盖@Transactional(readOnly=true)
。延迟数据库连接获取:
-
对于 Hibernate 5.2.10+,将数据库连接获取延迟到真正需要的时候(参见第 60 项)。
评估每个服务方法:
-
评估每个服务方法,以决定是否应该用
@Transactional
进行注释。 -
如果你决定用
@Transactional
注释一个服务方法,那么添加适当的@Transactional
。如果只调用只读的查询方法,应该添加@Transactional(readOnly=true)
,如果调用至少一个可以修改数据的查询方法,应该添加@Transactional
。测量和监控事务持续时间:
-
务必在当前事务传播机制(附录 G )的上下文中评估事务持续时间和行为,并争取短事务和短/快事务。
-
一旦获得数据库连接,它将保持打开状态,直到事务完成。因此,设计您的解决方案以避免长时间运行的事务。
-
避免在控制器类级别或服务类级别添加
@Transactional
,因为这可能导致长时间运行甚至不需要的事务(这样的类容易打开事务上下文,并为不需要与数据库交互的方法获取数据库连接)。例如,开发人员可能会添加包含不与数据库交互的业务逻辑的public
方法;在这种情况下,如果您延迟数据库连接获取,那么 Spring Boot 仍然会准备事务上下文,但永远不会为它获取数据库连接。另一方面,如果您不依赖于延迟数据库连接获取,那么 Spring Boot 将准备事务上下文,并将为其获取数据库连接。
仅此而已!
七、标识符
第 65 项:为什么避免在 MySQL 中使用 Hibernate 5 自动生成器类型
考虑下面的Author
实体,它依赖 Hibernate 5 AUTO
生成器类型来生成标识符:
@Entity
public class AuthorBad implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
// or
@GeneratedValue
private Long id;
...
}
在 MySQL 和 Hibernate 5 中,GenerationType.AUTO
生成器类型将导致使用TABLE
生成器。这大大增加了性能损失。TABLE
生成器类型伸缩性不好,比IDENTITY
和SEQUENCE
(MySQL 不支持)生成器类型慢得多,即使只有一个数据库连接。
例如,持久化一个新的AuthorBad
将产生三个 SQL 语句,如下所示:
SELECT
next_val AS id_val
FROM hibernate_sequence
FOR UPDATE
UPDATE hibernate_sequence
SET next_val = ?
WHERE next_val = ?
INSERT INTO author_bad (age, genre, name, id)
VALUES (?, ?, ?, ?)
根据经验,总是避开TABLE
发电机。
显然,最好通过一条INSERT
语句来保存新作者。为了实现这个目标,依靠IDENTITY
或本地发电机类型。IDENTITY
发电机类型可采用如下方式:
@Entity
public class AuthorGood implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Long id;
...
}
本地发电机类型可采用如下方式:
@Entity
public class AuthorGood implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy=GenerationType.AUTO, generator="native")
@GenericGenerator(name="native", strategy="native")
private Long id;
...
}
这一次,保持一个AuthorGood
将产生下面的INSERT
:
INSERT INTO author_good (age, genre, name)
VALUES (?, ?, ?)
GitHub 1 上有源代码。
第 66 项:如何通过 hi/lo 算法优化序列标识符的生成
这个项目依赖于 PostgreSQL,它支持SEQUENCE
生成器。MySQL 提供了TABLE
替代,但是不要用!参见第六十五项。
只要得到支持,数据库序列代表了生成标识符的正确方式(在 JPA 和 Hibernate ORM 中)。SEQUENCE
生成器支持批处理,是无表的,可以利用数据库序列预分配,并支持增量步骤。
不要忘记避开TABLE
标识符生成器,这是反作用的(详情见项目 65 )。
默认情况下,SEQUENCE
生成器必须通过SELECT
语句为每个新的序列值命中数据库。假设下面的Author
实体:
@Entity
public class Author implements Serializable {
...
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
...
}
每个持久化的Author
都需要一个标识符(当前序列值),该标识符是通过在下面的SELECT
中实现的数据库往返获取的:
SELECT
nextval('hibernate_sequence')
依赖缓存序列或数据库序列预分配没有帮助。对于缓存的序列,应用仍然需要为每个新的序列值进行一次数据库往返。另一方面,数据库序列预分配仍然具有显著的数据库往返分数。
这可以通过特定于 Hibernate 的 hi/lo 算法进行优化(特别是在插入次数较多的情况下)。该算法是 Hibernate 内置优化器的一部分,能够计算内存中的标识符值。因此,使用 hi/lo 减少了数据库往返次数,从而提高了应用性能。
该算法将序列域同步分成和组。 hi 值可由数据库序列(或表格生成器)提供,其初始值可配置(initial_value
)。基本上,在一次数据库往返中,hi/lo 算法从数据库中获取一个新的 hi 值,并使用它来生成由可配置增量(increment_size
)给出的多个标识符,该增量代表 lo 条目的数量。当 lo 在此范围内时,不需要获取新的 hi 的数据库往返,并且可以安全地使用内存中生成的标识符。当所有的 lo 值都被使用时,一个新的 hi 值通过一个新的数据库往返被提取。
在代码中,hi/lo 算法可以用于Author
实体,如下所示:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "hilo")
@GenericGenerator(name = "hilo", strategy =
"org.hibernate.id.enhanced.SequenceStyleGenerator",
parameters = {
@Parameter(name = "sequence_name", value = "hilo_sequence"),
@Parameter(name = "initial_value", value = "1"),
@Parameter(name = "increment_size", value = "100"),
@Parameter(name = "optimizer", value = "hilo")
}
)
private Long id;
...
}
高/低算法需要几个参数:
-
sequence_name
:数据库序列的名称(如hilo_sequence
);数据库序列是通过以下语句创建的: -
initial_value
:这是第一个序列值或第一个(如 1)
** increment_size
:这是在获取下一个 hi 之前,将在内存中计算的标识符的数量(第 lo 条目的数量)(例如,100)
* `optimizer`:这是 Hibernate 内置优化器的名称(在本例中为`hilo`)*
CREATE
sequence hilo_sequence start 1 increment 1
为了在内存中生成标识符,hi/lo 算法使用以下公式来计算值的有效范围:
[increment_size x (hi - 1) + 1, increment_size x hi]
例如,按照这些设置,内存中生成的标识符的范围将是:
-
对于
hi=1
,范围为[1, 100]
-
对于
hi=2
,范围为[101, 200]
-
对于
hi=3
,范围为[201, 300]
-
...
lo 值的范围是从(hi - 1) * increment_size) + 1
开始的0, increment_size)
。
图 7-1 显示了 hi/lo 如何为 Ned 和 Jo 工作的逐步图示(的initial_value
hi是1
,increment_size
是2
)。
图 7-1
高/低算法
-
Ned 启动一个事务,并从数据库获取一个新的 hi ,并获得值 1。
-
Ned 有两个内存中生成的标识符(1 和 2)。他使用值为 1 的标识符来插入一行。
-
Jo 开始她的事务,并从数据库获取一个新的 hi 。她得到值 2。
-
Jo 有两个内存中生成的标识符(3 和 4)。她使用值为 3 的标识符插入一行。
-
Jo 再触发一次内存中标识符值为 4 的插入。
-
Jo 没有更多内存中生成的标识符;因此,程序必须获取新的 hi 。这一次,她从数据库中获得值 3。基于这个 hi ,Jo 可以在内存中生成值为 5 和 6 的标识符。
-
Ned 使用内存中生成的值为 2 的标识符来插入新行。
-
Ned 没有更多内存中生成的标识符;因此,程序必须获取新的 hi 。这一次,他从数据库中获得值 4。基于这个 hi ,Ned 可以在内存中生成值为 7 和 8 的标识符。
也就是说,测试 hi/lo 算法的一个简单方法是采用快速批处理过程。让我们批量插入 1000 个Author
实例(在author
表中)。以下服务方法通过saveAll()
内置方法批量处理 1000 个插件,批量大小为 30 个(虽然saveAll()
对于示例来说是可以的,但对于生产来说不是合适的选择;更多细节在第 46 项):
public void batch1000Authors() {
List<Author> authors = new ArrayList<>();
for (int i = 1; i <= 1000; i++) {
Author author = new Author();
author.setName("Author_" + i);
authors.add(author);
}
authorRepository.saveAll(authors);
}
由于 hi/lo 算法,所有 1,000 个标识符仅使用 10 次数据库往返就生成了。代码只读取 10 个 hi ,并且对于每个 hi ,它在内存中生成 100 个标识符。这比 1000 次数据库往返要好得多。获取新的 hi 的每次往返如下:
SELECT
nextval('hilo_sequence')
完整的应用可在 GitHub 2 上获得。
处理外部系统
hi 值由数据库提供。并发事务将获得唯一的 hi 值;因此,你不必担心 hi 的唯一性。两个连续的事务将接收两个连续的 hi 值。
现在,让我们假设一个场景,该场景涉及到我们的应用外部的一个系统,该系统需要在author
表中插入行。这个系统不使用高/低算法。
首先,应用获取一个新的 hi (例如 1),并使用它来生成 100 个内存标识符。让我们用生成的内存标识符 1、2 和 3 插入三个Author
:
@Transactional
public void save3Authors() {
for (int i = 1; i <= 3; i++) {
Author author = new Author();
author.setName("Author_" + i);
authorRepository.save(author); // uses ids: 1, 2 and 3
}
}
此外,外部系统试图在author
表中插入一行。模拟这种行为可以通过本机INSERT
轻松完成,如下所示:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Modifying
@Query(value = "INSERT INTO author (id, name)
VALUES (NEXTVAL('hilo_sequence'), ?1)",
nativeQuery = true)
public void saveNative(String name);
}
执行NEXTVAL('hilo_sequence')
获取下一个序列值将返回 2。但是这个应用已经使用这个标识符插入了一个Author
;因此,外部系统的尝试将会失败,并出现以下错误:
ERROR: duplicate key value violates unique constraint "author_pkey"
Detail: Key (id)=(2) already exists.
在存在外部系统的情况下,hi/lo 算法并不是正确的选择,因为外部系统的作用与前面介绍的场景相同。因为数据库序列不知道内存中生成的最高标识符,所以它返回可能已经用作标识符的序列值。这会导致重复的标识符错误。有两种方法可以避免这种问题:
-
外部系统应该知道 hi/lo 的存在,并相应地采取行动
-
使用另一个特定于 Hibernate 的内置优化器(参见第 67 项
完整的应用可在 GitHub 3 上获得。
第 67 项:如何通过合并(-lo)算法优化序列标识符的生成
如果你不熟悉 hi/lo 算法,那么可以考虑在此之前阅读第 66 项。
pooled 和 pooled-lo 算法是具有不同策略的 hi/lo 算法,旨在防止出现在项目 66 中的问题。作为快速余数,当不知道 hi/lo 存在和/或行为的外部系统试图在相关表中插入行时,经典 hi/lo 算法会导致重复标识符错误。
共享算法
考虑到Author
实体,汇集算法可以设置如下:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "hilopooled")
@GenericGenerator(name = "hilopooled",
strategy = "org.hibernate.id.enhanced.SequenceStyleGenerator",
parameters = {
@Parameter(name = "sequence_name", value = "hilo_sequence"),
@Parameter(name = "initial_value", value = "1"),
@Parameter(name = "increment_size", value = "100"),
@Parameter(name = "optimizer", value = "pooled")
}
)
private Long id;
...
}
注意参数optimizer
的值,它指示 Hibernate 使用池化的内置优化器。采用这种算法会为hilo_sequence
产生以下CREATE
语句:
CREATE
sequence hilo_sequence start 1 increment 100
注意increment 100
部分(或者,一般来说,increment increment_size
部分)。
汇集算法从数据库中提取当前序列值作为顶部边界标识符。当前序列值计算为前一序列值加上increment_size
。这样,应用将使用在先前的上边界(也称为最低边界)和当前的上边界(包括上边界)之间生成的内存中标识符。
让我们把这些单词用图形表示出来。图 7-2 一步一步地显示了奈德和乔的联合工作方式(的initial_value
嗨是 1,increment_size
是 2)。
图 7-2
共享算法
-
Ned 启动一个事务,并从数据库获取一个新的 hi ,并获得值 1(这是
initial_value
)。为了确定顶部边界标识符,自动获取新的 hi ,值为 3(这是initial_value
+increment_size
)。只是这一次,内存中生成的标识符的数量将等于increment_size
+ 1。 -
由于 pooled 使用获取的 hi 作为顶部边界标识符,Ned 有三个内存中生成的标识符(1、2 和 3)。他使用值为 1 的标识符来插入一行。
-
Jo 开始她的事务,并从数据库获取一个新的 hi 。她得到了值 5。
-
Jo 有两个内存中生成的标识符(4 和 5)。她使用值为 4 的标识符插入一行。
-
Jo 用值为 5 的内存中标识符再触发一次插入。
-
Jo 没有更多内存中生成的标识符;因此,她必须取一个新的 hi 。这一次,她从数据库中获得值 7。基于这个 hi ,Jo 可以在内存中生成值为 6 和 7 的标识符。
-
Ned 使用内存中生成的值为 2 的标识符来插入新行。
-
Ned 使用内存中生成的值为 3 的标识符来插入新行。
-
Ned 没有更多内存中生成的标识符;因此,他必须取一个新的 hi 。这一次,他从数据库中获得值 9。基于这个 hi ,Ned 可以在内存中生成值为 8 和 9 的标识符。
处理外部系统
现在,让我们重温一下第 66 项中标题为“处理外部系统”的章节。记住initial_value
是 1,increment_size
是 100。
首先,应用获取一个新的 hi (例如 101)。接下来,应用用生成的内存标识符 1、2 和 3 插入三个Author
。
此外,外部系统试图在author
表中插入一行。这个动作是由依赖于NEXTVAL('hilo_sequence')
获取下一个序列值的本机INSERT
模拟的。执行NEXTVAL('hilo_sequence')
获取下一个序列值将返回 201。这一次,外部系统将成功地插入一个标识符为 201 的行。如果我们的应用继续插入更多的行(而外部系统没有),那么在某个时刻,新的 hi 301 将被获取。这个 hi 将是新的上边界标识符,而唯一的下边界标识符将是 301-100 = 201;因此,下一行标识符将是 202。
看起来外部系统可以在这个应用旁边愉快地生活和工作,这要归功于池化算法。
与经典的 hi/lo 算法相比,Hibernate 特定的池算法不会给希望与我们的表进行交互的外部系统带来问题。换句话说,外部系统可以依靠池算法在表中同时插入行。然而,旧版本的 Hibernate 可能会引发由使用最低边界作为标识符的外部系统触发的INSERT
语句引起的异常。这是更新到 Hibernate 最新版本(例如 Hibernate 5.x)的一个很好的理由,它已经修复了这个问题。这样,您可以毫无顾虑地利用池算法。
完整的应用可在 GitHub 4 上获得。
池化 Lo 算法
考虑到Author
实体,pooled-lo 算法可以设置如下:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "hilopooled")
@GenericGenerator(name = "hilopooled",
strategy = "org.hibernate.id.enhanced.SequenceStyleGenerator",
parameters = {
@Parameter(name = "sequence_name", value = "hilo_sequence"),
@Parameter(name = "initial_value", value = "1"),
@Parameter(name = "increment_size", value = "100"),
@Parameter(name = "optimizer", value = "pooled-lo")
}
)
private Long id;
...
}
注意参数optimizer
的值,它指示 Hibernate 使用 pooled-lo 内置优化器。采用这种算法会为hilo_sequence
产生下面的CREATE
语句(与池算法中的语句相同):
CREATE
sequence hilo_sequence start 1 increment 100
注意increment 100
部分(或者,一般来说,increment increment_size
部分)。
pooled-lo 是与 pooled 类似的 hi/lo 的优化。这一次,该算法的策略从数据库中获取当前序列值,并将其用作内存中包含的最低边界标识符。内存中生成的标识符的数量等于increment_size
。
让我们把这些单词用图形表示出来。图 7-3 显示了 pooled-lo 如何为 Ned 和 Jo 工作的分步过程(的initial_value
hi为 1,increment_size
为 2)。
图 7-3
池化 lo 算法
-
Ned 启动一个事务,从数据库获取一个新的 hi ,并获得值 1。
-
Ned 有两个内存中生成的标识符(1 和 2)。他使用值为 1 的标识符来插入一行。
-
Jo 开始她的事务,并从数据库获取一个新的 hi 。她得到值 3。
-
Jo 有两个内存中生成的标识符(3 和 4)。她使用值为 3 的标识符插入一行。
-
Jo 再触发一次内存中标识符为 4 的插入。
-
Jo 没有更多内存中生成的标识符;因此,它必须获取新的 hi 。这一次,她从数据库中获得值 5。基于这个 hi ,Jo 可以在内存中生成值为 5 和 6 的标识符。
-
Ned 使用内存中生成的值为 2 的标识符来插入新行。
-
Ned 没有更多内存中生成的标识符;因此,他必须取一个新的 hi 。这一次,他从数据库中获得值 7。基于这个 hi ,Ned 可以在内存中生成值为 7 和 8 的标识符。
处理外部系统
现在,让我们重温一下第 66 项中标题为“处理外部系统”的章节。记住initial_value
是 1,increment_size
是 100。
首先,应用获取一个新的 hi (例如 1)。接下来,应用用生成的内存标识符 1、2 和 3 插入三个Author
。
此外,外部系统试图在author
表中插入一行。这个动作是由依赖于NEXTVAL('hilo_sequence')
获取下一个序列值的本机INSERT
模拟的。执行NEXTVAL('hilo_sequence')
获取下一个序列值将返回 101。这一次,外部系统将成功地插入一个标识符为 101 的行。如果应用继续插入更多的行(而外部系统没有),那么在某个时刻,新的 hi 201 将被获取。这个 hi 将是新的包含下边界标识符。
同样,由于 pooled-lo 算法,看起来外部系统可以在这个应用旁边愉快地生活和工作。
完整的应用可在 GitHub 5 上获得。
第 68 项:如何正确重写 equals()和 hashCode()
在实体中覆盖equals()
和hashCode()
可能是一项微妙的任务,因为这与普通旧 Java 对象(POJO)和 Java Beans 的情况不同。要考虑的主要语句是, Hibernate 要求一个实体在其所有状态转换( 【瞬态(新)、托管(持久)、分离 和 【移除】)之间必须等于自身。如果您需要快速了解 Hibernate 实体状态转换,可以考虑阅读附录 A (在它的末尾)。
为了检测实体的变化,Hibernate 使用其内部机制,称为脏检查。这种机制不使用equals()
和hashCode()
,但是,根据 Hibernate 文档,如果实体被存储在一个Set
中或者被重新附加到一个新的持久化上下文,那么开发者应该覆盖equals()
和hashCode()
。此外,通过助手方法同步双向关联的两端也需要您覆盖equals()
和hashCode()
。因此,有三种场景涉及到覆盖equals()
和hashCode()
。
为了学习如何覆盖equals()
和hashCode()
以尊重所有状态转换中实体相等的一致性,开发人员必须测试几个场景。
构建单元测试
首先创建一个新的实体实例(在瞬态状态)并将其添加到Set
中。单元测试的目的是针对不同的状态转换,检查来自Set
的这个瞬态实体的一致性。考虑存储在Set
中的Book
实体的一个瞬态实例,如下面的单元测试所示(在测试期间Set
的内容不会改变):
Book book = new Book();
Set<Book> books = new HashSet<>();
@BeforeClass
public static void setUp() {
book.setTitle("Modern History");
book.setIsbn("001-100-000-111");
books.add(book);
}
让我们从检查从未被持久化的book
和Set
内容之间相等的一致性开始:
@Test
public void A_givenBookInSetWhenContainsThenTrue() throws Exception {
assertTrue(books.contains(book));
}
此外,book
从瞬态转变到管理的状态。在第一个断言点,book
的状态是瞬态。对于数据库生成的标识符,book
的id
应该是null
。对于一个赋值的标识符,book
的id
应该是非null
。因此,根据具体情况,测试依赖于assertNull()
或assertNotNull()
。在持久化book
实体(状态被管理)之后,测试检查book
的标识符是非null
并且Set
包含book
:
@Test
public void B_givenBookWhenPersistThenSuccess() throws Exception {
assertNull(book.getId());
// for assigned identifier, assertNotNull(book.getId());
entityManager.persistAndFlush(book);
assertNotNull(book.getId());
assertTrue(books.contains(book));
}
下一个测试为分离的book
设置一个新标题。此外,book
实体被合并(换句话说,Hibernate 在持久性上下文中加载一个包含来自数据库的最新数据的实体,并更新它以镜像book
实体)。在断言点,测试检查返回的(托管 ) mergedBook
实体和Set
内容之间相等的一致性:
@Test
public void C_givenBookWhenMergeThenSuccess() throws Exception {
book.setTitle("New Modern History");
assertTrue(books.contains(book));
Book mergedBook = entityManager.merge(book);
entityManager.flush();
assertTrue(books.contains(mergedBook));
}
此外,通过EntityManager#find(Book.class, book.getId())
加载foundBook
实体。在断言点,测试检查foundBook
( 管理的实体)和Set
内容之间相等的一致性:
@Test
public void D_givenBookWhenFindThenSuccess() throws Exception {
Book foundBook = entityManager.find(Book.class, book.getId());
entityManager.flush();
assertTrue(books.contains(foundBook));
}
此外,通过EntityManager#find(Book.class, book.getId())
提取foundBook
实体。之后,通过detach()
方法显式分离它。最后,测试检查这个分离的实体和Set
内容之间相等的一致性:
@Test
public void E_givenBookWhenFindAndDetachThenSuccess() throws Exception {
Book foundBook = entityManager.find(Book.class, book.getId());
entityManager.detach(foundBook);
assertTrue(books.contains(foundBook));
}
在最后一个测试中,foundBook
实体是通过EntityManager#find(Book.class, book.getId())
获取的。之后,通过EntityManager#remove()
方法移除该实体,并且测试检查移除的实体和Set
内容之间相等的一致性。最后,实体从Set
中移除,并再次声明:
@Test
public void F_givenBookWhenFindAndRemoveThenSuccess() throws Exception {
Book foundBook = entityManager.find(Book.class, book.getId());
entityManager.remove(foundBook);
entityManager.flush();
assertTrue(books.contains(foundBook));
books.remove(foundBook);
assertFalse(books.contains(foundBook));
}
好的,目前为止一切顺利!现在,让我们以不同的方式覆盖equals()
和hashCode()
,看看哪些方法通过了测试。
重写 equals()和 hashCode()的最佳方法
通过测试的实体是在其所有状态转换(瞬态、附着、脱离、和移除)中与其自身相等的实体。
使用业务密钥
商业关键字是唯一的实体字段。它不可为空或可更新,这意味着它是在实体创建时分配的,并且保持不变(例如,SSN、ISBN、CNP 等)。).例如,以下实体有一个isbn
字段作为其业务关键字:
@Entity
public class BusinessKeyBook implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Column(nullable = false, unique = true, updatable = false, length = 50)
private String isbn;
// getter and setters omitted for brevity
}
由于isbn
从创建实体的那一刻起就是已知的,它可以在equals()
和hashCode()
中使用,如下所示:
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
BusinessKeyBook other = (BusinessKeyBook) obj;
return Objects.equals(isbn, other.getIsbn());
}
@Override
public int hashCode() {
return Objects.hash(isbn);
}
业务密钥相等通过测试。这是覆盖equals()
和hashCode()
的最佳选择。但是,有些实体没有业务键。在这种情况下,应考虑其他方法。
使用@NaturalId
用@NaturalId
注释业务键会将该字段转换成实体的自然标识符(默认情况下,自然标识符是不可变的)。一本书的isbn
号是典型的自然标识符。自然标识符不能代替实体标识符。实体标识符可以是一个代理键,非常适合不为表和索引页增加内存压力。实体标识符可以像往常一样用于提取实体。此外,特定于 Hibernate 的 API 允许您通过专用的方法用相关的自然键获取实体。 Item 69 详细剖析这个话题。
@Entity
public class NaturalIdBook implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@NaturalId
@Column(nullable = false, updatable = false, unique = true, length = 50)
private String isbn;
// getters and setters omitted for brevity
}
由于isbn
从创建实体的那一刻起就是已知的,它可以在equals()
和hashCode()
中使用,如下所示:
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
BusinessKeyBook other = (BusinessKeyBook) obj;
return Objects.equals(isbn, other.getIsbn());
}
@Override
public int hashCode() {
return Objects.hash(isbn);
}
平等通过了考验。当业务键也应该用于通过 Hibernate ORM API 获取实体时,这是覆盖equals()
和hashCode()
的最佳选择。
手动分配的标识符
手动分配实体标识符时,实体如下所示:
@Entity
public class IdManBook implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private Long id;
private String title;
private String isbn;
// getters and setters omitted for brevity
}
在创建这个实体的过程中,代码必须调用setId()
来显式设置一个标识符。所以,实体标识符从一开始就是已知的。这意味着实体标识符可用于覆盖equals()
和hashCode()
,如下所示:
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
IdManBook other = (IdManBook) obj;
return Objects.equals(id, other.getId());
}
@Override
public int hashCode() {
return Objects.hash(id);
}
手动分配的标识符相等通过测试。当不需要使用自动递增的实体标识符时,这是覆盖equals()
和hashCode()
的好选择。
数据库生成的标识符
自动递增的实体标识符通常是最常用的实体。一个瞬态实体的实体标识符只有在数据库往返之后才知道。典型的实体依赖于IDENTITY
生成器,如下所示:
@Entity
public class IdGenBook implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String isbn;
// getters and setters omitted for brevity
}
依靠生成的实体标识符来覆盖equals()
和hashCode()
有点棘手。下面列出了正确的实现方式:
@Override
public boolean equals(Object obj) {
if(obj == null) {
return false;
}
if (this == obj) {
return true;
}
if (getClass() != obj.getClass()) {
return false;
}
IdGenBook other = (IdGenBook) obj;
return id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return 2021;
}
在这个实现中有两条重要的线。两者都是相对于一个瞬态对象有一个null
ID,并且在持久化成为托管后,有一个有效的(非null
) ID 来构造的。这意味着同一对象在不同的状态转换中可以有不同的 id;因此,基于 ID 的hashCode()
(例如Objects.hash(getId())
)将返回两个不同的值(换句话说,该对象在状态转换中不等于其自身;它不会在Set
中被发现。从hashCode()
返回一个常量就能解决问题。
return 2021;
此外,应按照如下方式进行相等性测试:
return id != null && id.equals(other.getId());
如果当前对象 ID 是null
,那么equals()
返回false
。如果equals()
被执行,意味着涉及的对象不是同一个对象的引用;因此它们是两个瞬态对象或者一个瞬态和一个非瞬态对象,这样的对象不能相等。只有当当前对象 ID 不是null
并且与另一个对象 ID 相等时,两个对象才被视为相等。这意味着只有当两个 id 为null
的对象是同一个对象的引用时,它们才被认为是相等的。这是可以实现的,因为hashCode()
返回一个常数;因此,对于null
id,我们依赖于Object
引用等式。
从hashCode()
返回一个常量值将有助于满足这里提到的 Hibernate 需求,但是在巨大的Set
或Map
的情况下可能会影响性能,因为所有的对象都将存放在同一个哈希桶中。然而,将巨大的Set
和 Hibernate 结合起来会导致性能损失,这已经超出了我们的担忧。因此,从hashCode()
返回一个常量值是没有问题的。根据经验,最好使用小的结果集来避免过多的性能损失。
这个实现通过了测试。这是基于数据库生成的标识符覆盖equals()
和hashCode()
的推荐方法。
必须避免重写 equals()和 hashCode()的方法
未通过测试的实体是指在其所有状态转换中被认为不等于自身的实体(瞬态、附着、分离、和移除)。
默认实现(JVM)
依赖默认的equals()
和hashCode()
意味着不显式地覆盖它们中的任何一个:
@Entity
public class DefaultBook implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String isbn;
// getters and setters omitted for brevity
// no explicit equals() and hashCode()
}
当这些方法没有被覆盖时,Java 将使用它们的默认实现。不幸的是,默认的实现并不真正服务于确定两个对象是否具有相同值的目标。默认情况下,equals()
认为两个对象相等,当且仅当它们由相同的内存地址(相同的对象引用)表示,而hashCode()
返回对象内存地址的整数表示。这是一个被称为身份散列码的native
功能。
在这些坐标中,equals()
和hashCode()
的默认实现将无法通过下面的java.lang.AssertionError
: C_givenBookWhenMergeThenSuccess()
、D_givenBookWhenFindThenSuccess()
、E_givenBookWhenFindAndDetachThenSuccess()
和F_givenBookWhenFindAndRemoveThenSuccess()
的测试。发生这种情况是因为测试C
、D
、E
和F
断言对象mergedBook
和foundBook
之间相等,它们具有与book
不同的内存地址。
依赖默认equals()
和hashCode()
是一个糟糕的决定。
数据库生成的标识符
数据库生成的标识符通常通过IDENTITY
生成器使用,如下所示:
@Entity
public class IdBook implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String isbn;
// getters and setters omitted for brevity
}
您可以根据数据库生成的标识符覆盖equals()
和hashCode()
,如下所示:
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final IdBook other = (IdBook) obj;
if (!Objects.equals(this.id, other.id)) {
return false;
}
return true;
}
@Override
public int hashCode() {
int hash = 3;
hash = 89 * hash + Objects.hashCode(this.id);
return hash;
}
A_givenBookInSetWhenContainsThenTrue()
是唯一通过的测试。剩下的都用java.lang.AssertionError
失败。这是因为测试B
、C
、D
、E
和F
断言具有非null
ID 的对象和存储在 ID 为null
的Set
中的book
对象之间相等。
避免依赖数据库生成的标识符来覆盖equals()
和hashCode()
。
Lombok @EqualsAndHashCode
由于 Lombok 现在非常流行,所以它也常用于实体。实体中最常用的 Lombok 注释之一是@EqualsAndHashCode
。该注释生成符合龙目岛文档的equals()
和hashCode()
。但是,生成的equals()
和hashCode()
对这些实体是否正确/合适呢?通常,在生产中会遇到以下代码:
@Entity
@EqualsAndHashCode
public class LombokDefaultBook implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String isbn;
// getters and setters omitted for brevity
}
Lombok 将生成如下内容:
public boolean equals(Object o) {
if (o == this) {
return true;
}
if (!(o instanceof LombokDefaultBook)) {
return false;
}
LombokDefaultBook other = (LombokDefaultBook) o;
if (!other.canEqual(this)) {
return false;
}
Object this$id = getId();
Object other$id = other.getId();
if (this$id == null ? other$id != null : !this$id.equals(other$id)) {
return false;
}
Object this$title = getTitle();
Object other$title = other.getTitle();
if (this$title == null ? other$title != null :
!this$title.equals(other$title)) {
return false;
}
Object this$isbn = getIsbn();
Object other$isbn = other.getIsbn();
return this$isbn == null ? other$isbn == null :
this$isbn.equals(other$isbn);
}
protected boolean canEqual(Object other) {
return other instanceof LombokDefaultBook;
}
public int hashCode() {
int PRIME = 59; int result = 1;
Object $id = getId();
result = result * 59 + ($id == null ? 43 : $id.hashCode());
Object $title = getTitle();
result = result * 59 + ($title == null ? 43 : $title.hashCode());
Object $isbn = getIsbn();
result = result * 59 + ($isbn == null ? 43 : $isbn.hashCode());
return result;
}
默认情况下,Lombok 使用所有这些字段来生成equals()
和hashCode()
。显然,这对于平等的一致性来说是不行的。运行这些测试表明,这个实现只通过了A_givenBookInSetWhenContainsThenTrue()
测试。
依靠默认的 Lombok @EqualsAndHashCode
来覆盖equals()
和hashCode()
是一个糟糕的决定。另一个常见的场景包括排除诸如title
和isbn
之类的字段,只依赖于id
、@EqualsAndHashCode(exclude = {"title", "isbn"})
。这在手动分配标识符的情况下是有用的,但是在数据库生成的标识符的情况下是无用的。
一些 Lombok 注释是其他 Lombok 注释的快捷方式。在实体的情况下,避免使用@Data
,这是所有字段上的@ToString
、@EqualsAndHashCode
、@Getter
、所有非final
字段上的@Setter
和@RequiredArgsConstructor
的快捷方式。而是只使用@Getter
和@Setter
方法,并实现equals()
、hashCode()
和toString()
方法,就像您在本文中看到的那样。
搞定了。GitHub 6 上有源代码。
第 69 项:如何在 Spring 风格中使用 Hibernate 特有的@NaturalId
Hibernate ORM 支持通过@NaturalId
注释将业务键声明为自然 ID。这个特性是 Hibernate 特有的,但是也可以用在 Spring 风格中。
业务关键字必须是唯一的(例如,图书 ISBN、人物 SSN、CNP 等)。).一个实体可以同时具有一个标识符(例如,自动生成的标识符)和一个或多个自然 id。
如果实体只有一个@NaturalId
,那么开发人员可以通过Session.bySimpleNaturalId()
方法找到它(及其风格)。如果实体有不止一个@NaturalId
(一个实体可以有一个复合的自然 ID),那么开发人员可以通过Session.byNaturalId()
方法(以及它的各种风格)找到它。
自然 id 可以是可变的或不可变的(默认)。您可以通过编写:@NaturalId(mutable = true)
在可变和不可变之间切换。建议将标记为@NaturalId
的字段也标记为@Column
,通常是这样的:
-
不可变的自然 ID:
-
可变自然 ID:
@Column(nullable = false, updatable = false, unique = true)
@Column(nullable = false, updatable = true, unique = true)
另外,equals()
和hashCode()
应该实现为以自然 ID 为中心。
自然 id 可以缓存在二级缓存中,如第 70 项所述。这在 web 应用中非常有用。自然 ID 非常适合作为可加书签的 URL 的一部分(例如,isbn
是自然 ID 和 http://bookstore.com/books?isbn=001
请求中的查询参数);因此,可以根据客户端发送的信息提取数据。
基于这些陈述,下面的Book
实体包含一个名为isbn
的自然 ID:
@Entity
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private int price;
@NaturalId(mutable = false)
@Column(nullable = false, updatable = false, unique = true, length = 50)
private String isbn;
// getters and setters omitted for brevity
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (getClass() != o.getClass()) {
return false;
}
Book other = (Book) o;
return Objects.equals(isbn, other.getIsbn());
}
@Override
public int hashCode() {
return Objects.hash(isbn);
}
@Override
public String toString() {
return "Book{" + "id=" + id + ", title=" + title
+ ", isbn=" + isbn + ", price=" + price + '}';
}
}
通过 Spring 风格的自然 ID 查找Book
s 从定义一个建议命名为NaturalRepository
的接口开始。这需要通过添加另外两个方法来微调内置的JpaRepository
存储库:findBySimpleNaturalId()
和findByNaturalId()
:
@NoRepositoryBean
public interface NaturalRepository<T, ID extends Serializable>
extends JpaRepository<T, ID> {
// use this method when your entity has a single field annotated with @NaturalId
Optional<T> findBySimpleNaturalId(ID naturalId);
// use this method when your entity has more than one field annotated with @NaturalId
Optional<T> findByNaturalId(Map<String, Object> naturalIds);
}
接下来,扩展SimpleJpaRepository
类并实现NaturalRepository
。这允许您通过添加方法来自定义基本存储库。换句话说,您可以扩展特定于持久性技术的存储库基类,并将该扩展用作存储库代理的定制基类:
@Transactional(readOnly = true)
public class NaturalRepositoryImpl<T, ID extends Serializable>
extends SimpleJpaRepository<T, ID> implements NaturalRepository<T, ID> {
private final EntityManager entityManager;
public NaturalRepositoryImpl(JpaEntityInformation entityInformation,
EntityManager entityManager) {
super(entityInformation, entityManager);
this.entityManager = entityManager;
}
@Override
public Optional<T> findBySimpleNaturalId(ID naturalId) {
Optional<T> entity = entityManager.unwrap(Session.class)
.bySimpleNaturalId(this.getDomainClass())
.loadOptional(naturalId);
return entity;
}
@Override
public Optional<T> findByNaturalId(Map<String, Object> naturalIds) {
NaturalIdLoadAccess<T> loadAccess
= entityManager.unwrap(Session.class)
.byNaturalId(this.getDomainClass());
naturalIds.forEach(loadAccess::using);
return loadAccess.loadOptional();
}
}
此外,您必须告诉 Spring 使用这个定制的库基类来代替默认的库基类。这可以通过@EnableJpaRepositories
注释的repositoryBaseClass
属性很容易地完成:
@SpringBootApplication
@EnableJpaRepositories(repositoryBaseClass = NaturalRepositoryImpl.class)
public class MainApplication {
...
}
测试时间
现在,让我们基于前面的实现尝试在 Spring 风格中使用@NaturalId
。首先,为Book
实体定义一个经典的 Spring 存储库。这一次,延长NaturalRepository
如下:
@Repository
public interface BookRepository<T, ID>
extends NaturalRepository<Book, Long> {
}
进一步,让我们持久化两本书(两个Book
实例)。一个isbn
等于 001-AR ,另一个isbn
等于 002-RH 。由于isbn
是自然 ID,我们取第一个Book
如下:
Optional<Book> foundArBook
= bookRepository.findBySimpleNaturalId("001-AR");
后台触发的 SQL 语句如下:
SELECT
book_.id AS id1_0_
FROM book book_
WHERE book_.isbn = ?
SELECT
book0_.id AS id1_0_0_,
book0_.isbn AS isbn2_0_0_,
book0_.price AS price3_0_0_,
book0_.title AS title4_0_0_
FROM book book0_
WHERE book0_.id = ?
有两个查询?!是的,你没看错!第一个SELECT
被触发以获取对应于指定的自然 ID 的实体标识符。触发第二个SELECT
通过第一个SELECT
获取的标识符获取实体。主要地,这种行为是由持久化上下文中实体的标识符如何存储来决定的。
显然,触发两个SELECT
语句可以被解释为潜在的性能损失。然而,如果实体存在于(已经加载)当前的持久性上下文中,那么这两个语句都不会被触发。此外,二级缓存可用于优化实体标识符检索,如第 70 项所述。
复合自然标识
当多个字段用@NaturalId
标注时,得到一个复合自然 ID。对于复合自然 ID,开发人员必须通过指定所有的 ID 来执行查找操作;否则,结果是类型为Entity [...] defines its natural-id with n properties but only k were specified
的异常。
例如,假设Book
实体有一个sku
字段作为另一个自然 ID。所以,isbn
和sku
代表一个复合的自然 ID:
@Entity
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private int price;
@NaturalId(mutable = false)
@Column(nullable = false, updatable = false, unique = true, length = 50)
private String isbn;
@NaturalId(mutable = false)
@Column(nullable = false, updatable = false, unique = true)
private Long sku;
//getters and setters omitted for brevity
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (getClass() != o.getClass()) {
return false;
}
Book other = (Book) o;
return Objects.equals(isbn, other.getIsbn())
&& Objects.equals(sku, other.getSku());
}
@Override
public int hashCode() {
return Objects.hash(isbn, sku);
}
@Override
public String toString() {
return "Book{" + "id=" + id + ", title=" + title
+ ", isbn=" + isbn + ", price=" + price + ", sku=" + sku + '}';
}
}
让我们假设由isbn
001-AR 和sku
1 标识的Book
的存在。通过findByNaturalId()
可以找到这个Book
,如下:
Map<String, Object> ids = new HashMap<>();
ids.put("sku", 1L);
ids.put("isbn", "001-AR");
Optional<Book> foundArBook = bookRepository.findByNaturalId(ids);
后台触发的 SQL 语句有:
SELECT
book_.id AS id1_0_
FROM book book_
WHERE book_.isbn = ? AND book_.sku = ?
SELECT
book0_.id AS id1_0_0_,
book0_.isbn AS isbn2_0_0_,
book0_.price AS price3_0_0_,
book0_.sku AS sku4_0_0_,
book0_.title AS title5_0_0_
FROM book book0_
WHERE book0_.id = ?
完整的代码可以在 GitHub 7 上找到。
第 70 项:如何使用 Hibernate 特有的@NaturalId 并跳过实体标识符检索
在继续这个之前,考虑一下第 69 项。此外,来自项目 69 的Book
实体被认为是众所周知的。
通过自然 ID 获取实体需要两条SELECT
语句。一个SELECT
获取与给定自然 ID 相关联的实体的标识符,一个SELECT
通过该标识符获取实体。第二个SELECT
语句没什么特别的。当开发者调用findById()
时,这个SELECT
也被触发。如果与给定标识符相关联的实体不在持久上下文或二级缓存中,那么这个SELECT
将从数据库中获取它。但是,第一个SELECT
只特定于通过自然 ID 获取的实体。每当实体标识符未知时触发此SELECT
表示性能损失。
然而,Hibernate 提供了一个解决方法。这个变通方法是@NaturalIdCache
。该注释在实体级用于指定与被注释实体相关联的自然 ID 值应该缓存在二级缓存中(如果没有指定区域,则使用{
entity-name
}##NaturalId
)。除了@NaturalIdCache
之外,实体也可以用@Cache
标注(不一定要同时有两个标注)。这样,实体本身也会被缓存。然而,当使用@Cache
时,注意以下关于选择缓存策略的注意事项很重要。
READ_ONLY
缓存策略只是不可变实体的一个选项。TRANSACTIONAL
缓存策略是特定于 JTA 环境的,其同步缓存机制导致性能不佳。NONSTRICT_READ_WRITE
缓存策略将依赖于通读数据获取策略;因此,仍然需要第一个SELECT
将数据带入二级缓存。最后,READ_WRITE
缓存策略是一种异步直写缓存并发策略,它服务于这里的目的。详情见附录 G 。
在持久化时,如果实体标识符是已知的(例如,有手动分配的 id、SEQUENCE
和TABLE
生成器等。)然后,在自然 ID 旁边,实体本身通过直写被缓存。因此,通过自然 ID 获取这个实体不会影响数据库(不需要 SQL 语句)。另一方面,如果实体标识符在持久化时未知,则实体本身不会通过直写进行缓存。使用IDENTITY
生成器(或本机生成器类型),只缓存指定的自然 ID 和数据库返回的本机生成的身份值。在获取时,从二级高速缓存中获取与该自然 ID 相关联的实体的标识符。此外,通过SELECT
语句从数据库中提取相应的实体,并通过通读数据提取策略将其存储在二级缓存中。后续提取不会命中数据库。然而,在插入时将具有数据库生成的 id 的实体放入二级缓存中是一个公开的问题,其主要优先级在 HHH-79648。
仅使用@NaturalIdCache
仅将@NaturalIdCache
添加到Book
实体将产生以下代码:
@Entity
@NaturalIdCache
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private int price;
@NaturalId(mutable = false)
@Column(nullable = false, updatable = false, unique = true, length = 50)
private String isbn;
// code omitted for brevity
}
考虑一个Book
被保存在数据库中,数据库生成的id
1 和isbn
001-AR 。日志输出揭示了当前事务中相关操作的以下序列:
begin
Executing identity-insert immediately
insert into book (isbn, price, title) values (?, ?, ?)
Natively generated identity: 1
committing
您可以(第一次)通过自然 ID 获取此实体,如下所示:
Optional<Book> foundArBook
= bookRepository.findBySimpleNaturalId("001-AR");
从二级高速缓存中取出自然 ID。相关日志如下所示:
begin
Getting cached data from region [`Book##NaturalId` (AccessType[read-
write])] by key [com.bookstore.entity.Book##NaturalId[001-AR]]
Cache hit : region = `Book##NaturalId`, key =
`com.bookstore.entity.Book##NaturalId[001-AR]`
...
Book
没有缓存在二级缓存中;因此,它是从数据库中提取的:
...
select book0_.id as id1_0_0_, book0_.isbn as isbn2_0_0_, book0_.price as
price3_0_0_, book0_.title as title4_0_0_ from book book0_ where
book0_.id=?
Done materializing entity [com.bookstore.entity.Book#1]
committing
仅使用@NaturalIdCache
将在二级缓存中缓存自然 id。因此,它消除了获取与给定自然 ID 相关联的实体的未知标识符所需的SELECT
。实体不缓存在二级缓存中。当然,它们仍然缓存在持久性上下文中。
使用@NaturalIdCache 和@Cache
将@NaturalIdCache
和@Cache
添加到Book
实体将产生以下代码:
@Entity
@NaturalIdCache
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "Book")
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private int price;
@NaturalId(mutable = false)
@Column(nullable = false, updatable = false, unique = true, length = 50)
private String isbn;
// code omitted for brevity
}
考虑一个Book
被保存在数据库中,数据库生成的id
1 和isbn
001-AR 。日志输出揭示了当前事务中相关操作的以下序列:
begin
Executing identity-insert immediately
insert into book (isbn, price, title) values (?, ?, ?)
Natively generated identity: 1
committing
您可以(第一次)通过自然 ID 获取此实体,如下所示:
Optional<Book> foundArBook
= bookRepository.findBySimpleNaturalId("001-AR");
从二级高速缓存中取出自然 ID。相关日志如下所示:
begin
Getting cached data from region [`Book##NaturalId` (AccessType[read-
write])] by key [com.bookstore.entity.Book##NaturalId[001-AR]]
Cache hit : region = `Book##NaturalId`, key =
`com.bookstore.entity.Book##NaturalId[001-AR]`
...
此外,JPA 持久性提供者试图获取Book
实体,但是这个实体还没有缓存在二级缓存中(还记得 HHH-7964 9 )。日志输出非常清楚:
...
Getting cached data from region [`Book` (AccessType[read-write])]
by key [com.bookstore.entity.Book#1]
Cache miss : region = `Book`, key = `com.bookstore.entity.Book#1`
...
由于Book
不在二级缓存中,所以必须从数据库中加载Book
:
...
select book0_.id as id1_0_0_, book0_.isbn as isbn2_0_0_, book0_.price as
price3_0_0_, book0_.title as title4_0_0_ from book book0_
where book0_.id=?
...
这次Book
是通过通读缓存的。这个日志又是相关的:
...
Adding entity to second-level cache: [com.bookstore.entity.Book#1]
Caching data from load [region=`Book` (AccessType[read-write])] :
key[com.bookstore.entity.Book#1] ->
value[CacheEntry(com.bookstore.entity.Book)]
Done entity load : com.bookstore.entity.Book#1
committing
后续提取不会命中数据库。自然 ID 和实体都在二级缓存中。
使用@NaturalIdCache
将自然 id 缓存在二级缓存中;因此,它消除了获取与给定自然 ID 相关联的实体的未知标识符所需的SELECT
。使用READ_WRITE
策略将@Cache
添加到等式中会导致以下两种行为:
-
对于
IDENTITY
(或原生生成器类型),实体将通过通读进行缓存(记得 HHH-7964 9 )。 -
对于手动分配的 id,
SEQUENCE
和TABLE
生成器等。,实体将通过直写缓存,这显然是更好的方式。数据库序列是使用 JPA 和 Hibernate ORM 时的最佳标识符生成器选择,但并非所有数据库都支持它们(例如,虽然 PostgreSQL、Oracle、SQL Server 2012、DB2、HSQLDB 等数据库支持数据库序列,但 MySQL 不支持)。或者,MySQL 可以依赖于
TABLE
生成器,但这不是一个好的选择(参见 Item 65 )。因此,在 MySQL 的情况下,依靠IDENTITY
生成器和通读比依靠TABLE
生成器和通读要好。
完整的代码可以在 GitHub 10 上找到。这段代码使用了 MySQL。
第 71 项:如何定义引用@NaturalId 列的关联
如果你不熟悉 Hibernate 特有的@NaturalId
以及在 Spring Boot 如何使用,可以考虑第 69 项和第 70 项。
考虑以下通过email
字段定义自然 ID 的Author
实体:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int age;
private String name;
private String genre;
@NaturalId(mutable = false)
@Column(nullable = false, updatable = false, unique = true, length = 50)
private String email;
...
}
现在,让我们假设Book
实体应该定义一个不引用Author
主键的关联。更准确地说,这种关联指的是email
的自然本我。为此,你可以依靠@JoinColumn
和referencedColumnName
元素。此元素的值是应该用作外键的数据库列的名称:
@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(referencedColumnName = "email")
private Author author;
...
}
一般来说,关联可以引用任何列(不仅仅是自然 ID 列),只要该列包含唯一值。
测试时间
考虑图 7-4 所示的数据快照。
图 7-4
数据快照
注意book.author_email
列,它代表外键并引用author.email
列。下面的服务方法通过标题获取一本书,并调用getAuthor()
来延迟获取作者:
@Transactional(readOnly = true)
public void fetchBookWithAuthor() {
Book book = bookRepository.findByTitle("Anthology gaps");
Author author = book.getAuthor();
System.out.println(book);
System.out.println(author);
}
触发取作者的SELECT
如下:
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.email AS email3_0_0_,
author0_.genre AS genre4_0_0_,
author0_.name AS name5_0_0_
FROM author author0_
WHERE author0_.email = ?
完整的应用可在 GitHub 11 上获得。
第 72 项:如何获取自动生成的密钥
考虑下面的Author
实体,它将密钥生成委托给数据库系统:
@Entity
public class Author implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int age;
private String name;
private String genre;
...
}
现在,让我们看看如何通过getId()
、JdbcTemplate
和SimpleJdbcInsert
检索数据库自动生成的主键。
通过 getId()检索自动生成的键
在 JPA 风格中,您可以通过getId()
检索自动生成的密钥,如下例所示:
public void insertAuthorGetAutoGeneratedKeyViaGetId() {
Author author = new Author();
author.setAge(38);
author.setName("Alicia Tom");
author.setGenre("Anthology");
authorRepository.save(author);
long pk = author.getId();
System.out.println("Auto generated key: " + pk);
}
通过 JdbcTemplate 检索自动生成的键
您可以使用JdbcTemplate
通过update()
方法检索自动生成的密钥。这个方法有不同的风格,但是这里需要的签名是:
public int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder) throws DataAccessException
PreparedStatementCreator
是一个函数接口,它接受java.sql.Connection
的一个实例并返回一个java.sql.PreparedStatement
对象。KeyHolder
对象包含由update()
方法返回的自动生成的密钥。在代码中,如下所示:
@Repository
public class JdbcTemplateDao implements AuthorDao {
private static final String SQL_INSERT
= "INSERT INTO author (age, name, genre) VALUES (?, ?, ?);";
private final JdbcTemplate jdbcTemplate;
public JdbcTemplateDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
@Transactional
public long insertAuthor(int age, String name, String genre) {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection
.prepareStatement(SQL_INSERT, Statement.RETURN_GENERATED_KEYS);
ps.setInt(1, age);
ps.setString(2, name);
ps.setString(3, genre);
return ps;
}, keyHolder);
return keyHolder.getKey().longValue();
}
}
在本例中,PreparedStatement
被指示通过Statement.RETURN_GENERATED_KEYS
返回自动生成的密钥。或者,同样的事情可以如下完成:
// alternative 1
PreparedStatement ps = connection
.prepareStatement(SQL_INSERT, new String[]{"id"});
// alternative 2
PreparedStatement ps = connection
.prepareStatement(SQL_INSERT, new int[] {1});
通过 SimpleJdbcInsert 检索自动生成的键
因此,您可以调用SimpleJdbcInsert.executeAndReturnKey()
方法向author
表中插入一条新记录,并取回自动生成的键:
@Repository
public class SimpleJdbcInsertDao implements AuthorDao {
private final SimpleJdbcInsert simpleJdbcInsert;
public SimpleJdbcInsertDao(DataSource dataSource) {
this.simpleJdbcInsert = new SimpleJdbcInsert(dataSource)
.withTableName("author").usingGeneratedKeyColumns("id");
}
@Override
@Transactional
public long insertAuthor(int age, String name, String genre) {
return simpleJdbcInsert.executeAndReturnKey(
Map.of("age", age, "name", name, "genre", genre)).longValue();
}
}
完整的应用可在 GitHub 12 上获得。
第 73 项:如何生成自定义序列 id
第 66 项和第 67 项深入讨论了 hi/lo 算法及其优化。现在,让我们假设应用需要定制的基于序列的 id。例如,A-0000000001
、A-0000000002
、A-0000000003..
类型的 id。您可以通过扩展特定于 Hibernate 的SequenceStyleGenerator
并覆盖generate()
和configure()
方法来生成这些类型的 id(以及任何其他定制模式),如下所示:
public class CustomSequenceIdGenerator extends SequenceStyleGenerator {
public static final String PREFIX_PARAM = "prefix";
public static final String PREFIX_DEFAULT_PARAM = "";
private String prefix;
public static final String NUMBER_FORMAT_PARAM = "numberFormat";
public static final String NUMBER_FORMAT_DEFAULT_PARAM = "%d";
private String numberFormat;
@Override
public Serializable generate(SharedSessionContractImplementor session,
Object object) throws HibernateException {
return prefix + String.format(numberFormat,
super.generate(session, object));
}
@Override
public void configure(Type type, Properties params,
ServiceRegistry serviceRegistry) throws MappingException {
super.configure(LongType.INSTANCE, params, serviceRegistry);
prefix = ConfigurationHelper.getString(
PREFIX_PARAM, params, PREFIX_DEFAULT_PARAM);
numberFormat = ConfigurationHelper.getString(
NUMBER_FORMAT_PARAM, params, NUMBER_FORMAT_DEFAULT_PARAM);
}
}
顾名思义,调用generate()
方法来生成 ID。它的实现有两个步骤:通过super.generate()
从序列中提取下一个值,然后使用提取的值生成一个自定义 ID。
在实例化CustomSequenceIdGenerator
时调用configure()
方法。它的实现有两个步骤:它将Type
设置为LongType
,因为序列产生Long
值,然后它处理发电机参数设置如下:
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "hilopooledlo")
@GenericGenerator(name = "hilopooledlo",
strategy = "com.bookstore.generator.id.StringPrefixedSequenceIdGenerator",
parameters = {
@Parameter(name = CustomSequenceIdGenerator.SEQUENCE_PARAM,
value = "hilo_sequence"),
@Parameter(name = CustomSequenceIdGenerator.INITIAL_PARAM,
value = "1"),
@Parameter(name = CustomSequenceIdGenerator.OPT_PARAM,
value = "pooled-lo"),
@Parameter(name = CustomSequenceIdGenerator.INCREMENT_PARAM,
value = "100"),
@Parameter(name = CustomSequenceIdGenerator.PREFIX_PARAM,
value = "A-"),
@Parameter(name = CustomSequenceIdGenerator.NUMBER_FORMAT_PARAM,
value = "%010d")
}
)
private String id;
从这个例子开始,您可以实现任何种类的定制的基于序列的 IDs。完整的应用可在 GitHub 13 上获得。
项目 74:如何有效地实现复合主键
复合主键由两列(或更多列)组成,它们共同充当给定表的主键。
让我们快速考虑几个关于简单主键和复合主键的问题:
-
通常,主键(和外键)有一个默认索引,但是您也可以创建其他索引。
-
小主键(例如,数字键)导致小索引。大主键(例如,复合键和 UUID 键)会产生大索引。主键越小越好。从性能角度来看(所需空间和索引使用),数字主键是最佳选择。
-
复合主键导致大索引。因为它们很慢(想想
JOIN
语句),所以应该避免使用。或者,至少尽可能减少所涉及的列的数量,因为多列索引占用的内存也更大。 -
主键可以用在
JOIN
语句中,这是保持主键小的另一个原因。 -
主键应该很小,但仍然是唯一的。这在集群环境中可能是个问题,因为数字主键很容易发生冲突。为了避免集群环境中的冲突,大多数关系数据库依赖于数字序列。换句话说,集群中的每个节点都有自己的偏移量用于生成标识符。或者,但不是更好的,是使用 UUID 主键。UUIDs 在聚集索引中会带来性能损失,因为它们缺乏顺序性,而且它们占用的内存也更大(有关详细信息,请查看本文的最后一节)。
-
在表之间共享主键通过使用更少的索引和没有外键列来减少内存占用(见
@MapsId
、第 11 项)。因此,请使用共享主键!
正如第三点所强调的,组合键不是很有效,应该避免使用。如果你不能避免它们,至少正确地实现它们。组合键应该遵守以下四条规则:
-
组合键类必须是
public
-
组合键类必须实现
Serializable
-
组合键必须定义
equals()
和hashCode()
-
组合键必须定义一个无参数构造函数
现在,让我们假设Author
和Book
是涉及一个懒惰的双向@OneToMany
关联的两个实体。Author
标识符是由name
和age
列组成的复合标识符。Book
实体使用这个组合键来引用它自己的Author
。Book
标识符是一个典型的数据库生成的数字标识符。
要定义Author
的复合主键,可以依靠@Embeddable - @EmbeddedId
对或@IdClass
JPA 注释。
通过@ Embeddable 和@EmbeddedId 的组合键
第一步包括在一个单独的类中提取组合键列,并用@Embeddable
对其进行注释。因此,提取名为AuthorId
的类中的name
和age
列,如下所示:
@Embeddable
public class AuthorId implements Serializable {
private static final long serialVersionUID = 1L;
@Column(name = "name")
private String name;
@Column(name = "age")
private int age;
public AuthorId() {
}
public AuthorId(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
@Override
public int hashCode() {
int hash = 3;
hash = 23 * hash + Objects.hashCode(this.name);
hash = 23 * hash + this.age;
return hash;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final AuthorId other = (AuthorId) obj;
if (this.age != other.age) {
return false;
}
if (!Objects.equals(this.name, other.name)) {
return false;
}
return true;
}
@Override
public String toString() {
return "AuthorId{" + "name=" + name + ", age=" + age + '}';
}
}
所以,AuthorId
是Author
实体的复合主键。在代码中,这相当于添加一个用@EmbeddedId
注释的AuthorId
类型的字段,如下所示:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@EmbeddedId
private AuthorId id;
private String genre;
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();
public void addBook(Book book) {
this.books.add(book);
book.setAuthor(this);
}
public void removeBook(Book book) {
book.setAuthor(null);
this.books.remove(book);
}
public void removeBooks() {
Iterator<Book> iterator = this.books.iterator();
while (iterator.hasNext()) {
Book book = iterator.next();
book.setAuthor(null);
iterator.remove();
}
}
public AuthorId getId() {
return id;
}
public void setId(AuthorId id) {
this.id = id;
}
public String getGenre() {
return genre;
}
public void setGenre(String genre) {
this.genre = genre;
}
public List<Book> getBooks() {
return books;
}
public void setBooks(List<Book> books) {
this.books = books;
}
@Override
public String toString() {
return "Author{" + "id=" + id + ", genre=" + genre + '}';
}
}
Book
实体使用AuthorId
组合键来引用它自己的Author
。为此,@ManyToOne
映射使用了属于组合键的两列:
@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)
@JoinColumns({
@JoinColumn(
name = "name",
referencedColumnName = "name"),
@JoinColumn(
name = "age",
referencedColumnName = "age")
})
private Author author;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
public Author getAuthor() {
return author;
}
public void setAuthor(Author author) {
this.author = author;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (getClass() != obj.getClass()) {
return false;
}
return id != null && id.equals(((Book) obj).id);
}
@Override
public int hashCode() {
return 2021;
}
@Override
public String toString() {
return "Book{" + "id=" + id + ", title="
+ title + ", isbn=" + isbn + '}';
}
}
测试时间
让我们考虑几个涉及到对Author
实体操作的常见操作。让我们看看触发的 SQL 语句。
坚持一个作者和三本书
首先,我们来坚持一个作者有三本书。注意我们如何实例化AuthorId
来创建作者的主键:
@Transactional
public void addAuthorWithBooks() {
Author author = new Author();
author.setId(new AuthorId("Alicia Tom", 38));
author.setGenre("Anthology");
Book book1 = new Book();
book1.setIsbn("001-AT");
book1.setTitle("The book of swords");
Book book2 = new Book();
book2.setIsbn("002-AT");
book2.setTitle("Anthology of a day");
Book book3 = new Book();
book3.setIsbn("003-AT");
book3.setTitle("Anthology today");
author.addBook(book1);
author.addBook(book2);
author.addBook(book3);
authorRepository.save(author);
}
调用addAuthorWithBooks()
将触发以下 SQL 语句:
SELECT
author0_.age AS age1_0_1_,
author0_.name AS name2_0_1_,
author0_.genre AS genre3_0_1_,
books1_.age AS age4_1_3_,
books1_.name AS name5_1_3_,
books1_.id AS id1_1_3_,
books1_.id AS id1_1_0_,
books1_.age AS age4_1_0_,
books1_.name AS name5_1_0_,
books1_.isbn AS isbn2_1_0_,
books1_.title AS title3_1_0_
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.age = books1_.age
AND author0_.name = books1_.name
WHERE author0_.age = ?
AND author0_.name = ?
INSERT INTO author (genre, age, name)
VALUES (?, ?, ?)
INSERT INTO book (age, name, isbn, title)
VALUES (?, ?, ?, ?)
INSERT INTO book (age, name, isbn, title)
VALUES (?, ?, ?, ?)
INSERT INTO book (age, name, isbn, title)
VALUES (?, ?, ?, ?)
事情的发生与简单主键的情况完全一样。由于这是一个显式分配的主键,Hibernate 触发一个SELECT
来确保数据库中没有其他记录具有这个 ID。一旦确定了这一点,Hibernate 就会触发适当的INSERT
语句,一个针对author
表,三个针对book
表。
按名字查找作者
name
列是复合主键的一部分,但是它也可以在查询中使用。以下查询按姓名查找作者。注意我们是如何通过id
引用name
列的:
@Query("SELECT a FROM Author a WHERE a.id.name = ?1")
public Author fetchByName(String name);
调用fetchByName()
的服务方法可以编写如下:
@Transactional(readOnly = true)
public void fetchAuthorByName() {
Author author = authorRepository.fetchByName("Alicia Tom");
System.out.println(author);
}
调用fetchAuthorByName()
将触发下面的SELECT
语句:
SELECT
author0_.age AS age1_0_,
author0_.name AS name2_0_,
author0_.genre AS genre3_0_
FROM author author0_
WHERE author0_.name = ?
事情的发生与简单主键的情况完全一样。在name
之前获取一个作者只需要一个SELECT
。类似地,我们可以通过age
获取作者,这是组合键的另一列。
拿走作者的一本书
假设我们已经通过下面的JOIN FETCH
查询加载了一个作者及其相关书籍:
@Query("SELECT a FROM Author a "
+ "JOIN FETCH a.books WHERE a.id = ?1")
public Author fetchWithBooks(AuthorId id);
让我们通过服务方法删除第一本书:
@Transactional
public void removeBookOfAuthor() {
Author author = authorRepository.fetchWithBooks(
new AuthorId("Alicia Tom", 38));
author.removeBook(author.getBooks().get(0));
}
调用removeBookOfAuthor()
会触发以下 SQL 语句:
SELECT
author0_.age AS age1_0_0_,
author0_.name AS name2_0_0_,
books1_.id AS id1_1_1_,
author0_.genre AS genre3_0_0_,
books1_.age AS age4_1_1_,
books1_.name AS name5_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.title AS title3_1_1_,
books1_.age AS age4_1_0__,
books1_.name AS name5_1_0__,
books1_.id AS id1_1_0__
FROM author author0_
INNER JOIN book books1_
ON author0_.age = books1_.age
AND author0_.name = books1_.name
WHERE (author0_.age, author0_.name)=(?, ?)
DELETE FROM book WHERE id = ?
事情的发生与简单主键的情况完全一样。只注意SELECT
语句的WHERE
子句。WHERE a.id = ?1
被解释为WHERE (author0_.age, author0_.name)=(?, ?)
。
删除作者
删除作者也会级联到相关的图书:
@Transactional
public void removeAuthor() {
authorRepository.deleteById(new AuthorId("Alicia Tom", 38));
}
触发的 SQL 语句如下:
SELECT
author0_.age AS age1_0_0_,
author0_.name AS name2_0_0_,
author0_.genre AS genre3_0_0_
FROM author author0_
WHERE author0_.age = ? AND author0_.name = ?
SELECT
books0_.age AS age4_1_0_,
books0_.name AS name5_1_0_,
books0_.id AS id1_1_0_,
books0_.id AS id1_1_1_,
books0_.age AS age4_1_1_,
books0_.name AS name5_1_1_,
books0_.isbn AS isbn2_1_1_,
books0_.title AS title3_1_1_
FROM book books0_ WHERE books0_.age = ? AND books0_.name = ?
-- the below DELETE is triggered for each associated book
DELETE FROM book WHERE id = ?
DELETE FROM author
WHERE age = ? AND name = ?
事情的发生与简单主键的情况完全一样。由于要删除的数据在持久性上下文中不可用,Hibernate 通过两个SELECT
语句加载该数据(一个SELECT
用于作者,一个用于相关书籍)。此外,Hibernate 执行删除。显然,在这种背景下依靠deleteById()
是没有效率的,所以要优化删除,可以考虑第 6 项。完整的应用可在 GitHub 14 上获得。
通过@IdClass 的组合键
依赖@Embeddable
很简单,但并不总是可行的。想象一种情况,你不能修改应该成为组合键的类,所以你不能添加@Embeddable
。幸运的是,这种情况可以利用另一个名为@IdClass
的注释。该注释在类级别应用于使用组合键作为@IdClass
( name_of_the_composite_key_class
)的实体。所以,如果AuthorId
是Author
实体的组合键,那么@IdClass
的用法如下:
@Entity
@IdClass(AuthorId.class)
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private String name;
@Id
private int age;
private String genre;
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();
...
}
除了@IdClass
之外,注意组合键列用@Id
标注。这是代替@EmbeddedId
所需要的。
仅此而已!其余的代码与@Embeddable
的情况相同,包括测试结果。完整的应用可在 GitHub 15 上获得。
通用唯一标识符(UUID)怎么样?
最常用的合成标识符(或替代标识符)是数字或 UUIDs。与自然键相比,代理标识符在我们的世界中没有任何意义或对应关系。替代标识符可以由数字序列生成器(例如,身份或序列)或伪随机数生成器(例如,GUID 和 UUID)生成。
最常见的是,UUID 16 代理标识符在代理数字主键容易发生冲突的集群环境中被讨论。UUID 主键在这种环境中不容易发生冲突,并且简化了复制。例如,在 MySQL 中,UUIDs 被用作AUTO_INCREMENT
主键的替代项,而在 PostgreSQL 中,UUIDs 被用作(BIG
) SERIAL
的替代项。
回想一下,在集群环境中,大多数关系数据库依靠数字序列和每个节点不同的偏移量来避免冲突的风险。在 UUID 上使用数字序列,因为它们比 uuid 需要更少的内存(一个 UUID 需要 16 个字节,而BIGINT
需要 8 个字节,INTEGER
需要 4 个字节),并且索引使用更高效。此外,由于 UUID 不是顺序的,它们在聚集索引级别引入了性能损失。更准确地说,我们讨论一个称为索引碎片的问题,它是由 UUIDs 是随机的这一事实引起的。一些数据库(例如 MySQL 8.0)在减轻 UUID 性能损失方面有了显著的改进(有三个新函数:UUID_TO_BIN
、BIN_TO_UUID
和IS_UUID
),而其他数据库仍然容易出现这些问题。正如瑞克·詹姆斯强调的,“如果你不能避免 UUIDs(这将是我的第一个建议)...然后建议阅读他的文章 17 以深入了解主要问题和潜在的解决方案。
假设你必须使用 UUID,让我们来看看最好的方法。
通过 GenerationType 生成 UUID。汽车
使用 JPA 时,您可以通过GenerationType.AUTO
自动分配 UUID,如下例所示:
import java.util.UUID;
...
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
...
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
...
}
您可以通过服务方法轻松地插入作者,如下所示(authorRepository
只是Author
实体的经典 Spring 存储库):
public void insertAuthor() {
Author author = new Author();
author.setName("Joana Nimar");
author.setGenre("History");
author.setAge(34);
authorRepository.save(author);
}
调用insertAuthor()
将导致下面的INSERT
语句(注意突出显示的 UUID):
INSERT INTO author (age, genre, name, id)
VALUES (?, ?, ?, ?)
Binding:[34, History, Joana Nimar, 3636f5d5-2528-4a17-9a90-758aa416da18]
默认情况下,MySQL 8 将一个java.util.UUID
标识符映射到一个BINARY(255)
列类型,这太多了。一个BINARY(16)
应该更好。因此,一定要相应地调整您的模式。通过 JPA 注释(不建议在生产中使用),您可以如下使用columnDefinition
:
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(columnDefinition = "BINARY(16)")
private UUID id;
一般来说,当数据库没有 UUID 的专用类型时,使用BINARY(16)
。对于甲骨文,使用RAW(16)
。PostgreSQL 和 SQL Server 有专用于 UUID 的数据类型。
GenerationType.AUTO
和 UUIDs 也可以很好地处理插入批处理。
完整的应用可在 GitHub 18 上获得。
手动分配的 UUID
只需省略@GeneratedValue
即可手动分配 UUID:
import java.util.UUID;
...
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(columnDefinition = "BINARY(16)")
private UUID id;
...
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
...
}
此外,您可以手动分配 UUID。例如,通过UUID#randomUUID()
方法:
public void insertAuthor() {
Author author = new Author();
author.setId(UUID.randomUUID());
author.setName("Joana Nimar");
author.setGenre("History");
author.setAge(34);
authorRepository.save(author);
}
调用insertAuthor()
将导致下面的INSERT
语句(注意突出显示的 UUID):
INSERT INTO author (age, genre, name, id)
VALUES (?, ?, ?, ?)
Binding:[34, History, Joana Nimar, 24de5cbe-a542-432e-9e08-b77964dbf0d0]
完整的应用可在 GitHub 19 上获得。
特定于 Hibernate 的 uuid2
Hibernate 还可以代表您生成一个 UUID 标识符,如下所示:
import java.util.UUID;
...
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(columnDefinition = "BINARY(16)")
@GeneratedValue(generator = "uuid2")
@GenericGenerator(name = "uuid2", strategy = "uuid2")
private UUID id;
...
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
...
}
Hibernate 专用uuid2
发生器符合 RFC 4122 20 标准。它适用于java.util.UUID
、byte[]
和String
Java 类型。Hibernate ORM 还有一个名为uuid
的不符合 RFC 4122 的 UUID 生成器。应该避免使用这种传统的 UUID 发电机。
完整的应用可在 GitHub 21 上获得。
第 75 项:如何在组合键中定义关系
如果您不熟悉复合主键,建议先阅读第 74 项。也就是说,考虑双向惰性@OneToMany
关联中的Author
和Book
实体。Author
有一个由出版商和作者姓名组成的组合键。虽然作者的名字是一个String
,但出版商实际上是一个实体,更多作者可以拥有同一个出版商。Publisher
实体映射发布者名称和唯一注册码(URC):
@Entity
public class Publisher implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int urc;
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public int getUrc() {
return urc;
}
public void setUrc(int urc) {
this.urc = urc;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public int hashCode() {
int hash = 3;
hash = 79 * hash + this.urc;
hash = 79 * hash + Objects.hashCode(this.name);
return hash;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final Publisher other = (Publisher) obj;
if (this.urc != other.urc) {
return false;
}
if (!Objects.equals(this.name, other.name)) {
return false;
}
return true;
}
@Override
public String toString() {
return "Publisher{" + "id=" + id + ", urc=" + urc
+ ", name=" + name + '}';
}
}
作者主键包含Publisher
,所以复合主键类应该定义一个@ManyToOne
关系,如下所示:
@Embeddable
public class AuthorId implements Serializable {
private static final long serialVersionUID = 1L;
@ManyToOne
@JoinColumn(name = "publisher")
private Publisher publisher;
@Column(name = "name")
private String name;
public AuthorId() {
}
public AuthorId(Publisher publisher, String name) {
this.publisher = publisher;
this.name = name;
}
public Publisher getPublisher() {
return publisher;
}
public String getName() {
return name;
}
@Override
public int hashCode() {
int hash = 7;
hash = 97 * hash + Objects.hashCode(this.publisher);
hash = 97 * hash + Objects.hashCode(this.name);
return hash;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final AuthorId other = (AuthorId) obj;
if (!Objects.equals(this.name, other.name)) {
return false;
}
if (!Objects.equals(this.publisher, other.publisher)) {
return false;
}
return true;
}
@Override
public String toString() {
return "AuthorId{ " + "publisher=" + publisher
+ ", name=" + name + '}';
}
}
此外,Author
实体使用AuthorId
类作为它的标识符,就像您在“通过@ Embeddable 和@EmbeddedId 的组合键”一节中看到的一样:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@EmbeddedId
private AuthorId id;
private String genre;
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();
...
}
最后,Book
实体引用Author
标识符:
@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)
@JoinColumns({
@JoinColumn(
name = "publisher",
referencedColumnName = "publisher"),
@JoinColumn(
name = "name",
referencedColumnName = "name")
})
private Author author;
...
}
测试时间
本节考虑了几个涉及到对Author
实体操作的常见操作。让我们看看触发的 SQL 语句。
坚持发行人
要为Author
定义一个组合键,您至少需要一个Publisher
存在,所以让我们持久化一个:
@Transactional
public void addPublisher() {
Publisher publisher = new Publisher();
publisher.setName("GreatBooks Ltd");
publisher.setUrc(92284434);
publisherRepository.save(publisher);
}
这个方法触发一个简单的INSERT
:
INSERT INTO publisher (name, urc)
VALUES (?, ?)
坚持两位作者
现在,让我们使用前面持久化的发布者来定义两个作者的复合主键:
@Transactional
public void addAuthorsWithBooks() {
Publisher publisher = publisherRepository.findByUrc(92284434);
Author author1 = new Author();
author1.setId(new AuthorId(publisher, "Alicia Tom"));
author1.setGenre("Anthology");
Author author2 = new Author();
author2.setId(new AuthorId(publisher, "Joana Nimar"));
author2.setGenre("History");
Book book1 = new Book();
book1.setIsbn("001-AT");
book1.setTitle("The book of swords");
Book book2 = new Book();
book2.setIsbn("002-AT");
book2.setTitle("Anthology of a day");
Book book3 = new Book();
book3.setIsbn("003-AT");
book3.setTitle("Anthology today");
author1.addBook(book1);
author1.addBook(book2);
author2.addBook(book3);
authorRepository.save(author1);
authorRepository.save(author2);
}
调用addAuthorsWithBooks()
会触发以下 SQL 语句:
-- fetch the publisher used to shape the composite key
SELECT
publisher0_.id AS id1_2_,
publisher0_.name AS name2_2_,
publisher0_.urc AS urc3_2_
FROM publisher publisher0_
WHERE publisher0_.urc = ?
-- ensure that the first author is not in the database
SELECT
author0_.name AS name1_0_1_,
author0_.publisher AS publishe3_0_1_,
author0_.genre AS genre2_0_1_,
books1_.name AS name4_1_3_,
books1_.publisher AS publishe5_1_3_,
books1_.id AS id1_1_3_,
books1_.id AS id1_1_0_,
books1_.name AS name4_1_0_,
books1_.publisher AS publishe5_1_0_,
books1_.isbn AS isbn2_1_0_,
books1_.title AS title3_1_0_
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.name = books1_.name
AND author0_.publisher = books1_.publisher
WHERE author0_.name = ?
AND author0_.publisher = ?
-- persist the first author
INSERT INTO author (genre, name, publisher)
VALUES (?, ?, ?)
-- this author has two books
INSERT INTO book (name, publisher, isbn, title)
VALUES (?, ?, ?, ?)
INSERT INTO book (name, publisher, isbn, title)
VALUES (?, ?, ?, ?)
-- ensure that the second author is not in the database
SELECT
author0_.name AS name1_0_1_,
author0_.publisher AS publishe3_0_1_,
author0_.genre AS genre2_0_1_,
books1_.name AS name4_1_3_,
books1_.publisher AS publishe5_1_3_,
books1_.id AS id1_1_3_,
books1_.id AS id1_1_0_,
books1_.name AS name4_1_0_,
books1_.publisher AS publishe5_1_0_,
books1_.isbn AS isbn2_1_0_,
books1_.title AS title3_1_0_
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.name = books1_.name
AND author0_.publisher = books1_.publisher
WHERE author0_.name = ?
AND author0_.publisher = ?
-- persist the second author
INSERT INTO author (genre, name, publisher)
VALUES (?, ?, ?)
-- this author has a single book
INSERT INTO book (name, publisher, isbn, title)
VALUES (?, ?, ?, ?)
按名字查找作者
name
列是复合主键的一部分,但也可以在查询中使用。以下查询按姓名查找作者。注意我们是如何通过id
引用name
列的:
@Query("SELECT a FROM Author a WHERE a.id.name = ?1")
public Author fetchByName(String name);
调用fetchByName()
的服务方法可以编写如下:
@Transactional(readOnly = true)
public void fetchAuthorByName() {
Author author = authorRepository.fetchByName("Alicia Tom");
System.out.println(author);
}
调用fetchAuthorByName()
将触发以下SELECT
语句:
SELECT
author0_.name AS name1_0_,
author0_.publisher AS publishe3_0_,
author0_.genre AS genre2_0_
FROM author author0_
WHERE author0_.name = ?
SELECT
publisher0_.id AS id1_2_0_,
publisher0_.name AS name2_2_0_,
publisher0_.urc AS urc3_2_0_
FROM publisher publisher0_
WHERE publisher0_.id = ?
需要第二个SELECT
来获取刚刚获取的作者的出版商。显然,这不是很高效,但这是获取Author
标识符所要付出的代价。
拿走作者的一本书
假设我们已经通过下面的JOIN FETCH
查询加载了一个作者和相关的书籍:
@Query("SELECT a FROM Author a "
+ "JOIN FETCH a.books WHERE a.id = ?1")
public Author fetchWithBooks(AuthorId id);
此外,让我们通过服务方法移除第一本书:
@Transactional
public void removeBookOfAuthor() {
Publisher publisher = publisherRepository.findByUrc(92284434);
Author author = authorRepository.fetchWithBooks(
new AuthorId(publisher, "Alicia Tom"));
author.removeBook(author.getBooks().get(0));
}
调用removeBookOfAuthor()
会触发以下 SQL 语句:
SELECT
publisher0_.id AS id1_2_,
publisher0_.name AS name2_2_,
publisher0_.urc AS urc3_2_
FROM publisher publisher0_
WHERE publisher0_.urc = ?
SELECT
author0_.name AS name1_0_0_,
author0_.publisher AS publishe3_0_0_,
books1_.id AS id1_1_1_,
author0_.genre AS genre2_0_0_,
books1_.name AS name4_1_1_,
books1_.publisher AS publishe5_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.title AS title3_1_1_,
books1_.name AS name4_1_0__,
books1_.publisher AS publishe5_1_0__,
books1_.id AS id1_1_0__
FROM author author0_
INNER JOIN book books1_
ON author0_.name = books1_.name
AND author0_.publisher = books1_.publisher
WHERE (author0_.name, author0_.publisher)=(?, ?)
DELETE FROM book
WHERE id = ?
删除作者
删除作者也会级联到相关的图书:
@Transactional
public void removeAuthor() {
Publisher publisher = publisherRepository.findByUrc(92284434);
authorRepository.deleteById(new AuthorId(publisher, "Alicia Tom"));
}
触发的 SQL 语句非常简单。在获取出版商、作者及其相关书籍的三个SELECT
语句之后,是两个DELETE
语句。因为这个作者只有一本书,所以只有一个DELETE
触发了book
表。最后,第二个DELETE
从author
表中删除相应的行:
SELECT
publisher0_.id AS id1_2_,
publisher0_.name AS name2_2_,
publisher0_.urc AS urc3_2_
FROM publisher publisher0_
WHERE publisher0_.urc = ?
SELECT
author0_.name AS name1_0_0_,
author0_.publisher AS publishe3_0_0_,
author0_.genre AS genre2_0_0_
FROM author author0_
WHERE author0_.name = ?
AND author0_.publisher = ?
SELECT
books0_.name AS name4_1_0_,
books0_.publisher AS publishe5_1_0_,
books0_.id AS id1_1_0_,
books0_.id AS id1_1_1_,
books0_.name AS name4_1_1_,
books0_.publisher AS publishe5_1_1_,
books0_.isbn AS isbn2_1_1_,
books0_.title AS title3_1_1_
FROM book books0_
WHERE books0_.name = ?
AND books0_.publisher = ?
DELETE FROM book
WHERE id = ?
DELETE FROM author
WHERE name = ?
AND publisher = ?
看起来组合键中的映射关系在技术上是可行的,但是,在查询级别,它并不高效。Hibernate 每次需要构造实体标识符的时候,都要触发一个额外的SELECT
。但是,如果这部分实体标识符可以存储在二级缓存中,那么这个额外的SELECT
就可以减轻。
完整的应用可在 GitHub 22 上获得。
第 76 项:如何为连接表使用实体
考虑图 7-5 所示的多对多关联的连接表。
图 7-5
多对多表关系
正如所料,author_book
表映射了author
和book
表的主键。但是如何向这个表中添加更多的列呢?例如,如何添加一个列作为publishedOn
来存储每本书出版的日期?到目前为止,这是不可能的!
如果您为该表定义了一个实体,则可以在author_book
连接表中添加更多的列。
为连接表定义复合主键
如果不熟悉组合键,可以考虑第 74 项。
第一步是通过@Embeddable
将author_id
和book_id
键连接成一个组合键,如下所示(这是对应于连接表的实体的主键):
@Embeddable
public class AuthorBookId implements Serializable {
private static final long serialVersionUID = 1L;
@Column(name = "author_id")
private Long authorId;
@Column(name = "book_id")
private Long bookId;
public AuthorBookId() {
}
public AuthorBookId(Long authorId, Long bookId) {
this.authorId = authorId;
this.bookId = bookId;
}
// getters omitted for brevity
@Override
public int hashCode() {
int hash = 7;
hash = 31 * hash + Objects.hashCode(this.authorId);
hash = 31 * hash + Objects.hashCode(this.bookId);
return hash;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final AuthorBookId other = (AuthorBookId) obj;
if (!Objects.equals(this.authorId, other.authorId)) {
return false;
}
if (!Objects.equals(this.bookId, other.bookId)) {
return false;
}
return true;
}
}
为连接表定义一个实体
此外,使用专用实体映射连接表:
@Entity
public class AuthorBook implements Serializable {
private static final long serialVersionUID = 1L;
@EmbeddedId
private AuthorBookId id;
@MapsId("authorId")
@ManyToOne(fetch = FetchType.LAZY)
private Author author;
@MapsId("bookId")
@ManyToOne(fetch = FetchType.LAZY)
private Book book;
private Date publishedOn = new Date();
public AuthorBook() {
}
public AuthorBook(Author author, Book book) {
this.author = author;
this.book = book;
this.id = new AuthorBookId(author.getId(), book.getId());
}
// getters and setters omitted for brevity
@Override
public int hashCode() {
int hash = 7;
hash = 29 * hash + Objects.hashCode(this.author);
hash = 29 * hash + Objects.hashCode(this.book);
return hash;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final AuthorBook other = (AuthorBook) obj;
if (!Objects.equals(this.author, other.author)) {
return false;
}
if (!Objects.equals(this.book, other.book)) {
return false;
}
return true;
}
}
插入作者和书
最后,我们需要将Author
和Book
插入到AuthorBook
中。换句话说,Author
和Book
应该为author
和book
属性定义一个@OneToMany
:
@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(mappedBy = "author",
cascade = CascadeType.ALL, orphanRemoval = true)
private List<AuthorBook> books = new ArrayList<>();
// getters and setters omitted for brevity
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (getClass() != obj.getClass()) {
return false;
}
return id != null && id.equals(((Author) obj).id);
}
@Override
public int hashCode() {
return 2021;
}
}
这里是Book
:
@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;
@OneToMany(mappedBy = "book",
cascade = CascadeType.ALL, orphanRemoval = true)
private List<AuthorBook> authors = new ArrayList<>();
// getters and setters omitted for brevity
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (getClass() != obj.getClass()) {
return false;
}
return id != null && id.equals(((Book) obj).id);
}
@Override
public int hashCode() {
return 2021;
}
}
此时,连接表有了一个实体,多对多关联已经转换为两个双向的一对多关联。
*八、计算属性
项目 77:如何映射计算的非持久属性
此项是关于根据持久实体属性映射实体的计算非持久属性。它使用图 8-1 所示的Book
实体映射。
图 8-1
图书类图
每本书都有一个价格,通过名为price
的持久字段映射。并且,基于price
,开发人员必须计算非持久字段discounted
的值。这是打了折扣的价格。我们假设每本书都有 25%的折扣。换句话说,在加载了一个Book
之后,getDiscounted()
属性应该返回应用了折扣的价格,price - price * 0.25
。
JPA 快速方法
JPA 快速方法包括用 JPA @Transient
注释getDiscounted()
属性,如下所示:
@Transient
public double getDiscounted() {
return this.price - this.price * 0.25;
}
这意味着每次调用getDiscounted()
方法时都会执行计算。如果计算相当复杂(例如,依赖于其他计算)或者必须多次调用该属性,则此实现不如下面的实现有效。
JPA @PostLoad
更好的方法包括两个步骤:
-
用
@Transient
注释discounted
字段 -
声明一个用
@PostLoad
注释的private
方法,并计算discounted
值
在代码行中,这两个项目符号如下所示:
@Entity
public class Book implements Serializable {
...
@Transient
private double discounted;
...
public double getDiscounted() {
return discounted;
}
@PostLoad
private void postLoad() {
this.discounted = this.price - this.price * 0.25;
}
}
有关 JPA 回调的更多细节,请考虑第 104 项。
这一次,计算是在实体加载后执行的。调用getDiscounted()
将返回discounted
的值,而无需在每次调用时重复计算。
完整的代码可以在 GitHub 1 上找到。
特定于 Hibernate 的@公式
discounted
的计算也可以写成一个 SQL 查询表达式。为此,请依赖 Hibernate 特有的@Formula
注释。下面的代码片段展示了如何在这种情况下使用@Formula
:
@Entity
public class Book implements Serializable {
...
@Formula("price - price * 0.25")
private double discounted;
...
@Transient
public double getDiscounted() {
return discounted;
}
}
获取一个Book
实体将通过下面的 SQL 语句完成(注意给定的公式是查询的一部分):
SELECT
book0_.id AS id1_0_,
book0_.isbn AS isbn2_0_,
book0_.price AS price3_0_,
book0_.title AS title4_0_,
book0_.price - book0_.price * 0.25 AS formula0_
FROM book book0_
调用getDiscounted()
将在查询时返回计算出的discounted
值。
完整的代码可以在 GitHub 2 上找到。
这两种计算discounted
的方法可以归入同一类。它们都假设加载了一个Book
实体,并且使用它的持久属性来计算非持久字段discounted
的值。主要区别在于公式的写法。@PostLoad
是用 Java 写的,@Formula
是用 SQL 查询表达式写的。
项目 78:如何通过@Generated 映射计算的持久属性
这一项是关于根据其他持久实体属性映射实体的计算持久属性。它使用图 8-2 所示的Book
实体映射。
图 8-2
图书类图
每本书都有一个价格,通过名为price
的持久字段映射。并且,基于price
,开发人员必须计算持久字段discounted
的值。这是打了折扣的价格。每本书都有 25%的折扣。换句话说,保持一个给定价格的Book
应该保持discounted
为price - price * 0.25
。更新price
也应该更新discounted
字段。
此外,让我们看看如何在INSERT
和/或UPDATE
时间计算discounted
。
特定于 Hibernate 的@Generated
考虑到discounted
是Book
实体的持久字段,必须基于price
持久字段进行计算。因此,discounted
字段被物化在book
表的一列中,其值将在INSERT
或/和UPDATE
时间被计算。这是一个生成的列;它对于列就像视图对于表一样。
Hibernate 提供了@Generated
注释。通过这个注释,开发人员指示 Hibernate(而不是数据库)何时计算相关的列值。该注释的值可以是GenerationTime.INSERT
(仅INSERT
时间)或GenerationTime.ALWAYS
( INSERT
和UPDATE
时间)。如果不应该生成该值,GenerationTime.NEVER
是合适的选择。
此外,您可以为discounted
字段提供自定义的@Column
定义。根据经验,生成的列不能通过INSERT
或UPDATE
语句直接编写。在代码中,如下所示:
@Entity
public class Book implements Serializable {
@Generated(value = GenerationTime.ALWAYS)
@Column(insertable = false, updatable = false)
private double discounted;
...
public double getDiscounted() {
return discounted;
}
}
对于GenerationTime.INSERT
,该栏应标注@Column(insertable = false)
。
discounted
值的计算公式在哪里?有两种方法可以指定公式。
通过 columnDefinition 元素的公式
可以通过@Column
注释的columnDefinition
元素将公式指定为 SQL 查询表达式,如下所示:
@Generated(value = GenerationTime.ALWAYS)
@Column(insertable = false, updatable = false,
columnDefinition = "double AS (price - price * 0.25)")
private double discounted;
如果数据库模式是从 JPA 注释(例如,spring.jpa.hibernate.ddl-auto=create
)生成的,那么columnDefinition
的存在将反映在CREATE TABLE
查询中,如下所示(为GenerationTime.INSERT
生成了相同的查询):
CREATE TABLE book (
id BIGINT NOT NULL AUTO_INCREMENT,
discounted DOUBLE AS (price - price * 0.25),
isbn VARCHAR(255),
price DOUBLE PRECISION NOT NULL,
title VARCHAR(255),
PRIMARY KEY (id)
)
依赖columnDefinition
需要从 JPA 注释生成数据库模式;因此,这不能代表生产解决方案。在生产中,spring.jpa.hibernate.ddl-auto
应该被禁用(未指定)或设置为validate
,数据库迁移应该通过专用工具(如 Flyway 或 Liquibase)来管理。
显然,这适用于eclipselink.ddl-generation
(特定于 EclipseLink 持久性提供者)和任何其他用于为数据库模式生成 DDL 的类似机制。这种机制应该只用于构建数据库模式的原型。
数据库(例如 MySQL、PostgreSQL)通常识别两种生成的列:存储的和虚拟的。通过columnDefinition
,该列将采用为所用数据库设置的默认值(在 MySQL 和 PostgreSQL 中,默认值为 virtual )。这两个概念将在下一节解释。
通过创建表的公式
在生产中,应该通过CREATE TABLE
将公式指定为数据库模式的一部分,而不是在columnDefinition
中。在 MySQL 中定义生成列的语法如下(对于其他数据库考虑事项,请阅读文档):
column_name data_type [GENERATED ALWAYS] AS (expression)
[VIRTUAL | STORED] [UNIQUE [KEY]]
首先,指定列名及其数据类型。
接下来,添加可选的GENERATED ALWAYS
子句,以指示该列是生成的列。实际上,AS (expression)
表示生成了列,而可选的GENERATED ALWAYS
只是以更明确的方式强调了这一点。没有GENERATED INSERT
!
列的类型可以是VIRTUAL
或STORED
。默认情况下,如果没有明确指定类型,MySQL 会使用VIRTUAL
:
-
VIRTUAL
:不存储列值,但是在读取行时,在任何BEFORE
触发器之后立即计算列值。一个虚拟列不占用任何存储空间(InnoDB 支持虚拟列上的二级索引)。 -
STORED
:插入或更新行时,计算并存储列值。一个存储的列确实需要存储空间,并且可以被索引。
之后,指定表达式。表达式可以包含运算符、文字、不带参数的内置函数或对同一表中任何列的引用。函数必须是标量和确定性的。
最后,如果生成的列是存储的,您可以为它定义一个惟一的约束。
在CREATE TABLE
中指定公式比使用columnDefinition
更灵活,并且通过专用工具(如 Flyway 或 Liquibase)保持数据库模式的可维护性。
一个 MySQL CREATE TABLE
示例,用于存储生成的列,可以编写如下:
CREATE TABLE book (
id BIGINT NOT NULL AUTO_INCREMENT,
discounted DOUBLE GENERATED ALWAYS AS ((`price` - `price` * 0.25)) STORED,
isbn VARCHAR(255),
price DOUBLE PRECISION NOT NULL,
title VARCHAR(255),
PRIMARY KEY (id)
)
在本书捆绑的应用中,这个 DDL 是在schema-sql.sql
中加入的。但是请记住,在生产中,您应该依赖 Flyway 或 Liquibase,它们提供了自动模式迁移。
测试时间
持久化$13.99的书将生成以下 SQL 语句:
INSERT INTO book (isbn, price, title)
VALUES (?, ?, ?)
Binding:[001-AH, 13.99, Ancient History]
SELECT
book_.discounted AS discount2_0_
FROM book book_
WHERE book_.id = ?
Binding:[1], Extracted:[10.4925]
在触发INSERT
并刷新之后,Hibernate 会自动触发一个SELECT
来获取计算出的discounted
值。这是将托管实体与基础表行同步所必需的。调用getDiscounted()
将返回 10.4925 。这就是@Generated
的效果。
此外,让我们触发一个UPDATE
,将新价格设置为 9.99 美元。产生的 SQL 语句是:
UPDATE book
SET isbn = ?,
price = ?,
title = ?
WHERE id = ?
Binding:[001-AH, 9.99, Ancient History, 1]
SELECT
book_.discounted AS discount2_0_
FROM book book_
WHERE book_.id = ?
Binding:[1], Extracted:[7.4925]
在触发UPDATE
并刷新之后,Hibernate 会自动触发一个SELECT
来获取计算出的discounted
值。这是将托管实体与基础表行同步所必需的。调用getDiscounted()
将返回 7.4925 。这就是@Generated
的效果。
完整的代码可以在 GitHub 3 上找到。
第 79 项:如何在 JPQL 查询中使用带多个参数的 SQL 函数
SQL 函数(MySQL、PostgreSQL 等)的存在。)可能会导致异常,如果 Hibernate 不能识别它们的话。
选择零件中的功能
例如,MySQL concat_ws()
函数(用于通过分隔符/分隔符连接多个字符串)不被 Hibernate 识别。从 Hibernate 5.3(或者更准确地说,5.2.18)开始,这些函数可以通过MetadataBuilderContributor
注册,并通过metadata_builder_contributor
属性通知 Hibernate。
图 8-3 描绘了concat_ws()
的一个使用案例。
图 8-3
MySQL concat_ws()函数
concat_ws()
函数用于连接Book
的title
和price
(来自数据库),用空格分隔$
符号和当前日期(来自应用)。
在 Spring 风格中,您可以通过@Query
编写如下查询:
@Repository
@Transactional(readOnly = true)
public interface BookRepository extends JpaRepository<Book, Long> {
@Query(value = "SELECT concat_ws(b.title, ?1, b.price, ?2) "
+ "FROM Book b WHERE b.id = 1")
String fetchTitleAndPrice(String symbol, Instant instant);
}
在纯 JPA 风格中,您可以通过EntityManager
编写如下查询:
@Repository
public class Dao<T, ID extends Serializable> implements GenericDao<T, ID> {
@PersistenceContext
private EntityManager entityManager;
@Override
@Transactional(readOnly = true)
public String fetchTitleAndPrice(String symbol, Instant instant) {
return (String) entityManager.createQuery(
"SELECT concat_ws(b.title, :symbol, b.price, :instant) "
+ "FROM Book b WHERE b.id = 1"
)
.setParameter("symbol", symbol)
.setParameter("instant", instant)
.getSingleResult();
}
}
但是,在通过MetadataBuilderContributor
注册concat_ws()
功能之前,这些尝试都不起作用,如下所示:
public class SqlFunctionsMetadataBuilderContributor
implements MetadataBuilderContributor {
@Override
public void contribute(MetadataBuilder metadataBuilder) {
metadataBuilder.applySqlFunction(
"concat_ws",
new SQLFunctionTemplate(
StandardBasicTypes.STRING,
"concat_ws(' ', ?1, ?2, ?3, ?4)"
)
);
}
}
与前面的示例类似,您可以注册任何其他 SQL 函数。比如你可以注册著名的date_trunc()
如下:
@Override
public void contribute(MetadataBuilder metadataBuilder) {
metadataBuilder.applySqlFunction(
"date_trunc", new SQLFunctionTemplate(
StandardBasicTypes.TIMESTAMP, "date_trunc('minute', ?1)"
)
);
}
最后,在application.properties
中设置spring.jpa.properties.hibernate.metadata_builder_contributor
,如下图:
spring.jpa.properties.hibernate.metadata_builder_contributor
=com.bookstore.config.SqlFunctionsMetadataBuilderContributor
运行该代码将显示类似如下的输出:
A People's History $ 32 2019-07-16 11:17:49.949732
完整的代码可以在 GitHub 4 上找到。
WHERE 部分中的函数
在 JPA 2.1 中,可以在 JPQL 查询的WHERE
部分使用函数,而无需注册函数。JPA 2.1 引入了function()
,它采用以下参数:
-
作为第一个参数调用的函数的名称
-
函数的所有参数
让我们调用同一个concat_ws()
函数,但是这次是在WHERE
子句中:
@Transactional(readOnly = true)
@Query(value = "SELECT b FROM Book b WHERE b.isbn "
+ "= function('concat_ws', '-', ?1, ?2)")
Book fetchByIsbn(String code, String author);
从服务方法中调用fetchByIsbn()
可以如下进行:
public Book fetchBookByIsbn() {
return bookRepository.fetchByIsbn("001", "JN");
}
触发的 SQL 如下:
SELECT
book0_.id AS id1_0_,
book0_.isbn AS isbn2_0_,
book0_.price AS price3_0_,
book0_.title AS title4_0_
FROM book book0_
WHERE book0_.isbn = concat_ws('-', ?, ?)
Binding:[001, JN]
您可以按如下方式调用 SQL 函数(标准函数或自定义函数):
-
在 JPQL 查询中,只要从这里引用标准函数 5
-
在
WHERE
部分和 JPA 2.1 中,可以通过function()
调用现成的 SQL 函数 -
在
SELECT
部分,未识别的 SQL 函数必须被注册
搞定了。完整的应用可在 GitHub 6 上获得。
第 80 项:如何通过@JoinFormula 将@ManyToOne 关系映射到 SQL 查询
让我们考虑图 8-4 中的表格和图 8-5 中的数据所反映的单向@ManyToOne
关系中涉及的Author
和Book
实体。
图 8-5
数据快照
图 8-4
一对多表关系
这个场景要求你找出哪本书比给定的书便宜。换句话说,当通过 ID 获取一本书(姑且称之为书 A )时,您想要获取另一本书,名为书 B,的同一作者的书,其价格与书 A 的价格相比是第二便宜的。可以通过以下方式实现这一点:
@Transactional(readOnly = true)
public void fetchBooks() {
Book book = bookRepository.findById(7L).orElseThrow();
Book nextBook = bookRepository.fetchNextSmallerPrice(
book.getPrice(), book.getAuthor().getId());
System.out.println("Fetched book with id 7: " + book);
System.out.println("Fetched book with next smallest price: " + nextBook);
}
其中fetchNextSmallerPrice()
是以下本机查询:
@Transactional(readOnly = true)
@Query(value="SELECT * FROM book WHERE price < ?1 AND author_id = ?2 "
+ "ORDER BY price DESC LIMIT 1",
nativeQuery = true)
Book fetchNextSmallerPrice(int price, long authorId);
需要两条SELECT
语句来获取book
和nextBook
。或者,通过 Hibernate 特有的@JoinFormula
将@ManyToOne
映射到前面的查询会更简单:
@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;
private int price;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
@ManyToOne(fetch = FetchType.LAZY)
@JoinFormula("(SELECT b.id FROM book b "
+ "WHERE b.price < price AND b.author_id = author_id "
+ "ORDER BY b.price DESC LIMIT 1)")
private Book nextBook;
public Book getNextBook() {
return nextBook;
}
public void setNextBook(Book nextBook) {
this.nextBook = nextBook;
}
...
}
基于这个映射,服务方法fetchBooks()
变成了:
@Transactional(readOnly = true)
public void fetchBooks() {
Book book = bookRepository.findById(7L).orElseThrow();
Book nextBook = book.getNextBook();
System.out.println("Fetched book with id 7: " + book);
System.out.println("Fetched book with next smallest price: " + nextBook);
}
为了取出book
和nextBook
,下面的SELECT
语句被执行两次:
SELECT
book0_.id AS id1_1_0_,
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_,
(SELECT
b.id
FROM book b
WHERE b.price < book0_.price AND b.author_id = book0_.author_id
ORDER BY b.price DESC LIMIT 1)
AS formula1_0_
FROM book book0_
WHERE book0_.id = ?
Binding:[7] Extracted:[4, 003-JN, 2, 41, History Today]
第三个提取值是 2 ,对应公式结果。这是nextBook
的 ID。因此,再次执行该查询来获取带有以下参数的nextBook
:
Binding:[2] Extracted:[4, 002-JN, 1, 34, A People's History]
再次注意,第三个提取的值( 1 )对应于公式结果。这允许您继续调用getNextBook()
。没有其他更便宜的书的时候,公式结果会是 null 。
一般来说,Hibernate 特有的@JoinFormula
注释可以用来定义任何SELECT
查询,以提供两个实体之间的关系。例如,你可以用它买到某个作者最便宜的书。为此,您也要在Author
中添加一个@ManyToOne
:
@Entity
public class Author implements Serializable {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinFormula("(SELECT b.id FROM book b "
+ "WHERE b.author_id = id "
+ "ORDER BY b.price ASC LIMIT 1)")
private Book cheapestBook;
...
}
用法:
Author author = authorRepository.findById(1L).orElseThrow();
Book cheapestBook = author.getCheapestBook();
完整的应用可在 GitHub 7 上获得。
*九、监控
第 81 项:为什么以及如何计算和断言 SQL 语句
假设您有映射到表的Author
实体,如图 9-1 所示,目标是自动执行下面的简单场景:
图 9-1
作者实体表
-
从数据库加载一个
Author
-
更新该
Author
的genre
一个简单的服务方法可以实现这个场景,如下所示:
@Service
public class BookstoreService {
private final AuthorRepository authorRepository;
...
public void updateAuthor() {
Author author = authorRepository.findById(1L).orElseThrow();
author.setGenre("History");
authorRepository.save(author);
}
}
但是,这些操作是原子性的吗?它们不是,因为开发人员不小心忘记在方法级别添加@Transactional
,并且没有继承的事务上下文。每个操作将在单独的事务中运行,这将导致性能损失。代码还容易出现意外行为和数据不一致。但是,这个事故对触发的 SQL 的数量和/或类型有负面影响吗?根据预期计算和断言 SQL 语句的数量将回答这个问题。
为计数和断言触发的 SQL 语句的机制提供支持需要两个库。计数是DataSource-Proxy
库的责任。在这个库的好处中(检查 Item 83 ,它将代理数据源并提取重要信息,如绑定参数的值和执行的 SQL 语句的数量。
关键是在构建代理之前调用countQuery()
方法。这指示DataSource-Proxy
创建一个DataSourceQueryCountListener
。除了数据源名称之外,该监听器还提供了数据库调用次数、总查询执行时间以及按类型划分的查询次数等指标:
public ProxyDataSourceInterceptor(final DataSource dataSource) {
super();
this.dataSource = ProxyDataSourceBuilder.create(dataSource)
.name("DATA_SOURCE_PROXY")
.logQueryBySlf4j(SLF4JLogLevel.INFO)
.multiline()
.countQuery()
.build();
}
有了这个监听器,被触发的 SQL 语句可以通过QueryCount
API 直接计数。或者,更好的是,您可以使用db-util
库。使用这个库的优点是名为SQLStatementCountValidator
的开箱即用的自动化验证器。这个验证器公开了下面的static
断言:assertSelectCount()
、assertInsertCount()
、assertUpdateCount()
和assertDeleteCount()
。
使用这个验证器需要三个主要步骤:
-
通过
SQLStatementCountValidator.reset()
复位QueryCount
-
执行 SQL 语句
-
应用适当的断言
回到updateAuthor()
方法,开发人员没有意识到忘记添加@Transactional
,因此,判断事务上下文中的代码,SQL 语句的预期数量等于两个,一个SELECT
和一个UPDATE
。预计不会出现INSERT
或DELETE
。您可以断言预期的查询,如下所示:
private final BookstoreService bookstoreService;
...
SQLStatementCountValidator.reset();
bookstoreService.updateAuthor();
assertSelectCount(1);
assertUpdateCount(1);
assertInsertCount(0);
assertDeleteCount(0);
根据经验,这些断言可以添加到单元测试中。建议断言所有类型的操作,而不仅仅是那些您期望发生的操作。例如,如果触发了一个意外的DELETE
,而您跳过了assertDeleteCount(0)
,那么您将无法捕捉到它。
运行该应用将导致以下异常:
com.vladmihalcea.sql.exception.SQLSelectCountMismatchException: Expected 1 statements but recorded 2 instead!
如果预期的 SQL 语句的数量与已执行的 SQL 语句的数量不同,那么SQLStatementCountValidator
将抛出类型为SQL
Foo
CountMismatchException
的异常,其中Foo
是Select
、Insert
、Update
或Delete
中的一个,这取决于 SQL 类型。
因此,应用断言了一个SELECT
,但是触发了两个。为什么预期的 SQL 语句数量不正确?因为每个语句都在一个单独的事务中运行,所以实际上触发了下面的 SQL 语句(检查右边的注释会发现现实与预期相差很远):
Author author = authorRepository.findById(1L).orElseThrow(); // 1 select
author.setGenre("History");
authorRepository.save(author); // 1 select, 1 update
列出这些 SQL 语句将揭示以下内容:
-- fetch the author
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 = ?
-- the fetched author is not managed,
-- therefore it must be fetched again before updating 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 = ?
-- update the author
UPDATE author
SET age = ?,
genre = ?,
name = ?
WHERE id = ?
因此,开发人员期望两个 SQL 语句,但实际上,有三个 SQL 语句。因此,有三次数据库往返,而不是两次。这是不对的,但是,由于计算和断言 SQL 语句,这个错误没有被发现。意识到这个错误后,开发人员修复了updateAuthor()
方法,如下所示:
@Service
public class BookstoreService {
private final AuthorRepository authorRepository;
...
@Transactional
public void updateAuthor() {
Author author = authorRepository.findById(1L).orElseThrow();
author.setGenre("History");
authorRepository.save(author);
}
}
再次计数和断言表明,SQL 语句的预期数量和类型符合实际情况。这次只触发一个SELECT
和一个UPDATE
。没有 ?? 和 ??,这样更好。
但是,等等!现在,既然您提供了事务上下文,那么有必要显式调用save()
方法吗?答案是否定的!在项 107 中可以看到,这种情况下调用save()
是多余的。通过删除这个显式调用,您不会影响被触发的 SQL 的数量,因为 Hibernate 脏检查机制会代表您触发UPDATE
。所以,updateAuthor()
方法最好的写法如下(当然现实中你会把作者 ID 作为参数传递给这个方法而不会依赖orElseThrow()
;这里使用它们只是为了简洁):
@Transactional
public void updateAuthor() {
Author author = authorRepository.findById(1L).orElseThrow();
author.setGenre("History");
}
GitHub 1 上有源代码。
项目 82:如何记录预准备语句的绑定和提取参数
考虑从一个INSERT
和一个SELECT
构建的Author
实体和两个准备好的语句。显示相应的 SQL 语句将如下所示:
INSERT INTO author (age, genre, name)
VALUES (?, ?, ?)
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 = ?
注意那些问号(?
)。它们是绑定和提取参数的占位符。大多数时候,看到这些参数的真实值而不是这些占位符是很有用的。有几种方法可以实现这一点。让我们来看看其中的三个。
微量
解决这个问题的最快方法可能是启用application.properties
中的TRACE
日志记录级别,如下所示:
logging.level.org.hibernate.type.descriptor.sql=TRACE
这一次,输出如下:
insert into author (age, genre, name) values (?, ?, ?)
binding parameter [1] as [INTEGER] - [34]
binding parameter [2] as [VARCHAR] - [History]
binding parameter [3] as [VARCHAR] - [Joana Nimar]
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=?
binding parameter [1] as [BIGINT] - [1]
extracted value ([age2_0_0_] : [INTEGER]) - [34]
extracted value ([genre3_0_0_] : [VARCHAR]) - [History]
extracted value ([name4_0_0_] : [VARCHAR]) - [Joana Nimar]
对于每个参数,输出包含其类型(绑定参数或提取值)、位置或名称、数据类型和值。
GitHub 2 上有源代码。
当您使用启动器时,默认情况下 Spring Boot 依赖于 Logback。如果您不想在application.properties
中设置TRACE
日志级别,那么只需添加或创建一个 Logback 配置文件。Spring Boot 会自动识别类路径中的logback-spring.xml
、logback.xml
、logback-spring.groovy
或logback.groovy
文件并进行相应处理。下面是来自logback-spring.xml
的一个样本(完整的文件可以在 GitHub 3 上获得):
...
<logger name="org.hibernate.type.descriptor.sql"
level="trace" additivity="false">
<appender-ref ref="Console" />
</logger>
...
Log4j 2
通过Log4j
2 可以获得相同的结果。要启用它,首先要排除 Spring Boot 的默认日志记录,并添加Log4j
2 依赖项,如下所示:
<!-- Exclude Spring Boot's Default Logging -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Add Log4j2 Dependency -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
此外,配置log4j.xml
中的TRACE
级别如下(该文件应放在application.properties
旁边的/resources
文件夹中):
<Loggers>
<Logger name="org.hibernate.type.descriptor.sql" level="trace"/>
...
</Loggers>
当然,日志可以进一步调整,以符合Log4j
2 文档。
GitHub 4 上有源代码。
除了绑定和提取的参数之外,其他方法可以提供关于查询的更多细节。执行时间、批处理信息、查询类型等细节可以通过第 83 项中介绍的方法获得。
MySQL 和 profileSQL=true
仅对于 MySQL,绑定参数(不是提取的参数)通过两个步骤可见:
-
关闭
spring.jpa.show-sql
(省略或设置为false
) -
通过将
logger=Slf4JLogger&profileSQL=true
追加到 JDBC URL 来塑造它
GitHub 5 上有源代码。
项目 83:如何记录查询详细信息
要仅记录预准备语句的绑定参数和提取值,请参见第 82 项。
您可以通过几种方式获得 SQL 查询的详细信息。让我们来看看其中的三个。
通过数据源代理
DataSource-Proxy
是一个开源项目,它“通过代理为 JDBC 交互和查询执行提供了一个监听器框架”。它不依赖于其他库;一切都是可选的。它是高度可配置的、灵活的、可扩展的,并且是正确的选择。
在 Spring Boot 应用中启用该库需要几个步骤。首先,将datasource-proxy
的依赖关系添加到pom.xml
:
<dependency>
<groupId>net.ttddyy</groupId>
<artifactId>datasource-proxy</artifactId>
<version>${datasource-proxy.version}</version>
</dependency>
接下来,创建一个 bean post 处理器来拦截DataSource
bean,并通过ProxyFactory
和MethodInterceptor
的实现包装DataSource
bean。最终结果如以下代码片段所示:
private static class ProxyDataSourceInterceptor
implements MethodInterceptor {
private final DataSource dataSource;
public ProxyDataSourceInterceptor(final DataSource dataSource) {
super();
this.dataSource = ProxyDataSourceBuilder.create(dataSource)
.name("DATA_SOURCE_PROXY")
.logQueryBylf4j(SLF4JLogLevel.INFO)
.multiline()
.build();
}
...
}
这是可以定制细节级别的地方。可以使用丰富而流畅的 API 来调整细节(查看文档)。所有设置就绪后,只需调用build()
。典型的输出如下所示:
Name:DATA_SOURCE_PROXY, Connection:5, Time:131, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["insert into author (age, genre, name) values (?, ?, ?)"]
Params:[(34,History,Joana Nimar)]
GitHub 6 上有源代码。
通过 log4jdbc
log4jdbc
背后的官员声称“log4jdbc 是一个 Java jdbc 驱动程序,它可以使用 Java (SLF4J)日志记录系统的简单日志门面来记录其他 JDBC 驱动程序的 SQL 和/或 JDBC 调用(以及可选的 SQL 计时信息)”。
Spring Boot 应用可以在将它的依赖项添加到pom.xml
后立即利用log4jdbc
:
<dependency>
<groupId>com.integralblue</groupId>
<artifactId>log4jdbc-spring-boot-starter</artifactId>
<version>1.0.2</version>
</dependency>
官方文档提供了关于定制输出的详细信息。典型的输出包含 SQL(包括执行时间)、对所涉及的方法的审计以及作为表格的结果集,如图 9-2 所示。
图 9-2
log4jdbc 输出示例
GitHub 7 上有源代码。
Via P6spy
文档中说 P6Spy“…是一个框架,它能够无缝地截取数据库数据并记录日志,而无需对应用进行代码更改”。启用 P6spy 需要将pom.xml
添加到相应的依赖关系中:
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>${p6spy.version}</version>
</dependency>
此外,在application.properties
中,您设置了 JDBC URL 和驱动程序类名,如下所示:
spring.datasource.url=jdbc:p6spy:mysql://localhost:3306/bookstoredb
spring.datasource.driverClassName=com.p6spy.engine.spy.P6SpyDriver
最后,向应用根文件夹添加spy.properties
文件。该文件包含 P6Spy 配置。在这个应用中,日志将被输出到控制台,但是有一个非常简单的方法来切换到文件。关于 P6Spy 配置的更多细节可以在文档中找到。
输出可能如下所示:
insert into author (age, genre, name) values (?, ?, ?)
insert into author (age, genre, name) values (34, 'History', 'Joana Nimar');
#1562161760396 | took 0ms | commit | connection 0| url jdbc:p6spy:mysql://localhost:3306/bookstoredb?createDatabaseIfNotExist=true
GitHub 8 上有源代码。
项目 84:如何记录带有阈值的慢速查询
您可以通过DataSource-Proxy
使用 threshold 记录慢速查询。要熟悉DataSource-Proxy
,可以考虑第 83 项。
准备好DataSource-Proxy
之后,考虑以下步骤来记录缓慢的查询:
-
在 bean post 处理器中,定义一个常量,以毫秒为单位表示慢速查询的阈值:
-
此外,定义一个
SLF4JQueryLoggingListener
监听器并覆盖afterQuery()
方法,如下所示:
private static final long THRESHOLD_MILLIS = 30;
- 最后,使用这个
listener
来配置数据源代理:
SLF4JQueryLoggingListener listener
= new SLF4JQueryLoggingListener() {
@Override
public void afterQuery(ExecutionInfo execInfo,
List<QueryInfo> queryInfoList) {
// call query logging logic only // when it took more than threshold
if (THRESHOLD_MILLIS <= execInfo.getElapsedTime()) {
logger.info("Slow SQL detected ...");
super.afterQuery(execInfo, queryInfoList);
}
}
};
listener.setLogLevel(SLF4JLogLevel.WARN);
this.dataSource = ProxyDataSourceBuilder.create(dataSource)
.name("DATA_SOURCE_PROXY")
.multiline()
.listener(listener)
.build();
搞定了。现在,记录的 SQL 将只是那些超过阈值的 SQL。GitHub 9 上有源代码。
从 Hibernate 5.4.5 开始,您可以通过一个名为hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS
的新属性记录阈值为毫秒的慢速查询。您只需在application.properties
中添加该属性,并以毫秒为单位指定阈值,如下例所示:
spring.jpa.properties.hibernate.session
.events.log.LOG_QUERIES_SLOWER_THAN_MS=25
GitHub 10 上有完整的例子。如果您没有使用 Hibernate 5.4.5+,那么可以使用第三方库来记录慢速查询。
第 85 项:日志事务和查询方法详细信息
有时,为了理解数据访问层中发生的事情,您需要记录关于正在运行的事务(例如,您可能需要理解某个事务传播场景)和查询方法(例如,您可能需要记录某个query-method
的执行时间)的更多细节。
记录事务详细信息
默认情况下,logger INFO
级别不会透露正在运行的事务的细节。但是您可以通过在下面的行中添加application.properties
来轻松地公开事务细节:
logging.level.ROOT=INFO
logging.level.org.springframework.orm.jpa=DEBUG
logging.level.org.springframework.transaction=DEBUG
logging.level.org.hibernate.engine.transaction.internal.TransactionImpl=DEBUG
有时记录连接池的状态也很有用。对于 hikar ICP(Spring Boot 应用中推荐和默认的连接池),您可以通过将application.properties
添加到以下设置中来实现:
logging.level.com.zaxxer.hikari.HikariConfig=DEBUG
logging.level.com.zaxxer.hikari=DEBUG
如果你需要更多的细节,用TRACE
代替DEBUG
。
通过事务回调获得控制权
Spring Boot 允许您启用一组回调,这些回调对于在事务提交/完成之前和之后获取控制权非常有用。从全局来看(在应用级别),您可以通过 AOP 组件来实现,如下所示:
@Aspect
@Component
public class TransactionProfiler extends TransactionSynchronizationAdapter {
Logger logger = LoggerFactory.getLogger(this.getClass());
@Before("@annotation(
org.springframework.transaction.annotation.Transactional)")
public void registerTransactionSyncrhonization() {
TransactionSynchronizationManager.registerSynchronization(this);
}
@Override
public void afterCompletion(int status) {
logger.info("After completion (global) ...");
}
@Override
public void afterCommit() {
logger.info("After commit (global) ...");
}
@Override
public void beforeCompletion() {
logger.info("Before completion (global) ...");
}
@Override
public void beforeCommit(boolean readOnly) {
logger.info("Before commit (global) ...");
}
}
例如,您可以调用这个服务方法:
@Transactional
public void updateAuthor() {
Author author = authorRepository.findById(1L).orElseThrow();
author.setAge(49);
}
该日志将包含类似如下的内容:
Hibernate: 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=?
c.b.profiler.TransactionProfiler: Before commit (global) ...
c.b.profiler.TransactionProfiler: Before completion (global) ...
Hibernate: update author set age=?, genre=?, name=? where id=?
c.b.profiler.TransactionProfiler: After commit (global) ...
c.b.profiler.TransactionProfiler: After completion (global) ...
您还可以通过TransactionSynchronizationManager#registerSynchronization()
在方法级利用这些回调,如下所示:
@Transactional
public void updateAuthor() {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCompletion(int status) {
logger.info("After completion (method) ...");
}
@Override
public void afterCommit() {
logger.info("After commit (method) ...");
}
@Override
public void beforeCompletion() {
logger.info("Before completion (method) ...");
}
@Override
public void beforeCommit(boolean readOnly) {
logger.info("Before commit (method) ...");
}
});
Author author = authorRepository.findById(1L).orElseThrow();
author.setAge(51);
}
这一次,输出如下:
Hibernate: 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=?
c.b.profiler.TransactionProfiler: Before commit (method) ...
c.b.profiler.TransactionProfiler: Before completion (method) ...
Hibernate: update author set age=?, genre=?, name=? where id=?
c.b.profiler.TransactionProfiler: After commit (method) ...
c.b.profiler.TransactionProfiler: After completion (method) ...
TransactionSynchronizationManager
类提供了其他有用的方法,比如isActualTransactionActive()
、getCurrentTransactionName()
、isCurrentTransactionReadOnly()
和getCurrentTransactionIsolationLevel()
。这些方法中的每一个都有相应的 setter。
完整的应用可在 GitHub 11 上获得。
日志查询-方法执行时间
您可以通过 AOP 轻松记录查询方法的执行时间。下面的组件非常简单:
@Aspect
@Component
public class RepositoryProfiler {
Logger logger = LoggerFactory.getLogger(this.getClass());
@Pointcut("execution(public *
org.springframework.data.repository.Repository+.*(..))")
public void intercept() {
}
@Around("intercept()")
public Object profile(ProceedingJoinPoint joinPoint) {
long startMs = System.currentTimeMillis();
Object result = null;
try {
result = joinPoint.proceed();
} catch (Throwable e) {
logger.error(e.getMessage(), e);
// do whatever you want with the exception
}
long elapsedMs = System.currentTimeMillis() - startMs;
// you may like to use logger.debug
logger.info(joinPoint.getTarget()+"."+joinPoint.getSignature()
+ ": Execution time: " + elapsedMs + " ms");
// pay attention that this line may return null
return result;
}
}
例如,您可以调用这个服务方法:
@Transactional
public void updateAuthor() {
Author author = authorRepository.findById(1L).orElseThrow();
author.setAge(49);
}
那么日志将包含类似如下的内容:
c.bookstore.profiler.RepositoryProfiler : org.springframework.data.jpa.repository.support.SimpleJpaRepository@780dbed7.Optional org.springframework.data.repository.CrudRepository.findById(Object):
Execution time: 47 ms
完整的应用可在 GitHub 12 上获得。
十、配置数据源和连接池
项目 86:如何自定义 HikariCP 设置
Spring Boot 依赖 HikariCP 作为默认连接池。
在您的项目中添加spring-boot-starter-jdbc
或spring-boot-starter-data-jpa
“starters”将使用默认设置自动添加对 HikariCP 的依赖。
知道如何改变连接池的配置很重要。大多数时候,默认设置不能满足生产要求。为生产调整连接池参数的最佳方式是使用 Vlad Mihalcea 的 FlexyPool 1 。FlexyPool 可以确定维持连接池高性能所需的最佳设置。FlexyPool 只是几个令人惊叹的工具之一。更多详情,请查看附录 J 。
假设您已经为连接池设置了最佳值,本章将向您展示在 HikariCP 的生产环境中设置这些值的几种方法。
通过 application.properties 调整 HikariCP 参数
您可以在application.properties
文件中调整 HikariCP 的参数。每个参数值都可以通过将其名称作为后缀添加到以spring.datasource.hikari.*
开头的 Spring 属性来更改。*
是参数名的占位符。参数列表及其含义可在 HikariCP 文档中找到。以下代码片段显示了最常见参数的示例设置:
spring.datasource.hikari.connectionTimeout=50000
spring.datasource.hikari.idleTimeout=300000
spring.datasource.hikari.maxLifetime=900000
spring.datasource.hikari.maximumPoolSize=8
spring.datasource.hikari.minimumIdle=8
spring.datasource.hikari.poolName=MyPool
spring.datasource.hikari.connectionTestQuery=select 1 from dual
# disable auto-commit
spring.datasource.hikari.autoCommit=false
# more settings can be added as spring.datasource.hikari.*
或者,像这样:
spring.datasource.hikari.connection-timeout=50000
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=900000
spring.datasource.hikari.maximum-pool-size=8
spring.datasource.hikari.minimum-idle=8
spring.datasource.hikari.pool-name=MyPool
spring.datasource.hikari.connection-test-query=select 1 from dual
Spring Boot 处理application.properties
并根据这些值配置 HikariCP 连接池。完整的代码可以在 GitHub 2 上找到。
通过 application.properties 和 DataSourceBuilder 调整 HikariCP 参数
您可以使用application.properties
文件和DataSourceBuilder
来调整 HikariCP 的参数。该类为构建具有通用实现和属性的DataSource
提供支持。这次在application.properties
中,参数名被指定为自定义属性的后缀(如app.datasource.*
):
app.datasource.connection-timeout=50000
app.datasource.idle-timeout=300000
app.datasource.max-lifetime=900000
app.datasource.maximum-pool-size=8
app.datasource.minimum-idle=8
app.datasource.pool-name=MyPool
app.datasource.connection-test-query=select 1 from dual
# disable auto-commit
app.datasource.auto-commit=false
# more settings can be added as app.datasource.*
此外,配置DataSource
需要两步:
-
使用
@ConfigurationProperties
加载app.datasource
类型的属性 -
使用
DataSourceBuilder
构建HikariDataSource
的实例
以下代码不言自明:
@Configuration
public class ConfigureDataSource {
@Bean
@Primary
@ConfigurationProperties("app.datasource")
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@ConfigurationProperties("app.datasource")
public HikariDataSource dataSource(DataSourceProperties properties) {
return properties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
}
完整的代码可以在 GitHub 3 上找到。
通过 DataSourceBuilder 调整 HikariCP 参数
您可以通过DataSourceBuilder
以编程方式调整 HikariCP 参数。换句话说,连接池的参数通过DataSourceBuilder
API 直接设置。这可以分两步完成:
-
创建一个
HikariDataSource
的实例 -
调用专用方法来形成该数据源
除了setJdbcUrl()
、setUsername()
和setPassword()
方法之外,DataSourceBuilder
API 还公开了 HikariCP 参数的专用方法,如以下代码片段所示:
@Configuration
public class ConfigureDataSource {
@Bean
public HikariDataSource dataSource() {
HikariDataSource hds = new HikariDataSource();
hds.setJdbcUrl("jdbc:mysql://localhost:3306/numberdb"
+ "?createDatabaseIfNotExist=true");
hds.setUsername("root");
hds.setPassword("root");
hds.setConnectionTimeout(50000);
hds.setIdleTimeout(300000);
hds.setMaxLifetime(900000);
hds.setMaximumPoolSize(8);
hds.setMinimumIdle(8);
hds.setPoolName("MyPool");
hds.setConnectionTestQuery("select 1 from dual");
hds.setAutoCommit(false);
return hds;
}
}
完整的代码可以在 GitHub 4 上找到。
调整其他连接池
本论文也可以应用于其他连接池。在大局不变的情况下,开发者需要做一些小的调整,如下面的例子列表(下面的例子使用application.properties
和DataSourceBuilder
): BoneCP 5 ,C3P0 6 ,DBCP2 7 ,Tomcat, 8 和 ViburDBCP 9 。
这些示例主要遵循三个步骤:
-
在
pom.xml
(对于 Maven)中,添加连接池对应的依赖关系 -
在
application.properties
中,通过自定义前缀配置连接池,例如app.datasource.*
-
编写一个通过
DataSourceBuilder
返回DataSource
的@Bean
第 87 项:如何用两个连接池配置两个数据源
这一项处理带有两个连接池的两个数据库的配置。更准确地说,名为Author
的实体被映射到名为authorsdb
的数据库中名为author
的表,而另一个名为Book
的实体被映射到名为booksdb
的数据库中名为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;
private String books;
// 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;
private String authors;
// getters and setters omitted for brevity
}
从AuthorRepository
调用查询方法将导致针对authorsdb
数据库触发 SQL 语句,而从BookRepository
调用查询方法将导致针对booksdb
数据库触发 SQL 语句。
一、重点关注application.properties
。这里,让我们添加数据源的配置。更准确地说,让我们添加两个 JDBC URL 和连接池的配置。注意,第一个数据源使用了app.datasource.ds1
前缀,而第二个数据源使用了app.datasource.ds2
前缀:
app.datasource.ds1.url=jdbc:mysql://localhost:3306/authorsdb
?createDatabaseIfNotExist=true
app.datasource.ds1.username=root
app.datasource.ds1.password=root
app.datasource.ds1.connection-timeout=50000
app.datasource.ds1.idle-timeout=300000
app.datasource.ds1.max-lifetime=900000
app.datasource.ds1.maximum-pool-size=8
app.datasource.ds1.minimum-idle=8
app.datasource.ds1.pool-name=MyPoolDS1
app.datasource.ds1.connection-test-query=select 1 from dual
app.datasource.ds2.url=jdbc:mysql://localhost:3306/booksdb
?createDatabaseIfNotExist=true
app.datasource.ds2.username=root
app.datasource.ds2.password=root
app.datasource.ds2.connection-timeout=50000
app.datasource.ds2.idle-timeout=300000
app.datasource.ds2.max-lifetime=900000
app.datasource.ds2.maximum-pool-size=4
app.datasource.ds2.minimum-idle=4
app.datasource.ds2.pool-name=MyPoolDS2
app.datasource.ds2.connection-test-query=select 1 from dual
这些配置也可以在@Configuration
类中以编程方式设置。这里有一个例子:
@Bean
public HikariDataSource dataSource() {
HikariDataSource hds = new HikariDataSource();
hds.setJdbcUrl("jdbc:mysql://localhost:3306/numberdb
?createDatabaseIfNotExist=true");
...
return hds;
}
此外,这些设置被加载并用于在用@Configuration
注释的类中创建HikariDataSource
的实例。每个数据库都有一个关联的HikariDataSource
:
@Configuration
public class ConfigureDataSources {
// first database, authorsdb
@Primary
@Bean(name = "configAuthorsDb")
@ConfigurationProperties("app.datasource.ds1")
public DataSourceProperties firstDataSourceProperties() {
return new DataSourceProperties();
}
@Primary
@Bean(name = "dataSourceAuthorsDb")
@ConfigurationProperties("app.datasource.ds1")
public HikariDataSource firstDataSource(
@Qualifier("configAuthorsDb") DataSourceProperties properties) {
return properties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
// second database, booksdb
@Bean(name = "configBooksDb")
@ConfigurationProperties("app.datasource.ds2")
public DataSourceProperties secondDataSourceProperties() {
return new DataSourceProperties();
}
@Bean(name = "dataSourceBooksDb")
@ConfigurationProperties("app.datasource.ds2")
public HikariDataSource secondDataSource(
@Qualifier("configBooksDb") DataSourceProperties properties) {
return properties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
}
接下来,为每个HikariDataSource
,配置一个LocalContainerEntityManagerFactoryBean
和一个PlatformTransactionManager
。告诉 Spring Boot 映射到authorsdb
的实体在com.bookstore.ds1
包中;
@Configuration
@EnableJpaRepositories(
entityManagerFactoryRef = "ds1EntityManagerFactory",
transactionManagerRef = "ds1TransactionManager",
basePackages = "com.bookstore.ds1"
)
@EnableTransactionManagement
public class FirstEntityManagerFactory {
@Bean
@Primary
public LocalContainerEntityManagerFactoryBean ds1EntityManagerFactory(
EntityManagerFactoryBuilder builder,
@Qualifier("dataSourceAuthorsDb") DataSource dataSource) {
return builder
.dataSource(dataSource)
.packages(packagesToScan())
.persistenceUnit("ds1-pu")
.properties(hibernateProperties())
.build();
}
@Bean
@Primary
public PlatformTransactionManager ds1TransactionManager(
@Qualifier("ds1EntityManagerFactory") EntityManagerFactory ds1EntityManagerFactory) {
return new JpaTransactionManager(ds1EntityManagerFactory);
}
protected String[] packagesToScan() {
return new String[]{
"com.bookstore.ds1"
};
}
protected Map<String, String> hibernateProperties() {
return new HashMap<String, String>() {
{
put("hibernate.dialect",
"org.hibernate.dialect.MySQL5Dialect");
put("hibernate.hbm2ddl.auto", "create");
}
};
}
}
接下来,为第二个数据源配置一个LocalContainerEntityManagerFactoryBean
和一个PlatformTransactionManager
。这次,告诉 Spring Boot 映射到booksdb
的实体在com.bookstore.ds2
包中:
@Configuration
@EnableJpaRepositories(
entityManagerFactoryRef = "ds2EntityManagerFactory",
transactionManagerRef = "ds2TransactionManager",
basePackages = "com.bookstore.ds2"
)
@EnableTransactionManagement
public class SecondEntityManagerFactory {
@Bean
public LocalContainerEntityManagerFactoryBean ds2EntityManagerFactory(
EntityManagerFactoryBuilder builder,
@Qualifier("dataSourceBooksDb") DataSource dataSource) {
return builder
.dataSource(dataSource)
.packages(packagesToScan())
.persistenceUnit("ds2-pu")
.properties(hibernateProperties())
.build();
}
@Bean
public PlatformTransactionManager ds2TransactionManager(
@Qualifier("ds2EntityManagerFactory") EntityManager
Factory secondEntityManagerFactory) {
return new JpaTransactionManager(secondEntityManagerFactory);
}
protected String[] packagesToScan() {
return new String[]{
"com.bookstore.ds2"
};
}
protected Map<String, String> hibernateProperties() {
return new HashMap<String, String>() {
{
put("hibernate.dialect",
"org.hibernate.dialect.MySQL5Dialect");
put("hibernate.hbm2ddl.auto", "create");
}
};
}
}
测试时间
AuthorRepository
添加到com.bookstore.ds1
包,BookRepository
添加到com.bookstore.ds2
包:
package com.bookstore.ds1;
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
}
package com.bookstore.ds2;
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
}
可以在服务方法中持久化作者,如下所示:
public Author persistAuthor() {
Author author = new Author();
author.setName("Joana Nimar");
author.setGenre("History");
author.setAge(34);
author.setBooks("A History of Ancient Prague, A People's History");
return authorRepository.save(author);
}
调用persistAuthor()
会将作者保存在authorsdb
数据库中。
持久化一本书可以在如下的服务方法中完成:
public Book persistBook() {
Book book = new Book();
book.setIsbn("001-JN");
book.setTitle("A History of Ancient Prague");
book.setAuthors("Joana Nimar");
return bookRepository.save(book);
}
调用persistBook()
会将图书保存在booksdb
数据库中。
GitHub 10 上有完整的应用。
十一、审计
第 88 项:如何跟踪创建和修改时间以及实体用户
此项说明如何添加自动生成的持久字段来跟踪创建和修改时间以及用户。审核对于维护记录的历史很有用。这可以帮助您跟踪用户活动。
让我们考虑这些持久字段的以下名称(可以随意修改这些名称):
-
created:
将行插入数据库时的时间戳 -
createdBy:
触发此行插入的当前登录用户 -
lastModified:
该行上次更新的时间戳 -
lastModifiedBy:
触发上次更新的当前登录用户默认情况下,时间戳将保存在本地时区,但让我们改为保存在 UTC(或 GMT)中。在 MySQL 中,用 UTC(或 GMT)存储时间戳可以分两步完成(参见 Item 111 ):
-
将
useLegacyDatetimeCode=false
添加到 JDBC 网址 -
将
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
添加到application.properties
通过 Spring Data JPA 审计或 Hibernate 支持,可以将这些自动生成的持久字段添加到实体中。在这两种情况下,这些字段都被添加到一个用@MappedSuperclass
标注的abstract
非实体类中(?? 指定一个类,它的映射信息被应用到从它继承的实体)。
我们把这个类命名为BaseEntity
,如图 11-1 所示。
图 11-1
基本实体类图
实体可以通过扩展BaseEntity
来继承这些字段。例如,让我们跟踪Author
和Book
实体的用户活动。
图 11-2 不言自明。
图 11-2
领域模型
现在,让我们通过 Spring Data JPA auditing 把它放到代码中。
依靠 Spring 数据 JPA 审计
Spring Data 提供了四个注释来实现这个目标。这些注释是@CreatedBy
(对于createdBy
字段)、@CreatedDate
(对于created
字段)、@LastModifiedBy
(对于lastModifiedBy
字段)和@LastModifiedDate
(对于lastModified
字段)。对字段进行相应的注释只是解决方案的一半。
另外,在@MappedSuperclass
注释的旁边,BaseEntity
要用@EntityListeners({AuditingEntityListener.class})
注释。指定为监听器的类(AuditingEntityListener
)是一个 Spring Data JPA 实体监听器类。它使用回调方法(用@PrePersist
和@PreUpdate
注释注释)来保存和更新created
、createdBy
、lastModified
和lastModifiedBy
字段。每当实体被持久化或更新时,都会发生这种情况。话虽如此,代码如下:
@MappedSuperclass
@EntityListeners({AuditingEntityListener.class})
public abstract class BaseEntity<U> {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
protected Long id;
@CreatedDate
protected LocalDateTime created;
@CreatedBy
protected U createdBy;
@LastModifiedDate
protected LocalDateTime lastModified;
@LastModifiedBy
protected U lastModifiedBy;
}
Author
和Book
扩展BaseEntity
如下:
@Entity
public class Author extends BaseEntity<String> implements Serializable {
...
}
@Entity
public class Book extends BaseEntity<String> implements Serializable {
...
}
但这还不是全部!此时,JPA 可以使用当前系统时间填充created
和lastModified
字段,但是它不能填充createdBy
和lastModifiedBy
。对于这个任务,JPA 需要知道当前登录的用户。换句话说,开发人员需要提供一个AuditorAware
的实现,并覆盖getCurrentAuditor()
方法。
当前登录的用户通过 Spring Security 在getCurrentAuditor()
中获取。在这个例子中,有一个带有硬编码用户的虚拟实现,但是只要您已经有了 Spring 安全性,就应该很容易钩住真正的用户:
public class AuditorAwareImpl implements AuditorAware<String> {
@Override
public Optional<String> getCurrentAuditor() {
// use Spring Security to retrieve the currently logged-in user(s)
return Optional.of(Arrays.asList("mark1990", "adrianm", "dan555")
.get(new Random().nextInt(3)));
}
}
最后一步是通过在配置类上指定@EnableJpaAuditing
来启用 JPA 审计。@EnableJpaAuditing
接受一个元素,auditorAwareRef
。这个元素的值是AuditorAware
bean 的名称:
@SpringBootApplication
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
public class MainApplication {
...
}
搞定了。查看“测试时间”部分,了解应用的快速运行和输出。完整的代码可以在 GitHub 1 上找到。
依靠 Hibernate 支持
如果由于某种原因,这种方法不合适,您可以依赖 Hibernate 支持。
创建的和最后修改的字段
对于created
和lastModified
字段,Hibernate 提供了两个内置注释(@CreationTimestamp
和@UpdateTimestamp
),可以开箱即用。
@CreationTimestamp
和@UpdateTimestamp
都执行时间戳的内存生成(使用 VM 时间)。
createdBy
和lastModifiedBy
字段需要必须实现的注释,您很快就会看到。现在,让我们考虑一下createdBy
的注释是@CreatedBy
,而lastModifiedBy
的注释是@ModifiedBy
。将所有这些放在BaseEntity
中会产生以下代码:
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
...
@MappedSuperclass
public abstract class BaseEntity<U> {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
protected Long id;
@CreationTimestamp
protected LocalDateTime created;
@UpdateTimestamp
protected LocalDateTime lastModified;
@CreatedBy
protected U createdBy;
@ModifiedBy
protected U lastModifiedBy;
}
“创建者”和“最后修改者”字段
对于createdBy
和lastModifiedBy
字段,没有 Hibernate 特有的内置注释。但是您可以通过 Hibernate 特有的AnnotationValueGeneration
接口构建@CreatedBy
和@ModifiedBy
注释。该接口表示基于定制 Java 生成器注释类型的ValueGeneration
,其中ValueGeneration
描述属性值的生成。首先,让我们使用@ValueGenerationType
定义@CreatedBy
注释,如下所示:
@ValueGenerationType(generatedBy = CreatedByValueGeneration.class)
@Retention(RetentionPolicy.RUNTIME)
public @interface CreatedBy {
}
然后是@ModifiedBy
标注:
@ValueGenerationType(generatedBy = ModifiedByValueGeneration.class)
@Retention(RetentionPolicy.RUNTIME)
public @interface ModifiedBy {
}
从 Hibernate 4.3 开始,通过@ValueGenerationType
元注释,可以使用一种新的方法来声明生成的属性和定制生成器。@Generated
注释已经被改进以使用@ValueGenerationType
。
CreatedByValueGeneration
类实现AnnotationValueGeneration
并提供用户名的生成器(创建实体的用户)。这里列出了相关的代码(只有在实体第一次被持久化时,才应该生成这个时间戳;因此,将生成时间设置为GenerationTiming.INSERT
):
public class CreatedByValueGeneration
implements AnnotationValueGeneration<CreatedBy> {
private final ByValueGenerator generator
= new ByValueGenerator(new UserService());
...
@Override
public GenerationTiming getGenerationTiming() {
return GenerationTiming.INSERT;
}
@Override
public ValueGenerator<?> getValueGenerator() {
return generator;
}
...
}
ModifiedByValueGeneration
类实现AnnotationValueGeneration
并提供用户名(修改实体的用户)的生成器。这里列出了相关的代码(这个时间戳应该在实体的每次更新时生成;因此,将生成时间设置为GenerationTiming.ALWAYS
):
public class ModifiedByValueGeneration
implements AnnotationValueGeneration<ModifiedBy> {
private final ModifiedByValueGenerator generator
= new ModifiedByValueGenerator(new UserService());
...
@Override
public GenerationTiming getGenerationTiming() {
return GenerationTiming.ALWAYS;
}
@Override
public ValueGenerator<?> getValueGenerator() {
return generator;
}
...
}
CreatedByValueGeneration
和ModifiedByValueGeneration
返回的generator
为ByValueGenerator
。这代表了ValueGenerator
接口的简单实现。这个类的结果是generateValue()
方法:
public class ByValueGenerator implements ValueGenerator<String> {
public final UserService userService;
public ByValueGenerator(UserService userService) {
this.userService = userService;
}
@Override
public String generateValue(Session session, Object entity) {
// Hook into a service to get the current user, etc.
return userService.getCurrentUserName();
}
}
UserService
应该使用 Spring Security 通过getCurrentUserName()
返回当前登录的用户。现在,让我们简单地使用一个虚拟实现:
@Service
public class UserService {
public String getCurrentUserName() {
// use Spring Security to retrieve the currently logged-in user(s)
return Arrays.asList("mark1990", "adrianm", "dan555")
.get(new Random().nextInt(3));
}
}
显然,您可以快速挂钩自己的处理登录用户的服务。
完整的代码可以在 GitHub 2 上找到。
测试时间
这两种方法产生相同的 SQL 语句和结果;因此,下面的讨论涵盖了这两者。
保留作者会触发以下 SQL 语句:
INSERT INTO author (created, created_by, last_modified,
last_modified_by, age, genre, name)
VALUES (?, ?, ?, ?, ?, ?, ?)
保存一本书会触发以下 SQL 语句:
INSERT INTO book (created, created_by, last_modified,
last_modified_by, author_id, isbn, title)
VALUES (?, ?, ?, ?, ?, ?, ?)
更新作者会触发以下 SQL 语句:
UPDATE author
SET created = ?,
created_by = ?,
last_modified = ?,
last_modified_by = ?,
age = ?,
genre = ?,
name = ?
WHERE id = ?
更新图书会触发以下 SQL 语句:
UPDATE book
SET created = ?,
created_by = ?,
last_modified = ?,
last_modified_by = ?,
author_id = ?,
isbn = ?,
title = ?
WHERE id = ?
图 11-3 显示了author
和book
表的快照。注意created
、created_by
、last_modified
和last_modified_by
列。
图 11-3
来自作者和图书表的数据快照
项目 89:如何启用 Hibernate 特有的环境审计
Item 88 讲述了如何通过 Spring Data JPA 审计和 Hibernate 值生成器来跟踪实体的创建和修改时间以及用户。Hibernate ORM 有一个名为 Hibernate Envers 的模块,专门用于审计/版本控制实体类。在它的特性中,Hibernate Envers 为每个修订版提供审计、记录数据,以及查询实体及其关联的历史快照。
这一项重复了启用 Hibernate Envers 的最佳实践。但不是在将 Hibernate Envers 依赖项添加到pom.xml
之前(对于 Maven):
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-envers</artifactId>
</dependency>
Hibernate Envers 将 Java 架构用于 XML 绑定(JAXB)API;因此,如果您遇到这种类型的异常:
Caused by: javax.xml.bind.JAXBException: Implementation of JAXB-API has not been found on module path or classpath
这意味着还需要以下依赖关系:
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
</dependency>
审计实体
准备应该通过 Hibernate Envers 审计的实体是一项简单的任务,需要您在实体类级别添加@Audited
注释。每个实体都在单独的数据库表中进行审计。要显式指定每个实体的审计表的名称,依赖于@AuditTable
注释(默认情况下,该名称的类型是entity
_AUD
)。对于Author
和Book
实体,可以这样做:
@Entity
@Audited
@AuditTable("author_audit")
public class Author implements Serializable {
...
}
@Entity
@Audited
@AuditTable("book_audit")
public class Book implements Serializable {
...
}
数据库模式显示了图 11-4 中的表格,包括author_audit
表格。
图 11-4
作者 _ 审计表
Revision
是 Hibernate Envers 特有的术语。修改了被审计实体(INSERT
、UPDATE
或DELETE
)的数据库事务。revinfo
表(图 11-4 中的最后一个表)存储了版本号及其纪元时间戳。
author_audit
(和book_audit
)表存储某个版本的实体快照。rev
列包含修订号。
revtype
列值取自RevisionType
枚举,其定义如下:
-
0
(或ADD
):插入了一个数据库表格行。 -
1
(或MOD
):更新了一个数据库表行。 -
2
(或DEL
):删除了一个数据库表行。
revend
列保存审计实体中的最终修订号。仅当使用有效性审核策略时,此列才会显示。ValidityAuditStrategy
稍后再讨论。
模式生成
除了实际的实体表之外,使用 Hibernate Envers 还需要一套表。只要将spring.jpa.hibernate.ddl-auto
设置为将模式 DDL 导出到数据库中,就可以从 JPA 注释(例如@Audited
和@AuditedTable
)中生成这些表。这样的应用可以在 GitHub 3 上找到。
但是,在生产中,依赖这种实践是一个坏主意。如果不需要自动模式迁移,那么schema-*.sql
可以为您完成这项工作。否则,最好依靠 Flyway 或 Liquibase 之类的工具。
在这两种情况下,开发人员都需要 Envers 表的CREATE TABLE
语句。在这种情况下,这些语句如下(注意,表的名称对应于通过@AuditedTable
指定的名称):
CREATE TABLE author_audit (
id BIGINT(20) NOT NULL,
rev INT(11) NOT NULL,
revtype TINYINT(4) DEFAULT NULL,
revend INT(11) DEFAULT NULL,
age INT(11) DEFAULT NULL,
genre VARCHAR(255) DEFAULT NULL,
name VARCHAR(255) DEFAULT NULL,
PRIMARY KEY (id,rev),
KEY FKp4vbplw134mimnk3nlxfvmch0 (rev),
KEY FKdtg6l7ccqhpsdnkltcoisi9l9 (revend));
CREATE TABLE book_audit (
id BIGINT(20) NOT NULL,
rev INT(11) NOT NULL,
revtype TINYINT(4) DEFAULT NULL,
revend INT(11) DEFAULT NULL,
isbn VARCHAR(255) DEFAULT NULL,
title VARCHAR(255) DEFAULT NULL,
author_id BIGINT(20) DEFAULT NULL,
PRIMARY KEY (id,rev),
KEY FKjx5fxkthrd6kxbxb3ukwb04mf (rev),
KEY FKr9ed64q1nek7vjfbcxm04v8ic (revend));
CREATE TABLE revinfo (
rev INT(11) NOT NULL AUTO_INCREMENT,
revtstmp BIGINT(20) DEFAULT NULL,
PRIMARY KEY (rev));
如果 Envers 不能自动识别所使用的模式,那么模式名应该如下传递:
-
对于 MySQL:
spring.jpa.properties.org.hibernate.envers.default_catalog
-
其他:
spring.jpa.properties.org.hibernate.envers.default_schema
完整的代码可以在 GitHub 4 上找到。
查询实体快照
Hibernate Envers 为查询实体快照提供了支持。起点由AuditReaderFactory
表示,它是AuditReader
对象的工厂。
您可以通过 JPA EntityManager
或 Hibernate Session
构建一个AuditReader
,如下所示:
EntityManager em;
...
// via EntityManager
AuditReader reader = AuditReaderFactory.get(em);
// via Session
AuditReader reader = AuditReaderFactory.get(em.unwrap(Session.class));
AuditReader
是审计日志的一系列特性的入口点。在它的特性中,AuditReader
允许您通过createQuery()
方法查询审计日志。这里有两个例子:
-
获取在修订版 #3 中修改的所有
Book
实例: -
获取所有被审计状态中的所有
Book
实例:
List<Book> books = reader.createQuery()
.forEntitiesAtRevision(Book.class, 3).getResultList();
List<Book> books = reader.createQuery()
.forRevisionsOfEntity(Book.class, true, true).getResultList();
我强烈建议您花些时间了解一下这个 API,因为它有太多的特性。尤其是当您需要更高级的查询时。
有效性审计策略审计日志记录策略
默认情况下,Hibernate Envers 使用名为DefaultAuditStrategy
的审计日志策略。让我们使用下面的查询(获取在修订版 #3 中修改的所有Book
实例):
List<Book> books = reader.createQuery()
.forEntitiesAtRevision(Book.class, 3).getResultList();
幕后触发的SELECT
如下:
SELECT
book_aud0_.id AS id1_3_,
book_aud0_.rev AS rev2_3_,
book_aud0_.revtype AS revtype3_3_,
book_aud0_.isbn AS isbn4_3_,
book_aud0_.title AS title5_3_,
book_aud0_.author_id AS author_i6_3_
FROM book_audit book_aud0_
WHERE book_aud0_.rev =
(
SELECT MAX(book_aud1_.rev)
FROM book_audit book_aud1_
WHERE book_aud1_.rev <= ?
AND book_aud0_.id = book_aud1_.id
)
AND book_aud0_.revtype <> ?
很明显,这个查询的性能不是很好,尤其是当审计日志很大时(查看SELECT
子查询)。
但是DefaultAuditStrategy
只是AuditStrategy
的实现之一。另一个是ValidityAuditStrategy
。您可以使用application.properties
在 Spring Boot 应用中启用该策略,如下所示:
spring.jpa.properties.org.hibernate.envers.audit_strategy
=org.hibernate.envers.strategy.ValidityAuditStrategy
在 Hibernate 版之前,正确的值是org.hibernate.envers.strategy.internal.ValidityAuditStrategy
。
一旦ValidityAuditStrategy
被启用,您可以再次尝试相同的查询。这一次,SQL 语句更加高效:
SELECT
book_aud0_.id AS id1_3_,
book_aud0_.rev AS rev2_3_,
book_aud0_.revtype AS revtype3_3_,
book_aud0_.revend AS revend4_3_,
book_aud0_.isbn AS isbn5_3_,
book_aud0_.title AS title6_3_,
book_aud0_.author_id AS author_i7_3_
FROM book_audit book_aud0_
WHERE book_aud0_.rev <= ?
AND book_aud0_.revtype <> ?
AND (book_aud0_.revend > ?
OR book_aud0_.revend IS NULL)
这次,没有SELECT
子查询!很好!此外,这可以通过为revend
和rev
列添加一个索引来改进。通过这种方式,避免了顺序扫描,Envers 变得更加高效。然而,revend
列仅在您使用ValidityAuditStrategy
时出现,并且它引用了revinfo
表。其目的是标记该实体快照仍然有效的最后一次修订。
请记住,ValidityAuditStrategy
非常擅长快速实体快照抓取,但是在保存数据库中的实体状态时,它的性能比DefaultAuditStrategy
差。通常在写作过程中花费额外的时间和更快的阅读速度是值得的,但这不是一个普遍的规则。如果你需要的话,选择DefaultAuditStrategy
并没有错。
项目 90:如何检查持久性上下文
你有没有想过持久性上下文中有什么?或者某个实体或集合是否在当前的持久化上下文中?您可以通过org.hibernate.engine.spi.PersistenceContext
检查 Hibernate 持久性上下文。首先,一个帮助器方法利用SharedSessionContractImplementor
来获取PersistenceContext
,如下所示:
@PersistenceContext
private final EntityManager entityManager;
...
private org.hibernate.engine.spi.PersistenceContext getPersistenceContext() {
SharedSessionContractImplementor sharedSession = entityManager.unwrap(
SharedSessionContractImplementor.class
);
return sharedSession.getPersistenceContext();
}
此外,PersistenceContext
提供了大量添加、删除和检查其内容的方法。例如,以下方法显示了受管实体的总数及其相关信息,包括它们的状态和水合状态:
private void briefOverviewOfPersistentContextContent() {
org.hibernate.engine.spi.PersistenceContext persistenceContext
= getPersistenceContext();
int managedEntities
= persistenceContext.getNumberOfManagedEntities();
int collectionEntriesSize
= persistenceContext.getCollectionEntriesSize();
System.out.println("Total number of managed entities: "
+ managedEntities);
System.out.println("Total number of collection entries: "
+ collectionEntriesSize);
// getEntitiesByKey() will be removed and probably replaced
// with #iterateEntities()
Map<EntityKey, Object> entitiesByKey
= persistenceContext.getEntitiesByKey();
if (!entitiesByKey.isEmpty()) {
System.out.println("\nEntities by key:");
entitiesByKey.forEach((key, value) -> System.out.println(key
+ ": " + value));
System.out.println("\nStatus and hydrated state:");
for (Object entry : entitiesByKey.values()) {
EntityEntry ee = persistenceContext.getEntry(entry);
System.out.println(
"Entity name: " + ee.getEntityName()
+ " | Status: " + ee.getStatus()
+ " | State: " + Arrays.toString(ee.getLoadedState()));
}
}
if (collectionEntriesSize > 0) {
System.out.println("\nCollection entries:");
persistenceContext.forEachCollectionEntry(
(k, v) -> System.out.println("Key:" + k
+ ", Value:" + (v.getRole() == null ? "" : v)), false);
}
}
让我们看看双向懒惰@OneToMany
关联中的Author
和Book
实体。以下服务方法:
-
找到一个作者
-
获取相关书籍
-
删除作者和相关书籍
-
用一本书创建一个新作者
在每个操作之后,执行briefOverviewOfPersistentContextContent()
方法调用:
@Transactional
public void sqlOperations() {
briefOverviewOfPersistentContextContent();
Author author = authorRepository.findByName("Joana Nimar");
briefOverviewOfPersistentContextContent();
author.getBooks().get(0).setIsbn("not available");
briefOverviewOfPersistentContextContent();
authorRepository.delete(author);
authorRepository.flush();
briefOverviewOfPersistentContextContent();
Author newAuthor = new Author();
newAuthor.setName("Alicia Tom");
newAuthor.setAge(38);
newAuthor.setGenre("Anthology");
Book book = new Book();
book.setIsbn("001-AT");
book.setTitle("The book of swords");
newAuthor.addBook(book); // use addBook() helper
authorRepository.saveAndFlush(newAuthor);
briefOverviewOfPersistentContextContent();
}
调用sqlOperations()
输出:
最初,持久性上下文为空:
Total number of managed entities: 0
Total number of collection entities: 0
在的SELECT
被触发后,乔安娜·尼玛尔:
Total number of managed entities: 1
Total number of collection entries: 1
Entities by key:
EntityKey[com.bookstore.entity.Author#4]:
Author{id=4, name=Joana Nimar, genre=History, age=34}
Status and hydrated state (because we required the hydrated state, Hibernate will trigger a SELECT to fetch the books of this author):
Entity name: com.bookstore.entity.Author
| Status: MANAGED
| State: [34, [Book{id=1, title=A History of Ancient Prague,
isbn=001-JN}, Book{id=2, title=A People's History,
isbn=002-JN}], History, Joana Nimar]
Collection entries:
Key:[Book{id=1, title=A History of Ancient Prague, isbn=001-JN}, Book{id=2, title=A People's History, isbn=002-JN}], Value:CollectionEntry[com.bookstore.entity.Author.books#4]
在针对乔安娜·尼玛尔的图书的SELECT
语句被触发后(有两本书):
Total number of managed entities: 3
Total number of collection entries: 1
Entities by key:
EntityKey[com.bookstore.entity.Book#2]:
Book{id=2, title=A People's History, isbn=002-JN}
EntityKey[com.bookstore.entity.Author#4]:
Author{id=4, name=Joana Nimar, genre=History, age=34}
EntityKey[com.bookstore.entity.Book#1]:
Book{id=1, title=A History of Ancient Prague, isbn=not available}
Status and hydrated state:
Entity name: com.bookstore.entity.Book
| Status: MANAGED
| State: [Author{id=4, name=Joana Nimar, genre=History, age=34},
002-JN, A People's History]
Entity name: com.bookstore.entity.Author
| Status: MANAGED
| State: [34, [Book{id=1, title=A History of Ancient Prague,
isbn=not available}, Book{id=2, title=A People's History,
isbn=002-JN}], History, Joana Nimar]
Entity name: com.bookstore.entity.Book
| Status: MANAGED
| State: [Author{id=4, name=Joana Nimar, genre=History, age=34},
001-JN, A History of Ancient Prague]
Collection entries:
Key:[Book{id=1, title=A History of Ancient Prague, isbn=not available}, Book{id=2, title=A People's History, isbn=002-JN}], Value:CollectionEntry[com.bookstore.entity.Author.books#4]
在作者和相关书籍的DELETE
语句被触发后:
Total number of managed entities: 0
Total number of collection entities: 0
在持续新作者和他们的书的INSERT
语句被触发后:
Total number of managed entities: 2
Total number of collection entries: 1
Entities by key:
EntityKey[com.bookstore.entity.Book#5]:
Book{id=5, title=The book of swords, isbn=001-AT}
EntityKey[com.bookstore.entity.Author#5]:
Author{id=5, name=Alicia Tom, genre=Anthology, age=38}
Status and hydrated state:
Entity name: com.bookstore.entity.Book
| Status: MANAGED
| State: [Author{id=5, name=Alicia Tom, genre=Anthology, age=38},
001-AT, The book of swords]
Entity name: com.bookstore.entity.Author
| Status: MANAGED
| State: [38, [Book{id=5, title=The book of swords,
isbn=001-AT}], Anthology, Alicia Tom]
Collection entries:
Key:[Book{id=5, title=The book of swords, isbn=001-AT}], Value:CollectionEntry[com.bookstore.entity.Author.books#5]
->[com.bookstore.entity.Author.books#5]
这只是一个让你熟悉PersistenceContext
API 的例子。仔细阅读文档以发现更多有用的方法。
完整的应用可在 GitHub 5 上获得。
项目 91:如何提取表元数据
您可以通过 Hibernate SPI,org.hibernate.integrator.spi.Integrator
提取表元数据(或者一般来说,数据库元数据)。实现Integrator
包括覆盖integrate()
方法并返回metadata.getDatabase()
,如下所示:
public class DatabaseTableMetadataExtractor
implements org.hibernate.integrator.spi.Integrator {
public static final DatabaseTableMetadataExtractor EXTRACTOR
= new DatabaseTableMetadataExtractor();
private Database database;
// this method will be deprecated starting with Hibernate 6.0
@Override
public void integrate(
Metadata metadata,
SessionFactoryImplementor sessionImplementor,
SessionFactoryServiceRegistry serviceRegistry) {
database = metadata.getDatabase();
}
@Override
public void disintegrate(
SessionFactoryImplementor sessionImplementor,
SessionFactoryServiceRegistry serviceRegistry) {
}
public Database getDatabase() {
return database;
}
}
接下来,通过LocalContainerEntityManagerFactoryBean
注册该Integrator
,如下所示:
@Configuration
@EnableJpaRepositories(
entityManagerFactoryRef = "entityManagerFactory",
transactionManagerRef = "transactionManager",
basePackages = "com.bookstore.*"
)
@EnableTransactionManagement
public class EntityManagerFactoryConfig {
@Bean
@Primary
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
EntityManagerFactoryBuilder builder, DataSource dataSource) {
return builder
.dataSource(dataSource)
.packages(packagesToScan())
.persistenceUnit("ds-pu")
.properties(hibernateProperties())
.build();
}
@Bean
@Primary
public PlatformTransactionManager transactionManager(
@Qualifier("entityManagerFactory") EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
protected String[] packagesToScan() {
return new String[]{
"com.bookstore.*"
};
}
protected Map<String, Object> hibernateProperties() {
return new HashMap<String, Object>() {
{
put("hibernate.dialect",
"org.hibernate.dialect.MySQL5Dialect");
put("hibernate.hbm2ddl.auto", "create");
put("hibernate.integrator_provider",
(IntegratorProvider) () -> Collections.singletonList(
DatabaseTableMetadataExtractor.EXTRACTOR
));
}
};
}
}
搞定了。现在,让我们使用图 11-5 所示的领域模型。
图 11-5
领域模型
您可以提取并显示映射表的元数据,如下所示(每个实体类有一个映射表):
public void extractTablesMetadata() {
for (Namespace namespace :
DatabaseTableMetadataExtractor.EXTRACTOR
.getDatabase()
.getNamespaces()) {
namespace.getTables().forEach(this::displayTablesMetdata);
}
}
private void displayTablesMetdata(Table table) {
System.out.println("\nTable: " + table);
Iterator it = table.getColumnIterator();
while (it.hasNext()) {
System.out.println(it.next());
}
}
调用extractTablesMetadata()
将产生以下输出:
Table: org.hibernate.mapping.Table(Author)
org.hibernate.mapping.Column(id)
org.hibernate.mapping.Column(age)
org.hibernate.mapping.Column(genre)
org.hibernate.mapping.Column(name)
Table: org.hibernate.mapping.Table(Book)
org.hibernate.mapping.Column(id)
org.hibernate.mapping.Column(isbn)
org.hibernate.mapping.Column(title)
org.hibernate.mapping.Column(author_id)
Table: org.hibernate.mapping.Table(Ebook)
org.hibernate.mapping.Column(format)
org.hibernate.mapping.Column(ebook_book_id)
Table: org.hibernate.mapping.Table(Paperback)
org.hibernate.mapping.Column(sizeIn)
org.hibernate.mapping.Column(weightLbs)
org.hibernate.mapping.Column(paperback_book_id)
完整的应用可在 GitHub 6 上获得。
十二、模式
第 92 项:如何在 Spring Boot 设立飞行通道
对于生产来说,不要依赖hibernate.ddl-auto
(或对应方)将模式 DDL 导出到数据库。只需移除(禁用)hibernate.ddl-auto
或将其设置为validate
,并依靠 Flyway 或 Liquibase。本文介绍了将 Flyway 设置为 Spring Boot 数据库迁移工具的几个方面。
本节包含 MySQL 和 PostgreSQL 的应用。
在这种情况下,重要的是要知道术语数据库、模式、和目录在 MySQL 中表示相同的东西,而在 PostgreSQL 中,一个数据库与一个目录相同,并且可以有多个模式(两个同名的表可以位于同一个数据库中,但位于不同的模式)。
最快的 Flyway 设置(MySQL 和 PostgreSQL)
通过向项目添加一个 Flyway 依赖项,可以实现具有默认设置的最快设置。对于 Maven,将pom.xml
添加到以下依赖项中:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
Spring Boot 有一种国旗叫做spring.flyway.enabled
。该标志位默认设置为true
;因此,当 Spring Boot 获得关于 Flyway 存在的确认时,它将依赖 Flyway 的默认设置来迁移数据库。
默认情况下,Spring Boot 在classpath:db/migration
路径中查找 SQL 文件(可通过spring.flyway.locations
配置)。文件名称应遵循飞行路线命名惯例(如V1.1__
Description
.sql
)。此外,开发人员可以在该位置添加 Flyway 回调对应的 SQL 文件(如afterMigrate.sql
、beforeClean.sql
等)。).将相应地考虑这些文件。
GitHub 1 上有一个使用 Flyway 和 MySQL 的启动应用。
MySQL 数据库是通过 JDBC URL 的特定参数createDatabaseIfNotExist=true
创建的。Flyway 将连接到 JDBC URL 中的这个数据库,并对其运行 SQL 文件。
而且在 GitHub 2 上也有使用 Flyway 和 PostgreSQL 的启动应用。
这个应用依赖于默认的postgres
数据库和一个public
模式。SQL 文件根据该模式执行。
如果CREATE TABLE
中使用的表名与实体名不同(例如对于Author
实体,表名应该是author
),您必须使用@Table(name="
table name
")
来指示 JPA 相应的表。例如,对于名为author_history
的表,实体名称应该是AuthorHistory
,或者在实体级别将@Table
指定为@Table(name="author_history")
。
指示 Flyway 创建数据库
这一次,您将指示 Flyway 代表您创建 MySQL 数据库。
Flyway 不是为创建数据库而设计的(例如,执行CREATE DATABASE
语句)。它被设计为连接到一个现有的(空的或非空的)数据库,并且一旦连接建立,就对这个数据库执行所有给定的脚本。然而,Flyway 可以通过CREATE SCHEMA
创建模式。
主要是,可以通过spring.flyway.schemas
设置指示 Flyway 更新模式。如果有多个模式,它们的名称应该用逗号分隔。如果模式不存在,Flyway 将自动创建它们。
关系型数据库
在 MySQL 中, schema 相当于数据库;因此,Flyway 可以创建一个 MySQL 数据库。完成这项任务需要三个步骤:
-
从 JDBC URL 中删除数据库名称:
-
通过
spring.flyway.schemas
指示 Flyway 更新(并且,因为它不存在,所以创建)数据库:
spring.datasource.url=jdbc:mysql://localhost:3306/
- 通知实体数据库名称,如下例所示:
spring.flyway.schemas=bookstoredb
@Entity
@Table(schema = "bookstoredb") // or @Table(catalog = "bookstoredb")
public class Author implements Serializable {
...
}
搞定了。现在,Flyway 将代表您创建bookstoredb
数据库。
完整的应用可在 GitHub 3 上获得。
一种数据库系统
与 MySQL 相比,PostgreSQL 的情况有所不同,因为一个 PostgreSQL 数据库可以有多个模式。这一次,创建模式不会导致创建数据库。它会导致创建一个模式。
在 PostgreSQL 中,连接总是指向某个数据库。切换到另一个数据库需要新的连接。Flyway 连接到一个现有的数据库,CREATE SCHEMA
(通过spring.flyway.schemas
触发)将在这个数据库中创建一个模式。
实现此行为的步骤如下:
-
在 JDBC URL 中指定要连接的数据库(例如,方便的默认
postgres
数据库或您自己的数据库) -
通过
spring.flyway.schemas
指示 Flyway 更新(并且,因为它不存在,所以创建)数据库:
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
- 通知实体数据库名称,如下例所示:
spring.flyway.schemas=bookstore
@Entity
@Table(schema = "bookstore")
public class Author implements Serializable {
...
}
搞定了。现在 Flyway 将代表您创建bookstore
模式。完整的应用可在 GitHub 4 上获得。
结果对比如图 12-1 所示。
图 12-1
MySQL 和 PostgreSQL 中的模式
通过@FlywayDataSource 设置 Flyway
Flyway 可以通过前缀为spring.flyway.*
的 Spring Boot 属性进行配置,并放在application.properties
文件中。另一种方法是使用@FlywayDataSource
注释和 Flyway-fluent API。
在这种情况下,DataSource
很可能也是以编程方式配置的;因此,我们来考虑下面的 MySQL DataSource
(更多详情在第 86 项):
@Bean(name = "dataSource")
public HikariDataSource dataSource() {
HikariDataSource hds = new HikariDataSource();
hds.setJdbcUrl("jdbc:mysql://localhost:3306/bookstoredb
?createDatabaseIfNotExist=true");
hds.setUsername("root");
hds.setPassword("root");
hds.setConnectionTimeout(50000);
hds.setIdleTimeout(300000);
hds.setMaxLifetime(900000);
hds.setMaximumPoolSize(8);
hds.setMinimumIdle(8);
hds.setPoolName("MyPool");
hds.setConnectionTestQuery("select 1 from dual");
hds.setAutoCommit(false);
return hds;
}
接下来,必须将这个DataSource
传递给 Flyway。为此,您定义一个方法,该方法接收DataSource
作为参数,用@FlywayDataSource
对其进行注释,并如下实现它:
@FlywayDataSource
@Bean(initMethod = "migrate")
public Flyway flyway(@Qualifier("dataSource")
HikariDataSource dataSource) {
return Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration") // this path is default
.load();
}
Flyway 将连接到在dataSource
的 JDBC URL 中找到的数据库,并对其运行来自classpath:db/migration
的 SQL 文件。
请随意探索 fluent API,看看可以定制哪些设置。
在 GitHub 上可以找到 MySQL 5 和 PostgreSQL 6 的完整应用。
飞行路线和多模式
可以设置 Flyway 来迁移同一供应商或不同供应商的多个模式。对于这样的例子,请考虑:
-
在 MySQL 7 中自动创建和迁移两个数据库
-
在 PostgreSQL 8 中自动创建并迁移两个数据库
-
自动创建和迁移两个
DataSource
(MySQL 和 PostgreSQL) 9
第 93 项:如何通过 schema-*生成两个数据库。sql 并将实体与它们匹配
对于生产来说,不要依赖hibernate.ddl-auto
(或对应方)将模式 DDL 导出到数据库。只需移除(禁用)hibernate.ddl-auto
或将其设置为validate
并依靠schema-*.sql
,或者,对于生产,依靠 Flyway ( Item 92 )或 Liquibase。
依靠schema-*.sql
有助于避免从 JPA 注释生成模式。但是,它没有版本,所以不支持模式迁移。在schema-*.sql
中,开发人员可以调用应用每次运行时将执行的 SQL 语句。
例如,下面的schema-mysql.sql
包含特定于 MySQL 的 DDL 语句,用于创建两个数据库和两个表(authorsdb
数据库中的author
表和booksdb
数据库中的book
表):
CREATE DATABASE IF NOT EXISTS authorsdb;
CREATE TABLE IF NOT EXISTS authorsdb.author
(
id BIGINT(20) NOT NULL auto_increment,
age INT(11) NOT NULL,
genre VARCHAR(255) DEFAULT NULL,
name VARCHAR(255) DEFAULT NULL,
PRIMARY KEY (id)
);
CREATE DATABASE IF NOT EXISTS booksdb;
CREATE TABLE IF NOT EXISTS booksdb.book
(
id BIGINT(20) NOT NULL auto_increment,
isbn VARCHAR(255) DEFAULT NULL,
title VARCHAR(255) DEFAULT NULL,
PRIMARY KEY (id)
);
要指示 Spring Boot 执行来自schema-mysql.sql
的 DDL 语句,只需将application.properties
添加到以下设置中:
spring.datasource.initialization-mode=always
spring.datasource.platform=mysql
spring.datasource.initialization-mode
的可能值有always
、embedded
和never
。虽然always
和never
非常清楚,但是embedded
值(这是默认值)指示 Spring Boot 只有在依赖嵌入式数据库(例如 H2)时才初始化模式。
此外,在application.properties
中,在没有显式数据库的情况下设置 JDBC URL:
spring.datasource.url=jdbc:mysql://localhost:3306
此外,Author
实体应该被显式地映射到authorsdb.author
表,而Book
实体应该被映射到booksdb.book
表。为此,用@Table(schema="authorsdb")
标注Author
实体,用@Table(schema="booksdb")
标注Book
实体:
@Entity
@Table(schema="authorsdb")
public class Author implements Serializable {
...
}
@Entity
@Table(schema="booksdb")
public class Book implements Serializable {
...
}
仅此而已!现在,您可以像往常一样使用AuthorRepository
和BookRepository
。来自AuthorRepository
的查询方法将针对authorsdb
触发,而来自BookRepository
的查询方法将针对booksdb
触发。
GitHub 10 上有完整的应用。
如果您想通过 Hibernate 导入 SQL 脚本文件,那么您必须使用hibernate.hbm2ddl.import_files
属性。简单地传递要加载的文件作为这个属性的值。或者,您可以通过 JPA 2.1 模式生成特性来实现。要加载脚本,只需使用javax.persistence.sql-load-script-source
属性。GitHub 11 上有完整的例子。
十三、分页
项目 94:偏移分页何时以及为什么会成为性能损失
偏移分页非常流行,Spring Boot(更准确地说是 Spring Data Commons)通过Page
和Slice
API 提供了对它的支持。但是,随着项目的发展和数据的积累,依赖偏移分页可能会导致性能下降,即使在项目开始时这并不是一个问题。
处理偏移量分页意味着您可以忽略在达到期望的偏移量之前丢弃 n 条记录所带来的性能损失。更大的 n 会导致显著的性能损失。另一个代价是计算记录总数所需的额外的SELECT
(特别是如果您需要计算每个获取的页面)。虽然键集(seek)分页可能是一种可行的方法(作为一种不同的方法),但可以优化偏移分页以避免这种额外的SELECT
,如项目 95 和项目 96 中所述。因此,如果您熟悉这个主题,并且您所需要的只是一个优化的偏移分页,您可以简单地跳转到项目 95 和项目 96 。好,现在我们继续...
对于相对较小的数据集,offset 和 keyset 提供了几乎相同的性能。但是,您能保证数据集不会随时间增长吗?或者您能控制增长过程吗?大多数企业从少量数据开始,但当成功加速时,数据量也会快速增加。
偏移量和键集索引扫描
索引扫描输入偏移量将遍历索引范围,从开头到指定的偏移量。基本上,偏移量表示在将记录包含在结果中之前必须跳过的记录数,这意味着还必须进行计数。在 offset 中,根据必须获取和跳过的数据量(请记住,表通常会快速“增长”),这种方法可能会导致性能显著下降。偏移方法将遍历已经显示的记录。见图 13-1 。
图 13-1
偏移量中的索引扫描与键集分页
另一方面,键集中的索引扫描将只遍历所需的值,从上一个值开始(它跳过这些值,直到上一个值被取出)。在键集中,随着表记录的增加,性能几乎保持不变。
偏移分页的利弊
考虑偏移量分页的以下优点和缺点。
缺点:
-
插入可能会造成页面偏差
-
每次,它都从头开始对行进行编号
-
它应用一个过滤器来删除不必要的行
-
如果偏移量大于排序结果中的行数,则不返回任何行
优点:
-
它可以获取任意页面
反对使用偏移分页的一个重要参考和令人信服的论据是使用索引,LUKE! 1 网站。我强烈建议您花点时间看看 Markus Winand 的这个精彩演示 2 ,它涵盖了调优分页 SQL 的重要主题,比如在 offset 和 keyset 分页中使用索引和行值(PostgreSQL 支持)。
在开始分页实现之前,建议至少考虑以下两个方面:
-
有时不需要为每个页面触发一个
SELECT COUNT
(插入/删除很少发生)。在这种情况下,最好定期触发SELECT COUNT
或者只触发一次。 -
最好使用强大的过滤功能,而不是返回大量的页面。想想你最后一次浏览一个网站的几个页面是什么时候,如果你不记得了,这意味着你的客户的行为是一样的。他们更喜欢润色他们的过滤器,而不是浏览大量的页面。因此,提高你的过滤器,直到返回的结果适合几个页面。
Spring Boot 胶印分页
如果获胜者是偏移分页,那么 Spring Boot 通过Page
API 提供了对偏移分页的内置支持。考虑图 13-2 中Author
实体对应的author
表。
图 13-2
作者实体表
下面的例子依赖于Author
实体和AuthorRepository
存储库来形成一种实现分页的简单方法。首先,至少有五种方法可以获取结果集,如下所示:
如果您需要在接受参数Pageable
的查询方法中取消分页,那么只需传递参数Pageable.unpaged()
。
- 在没有显式排序的情况下调用内置的
findAll(Pageable)
(不建议使用):
*** 调用带有排序的内置findAll(Pageable)
:
authorRepository.findAll(PageRequest.of(page, size));
- 使用 Spring 数据查询构建器机制在存储库中定义新方法:
authorRepository.findAll(PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "price")));
- 使用 JPQL 和
@Query
,有和没有显式SELECT COUNT
:
Page<Author> findByName(String name, Pageable pageable);
Page<Author> queryFirst10ByName(String name, Pageable pageable);
- 使用本地查询和
@Query
,有和没有显式SELECT COUNT
:
@Query(value = "SELECT a FROM Author a WHERE a.genre = ?1",
countQuery = "SELECT COUNT(*) FROM Author a WHERE a.genre = ?1")
public Page<Author> fetchByGenreExplicitCount(
String genre, Pageable pageable);
@Query("SELECT a FROM Author a WHERE a.genre = ?1")
public Page<Author> fetchByGenre(String genre, Pageable pageable);
@Query(value = "SELECT * FROM author WHERE genre = ?1",
countQuery = "SELECT COUNT(*) FROM author WHERE genre = ?1",
nativeQuery = true)
public Page<Author> fetchByGenreNativeExplicitCount(
String genre, Pageable pageable);
@Query(value = "SELECT * FROM author WHERE genre = ?1",
nativeQuery = true)
public Page<Author> fetchByGenreNative(String genre, Pageable pageable);
此外,支持Author
分页所需的传统存储库将扩展PagingAndSortingRepository
,如下所示:
@Repository
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
}
接下来,服务方法可以按年龄升序获取页面Author
,如下所示:
public Page<Author> fetchNextPage(int page, int size) {
return authorRepository.findAll(PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age")));
}
接下来,从控制器调用它,如下所示:
@GetMapping("/authors/{page}/{size}")
public Page<Author> fetchAuthors(@PathVariable int page,
@PathVariable int size) {
return bookstoreService.fetchNextPage(page, size);
}
下面是一个可能的请求及其输出(获取包含五个作者和一个带有细节的pageable
元素的第一个页面):
http://localhost:8080/authors/1/5
{
"content":[
{
"id":22,
"age":24,
"name":"Kemal Ilias",
"genre":"History"
},
{
"id":28,
"age":24,
"name":"Sandra Ostapenco",
"genre":"History"
},
{
"id":16,
"age":24,
"name":"Joana Leonte",
"genre":"History"
},
{
"id":46,
"age":24,
"name":"Alp Ran",
"genre":"History"
},
{
"id":12,
"age":26,
"name":"Katre Mun",
"genre":"Anthology"
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageNumber":1,
"pageSize":5,
"offset":5,
"paged":true,
"unpaged":false
},
"totalPages":11,
"totalElements":51,
"last":false,
"numberOfElements":5,
"first":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":1,
"size":5,
"empty":false
}
获取这个结果需要两条 SQL 语句(第二条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_
ORDER BY author0_.age ASC
LIMIT 5, 5
SELECT
Count(author0_.id) AS col_0_0_
FROM author author0_
有时不需要为每个页面触发一个SELECT COUNT
,因为新的插入或删除非常罕见;因此,行数在很长时间内保持固定。在这种情况下,只在获取第一页时触发一次SELECT COUNT
,并依靠Slice
或List
而不是Page
进行分页。或者您可以定期触发SELECT COUNT
(例如,每 15 分钟、每 10 页等)。).
在分页的情况下,确定性排序顺序是强制性的。因此,不要忘记ORDER BY
子句。
请注意,如果您将对象添加到控制器中,Spring 可以派生出Pageable
对象。请求的参数遵循以下约定:
-
page
请求参数指示要检索的页面(缺省值为 0) -
size
请求参数指示要检索的页面的大小(缺省值是 20) -
sort
请求参数将排序属性表示为property, property (,ASC|DESC)
(默认为升序)
下面是一个控制器端点示例:
@GetMapping("/authors")
public Page<Author> fetchAuthors(Pageable pageable) {
return bookstoreService.fetchNextPagePageable(pageable);
}
以下是第 1 页的请求,大小为 3,按名称降序排列:
http://localhost:8080/authors?page=1&size=3&sort=name,desc
或者按姓名降序和流派升序排序:
http://localhost:8080/authors?page=1&size=3&sort=name,desc&sort=genre,asc
GitHub 3 上有源代码。
在决定哪种分页类型最合适之前,请考虑阅读这一整章。最有可能的是,本文中介绍的方法最容易导致性能损失,所以只将其作为下一步工作的里程碑。接下来的两个项目——项目 95 和项目 96——讨论偏移分页的优化。
更准确地说,通过COUNT(*) OVER()
窗口函数和SELECT COUNT
子查询,尽量避免额外的SELECT COUNT
。
图 13-3 中显示的时间-性能趋势图强调了COUNT(*) OVER()
往往比使用两个SELECT
语句或一个SELECT COUNT
子查询执行得更好。另一方面,一个SELECT COUNT
子查询似乎并没有比触发两个SELECT
语句带来更大的好处。这是因为应用和数据库运行在同一台机器上。如果您通过网络访问数据库,那么触发两个SELECT
语句会增加两次网络开销,而SELECT COUNT
子查询只会增加一次网络开销。在图 13-3 中,我们假设author
表有 100 万条记录,我们尝试获取 100 个实体的页面。更准确地说,我们获取第一页(0)、第 5000 页和第 9999 页。
图 13-3
使用偏移分页选择实体
图 13-3 中显示的时间性能趋势图是在具有以下特征的 Windows 7 机器上针对 MySQL 获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。
在 MySQL 中,为了对记录进行计数,还需要使用SQL_CALC_FOUND_ROWS
查询修饰符和附带的FOUND_ROWS()
函数。这种方法在本书中被跳过,因为它在 MySQL 8.0.17 中被标记为不推荐使用,并将在未来的 MySQL 版本中被删除。然而,一篇关于SQL_CALC_FOUND_ROWS
性能的有趣论文可以在这里找到 4 。
第 95 项:如何优化 COUNT(*) OVER 和 Page 的偏移分页
第 94 项强调了偏移分页的两个潜在性能损失:遍历已经显示的记录和每页两个单独的SELECT
语句(两次数据库往返)。一个SELECT
获取数据,另一个计算记录总数。此外,让我们尝试只在一次查询(一次数据库往返)中获取通过这两个SELECT
获得的信息。这样,消除了第二次往返所增加的性能损失。作为一个权衡,获取数据的SELECT
也需要一些时间来计数。
COUNT(*) OVER()窗口化聚合
COUNT(*) OVER()
是COUNT()
集合函数和OVER()
子句的组合,它将窗口函数与其他函数区分开来。OVER
指定聚合函数的窗口子句。
当 RDBMS 支持窗口函数(例如 MySQL 8)时,可以使用COUNT(*) OVER()
窗口聚合来消除获取记录总数所需的第二次数据库往返。如果你的 RDBMS 不支持窗口函数,考虑使用SELECT COUNT
子查询的项目 96 。
您可以通过本地查询编写一个COUNT(*) OVER()
查询。COUNT(*) OVER()
是获取数据的查询的一部分。它的目的是统计记录的总数。此外,每个提取的数据页可以是实体页(但仅当有计划修改它们时)或 DTO 页(对于只读数据)。让我们来看看如何去取一只Page<
dto
>
。
页数为页<dto
一个毫无创意的获取Page<
dto
>
的方法如下:
public Page<AuthorDto> findAll(Pageable pageable) {
Page<Author> page = authorRepository.findAll(pageable);
return new PageImpl<AuthorDto>(AuthorConverter/Mapper.convert/map(
page.getContent()), pageable, page.getTotalElements());
}
一些开发人员声称前面的例子是正确的,这是基于各种或多或少正确的原因。然而,在您做出决定之前,请考虑阅读弗拉德·米哈尔恰的推文 5 ,它反对这种反模式。Vlad 说:“不要提取实体,只使用映射器来创建 dto。这是非常低效的,但我一直看到这种反模式得到推广。”
虽然该方法返回一个Page<AuthorDto>
,但是当调用authorRepository.findAll()
时,它仍然将数据提取到持久性上下文中。因为内置的findAll()
用@Transactional(readOnly = true)
注释,所以持久化上下文不会保存水合状态。因此,实体将以只读模式加载。
最好避免以将数据转换为 dto 为唯一目的,将数据作为只读实体获取。在这种情况下,DTO 包含实体的所有属性(它是实体的镜像)。大多数时候,我们提取属性的子集(web 应用中的常见场景),这意味着我们只从实体中提取所需的属性到 dto,而丢弃其余的。获取超过需要的数据是一种不好的做法。因此,在这两种情况下,提取实体的唯一目的是使用 dto 映射器,这会导致性能下降。
一个Page<
dto
>
需要一个 DTO;因此,您定义了一个弹簧投影(DTO ),其中包含与应该提取的数据相对应的 getters。在这种情况下,是Author
实体的age
和name
:
public interface AuthorDto {
public String getName();
public int getAge();
@JsonIgnore
public long getTotal();
}
检查突出显示的两行代码。需要使用getTotal()
来映射COUNT(*) OVER()
的结果。这不是Author
实体的财产。而且,它用@JsonIgnore
进行了注释,因为它不会在发送给客户端的 JSON 响应中被序列化。它将在PageImpl
的构造函数中被用来创建一个Page<AuthorDto>
。但是,在此之前,这里列出了获取数据的 JPQL 查询和单次数据库往返中的记录总数(也可以使用WHERE
子句):
@Repository
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT name, age, COUNT(*) OVER() AS total FROM author", nativeQuery = true)
List<AuthorDto> fetchAll(Pageable pageable);
}
注意,该查询没有显式设置排序和限制结果集所需的ORDER BY
和LIMIT
子句。然而,使用传递的Pageable
,它包含页面、大小和排序信息,可以很好地完成这项工作。这个Pageable
将根据给定的大小、页面和排序信息,向生成的 SQL 语句添加缺失的ORDER BY
和LIMIT
子句。用两个整数替换Pageable
对象并将ORDER BY age LIMIT ?1, ?2
添加到查询中不会有问题。
LIMIT
子句由MySQL
和PostgreSQL
识别。SQL Server 支持SELECT TOP
子句,Oracle 使用ROWNUM
或ROWS FETCH NEXT
n
ROWS ONLY
。
调用fetchAll()
将触发以下 SQL 语句:
SELECT
name,
age,
COUNT(*) OVER() AS total
FROM author
ORDER BY age ASC
LIMIT ? ?
通过getTotal()
存储COUNT(*) OVER()
结果。由于fetchAll()
返回一个List<AuthorDto>
,它必须被转换成一个Page<AuthorDto>
。服务方法创建一个Pageable
并调用fetchAll()
。fetchAll()
的结果用于通过下面的PageImpl
构造函数创建一个Page<AuthorDto>
:
public PageImpl(List<T> content, Pageable pageable, long total)
服务方法非常简单:
public Page<AuthorDto> fetchNextPage(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age"));
List<AuthorDto> authors = authorRepository.fetchAll(pageable);
Page<AuthorDto> pageOfAuthors = new PageImpl(authors, pageable,
authors.isEmpty() ? 0 : authors.get(0).getTotal());
return pageOfAuthors;
}
REST 控制器端点可以如下调用fetchNextPage()
方法:
@GetMapping("/authors/{page}/{size}")
public Page<AuthorDto> fetchAuthors(
@PathVariable int page, @PathVariable int size) {
return bookstoreService.fetchNextPage(page, size);
}
下面是一些可能的 JSON 输出(注意,记录总数是 51):
http://localhost:8080/authors/1/3
{
"content":[
{
"age":23,
"name":"Wuth Troll"
},
{
"age":23,
"name":"Nagir Turok"
},
{
"age":24,
"name":"Alp Ran"
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":3,
"pageNumber":1,
"offset":3,
"paged":true,
"unpaged":false
},
"totalPages":17,
"totalElements":51,
"last":false,
"numberOfElements":3,
"first":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":1,
"size":3,
"empty":false
}
GitHub 6 上有源代码。
您可以很容易地只获取数据(没有pageable
元素)作为List<AuthorDto>
,如这个应用 7 。
页面为页面<实体 >
虽然Page<
dto
>
非常适合只读数据的分页,但是Page<
entity
>
更适合将被修改的实体。
提取实体不会映射COUNT(*) OVER()
结果。实体定义了它的属性集(id
、age
、name
和genre
,但是它没有一个特殊的属性来表示数据库中记录的总数。要解决这个问题,至少有一种方法可以进一步讨论。
使用专用属性
映射由COUNT(*) OVER()
返回的记录总数的一种方法是在相应的实体中添加一个专用属性。该属性可以映射到不可插入的或不可更新的列,如下例所示(total
属性没有设置器):
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int age;
private String name;
private String genre;
@Column(insertable = false, updatable = false)
long total;
...
public long getTotal() {
return total;
}
}
此外,在AuthorRepository
中,您可以依赖包含COUNT(*) OVER()
的原生 SQL,如下所示:
@Repository
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT id, name, age, genre, COUNT(*) OVER() AS total
FROM author", nativeQuery = true)
List<Author> fetchAll(Pageable pageable);
}
调用fetchAll()
将触发下面的SELECT
语句(注意,有一个单独的查询用于获取一页数据作为List<Author>
):
SELECT
id,
name,
age,
genre,
COUNT(*) OVER() AS total
FROM author
ORDER BY age ASC
LIMIT ?, ?
调用fetchAll()
的服务方法负责准备Page<Author>
,如下所示:
public Page<Author> fetchNextPage(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age"));
List<Author> authors = authorRepository.fetchAll(pageable);
Page<Author> pageOfAuthors = new PageImpl(authors, pageable,
authors.isEmpty() ? 0 : authors.get(0).getTotal());
return pageOfAuthors;
}
源代码可在 GitHub 8 上获得。应用公开了一个类型为http://localhost:8080/authors/{
page}/{size}的 REST 端点。返回的结果是一个 JSON,如下例所示(在author
表中有 51 条记录,这由total
字段公开):
http://localhost:8080/authors/1/3
{
"content":[
{
"id":7,
"age":23,
"name":"Wuth Troll",
"genre":"Anthology"
},
{
"id":48,
"age":23,
"name":"Nagir Turok",
"genre":"Anthology"
},
{
"id":46,
"age":24,
"name":"Alp Ran",
"genre":"History"
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":3,
"pageNumber":1,
"offset":3,
"paged":true,
"unpaged":false
},
"totalPages":17,
"totalElements":51,
"last":false,
"numberOfElements":3,
"first":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":1,
"size":3,
"empty":false
}
您可以很容易地只获取数据(没有pageable
元素)作为List<Author>
,如这个应用 9 中所示。
第 96 项:如何使用 SELECT COUNT 子查询和 Page 优化偏移量分页
第 94 项强调了偏移分页的两个潜在性能损失:遍历已经显示的记录和每页两个单独的SELECT
语句(两次数据库往返)。一个SELECT
获取数据,另一个对记录进行计数。让我们试着只在一个查询中触发这两个SELECT
(一次数据库往返)。这样就消除了第二次往返的性能损失。作为一个权衡,获取数据的SELECT
也会消耗一些时间来计数。
选择计数子查询
如果您的 RDBMS 不支持窗口函数(例如,版本 8 之前的 MySQL ),使用SELECT COUNT
子查询来消除获取记录总数所需的第二次数据库往返。如果你的 RDBMS 支持窗口函数,考虑第 95 条中的方法。
您可以通过本地查询或 JPQL 编写一个SELECT COUNT
子查询。作为一个子查询,这个SELECT COUNT
嵌套在获取数据的SELECT
中,其目的是计算记录总数。此外,每个提取的数据页可以是一个实体页(但只有在计划修改它们时)或一个 dto 页(对于只读数据)。让我们来看看如何获取一个Page<
dto
>
。
页数为页<dto
获取Page<
dto
>
的一个没有创意的方法如下:
public Page<AuthorDto> findAll(Pageable pageable) {
Page<Author> page = authorRepository.findAll(pageable);
return new PageImpl<AuthorDto>(AuthorConverter/Mapper.convert/map(
page.getContent()), pageable, page.getTotalElements());
}
虽然该方法返回一个Page<AuthorDto>
,但是当调用authorRepository.findAll()
时,它仍然将数据提取到持久性上下文中。这将是一种更好的方法,可以避免以只读实体的形式获取数据,然后将其转换为 DTO。此外,在这种情况下,DTO 包含实体的所有属性(它是实体的镜像)。提取属性的一个子集会强加被丢弃的数据,这些数据是毫无目的地获取的。
一个Page<
dto
>
需要一个 DTO;因此,您需要定义一个 Spring projection (DTO ),其中包含与应该获取的数据相对应的 getters。在这种情况下,是Author
实体的age
和name
:
public interface AuthorDto {
public String getName();
public int getAge();
@JsonIgnore
public long getTotal();
}
检查突出显示的两行代码。需要使用getTotal()
来映射SELECT COUNT
子查询的结果。这不是Author
实体的财产。而且,它用@JsonIgnore
进行了注释,因为它不会在发送给客户端的 JSON 响应中被序列化。它将在PageImpl
的构造函数中被用来创建一个Page<AuthorDto>
。但是,在此之前,在一次数据库往返中获取数据和记录总数的 JPQL 查询如下所示:
@Repository
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT a.name as name, a.age as age, "
+ "(SELECT count(a) FROM Author a) AS total FROM Author a")
List<AuthorDto> fetchAllJpql(Pageable pageable);
}
请注意,fetchAllJpql()
接受了一个类型为Pageable
的参数(一个包装了关于一页数据的元数据的对象,比如页面大小、总元素数、页码、排序等。).JPQL 没有提供限制查询的机制;因此,明确添加LIMIT
(或其对应方)是不可能的。这通常通过在Query
上使用setMaxResults()
方法来实现。然而,使用Pageable
可以很好地完成这项工作。传递的Pageable
将把ORDER BY
和LIMIT
子句添加到生成的 SQL 语句中。
调用fetchAllJpql()
将触发以下 SQL 语句:
SELECT
author0_.name AS col_0_0_,
author0_.age AS col_1_0_,
(SELECT COUNT(author1_.id)
FROM author author1_)
AS col_2_0_
FROM author author0_
ORDER BY author0_.age ASC
LIMIT ? ?
通过以下本机查询可以获得相同的效果:
@ repository public interface author repository 扩展 PagingAndSortingRepository+
"(SELECT count(*) AS total FROM author) AS t"
,
nativeQuery = true) List<AuthorDto> fetchAllNative(Pageable pageable);}
SELECT COUNT
子查询结果通过getTotal()
存储。由于fetchAllJqpl()
返回一个List<AuthorDto>
,它必须被转换成一个Page<AuthorDto>
。一个服务方法创建Pageable
并调用fetchAllJpql()
。fetchAllJpql()
的结果用于通过下面的PageImpl
构造函数创建一个Page<AuthorDto>
:
public PageImpl(List<T> content, Pageable pageable, long total)
服务方法非常简单:
public Page<AuthorDto> fetchNextPageJpql(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age"));
List<AuthorDto> authors = authorRepository.fetchAllJpql(pageable);
Page<AuthorDto> pageOfAuthors = new PageImpl(authors, pageable,
authors.isEmpty() ? 0 : authors.get(0).getTotal());
return pageOfAuthors;
}
REST 控制器端点可以调用fetchNextPageJpql()
方法,如下所示:
@GetMapping("/authors/{page}/{size}")
public Page<AuthorDto> fetchAuthorsJpql(
@PathVariable int page, @PathVariable int size) {
return bookstoreService.fetchNextPageJpql(page, size);
}
可能的输出是下面的 JSON(注意,记录总数是 51):
http://localhost:8080/authors/1/3
{
"content":[
{
"age":23,
"name":"Tylor Ruth"
},
{
"age":23,
"name":"Wuth Troll"
},
{
"age":24,
"name":"Kemal Ilias"
}
],
"pageable":{
"sort":{
"unsorted":false,
"sorted":true,
"empty":false
},
"pageSize":3,
"pageNumber":1,
"offset":3,
"paged":true,
"unpaged":false
},
"totalPages":17,
"totalElements":51,
"last":false,
"numberOfElements":3,
"first":false,
"sort":{
"unsorted":false,
"sorted":true,
"empty":false
},
"number":1,
"size":3,
"empty":false
}
GitHub 10 上有源代码。
您可以很容易地只获取数据(没有pageable
元素)作为List<AuthorDto>
,如这个应用 11 。
页面为页面<实体 >
虽然Page<
dto
>
非常适合对只读数据进行分页,但是Page<
entity
>
更适合将要被修改的实体。
提取实体不会映射SELECT COUNT
子查询结果。实体定义了一组属性,但是它没有表示数据库中记录总数的特殊属性。要解决这个问题,至少有一种方法,下面讨论。
使用额外的属性
映射由SELECT COUNT
子查询返回的记录总数的一种方法是向相应的实体添加一个额外的属性。该属性可以映射到不可插入或可更新的列,如下例所示(total
属性没有设置器)😗*
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int age;
private String name;
private String genre;
@Column(insertable = false, updatable = false)
long total;
...
public long getTotal() {
return total;
}
}
此外,在AuthorRepository
中,您可以依赖包含SELECT COUNT
子查询的原生 SQL,如下所示:
@Repository
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT t.total, id, name, age, genre FROM author, "
+ "(SELECT count(*) AS total FROM author) AS t",
nativeQuery = true)
List<Author> fetchAll(Pageable pageable);
}
调用fetchAll()
将触发下面的SELECT
语句(注意,有一个单独的查询用于获取一页数据作为List<Author>
):
SELECT
t.total,
id,
name,
age,
genre
FROM author,
(SELECT COUNT(*) AS total
FROM author) AS t
ORDER BY age ASC
LIMIT ?, ?
调用fetchAll()
的服务方法负责准备Page<Author>
,如下所示:
public Page<Author> fetchNextPage(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age"));
List<Author> authors = authorRepository.fetchAll(pageable);
Page<Author> pageOfAuthors = new PageImpl(authors, pageable,
authors.isEmpty() ? 0 : authors.get(0).getTotal());
return pageOfAuthors;
}
源代码可在 GitHub 12 上获得。应用公开了一个类型为http://localhost:8080/authors/{
page}/{size**}的 REST 端点。返回的结果是一个 JSON,如下例所示(在author
表中有 51 条记录,这由total
字段公开):
http://localhost:8080/authors/1/3
{
"content":[
{
"id":25,
"age":23,
"name":"Tylor Ruth",
"genre":"Anthology"
},
{
"id":7,
"age":23,
"name":"Wuth Troll",
"genre":"Anthology"
},
{
"id":22,
"age":24,
"name":"Kemal Ilias",
"genre":"History"
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":3,
"pageNumber":1,
"offset":3,
"paged":true,
"unpaged":false
},
"totalPages":17,
"totalElements":51,
"last":false,
"numberOfElements":3,
"first":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":1,
"size":3,
"empty":false
}
您可以很容易地只获取数据(没有pageable
元素)作为List<Author>
,如这个应用 13 。
项目 97:如何使用联接提取和可分页
考虑双向懒惰@OneToMany
关联中涉及的两个实体(Author
和Book
)。您可以通过(LEFT
) JOIN FETCH
( 第 39 项和第 41 项)在同一个查询中高效地获取相关书籍的作者。您可以通过组合(LEFT
) JOIN FETCH
和Pageable
对结果集应用分页。但是尝试实现这种组合会导致异常。例如,考虑以下查询:
@Transactional(readOnly = true)
@Query(value = "SELECT a FROM Author a
JOIN FETCH a.books WHERE a.genre = ?1")
Page<Author> fetchWithBooksByGenre (String genre, Pageable pageable);
调用fetchWithBooksByGenre()
会导致以下异常:
org.hibernate.QueryException: query specified join fetching,but the owner of the fetched association was not present in the select list [FromElement {explicit, not a collection join, fetch join, fetch non-lazy properties, classAlias = null, role = com.bookstore.entity.Author.books, tableName = book,tableAlias = books1_, origin = author author0_, columns = {author0_.id, className = com.bookstore.entity.Book}}]
这个异常的主要原因是来自 Spring 数据的缺失计数查询。
如果您实际上不需要一个Page
(例如,您不关心记录的总数等等),那么只需用Slice
或List
替换Page
。这将消除这一例外。
您可以通过countQuery
元素添加缺少的SELECT COUNT
,如下所示:
@Transactional
@Query(value = "SELECT a FROM Author a
LEFT JOIN FETCH a.books WHERE a.genre = ?1",
countQuery = "SELECT COUNT(a) FROM Author a WHERE a.genre = ?1")
Page<Author> fetchWithBooksByGenre(String genre, Pageable pageable);
或者您可以通过一个特别的实体图来添加它,如下所示(关于实体图的更多细节可以在项目 7 、项目 8 和项目 9 中找到):
@Transactional
@EntityGraph(attributePaths = {"books"},
type = EntityGraph.EntityGraphType.FETCH)
@Query(value = "SELECT a FROM Author a WHERE a.genre = ?1")
Page<Author> fetchWithBooksByGenre(String genre, Pageable pageable);
这一次,调用这些fetchWithBooksByGenre()
方法之一会产生以下 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
WHERE author0_.genre = ?
ORDER BY author0_.name ASC
SELECT
COUNT(author0_.id) AS col_0_0_
FROM author author0_
WHERE author0_.genre = ?
注意,分页发生在内存中(这些 SQL 语句中没有数据库分页)。此外,这将作为类型HHH000104:
的消息发出
firstResult/maxResults specified with collection fetch; applying in memory!.
依赖于内存中的分页会导致性能下降,尤其是当提取的集合很大时。因此,使用这段代码时要格外小心。为了理解和固定HHH000104
,请考虑第 98 项。
另一方面,让我们获取所有相关作者的书籍。例如:
@Transactional(readOnly = true)
@Query(value = "SELECT b FROM Book b
LEFT JOIN FETCH b.author WHERE b.isbn LIKE ?1%",
countQuery = "SELECT COUNT(b) FROM Book b WHERE b.isbn LIKE ?1%")
Page<Book> fetchWithAuthorsByIsbn(String isbn, Pageable pageable);
@Transactional(readOnly = true)
@EntityGraph(attributePaths = {"author"},
type = EntityGraph.EntityGraphType.FETCH)
@Query(value = "SELECT b FROM Book b WHERE b.isbn LIKE ?1%")
Page<Book> fetchWithAuthorsByIsbn(String isbn, Pageable pageable);
这两个查询都会触发以下查询:
SELECT
book0_.id AS id1_1_0_,
author1_.id AS id1_0_1_,
book0_.author_id AS author_i4_1_0_,
book0_.isbn AS isbn2_1_0_,
book0_.title AS title3_1_0_,
author1_.age AS age2_0_1_,
author1_.genre AS genre3_0_1_,
author1_.name AS name4_0_1_
FROM book book0_
LEFT OUTER JOIN author author1_
ON book0_.author_id = author1_.id
WHERE book0_.isbn LIKE ?
ORDER BY book0_.title ASC LIMIT ?
SELECT
COUNT(book0_.id) AS col_0_0_
FROM book book0_
WHERE book0_.isbn LIKE ?
这次分页是由数据库完成的,这比在内存中好得多。
完整的应用可在 GitHub 14 上获得。
第 98 项:如何修复 HHH000104
在第 97 项中,您看到HHH000104
是一个警告,告诉您内存中正在对结果集进行分页。例如,考虑双向惰性@OneToMany
关联中的Author
和Book
实体以及以下查询:
@Transactional
@Query(value = "SELECT a FROM Author a
LEFT JOIN FETCH a.books WHERE a.genre = ?1",
countQuery = "SELECT COUNT(a) FROM Author a WHERE a.genre = ?1")
Page<Author> fetchWithBooksByGenre(String genre, Pageable pageable);
调用fetchWithBooksByGenre()
工作正常,除了发出以下警告:HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
显然,从性能角度来看,在内存中分页并不好。
一般来说,不可能用 Hibernate ORM/JPA 注释来限制获取的集合的大小。例如,不可能限制@OneToMany
集合的大小。Hibernate 不能操作集合的子集,因为它必须管理整个集合实体的状态转换。
在这种情况下,Hibernate 不能简单地使用 SQL 级别的分页来截断结果集,因为它容易截断一些Book
行。这可能导致一个Author
只有一个Book
的子集。这就是为什么分页是在内存中完成的,Hibernate 可以控制整个结果集。不幸的是,特别是对于大的结果集,这会导致严重的性能损失。
由于HHH000104
是作为警告报告的,所以在日志中很有可能会遗漏。从 Hibernate 5.2.13 开始,如果hibernate.query.fail_on_pagination_over_collection_fetch
属性被启用,HHH000104
被报告为异常。在 Spring Boot,该属性可以在application.properties
中启用,如下所示:
spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true
这一次,丢失HHH000104
是不可能的,因此确保您总是在您的项目中启用该属性。
进一步,让我们看看如何修复HHH000104
并在数据库上执行分页。
提取受管实体
您可以将结果集作为一个Page
、Slice
或List
来获取。
正在获取页面
首先,让我们关注Page
并获取给定流派作者的 id:
@Transactional(readOnly = true)
@Query(value = "SELECT a.id FROM Author a WHERE a.genre = ?1")
Page<Long> fetchPageOfIdsByGenre(String genre, Pageable pageable);
进一步,让我们获取这些作者的书籍(获取的 id):
@Transactional(readOnly = true)
@QueryHints(value = @QueryHint(name = HINT_PASS_DISTINCT_THROUGH,
value = "false"))
@Query(value = "SELECT DISTINCT a FROM Author a
LEFT JOIN FETCH a.books WHERE a.id IN ?1")
List<Author> fetchWithBooks(List<Long> authorIds);
或者,您可以依赖实体图:
@Transactional(readOnly = true)
@EntityGraph(attributePaths = {"books"},
type = EntityGraph.EntityGraphType.FETCH)
@QueryHints(value = @QueryHint(name = HINT_PASS_DISTINCT_THROUGH,
value = "false"))
@Query(value = "SELECT DISTINCT a FROM Author a WHERE a.id IN ?1")
List<Author> fetchWithBooksEntityGraph(List<Long> authorIds);
服务方法可以如下调用这两个查询(在调用fetchWithBooks()
之前,建议您确保pageOfIds.getContent()
不为空):
@Transactional
public Page<Author> fetchAuthorsWithBooksByGenre(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
.fetchWithBooks(pageOfIds.getContent());
Page<Author> pageOfAuthors = new PageImpl(
listOfAuthors, pageable, pageOfIds.getTotalElements());
return pageOfAuthors;
}
同样,你可以调用fetchWithBooksEntityGraph()
。
注意,service-method 用@Transactional
进行了注释,这意味着将以读写模式获取实体。如果需要只读实体,那么就添加@Transactional(readOnly=true)
。
触发的 SQL 语句如下(这与实体图的用法相同):
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 (?, ?, ?, ?)
以下是 JSON 输出示例:
{
"content":[
{
"id":1,
"name":"Mark Janel",
"genre":"Anthology",
"age":23,
"books":[
{
"id":3,
"title":"The Beatles Anthology",
"isbn":"001-MJ"
},
{
"id":8,
"title":"Anthology From Zero To Expert",
"isbn":"002-MJ"
},
{
"id":9,
"title":"Quick Anthology",
"isbn":"003-MJ"
}
]
},
{
"id":6,
"name":"Merci Umaal",
"genre":"Anthology",
"age":31,
"books":[
{
"id":7,
"title":"Ultimate Anthology",
"isbn":"001-MU"
},
{
"id":10,
"title":"1959 Anthology",
"isbn":"002-MU"
}
]
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":2,
"pageNumber":0,
"offset":0,
"paged":true,
"unpaged":false
},
"totalElements":4,
"totalPages":2,
"last":false,
"numberOfElements":2,
"first":true,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":0,
"size":2,
"empty":false
}
此外,您可以优化实现,以避免使用单独的SELECT COUNT
进行偏移分页。一种快速的方法是使用COUNT(*) OVER()
进行本地查询,如下所示:
@Transactional(readOnly = true)
@Query(value = "SELECT a.id AS id, COUNT(*) OVER() AS total
FROM Author a WHERE a.genre = ?1",
nativeQuery = true)
List<Tuple> fetchTupleOfIdsByGenre(String genre, Pageable pageable);
服务方法应该处理List<Tuple>
,以便提取作者的 id 和元素的总数:
@Transactional
public Page<Author> fetchPageOfAuthorsWithBooksByGenreTuple(
int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "name"));
List<Tuple> tuples = authorRepository.fetchTupleOfIdsByGenre(
"Anthology", pageable);
List<Long> listOfIds = new ArrayList<>(tuples.size());
for(Tuple tuple: tuples) {
listOfIds.add(((BigInteger) tuple.get("id")).longValue());
}
List<Author> listOfAuthors
= authorRepository.fetchWithBooksJoinFetch(listOfIds);
Page<Author> pageOfAuthors = new PageImpl(listOfAuthors, pageable,
((BigInteger) tuples.get(0).get("total")).longValue());
return pageOfAuthors;
}
这一次,去掉了附加的SELECT COUNT
;因此,您可以将三个SELECT
语句减少到两个。
获取切片
依靠Slice
也是一种选择。使用Slice
而不是Page
消除了对这个额外的SELECT COUNT
查询的需要,并返回页面(记录)和一些元数据,但不返回记录总数。当您需要 Slice 提供的元数据但不需要记录总数时,或者当您通过只执行一次的单独的SELECT COUNT
获取记录总数时,这很有用。当插入/删除从不触发或很少触发时,通常会这样做。在这种情况下,记录的数量在页面导航之间不会改变,所以没有必要为每个页面触发一个SELECT COUNT
。
这需要三至两条 SQL 语句。下面是基于Slice
的实现:
@Transactional(readOnly = true)
@Query(value = "SELECT a.id FROM Author a WHERE a.genre = ?1")
Slice<Long> fetchSliceOfIdsByGenre(String genre, Pageable pageable);
@Transactional
public Slice<Author> fetchAuthorsWithBooksByGenre(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "name"));
Slice<Long> pageOfIds = authorRepository
.fetchSliceOfIdsByGenre("Anthology", pageable);
List<Author> listOfAuthors = authorRepository
.fetchWithBooks(pageOfIds.getContent());
Slice<Author> sliceOfAuthors = new SliceImpl(
listOfAuthors, pageable, pageOfIds.hasNext());
return sliceOfAuthors;
}
这将只触发两个 SQL SELECT
语句。您没有关于总行数的信息,但是您知道是否有更多的页面。示例 JSON 如下所示(检查last
元素):
{
"content":[
{
"id":1,
"name":"Mark Janel",
"genre":"Anthology",
"age":23,
"books":[
{
"id":3,
"title":"The Beatles Anthology",
"isbn":"001-MJ"
},
{
"id":8,
"title":"Anthology From Zero To Expert",
"isbn":"002-MJ"
},
{
"id":9,
"title":"Quick Anthology",
"isbn":"003-MJ"
}
]
},
{
"id":6,
"name":"Merci Umaal",
"genre":"Anthology",
"age":31,
"books":[
{
"id":7,
"title":"Ultimate Anthology",
"isbn":"001-MU"
},
{
"id":10,
"title":"1959 Anthology",
"isbn":"002-MU"
}
]
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":2,
"pageNumber":0,
"offset":0,
"paged":true,
"unpaged":false
},
"numberOfElements":2,
"first":true,
"last":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":0,
"size":2,
"empty":false
}
获取列表
我们也可以作为一个List<Author>
来获取数据。当您不需要由Page
或Slice
提供的任何元数据时,这很有用:
@Transactional(readOnly = true)
@Query(value = "SELECT a.id FROM Author a WHERE a.genre = ?1")
List<Long> fetchListOfIdsByGenre(String genre, Pageable pageable);
这一次,您使用Pageable
只是为了通过 Spring help 添加用于排序和分页的 SQL 子句。特别是在分页时,Spring 会根据方言选择合适的 SQL 子句(例如,对于 MySQL,它会添加LIMIT
)。移除Pageable
并使用原生查询也是一种选择。
调用fetchListOfIdsByGenre()
的服务方法如下:
@Transactional
public List<Author> fetchListOfAuthorsWithBooksByGenre(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "name"));
List<Long> listOfIds = authorRepository.fetchListOfIdsByGenre(
"Anthology", pageable);
List<Author> listOfAuthors
= authorRepository.fetchWithBooksJoinFetch(listOfIds);
return listOfAuthors;
}
这将触发以下两个SELECT
语句:
SELECT
author0_.id AS col_0_0_
FROM author author0_
WHERE author0_.genre = ?
ORDER BY author0_.name
ASC LIMIT ? ?
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 (?, ?)
和一个示例结果集的 JSON 表示:
[
{
"id":3,
"name":"Quartis Young",
"genre":"Anthology",
"age":51,
"books":[
{
"id":5,
"title":"Anthology Of An Year",
"isbn":"001-QY"
}
]
},
{
"id":5,
"name":"Pyla Lou",
"genre":"Anthology",
"age":41,
"books":[
{
"id":6,
"title":"Personal Anthology",
"isbn":"001-KL"
}
]
}
]
GitHub 15 上有完整的应用。
第 99 项:如何实现 Slice findAll()
Spring Boot 提供了基于偏移量的内置分页机制,该机制返回一个Page
或Slice
。每个 API 代表一个数据页面和一些页面元数据。主要区别在于,Page
包含记录的总数,而Slice
只能判断是否还有另一页可用。对于Page
,Spring Boot 提供了一个findAll()
方法,它可以接受一个Pageable
和/或一个Specification
或Example
作为参数。为了创建一个包含记录总数的Page
,这个方法触发了一个SELECT COUNT
额外的查询,紧接着这个查询用于获取当前页面的数据。这可能导致性能损失,因为每次请求页面时都会触发SELECT COUNT
查询。为了避免这个额外的查询,Spring Boot 提供了一个更宽松的 API,即Slice
API。使用Slice
而不是Page
消除了对这个额外的SELECT COUNT
查询的需要,并返回页面(记录)和一些页面元数据,但不返回记录总数。因此,虽然Slice
不知道记录的总数,但它仍然可以知道在当前页之后是否还有另一页可用,或者这是最后一页。问题是Slice
对于包含 SQL WHERE
子句的查询(包括那些使用内置在 Spring 数据中的查询构建器机制的查询)工作良好,但是对于findAll()
就不行了。这个方法仍然会返回一个Page
而不是一个Slice
,所以对于Slice<T> findAll(...)
会触发SELECT COUNT
查询。
快速实施
获取所有数据的Slice
的快速解决方案包括定义一个依赖于显式查询(JPQL)和Pageable
对象的方法。
提取切片<实体 >
考虑将这个方法命名为fetchAll()
。您可以将它添加到一个存储库中,如下所示(AuthorRepository
是对应于Author
实体的存储库):
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT a FROM Author a")
Slice<Author> fetchAll(Pageable pageable);
}
调用fetchAll()
将触发如下的单个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_
ORDER BY author0_.age ASC
LIMIT ? ?
调用fetchAll()
的服务方法可以编写如下:
public Slice<Author> fetchNextSlice(int page, int size) {
return authorRepository.fetchAll(PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age")));
}
考虑类型为localhost:8080/authors/{
page
}/{
size
}
的 REST 端点和author
表中总共 51 条记录。对大小为 3 的第二页的请求可以作为localhost:8080/authors/1/3
被触发,结果(作为 JSON)如下:
{
"content":[
{
"id":7,
"age":23,
"name":"Wuth Troll",
"genre":"Anthology"
},
{
"id":25,
"age":23,
"name":"Tylor Ruth",
"genre":"Anthology"
},
{
"id":16,
"age":24,
"name":"Joana Leonte",
"genre":"History"
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":3,
"pageNumber":1,
"offset":3,
"paged":true,
"unpaged":false
},
"numberOfElements":3,
"first":false,
"last":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":1,
"size":3,
"empty":false
}
没有关于记录总数的信息。但是,"last": false
表示这不是最后一页。
正在获取切片<dto
考虑以下 Spring Boot 投影(DTO):
public interface AuthorDto {
public String getName();
public int getAge();
}
考虑将这个方法命名为fetchAllDto()
。您可以将它添加到一个存储库中,如下所示(AuthorRepository
是对应于Author
实体的存储库):
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT a.name as name, a.age as age FROM Author a")
Slice<AuthorDto> fetchAllDto(Pageable pageable);
}
调用fetchAllDto()
将触发如下的单个SELECT
查询:
SELECT
author0_.name AS col_0_0_,
author0_.age AS col_1_0_
FROM author author0_
ORDER BY author0_.age ASC
LIMIT ? ?
调用fetchAllDto()
的服务方法可以编写如下:
public Slice<AuthorDto> fetchNextSlice(int page, int size) {
return authorRepository.fetchAllDto(PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age")));
}
考虑类型为localhost:8080/authors/{
page
}/{
size
}
的 REST 端点和author
表中总共 51 条记录。对大小为 3 的第二页的请求可以作为localhost:8080/authors/1/3
被触发,结果(作为 JSON)如下:
{
"content":[
{
"age":23,
"name":"Wuth Troll"
},
{
"age":23,
"name":"Tylor Ruth"
},
{
"age":24,
"name":"Joana Leonte"
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":3,
"pageNumber":1,
"offset":3,
"paged":true,
"unpaged":false
},
"numberOfElements":3,
"first":false,
"last":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":1,
"size":3,
"empty":false
}
GitHub 16 上有源代码。
Slice findAll(可分页可分页)的实现
将方法名保存为findAll
意味着您必须创建它的自定义实现。首先,编写一个abstract
类,并如下定义findAll()
:
@Repository
@Transactional(readOnly = true)
public abstract class SlicePagingRepositoryImplementation<T> {
@Autowired
private EntityManager entityManager;
private final Class<T> entityClass;
public SlicePagingRepositoryImplementation(Class<T> entityClass) {
this.entityClass = entityClass;
}
public Slice<T> findAll(Pageable pageable) {
return findAll(pageable, entityClass);
}
...
findAll(Pageable, Class<T>)
是一个负责构建查询的private
方法。一种简单的方法如下:
private Slice<T> findAll(Pageable pageable, Class<T> entityClass) {
final String sql = "SELECT e FROM " + entityClass.getSimpleName() + " e";
TypedQuery<T> query = entityManager.createQuery(sql, entityClass);
return this.readSlice(query, pageable);
}
最后,readSlice()
是一个private
方法,负责通过SliceImpl
和给定的query
创建一个Slice<T>
:
private Slice<T> readSlice(final TypedQuery<T> query,
final Pageable pageable) {
query.setFirstResult((int) pageable.getOffset());
query.setMaxResults(pageable.getPageSize() + 1);
final List<T> content = query.getResultList();
boolean hasNext = content.size() == (pageable.getPageSize() + 1);
if (hasNext) {
content.remove(content.size() - 1);
}
return new SliceImpl<>(content, pageable, hasNext);
}
完整的实现可在 GitHub 17 上获得。除此之外,还有其他几个实现,如下所示:
-
实现 18 基于
CriteriaBuilder
而不是硬编码的 SQL -
实现 19 让你提供一个
Sort
-
实现 20 个 ,允许你提供一个
Sort
和Specification
-
实现 21 ,允许你提供一个
Sort
、LockModeType
、QueryHints
和Specification
-
从
SimpleJpaRepository
实现 22 覆盖Page<T> readPage(...)
方法
项目 100:如何实现键集分页
建议在继续之前阅读第 94 项。
对于大型数据集,偏移分页会带来严重的性能损失,特别是遍历已经显示的记录以达到所需的偏移。在这种情况下,最好依靠键集分页,它在不断增长的数据中保持“恒定”的时间。下面列出了键集分页的优点和缺点。
缺点:
-
无法获取任意页面
-
编写
WHERE
子句并不(总是)容易
优点:
-
搜索上一页的最后一项
-
仅提取以下行
-
无限滚动机制
-
插入不会造成页面偏差
另一个要考虑的缺点是,Spring Boot 不提供对键集分页的内置支持。实际上,依赖偏移量而不是键集分页的主要原因是缺少工具支持。
考虑Author
实体对应的author
表,如图 13-4 所示。
图 13-4
作者实体表
目标是实现键集分页,以实体和 DTO 的形式获取作者。
选择一列作为最近访问的记录/行(如id
列),并在WHERE
和ORDER BY
子句中使用该列。依托id
列的习惯用法如下(多列排序遵循同样的思路):
SELECT ...
FROM ...
WHERE id < {last_seen_id}
ORDER BY id DESC
LIMIT {how_many_rows_to_fetch}
或者,像这样:
SELECT ...
FROM ...
WHERE ...
AND id < {last_seen_id}
ORDER BY id DESC
LIMIT {how_many_rows_to_fetch}
例如,在这种情况下应用第一个习惯用法可能会导致以下本机查询(第一个查询获取实体;第二个获取 DTO):
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query(value = "SELECT * FROM author AS a WHERE a.id < ?1
ORDER BY a.id DESC LIMIT ?2", nativeQuery = true)
List<Author> fetchAll(long id, int limit);
@Query(value = "SELECT name, age FROM author AS a WHERE a.id < ?1
ORDER BY a.id DESC LIMIT ?2", nativeQuery = true)
List<AuthorDto> fetchAllDto(long id, int limit);
}
在分页的情况下,确定的排序顺序是强制性的,所以不要忘记ORDER BY
子句。
LIMIT
子句由MySQL
和PostgreSQL
识别。SQL Server 支持SELECT TOP
子句,Oracle 使用ROWNUM
或ROWS FETCH NEXT
n
ROWS ONLY
。
AuthorDto
是一个简单的 Spring Boot 投影:
public interface AuthorDto {
public String getName();
public int getAge();
}
此外,服务方法可以调用fetchAll()
和fetchAllDto()
,如下所示:
public List<Author> fetchNextPage(long id, int limit) {
return authorRepository.fetchAll(id, limit);
}
public List<AuthorDto> fetchNextPageDto(long id, int limit) {
return authorRepository.fetchAllDto(id, limit);
}
REST 控制器端点如localhost:8080/authors/{
id
}/{
limit
}
可以帮助你测试这些服务方法。例如,通过localhost:8080/authors/5/3
调用fetchNextPage()
将输出以下内容:
[
{
"id":4,
"age":34,
"name":"Joana Nimar",
"genre":"History"
},
{
"id":3,
"age":51,
"name":"Quartis Young",
"genre":"Anthology"
},
{
"id":2,
"age":43,
"name":"Olivia Goy",
"genre":"Horror"
}
]
图 13-5 所示的时间-性能趋势图显示,键集分页比偏移量分页快得多。考虑有 100 万条记录的author
表。我们获取第一页(0)、第 5000 页和第 9999 页,共 100 条记录。
图 13-5
失调与键集的关系
GitHub 23 上有源代码。
第 101 项:如何向键集分页添加下一页按钮
建议在继续之前阅读第项 100 。
键集分页不依赖于元素的总数。但是,通过一点小技巧,对客户机的响应可以包含一条信息,表明是否有更多的记录要获取。客户端可以使用该信息来显示下一页按钮。例如,localhost:8080/authors/5/3
这样的 REST 控制器端点将返回三条记录(ID4、 3 和 2 ),但是在author
表中还有一条记录(ID 1 )。响应的last
元素表示这不是最后一页:
{
"authors":[
{
"id":4,
"age":34,
"name":"Joana Nimar",
"genre":"History"
},
{
"id":3,
"age":51,
"name":"Quartis Young",
"genre":"Anthology"
},
{
"id":2,
"age":43,
"name":"Olivia Goy",
"genre":"Horror"
}
],
"last":false
}
因此,您可以通过localhost:8080/authors/2/3
获取下一页。这一次,响应将包含一条记录(ID 1 ),并且last
元素是true
。这意味着这是最后一页,因此应禁用下一页按钮:
{
"authors":[
{
"id":1,
"age":23,
"name":"Mark Janel",
"genre":"Anthology"
}
],
"last":true
}
但是,如何添加last
元素呢?首先,您定义一个类,该类对获取的数据和额外的元素进行分组(在本例中是last
,但是可以添加更多的元素):
public class AuthorView {
private final List<Author> authors;
private final boolean last;
public AuthorView(List<Author> authors, boolean last) {
this.authors = authors;
this.last = last;
}
public List<Author> getAuthors() {
return authors;
}
public boolean isLast() {
return last;
}
}
此外,服务方法获取limit + 1
记录并如下确定last
的值:
public AuthorView fetchNextPage(long id, int limit) {
List<Author> authors = authorRepository.fetchAll(id, limit + 1);
if (authors.size() == (limit + 1)) {
authors.remove(authors.size() - 1);
return new AuthorView(authors, false);
}
return new AuthorView(authors, true);
}
最后,您修改 REST 控制器端点以返回List<AuthorView>
而不是List<Author>
:
@GetMapping("/authors/{id}/{limit}")
public AuthorView fetchAuthors(
@PathVariable long id, @PathVariable int limit) {
return bookstoreService.fetchNextPage(id, limit);
}
搞定了。GitHub 24 上有源代码。它还包括 DTO 案件。
第 102 项:如何通过 ROW_NUMBER()实现分页
到目前为止,分页主题已经在几篇文章中讨论过了。另一种获取页面数据的方法是使用ROW_NUMBER()
窗口函数,这是在项目 119 中引入的。如果你不熟悉ROW_NUMBER()
,最好把这一项推迟到你看完第 119 项之后。
考虑众所周知的Author
实体和下面的 DTO:
public interface AuthorDto {
public String getName();
public int getAge();
}
下面的本地查询是一个通过ROW_NUMBER()
获取页面作者的例子:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT * FROM (SELECT name, age, "
+ "ROW_NUMBER() OVER (ORDER BY age) AS row_num "
+ "FROM author) AS a WHERE row_num BETWEEN ?1 AND ?2",
nativeQuery = true)
List<AuthorDto> fetchPage(int start, int end);
}
或者,如果您还需要获取总行数,那么使用total
字段和使用COUNT(*) OVER()
窗口函数的查询来丰富 d to,如下所示:
public interface AuthorDto {
public String getName();
public int getAge();
public long getTotal();
}
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query(value = "SELECT * FROM (SELECT name, age, "
+ "COUNT(*) OVER() AS total, "
+ "ROW_NUMBER() OVER (ORDER BY age) AS row_num FROM author) AS a "
+ "WHERE row_num BETWEEN ?1 AND ?2",
nativeQuery = true)
List<AuthorDto> fetchPage(int start, int end);
}
完整的应用可在 GitHub 25 上获得。
**十四、问题
第 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
标注来触发UPDATE
SQL 语句,代替DELETE
SQL 语句;删除一个实体将导致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 上获得。
十五、继承
第 138 项:如何有效地使用单表继承
单表继承是默认的 JPA 策略。按照这种策略,继承层次结构中的所有类都通过数据库中的单个表来表示。
考虑图 15-1 中给出的继承层次。
图 15-1
单表继承域模型
在Author
和Book
之间有一个双向的懒惰@OneToMany
关联。Author
实体可以被视为根类,因为没有作者就没有书。Book
实体是基类。为了采用单表继承策略,这个类用@Inheritance
或@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
进行了注释。Ebook
和Paperback
实体扩展了Book
实体;所以,他们不需要自己的@Id
。
形成这种继承策略的表格如图 15-2 所示。
图 15-2
单表继承策略的表
book
表包含与Book
实体以及Ebook
和Paperback
实体相关联的列。它还包含一个名为dtype
的栏目。这被称为鉴别器列。Hibernate 使用这个列将结果集映射到相关的子类实例。默认情况下,鉴别器列保存实体的名称。
如果您必须对一个遗留数据库使用SINGLE_TABLE
策略,那么很可能您将没有一个鉴别器列,并且您不能改变表定义。在这种情况下,您可以使用@DiscriminatorFormula
来定义一个公式(一个派生值)作为继承鉴别器列。一旦你知道了@DiscriminatorFormula
,你可以很容易地在网上找到例子。
这里列出了Book
基类及其子类的相关代码:
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public class Book implements Serializable {
...
}
@Entity
public class Ebook extends Book implements Serializable {
...
}
@Entity
public class Paperback extends Book implements Serializable {
...
}
持久数据
是时候持久化一些数据了。下面的服务方法持久化一个包含三本书的Author
,这三本书是通过Book
、Ebook
和Paperback
实体创建的:
public void persistAuthorWithBooks() {
Author author = new Author();
author.setName("Alicia Tom");
author.setAge(38);
author.setGenre("Anthology");
Book book = new Book();
book.setIsbn("001-AT");
book.setTitle("The book of swords");
Paperback paperback = new Paperback();
paperback.setIsbn("002-AT");
paperback.setTitle("The beatles anthology");
paperback.setSizeIn("7.5 x 1.3 x 9.2");
paperback.setWeightLbs("2.7");
Ebook ebook = new Ebook();
ebook.setIsbn("003-AT");
ebook.setTitle("Anthology myths");
ebook.setFormat("kindle");
author.addBook(book); // use addBook() helper
author.addBook(paperback);
author.addBook(ebook);
authorRepository.save(author);
}
保存author
实例会触发以下 SQL 语句:
INSERT INTO author (age, genre, name)
VALUES (?, ?, ?)
Binding:[38, Anthology, Alicia Tom]
INSERT INTO book (author_id, isbn, title, dtype)
VALUES (?, ?, ?, 'Book')
Binding:[1, 001-AT, The book of swords]
INSERT INTO book (author_id, isbn, title, size_in, weight_lbs, dtype)
VALUES (?, ?, ?, ?, ?, 'Paperback')
Binding:[1, 002-AT, The beatles anthology, 7.5 x 1.3 x 9.2, 2.7]
INSERT INTO book (author_id, isbn, title, format, dtype)
VALUES (?, ?, ?, ?, 'Ebook')
Binding:[1, 003-AT, Anthology myths, kindle]
作者保存在author
表中,而书籍(book
、ebook
和paperback
)保存在book
表中。因此,持久化(写)数据是有效的,因为所有的书都保存在同一个表中。
查询和单表继承
现在,让我们来看看获取数据的效率。考虑以下BookRepository
:
@Repository
@Transactional(readOnly = true)
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT b FROM Book b WHERE b.author.id = ?1")
List<Book> fetchBooksByAuthorId(Long authorId);
Book findByTitle(String title);
}
通过作者标识符获取图书
让我们打电话给fetchBooksByAuthorId()
:
List<Book> books = bookRepository.fetchBooksByAuthorId(1L);
触发的SELECT
如下:
SELECT
book0_.id AS id2_1_,
book0_.author_id AS author_i8_1_,
book0_.isbn AS isbn3_1_,
book0_.title AS title4_1_,
book0_.format AS format5_1_,
book0_.size_in AS size_in6_1_,
book0_.weight_lbs AS weight_l7_1_,
book0_.dtype AS dtype1_1_
FROM book book0_
WHERE book0_.author_id = ?
继承为多态查询提供了支持。换句话说,获取的结果集被正确地映射到基类(Book
)和子类(Ebook
和Paperback
)。Hibernate 通过检查每个获取的行的鉴别器列来做到这一点。
按书名取书
更进一步,让我们为每本书调用findByTitle()
:
Book b1 = bookRepository.findByTitle("The book of swords"); // Book
Book b2 = bookRepository.findByTitle("The beatles anthology"); // Paperback
Book b3 = bookRepository.findByTitle("Anthology myths"); // Ebook
触发的SELECT
对于所有三种类型的图书都是相同的:
SELECT
book0_.id AS id2_1_,
book0_.author_id AS author_i8_1_,
book0_.isbn AS isbn3_1_,
book0_.title AS title4_1_,
book0_.format AS format5_1_,
book0_.size_in AS size_in6_1_,
book0_.weight_lbs AS weight_l7_1_,
book0_.dtype AS dtype1_1_
FROM book book0_
WHERE book0_.title = ?
获取b1
、b2
和b3
作为Book
实例不会混淆 Hibernate。由于b2
是一个Paperback
,它可以被显式强制转换以显示大小和重量:
Paperback p = (Paperback) b2;
System.out.println(p.getSizeIn());
System.out.println(p.getWeightLbs());
当然,这不像依赖子类的专用库那样实际。注意,我们在BookRepository
中定义了findByTitle()
。如果我们想从EbookRepository
或PaperbackRepository
中使用它,那么复制它是不实际的(一般来说,在所有存储库中复制查询方法是不实际的)。在这种情况下,首先在@NoRepositoryBean
类中定义findByTitle()
:
@NoRepositoryBean
public interface BookBaseRepository<T extends Book>
extends JpaRepository<T, Long> {
T findByTitle(String title);
@Query(value="SELECT b FROM #{#entityName} AS b WHERE b.isbn = ?1")
T fetchByIsbn(String isbn);
}
接下来,BookRepository
、EbookRepository
、PaperbackRepository
延伸BookBaseRepository
。通过这种方式,findByTitle()
和findByIsbn()
可以在所有扩展基本存储库的存储库中使用。完整的应用可在 GitHub 1 上获得。
去拿平装书
考虑下面列出的Paperback
存储库:
@Repository
@Transactional(readOnly = true)
public interface PaperbackRepository extends JpaRepository<Paperback, Long> {
Paperback findByTitle(String title);
}
现在,让我们触发两个查询。第一个查询使用标识一个Book
的标题。第二个查询使用标识一个Paperback
的标题:
// this is a Book
Paperback p1 = paperbackRepository.findByTitle("The book of swords");
// this is a Paperback
Paperback p2 = paperbackRepository.findByTitle("The beatles anthology");
两个查询触发相同的SELECT
:
SELECT
paperback0_.id AS id2_1_,
paperback0_.author_id AS author_i8_1_,
paperback0_.isbn AS isbn3_1_,
paperback0_.title AS title4_1_,
paperback0_.size_in AS size_in6_1_,
paperback0_.weight_lbs AS weight_l7_1_
FROM book paperback0_
WHERE paperback0_.dtype = 'Paperback'
AND paperback0_.title = ?
注意WHERE
子句。Hibernate 附加了一个基于dtype
的条件,只获取平装书;因此,p1
将是null
,而p2
将是Paperback
实例。太酷了,对吧?!
获取作者和相关书籍
考虑下面的Author
存储库:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
Author findByName(String name);
@Query("SELECT a FROM Author a JOIN FETCH a.books b")
public Author findAuthor();
}
调用findByName()
将获取没有相关书籍的作者:
@Transactional(readOnly = true)
public void fetchAuthorAndBooksLazy() {
Author author = authorRepository.findByName("Alicia Tom");
List<Book> books = author.getBooks();
}
调用getBooks()
如预期的那样触发了第二个查询:
-- fetch the author
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 = ?
-- fetch the books via getBooks()
SELECT
books0_.author_id AS author_i8_1_0_,
books0_.id AS id2_1_0_,
books0_.id AS id2_1_1_,
books0_.author_id AS author_i8_1_1_,
books0_.isbn AS isbn3_1_1_,
books0_.title AS title4_1_1_,
books0_.format AS format5_1_1_,
books0_.size_in AS size_in6_1_1_,
books0_.weight_lbs AS weight_l7_1_1_,
books0_.dtype AS dtype1_1_1_
FROM book books0_
WHERE books0_.author_id = ?
这正是预期的行为。
另一方面,由于有了JOIN FETCH
,调用findAuthor()
将在同一个SELECT
中获取作者和相关书籍:
@Transactional(readOnly = true)
public void fetchAuthorAndBooksEager() {
Author author = authorRepository.findAuthor();
}
被触发的SELECT
依赖于INNER JOIN
如下:
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id2_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_i8_1_1_,
books1_.isbn AS isbn3_1_1_,
books1_.title AS title4_1_1_,
books1_.format AS format5_1_1_,
books1_.size_in AS size_in6_1_1_,
books1_.weight_lbs AS weight_l7_1_1_,
books1_.dtype AS dtype1_1_1_,
books1_.author_id AS author_i8_1_0__,
books1_.id AS id2_1_0__
FROM author author0_
INNER JOIN book books1_
ON author0_.id = books1_.author_id
很好!看起来单表继承支持快速读写。
子类属性非空性问题
支持在基类 ( Book
)上指定不可为空的约束,这很简单,如下例所示:
public class Book implements Serializable {
...
@Column(nullable=false)
private String title;
...
}
试图持久化Book
将导致类型SQLIntegrityConstraintViolationException: Column 'title' cannot be null:
的预期异常
Book book = new Book();
book.setIsbn("001-AT");
book.setTitle(null);
但是试图在Book
的子类上添加不可空的约束是不允许的。换句话说,不可能向属于Ebook
或Paperback
的列添加NOT NULL
约束。这意味着下面的Ebook
被成功持久化:
Ebook ebook = new Ebook();
ebook.setIsbn("003-AT");
ebook.setTitle("Anthology myths");
ebook.setFormat(null);
显然,将format
设置为null
违背了创建这个Ebook
的目的。所以,创建一个Ebook
不应该接受format
的null
。以同样的方式,创建一个Paperback
不应该接受null
为sizeIn
或weightLbs
。
然而,有几种解决方案可以确保子类属性的非空性。首先,在域模型上,依靠javax.validation.constraints.NotNull
来注释相应的字段,如下面的例子所示:
public class Ebook extends Book implements Serializable {
...
@NotNull
private String format;
...
}
public class Paperback extends Book implements Serializable {
...
@NotNull
private String sizeIn;
@NotNull
private String weightLbs;
...
}
这一次,试图持久化这个ebook
将导致类型javax.validation.ConstraintViolationException
的异常,其中提到format
不能是null
。
这仅仅解决了问题的一半。还可以通过本地查询插入带有null
格式的行。阻止这种尝试意味着在数据库级别进行检查。
对于 MySQL,这可以通过为基类创建的一组触发器来实现(或者,在 PostgreSQL 和其他 RDBMSs 中,CHECK
约束)。例如,以下触发器在数据库级别起作用,不允许null
格式(在Ebook
的情况下)和null
大小或权重(在Paperback
的情况下):
下面是EBook
的触发器:
CREATE TRIGGER ebook_format_trigger
BEFORE INSERT ON book
FOR EACH ROW
BEGIN
IF NEW.DTYPE = 'Ebook' THEN
IF NEW.format IS NULL THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='The format of e-book cannot be null';
END IF;
END IF;
END;
以下是Paperback
的触发因素:
CREATE TRIGGER paperback_weight_trigger
BEFORE INSERT ON book
FOR EACH ROW
BEGIN
IF NEW.DTYPE = 'Paperback' THEN
IF NEW.weight_lbs IS NULL THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='The weight of paperback cannot be null';
END IF;
END IF;
END;
CREATE TRIGGER paperback_size_trigger
BEFORE INSERT ON book
FOR EACH ROW
BEGIN
IF NEW.DTYPE = 'Paperback' THEN
IF NEW.size_in IS NULL THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='The size of paperback cannot be null';
END IF;
END IF;
END;
这些触发器应该添加到您的模式文件中。将它们放在 SQL 文件中需要您在application.properties
中设置spring.datasource.separator
:
spring.datasource.separator=^;
然后在 SQL 文件中,不在触发器内的所有;
语句都需要用新的分隔符更新,如下例所示:
CREATE TRIGGER ebook_format_trigger
...
END ^;
在本书捆绑的代码中,触发器被添加到了data-mysql.sql
。最好将它们添加到schema-mysql.sql
中,或者添加到 Flyway 或 Liquibase 的 SQL 文件中。我用这种方式让您看到 Hibernate 如何基于单个表继承注释生成 DDL 模式。
根据经验,数据库触发器对于实现复杂的数据完整性约束和规则非常有用。这里的 2 就是支持这种说法的一个例子。
优化鉴别器列的内存占用
调整列的大小和数据类型是优化数据库内存占用的重要步骤。鉴别器列是由 JPA 持久性提供者添加的,它的数据类型和大小是VARCHAR(31)
。但是存储Paperback
名至少需要 9 个字节,而存储Ebook
名需要 4 个字节。想象一下,存储 100,000 本平装书和 500,000 本电子书。存储鉴别器列索引需要 100000÷9+500000÷4 = 2900000 字节,也就是 2.76MB。但是,将鉴别器列定义为TINYINT(1)
怎么样呢?这一次,需要 1 个字节,所以计算变成 100000∫1+500000∫1 = 600000 字节,这是 0.57MB。这是更好的方式!
您可以通过@DiscriminatorColumn
和@DiscriminatorValue
改变默认的鉴别器列。首先,使用@DiscriminatorColumn
改变鉴别器列的类型和大小。第二,使用@DiscriminatorValue
为每个类分配一个整数(这些整数应该进一步用于引用这些类):
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(
discriminatorType = DiscriminatorType.INTEGER,
columnDefinition = "TINYINT(1)"
)
@DiscriminatorValue("1")
public class Book implements Serializable {
...
}
@Entity
@DiscriminatorValue("2")
public class Ebook extends Book implements Serializable {
...
}
@Entity
@DiscriminatorValue("3")
public class Paperback extends Book implements Serializable {
...
}
仅此而已!完整的应用可在 GitHub 3 上获得。
现在让我们来看看单表继承的一些优缺点。
优点:
-
子类列不允许约束,但是,如你所见,这个问题有解决方案。
-
读写速度很快
-
@ManyToOne
、@OneToOne
、@OneToMany
是高效的 -
基类属性可以是不可空的
缺点:
第 139 项:如何从一个 SINGLE_TABLE 继承层次结构中获取某些子类
该项目使用来自项目 138 的领域模型和知识;因此,考虑先熟悉那个项目。
所以,在Author
和Book
之间,有一个双向的懒惰@OneToMany
联想。Ebook
和Paperback
实体依靠SINGLE_TABLE
继承策略来扩展Book
实体。
book
表包含与Book
实体以及Ebook
和Paperback
实体相关联的列。它还包含一个名为dtype
的栏目。这就是所谓的鉴别器列。
您可以通过其专用的存储库获取某个子类(例如,Ebook
),如下例所示(这里,查询通过标题获取一个Ebook
):
@Repository
@Transactional(readOnly = true)
public interface EbookRepository extends JpaRepository<Ebook, Long> {
Ebook findByTitle(String title);
}
SELECT
ebook0_.id AS id2_1_,
ebook0_.author_id AS author_i8_1_,
ebook0_.isbn AS isbn3_1_,
ebook0_.title AS title4_1_,
ebook0_.size_in AS size_in6_1_,
ebook0_.weight_lbs AS weight_l7_1_
FROM book ebook0_
WHERE ebook0_.dtype = 'Ebook'
AND ebook0_.title = ?
注意WHERE
子句。Hibernate 增加了一个基于dtype
的条件,只获取电子书。
这绝对很棒,但并不总是这样。例如,考虑EbookRepository
中的以下@Query
:
@Repository
@Transactional(readOnly = true)
public interface EbookRepository extends JpaRepository<Ebook, Long> {
@Query("SELECT b FROM Author a JOIN a.books b WHERE a.name = ?1)
public Ebook findByAuthorName(String name);
}
这一次,触发的SELECT
看起来如下:
SELECT
books1_.id AS id2_1_,
books1_.author_id AS author_i8_1_,
books1_.isbn AS isbn3_1_,
books1_.title AS title4_1_,
books1_.format AS format5_1_,
books1_.size_in AS size_in6_1_,
books1_.weight_lbs AS weight_l7_1_,
books1_.dtype AS dtype1_1_
FROM author author0_
INNER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.name = ?
鉴别器列(dtype
)没有自动添加到WHERE
子句中,所以这个查询不会只获取Ebook
。显然,这是不行的!这个问题的解决方案依赖于一个显式的TYPE
表达式,如下所示(参见粗体查询部分):
@Repository
@Transactional(readOnly = true)
public interface EbookRepository extends JpaRepository<Ebook, Long> {
@Query("SELECT b FROM Author a JOIN a.books b
WHERE a.name = ?1 AND TYPE(b) = 'Ebook'")
public Ebook findByAuthorName(String name);
}
这次触发的SELECT
如下:
SELECT
books1_.id AS id2_1_,
books1_.author_id AS author_i8_1_,
books1_.isbn AS isbn3_1_,
books1_.title AS title4_1_,
books1_.format AS format5_1_,
books1_.size_in AS size_in6_1_,
books1_.weight_lbs AS weight_l7_1_,
books1_.dtype AS dtype1_1_
FROM author author0_
INNER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.name = ?
AND books1_.dtype = 'Ebook'
感谢TYPE
表情,事情又回到正轨了!取一个Author
的Ebook
类型的Book
怎么样?通过TYPE
表达式,这种查询可以在BookRepository
中编写如下(查询定义与前一个完全相同,但它被放在BookRepository
中,并返回一个类型为Ebook
的Book
):
@Repository
@Transactional(readOnly = true)
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT b FROM Author a JOIN a.books b
WHERE a.name = ?1 AND TYPE(b) = 'Ebook'")
public Book findByAuthorName(String name);
}
完整的应用可在 GitHub 4 上获得。
第 140 项:如何有效地使用连接表继承
连接表是另一种 JPA 继承策略。按照这种策略,继承层次结构中的所有类都通过数据库中的单个表来表示。考虑图 15-3 中给出的继承层次。
图 15-3
连接表继承域模型
在Author
和Book
之间,有一个双向懒惰的@OneToMany
关联。Author
实体可以被视为根类,因为没有作者就没有书。Book
实体是基类。为了使用连接表继承策略,这个类用@Inheritance(strategy = InheritanceType.JOINED)
进行了注释。Ebook
和Paperback
实体扩展了Book
实体;所以,他们不需要自己的@Id
。形成这种继承策略的表格如图 15-4 所示。
图 15-4
连接表继承策略的表
这里列出了Book
基类和子类的相关代码:
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public class Book implements Serializable {
...
}
@Entity
@PrimaryKeyJoinColumn(name="ebook_book_id")
public class Ebook extends Book implements Serializable {
...
}
@Entity
@PrimaryKeyJoinColumn(name="paperback_book_id")
public class Paperback extends Book implements Serializable {
...
}
默认情况下,子类表包含一个主键列,该列也充当外键。这个外键引用了基类表的主键。您可以通过用@PrimaryKeyJoinColumn
注释子类来定制这个外键。例如,Ebook
和Paperback
子类依赖这个注释来定制外键列的名称:
@Entity
@PrimaryKeyJoinColumn(name="ebook_book_id")
public class Ebook extends Book implements Serializable {
...
}
@Entity
@PrimaryKeyJoinColumn(name="paperback_book_id")
public class Paperback extends Book implements Serializable {
...
}
默认情况下,基类的主键列名和子类的主键列名是相同的。
持久数据
下面的服务方法持久化一个包含三本书的Author
,这三本书是通过Book
、Ebook
和Paperback
实体创建的:
public void persistAuthorWithBooks() {
Author author = new Author();
author.setName("Alicia Tom");
author.setAge(38);
author.setGenre("Anthology");
Book book = new Book();
book.setIsbn("001-AT");
book.setTitle("The book of swords");
Paperback paperback = new Paperback();
paperback.setIsbn("002-AT");
paperback.setTitle("The beatles anthology");
paperback.setSizeIn("7.5 x 1.3 x 9.2");
paperback.setWeightLbs("2.7");
Ebook ebook = new Ebook();
ebook.setIsbn("003-AT");
ebook.setTitle("Anthology myths");
ebook.setFormat("kindle");
author.addBook(book); // use addBook() helper
author.addBook(paperback);
author.addBook(ebook);
authorRepository.save(author);
}
保存author
实例会触发以下 SQL 语句:
INSERT INTO author (age, genre, name)
VALUES (?, ?, ?)
Binding:[38, Anthology, Alicia Tom]
INSERT INTO book (author_id, isbn, title)
VALUES (?, ?, ?)
Binding:[1, 001-AT, The book of swords]
INSERT INTO book (author_id, isbn, title)
VALUES (?, ?, ?)
Binding:[1, 002-AT, The beatles anthology]
INSERT INTO paperback (size_in, weight_lbs, paperback_book_id)
VALUES (?, ?, ?)
Binding:[ 7.5 x 1.3 x 9.2, 2.7, 2]
INSERT INTO book (author_id, isbn, title)
VALUES (?, ?, ?)
Binding:[1, 003-AT, Anthology myths]
INSERT INTO ebook (format, ebook_book_id)
VALUES (?, ?)
Binding:[kindle, 3]
这一次,需要比单表继承策略更多的INSERT
语句(参见第 138 项)。主要是,基类的数据被插入到book
表中,而Ebook
类,分别是Paperback
类的数据被放入到ebook
和paperback
表中。插入越多,性能损失的机会就越大。
查询和连接表继承
现在,让我们来看看获取数据的效率。考虑以下BookRepository
:
@Repository
@Transactional(readOnly = true)
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT b FROM Book b WHERE b.author.id = ?1")
List<Book> fetchBooksByAuthorId(Long authorId);
Book findByTitle(String title);
}
通过作者标识符获取图书
让我们打电话给fetchBooksByAuthorId()
:
List<Book> books = bookRepository.fetchBooksByAuthorId(1L);
触发的SELECT
如下:
SELECT
book0_.id AS id1_1_,
book0_.author_id AS author_i4_1_,
book0_.isbn AS isbn2_1_,
book0_.title AS title3_1_,
book0_1_.format AS format1_2_,
book0_2_.size_in AS size_in1_3_,
book0_2_.weight_lbs AS weight_l2_3_,
CASE
WHEN book0_1_.ebook_book_id IS NOT NULL THEN 1
WHEN book0_2_.paperback_book_id IS NOT NULL THEN 2
WHEN book0_.id IS NOT NULL THEN 0
END AS clazz_
FROM book book0_
LEFT OUTER JOIN ebook book0_1_
ON book0_.id = book0_1_.ebook_book_id
LEFT OUTER JOIN paperback book0_2_
ON book0_.id = book0_2_.paperback_book_id
WHERE book0_.author_id = ?
有一个单独的SELECT
,但是 Hibernate 必须连接每个子类表。因此,子类表的数量决定了多态查询中连接的数量(对于 n 子类,将有 n 连接)。此外,连接的数量会影响查询速度和执行计划的效率。
按书名取书
让我们为每本书调用findByTitle()
:
Book b1 = bookRepository.findByTitle("The book of swords"); // Book
Book b2 = bookRepository.findByTitle("The beatles anthology"); // Paperback
Book b3 = bookRepository.findByTitle("Anthology myths"); // Ebook
触发的SELECT
对于所有三种类型的图书都是相同的:
SELECT
book0_.id AS id1_1_,
book0_.author_id AS author_i4_1_,
book0_.isbn AS isbn2_1_,
book0_.title AS title3_1_,
book0_1_.format AS format1_2_,
book0_2_.size_in AS size_in1_3_,
book0_2_.weight_lbs AS weight_l2_3_,
CASE
WHEN book0_1_.ebook_book_id IS NOT NULL THEN 1
WHEN book0_2_.paperback_book_id IS NOT NULL THEN 2
WHEN book0_.id IS NOT NULL THEN 0
END AS clazz_
FROM book book0_
LEFT OUTER JOIN ebook book0_1_
ON book0_.id = book0_1_.ebook_book_id
LEFT OUTER JOIN paperback book0_2_
ON book0_.id = book0_2_.paperback_book_id
WHERE book0_.title = ?
同样,只有一个SELECT
,但是 Hibernate 必须连接每个子类表。因此,通过基类存储库获取子类是没有效率的。让我们看看子类的专用存储库会发生什么。
去拿平装书
考虑下面列出的Paperback
存储库:
@Repository
@Transactional(readOnly = true)
public interface PaperbackRepository extends JpaRepository<Paperback, Long> {
Paperback findByTitle(String title);
}
现在,让我们触发两个查询。第一个查询使用标识一个Book
的标题。第二个查询使用标识一个Paperback
的标题:
// this is a Book
Paperback p1 = paperbackRepository.findByTitle("The book of swords");
// this is a Paperback
Paperback p2 = paperbackRepository.findByTitle("The beatles anthology");
两个查询触发相同的SELECT
( p1
将是null
,而p2
将获取一个Paperback
):
SELECT
paperback0_.paperback_book_id AS id1_1_,
paperback0_1_.author_id AS author_i4_1_,
paperback0_1_.isbn AS isbn2_1_,
paperback0_1_.title AS title3_1_,
paperback0_.size_in AS size_in1_3_,
paperback0_.weight_lbs AS weight_l2_3_
FROM paperback paperback0_
INNER JOIN book paperback0_1_
ON paperback0_.paperback_book_id = paperback0_1_.id
WHERE paperback0_1_.title = ?
通过专用存储库获取子类需要与基类表进行一次连接。
如果可能的话,避免通过基类存储库获取子类。使用子类的专用库。在第一种情况下,子类的数量影响连接的数量,而在第二种情况下,子类和基类表之间只有一个连接。换句话说,直接使用查询而不是子类实体。
当然,这不像依赖子类的专用库那样实际。注意,我们在BookRepository
中定义了findByTitle()
。如果我们想从EbookRepository
或PaperbackRepository
中使用它,那么复制它是不实际的(一般来说,在所有存储库中复制查询方法是不实际的)。在这种情况下,首先在@NoRepositoryBean
类中定义findByTitle()
:
@NoRepositoryBean
public interface BookBaseRepository<T extends Book>
extends JpaRepository<T, Long> {
T findByTitle(String title);
@Query(value="SELECT b FROM #{#entityName} AS b WHERE b.isbn = ?1")
T fetchByIsbn(String isbn);
}
接下来,BookRepository
、EbookRepository
、PaperbackRepository
延伸BookBaseRepository
。通过这种方式,findByTitle()
和findByIsbn()
可以在所有扩展基本存储库的存储库中使用。完整的应用可在 GitHub 5 上获得。
获取作者和相关书籍
考虑下面的Author
存储库:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
Author findByName(String name);
@Query("SELECT a FROM Author a JOIN FETCH a.books b")
public Author findAuthor();
}
调用findByName()
将获取没有相关书籍的作者:
@Transactional(readOnly = true)
public void fetchAuthorAndBooksLazy() {
Author author = authorRepository.findByName("Alicia Tom");
List<Book> books = author.getBooks();
}
调用getBooks()
触发第二个查询:
-- fetch the author
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 = ?
-- fetch the books via getBooks()
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_,
books0_1_.format AS format1_2_1_,
books0_2_.size_in AS size_in1_3_1_,
books0_2_.weight_lbs AS weight_l2_3_1_,
CASE
WHEN books0_1_.ebook_book_id IS NOT NULL THEN 1
WHEN books0_2_.paperback_book_id IS NOT NULL THEN 2
WHEN books0_.id IS NOT NULL THEN 0
END AS clazz_1_
FROM book books0_
LEFT OUTER JOIN ebook books0_1_
ON books0_.id = books0_1_.ebook_book_id
LEFT OUTER JOIN paperback books0_2_
ON books0_.id = books0_2_.paperback_book_id
WHERE books0_.author_id = ?
第二个SELECT
也有同样的缺点。每个子类表都有一个连接。因此,组合多态查询和深层类层次结构和/或大量子类会导致性能下降。
另一方面,由于有了JOIN FETCH
,调用findAuthor()
将在同一个SELECT
中获取作者和相关书籍:
@Transactional(readOnly = true)
public void fetchAuthorAndBooksEager() {
Author author = authorRepository.findAuthor();
}
这里列出了触发的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_1_.format AS format1_2_1_,
books1_2_.size_in AS size_in1_3_1_,
books1_2_.weight_lbs AS weight_l2_3_1_,
CASE
WHEN books1_1_.ebook_book_id IS NOT NULL THEN 1
WHEN books1_2_.paperback_book_id IS NOT NULL THEN 2
WHEN books1_.id IS NOT NULL THEN 0
END AS clazz_1_,
books1_.author_id AS author_i4_1_0__, books1_.id AS id1_1_0__
FROM author author0_
INNER JOIN book books1_
ON author0_.id = books1_.author_id
LEFT OUTER JOIN ebook books1_1_
ON books1_.id = books1_1_.ebook_book_id
LEFT OUTER JOIN paperback books1_2_
ON books1_.id = books1_2_.paperback_book_id
这一次,JPA 持久性提供者需要三个连接。因此,对于 n 子类,将有 n +1 个连接。这样效率不高。
下面是连接表继承的一些优点和缺点。
优点:
-
持久化子类实体需要两个
INSERT
语句 -
只有通过子类的专用存储库,读取才是有效的(换句话说,直接对子类实体使用查询)
-
数据库必须索引基类和所有子类主键
-
在多态查询的情况下,对于 n 子类,Hibernate 需要 n 或者 n +1 个连接。这可能会导致查询速度变慢,并增加确定最有效执行计划的难度
-
基类和子类属性可以是不可空的
-
只要不需要多态查询,这种策略就适合于深度类层次结构和/或大量子类
缺点:
完整的应用可在 GitHub 6 上获得。
如何使用 JPA 加入继承策略和策略设计模式
首先,努力使用继承策略,如SINGLE_TABLE
、JOINED
或TABLE_PER_CLASS
结合一个软件设计模式(如模板、状态、策略、访问者等)。).将继承策略与软件设计模式结合起来是利用 JPA 继承的最佳方式。要将特定属性从一个基类传播到所有子类,可以使用@MappedSuperclass
。
策略模式是一种众所周知的行为模式。简而言之,策略模式允许您定义一组算法,将每个算法包装在一个类中,并使它们可以互换。
例如,让我们假设在每天结束时,你的书店交付当天订购的书籍。对于电子书,你通过电子邮件发送下载链接,而对于平装书,我们发送包裹。当然,也可以采用其他策略,但还是让事情简单化吧。
您可以通过编写以下接口来开始开发:
public interface Delivery<T extends Book> {
Class<? extends Book> ofBook();
void deliver(T book);
}
deliver()
方法是动作发生的地方,而ofBook()
方法只是返回利用策略实现的图书的类类型。你会立刻明白为什么你需要这个方法。现在,让我们添加策略(为了简单起见,我们通过System.out.println()
来模拟交付):
@Component
public class PaperbackDeliver implements Delivery<Paperback> {
@Override
public void deliver(Paperback book) {
System.out.println("We've sent you a parcel containing the title "
+ book.getTitle() + " with a size of '" + book.getSizeIn()
+ "' and a weight of " + book.getWeightLbs());
}
@Override
public Class<? extends Book> ofBook() {
return Paperback.class;
}
}
@Component
public class EbookDeliver implements Delivery<Ebook> {
@Override
public void deliver(Ebook book) {
System.out.println("You can download the book named '"
+ book.getTitle() + "' from the following link: http://bookstore/" + book.getFormat() + "/" + book.getTitle());
}
@Override
public Class<? extends Book> ofBook() {
return Ebook.class;
}
}
接下来,您需要一个使用策略的服务。策略豆(EbookDeliver
和PaperbackDeliver
)作为List<Delivery>
被 Spring 自动注入。所以,新的策略会自动注入到DeliverService
中。此外,你循环这个列表并使用ofBook()
来构建一个策略图。该图中的键是 book 类类型(例如Ebook.class
和Paperback.class
),而值是策略本身(策略 bean 实例)。这样,您可以根据图书类型(ebook
或paperback
)调用适当的deliver()
方法:
public interface Deliverable {
void process();
}
@Service
public class DeliverService implements Deliverable {
private final BookRepository bookRepository;
private final List<Delivery> deliverStrategies;
private final Map<Class<? extends Book>, Delivery>
deliverStrategiesMap = new HashMap<>();
public DeliverService(BookRepository bookRepository,
List<Delivery> deliverStrategies) {
this.bookRepository = bookRepository;
this.deliverStrategies = deliverStrategies;
}
@PostConstruct
public void init() {
deliverStrategies.forEach((deliverStrategy) -> {
deliverStrategiesMap.put(deliverStrategy.ofBook(),
deliverStrategy);
});
}
@Override
public void process() {
// we just need some books to deliver
List<Book> allBooks = bookRepository.findAll();
for (Book book : allBooks) {
Delivery deliveryStrategy
= deliverStrategiesMap.get(book.getClass());
deliveryStrategy.deliver(book);
}
}
}
process()
方法负责应用策略。你循环应该交付的书籍,应用相应的策略。只是为了获取一些书籍进行测试,您可以应用一个findAll()
查询。
完整的应用可在 GitHub 7 上获得。而且,在 GitHub8上,你可以找到另一个使用访问者设计模式的例子。
第 141 项:如何有效地使用每个类的表继承
每类一张表是另一种 JPA 继承策略。按照这种策略,继承层次结构中的所有类都通过数据库中的单个表来表示。每个子类表存储从超类表继承的列(基类)。考虑图 15-5 中给出的继承层次。
图 15-5
每类表继承域模型
在Author
和Book
之间,有一个双向懒惰的@OneToMany
关联。Author
实体可以被看作是根类,因为没有作者就没有书。Book
实体是基类。为了采用每类一个表的继承策略,这个类用@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
进行了注释。Ebook
和Paperback
实体扩展了Book
实体,所以不需要自己的@Id
。形成这种继承策略的表格如图 15-6 所示。
图 15-6
每类表继承策略的表
每个子类表包含一个主键列。为了确保子类表中主键的唯一性,每类表策略不能依赖于IDENTITY
生成器。尝试使用IDENTITY
生成器会导致类型为Cannot use identity column key generation with <union-subclass> mapping
的异常。
这是 MySQL 等 RDBMSs 的一个重要缺点。不允许使用IDENTITY
,也不支持SEQUENCE
(MySQL 不支持数据库序列;因此不支持SEQUENCE
策略)。TABLE
生成器类型的伸缩性不好,比IDENTITY
和SEQUENCE
生成器类型慢得多,即使对于单个数据库连接也是如此。所以,你应该避免 MySQL 和每类一个表的继承策略的结合。
这里列出了Book
基类和子类的相关代码:
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public class Book implements Serializable {
...
}
@Entity
public class Ebook extends Book implements Serializable {
...
}
@Entity
public class Paperback extends Book implements Serializable {
...
}
持久数据
下面的服务方法持久化一个包含三本书的Author
,这三本书是通过Book
、Ebook
和Paperback
实体创建的:
public void persistAuthorWithBooks() {
Author author = new Author();
author.setName("Alicia Tom");
author.setAge(38);
author.setGenre("Anthology");
Book book = new Book();
book.setIsbn("001-AT");
book.setTitle("The book of swords");
Paperback paperback = new Paperback();
paperback.setIsbn("002-AT");
paperback.setTitle("The beatles anthology");
paperback.setSizeIn("7.5 x 1.3 x 9.2");
paperback.setWeightLbs("2.7");
Ebook ebook = new Ebook();
ebook.setIsbn("003-AT");
ebook.setTitle("Anthology myths");
ebook.setFormat("kindle");
author.addBook(book); // use addBook() helper
author.addBook(paperback);
author.addBook(ebook);
authorRepository.save(author);
}
保存author
实例会触发以下 SQL 语句:
INSERT INTO author (age, genre, name)
VALUES (?, ?, ?)
Binding:[38, Anthology, Alicia Tom]
INSERT INTO book (author_id, isbn, title, id)
VALUES (?, ?, ?, ?)
Binding:[1, 001-AT, The book of swords, 1]
INSERT INTO paperback (author_id, isbn, title, size_in, weight_lbs, id)
VALUES (?, ?, ?, ?, ?, ?)
Binding:[1, 002-AT, The beatles anthology, 7.5 x 1.3 x 9.2, 2.7, 2]
INSERT INTO ebook (author_id, isbn, title, format, id)
VALUES (?, ?, ?, ?, ?)
Binding:[1, 003-AT, Anthology myths, kindle, 3]
每个类的表为每个子类触发一个INSERT
,因此它比连接表继承策略更有效。
查询和每表类继承
现在,让我们来看看获取数据的效率。考虑以下BookRepository
:
@Repository
@Transactional(readOnly = true)
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT b FROM Book b WHERE b.author.id = ?1")
List<Book> fetchBooksByAuthorId(Long authorId);
Book findByTitle(String title);
}
通过作者标识符获取图书
让我们打电话给fetchBooksByAuthorId()
:
List<Book> books = bookRepository.fetchBooksByAuthorId(1L);
触发的SELECT
如下:
SELECT
book0_.id AS id1_1_,
book0_.author_id AS author_i4_1_,
book0_.isbn AS isbn2_1_,
book0_.title AS title3_1_,
book0_.format AS format1_2_,
book0_.size_in AS size_in1_3_,
book0_.weight_lbs AS weight_l2_3_,
book0_.clazz_ AS clazz_
FROM (SELECT
id, isbn, title, author_id,
NULL AS format, NULL AS size_in, NULL AS weight_lbs, 0 AS clazz_
FROM book
UNION
SELECT
id, isbn, title, author_id, format,
NULL AS size_in, NULL AS weight_lbs, 1 AS clazz_
FROM ebook
UNION
SELECT
id, isbn, title, author_id,
NULL AS format, size_in, weight_lbs, 2 AS clazz_
FROM paperback) book0_
WHERE book0_.author_id = ?
在多态查询的情况下,Hibernate 依靠 SQL 联合从基类和每个子类表中获取数据。显然,由于需要更多的联合,多态查询的效率会降低。
按书名取书
让我们为每本书调用findByTitle()
:
Book b1 = bookRepository.findByTitle("The book of swords"); // Book
Book b2 = bookRepository.findByTitle("The beatles anthology"); // Paperback
Book b3 = bookRepository.findByTitle("Anthology myths"); // Ebook
触发的SELECT
对于所有三种类型的图书都是相同的:
SELECT
book0_.id AS id1_1_,
book0_.author_id AS author_i4_1_,
book0_.isbn AS isbn2_1_,
book0_.title AS title3_1_,
book0_.format AS format1_2_,
book0_.size_in AS size_in1_3_,
book0_.weight_lbs AS weight_l2_3_,
book0_.clazz_ AS clazz_
FROM (SELECT
id, isbn, title, author_id,
NULL AS format, NULL AS size_in, NULL AS weight_lbs, 0 AS clazz_
FROM book
UNION
SELECT
id, isbn, title, author_id, format,
NULL AS size_in, NULL AS weight_lbs, 1 AS clazz_
FROM ebook
UNION
SELECT
id, isbn, title, author_id,
NULL AS format, size_in, weight_lbs, 2 AS clazz_
FROM paperback) book0_
WHERE book0_.title = ?
同样,Hibernate 依靠 SQL 联合从基类和每个子类表中获取数据。因此,通过基类存储库获取子类实体是没有效率的,应该避免。
去拿平装书
考虑下面列出的Paperback
存储库:
@Repository
@Transactional(readOnly = true)
public interface PaperbackRepository extends JpaRepository<Paperback, Long> {
Paperback findByTitle(String title);
}
现在,让我们触发两个查询。第一个查询使用标识一个Book
的标题。第二个查询使用标识一个Paperback
的标题:
// this is a Book
Paperback p1 = paperbackRepository.findByTitle("The book of swords");
// this is a Paperback
Paperback p2 = paperbackRepository.findByTitle("The beatles anthology");
两个查询触发相同的SELECT
( p1
将是null
,而p2
将获取一个Paperback
):
SELECT
paperback0_.id AS id1_1_,
paperback0_.author_id AS author_i4_1_,
paperback0_.isbn AS isbn2_1_,
paperback0_.title AS title3_1_,
paperback0_.size_in AS size_in1_3_,
paperback0_.weight_lbs AS weight_l2_3_
FROM paperback paperback0_
WHERE paperback0_.title = ?
通过专用库获取子类是高效的。
如果可能的话,避免通过基类存储库获取子类。最好使用子类专用的存储库。在第一种情况下,子类的数量影响联合的数量,而在第二种情况下,将不存在联合。换句话说,最好直接对子类实体使用查询。
当然,这不像依赖子类的专用库那样实际。注意,我们在BookRepository
中定义了findByTitle()
。如果我们想从EbookRepository
或PaperbackRepository
中使用它,那么复制它是不实际的(一般来说,在所有存储库中复制查询方法是不实际的)。在这种情况下,首先在@NoRepositoryBean
类中定义findByTitle()
:
@NoRepositoryBean
public interface BookBaseRepository<T extends Book>
extends JpaRepository<T, Long> {
T findByTitle(String title);
@Query(value="SELECT b FROM #{#entityName} AS b WHERE b.isbn = ?1")
T fetchByIsbn(String isbn);
}
接下来,BookRepository
、EbookRepository
、PaperbackRepository
延伸BookBaseRepository
。通过这种方式,findByTitle()
和findByIsbn()
可以在所有扩展基本存储库的存储库中使用。完整的应用可在 GitHub 9 上获得。
获取作者和相关书籍
考虑下面的Author
存储库:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
Author findByName(String name);
@Query("SELECT a FROM Author a JOIN FETCH a.books b")
public Author findAuthor();
}
调用findByName()
将获取没有相关书籍的作者:
@Transactional(readOnly = true)
public void fetchAuthorAndBooksLazy() {
Author author = authorRepository.findByName("Alicia Tom");
List<Book> books = author.getBooks();
}
调用getBooks()
触发第二个查询:
-- fetch the author
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 = ?
-- fetch the books via getBooks()
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_,
books0_.format AS format1_2_1_,
books0_.size_in AS size_in1_3_1_,
books0_.weight_lbs AS weight_l2_3_1_,
books0_.clazz_ AS clazz_1_
FROM (SELECT
id, isbn, title, author_id,
NULL AS format, NULL AS size_in, NULL AS weight_lbs, 0 AS clazz_
FROM book
UNION
SELECT
id, isbn, title, author_id, format,
NULL AS size_in, NULL AS weight_lbs, 1 AS clazz_
FROM ebook
UNION
SELECT
id, isbn, title, author_id,
NULL AS format, size_in, weight_lbs, 2 AS clazz_
FROM paperback) books0_
WHERE books0_.author_id = ?
第二个SELECT
也有同样的缺点。每个子类表都有一个联合。所以,深层次的类和/或大量的子类会导致性能下降。
另一方面,由于有了JOIN FETCH
,调用findAuthor()
将在同一个SELECT
中获取作者和相关书籍:
@Transactional(readOnly = true)
public void fetchAuthorAndBooksEager() {
Author author = authorRepository.findAuthor();
}
不幸的是,触发的SELECT
并不高效,因为它需要每个子类一个联合( n 子类导致 n 联合):
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_.format AS format1_2_1_,
books1_.size_in AS size_in1_3_1_,
books1_.weight_lbs AS weight_l2_3_1_,
books1_.clazz_ AS clazz_1_,
books1_.author_id AS author_i4_1_0__,
books1_.id AS id1_1_0__
FROM author author0_
INNER JOIN (SELECT
id, isbn, title, author_id,
NULL AS format, NULL AS size_in, NULL AS weight_lbs, 0 AS clazz_
FROM book
UNION
SELECT
id, isbn, title, author_id, format,
NULL AS size_in, NULL AS weight_lbs, 1 AS clazz_
FROM ebook
UNION
SELECT
id, isbn, title, author_id,
NULL AS format, size_in, weight_lbs, 2 AS clazz_
FROM paperback) books1_
ON author0_.id = books1_.author_id
现在,让我们考虑一下连接表继承的优缺点。
优点:
-
IDENTITY
发生器不能使用 -
只有通过子类的专用存储库,读取才是有效的(换句话说,最好直接对子类实体使用查询)
-
在多态查询的情况下,对于 n 子类,Hibernate 需要 n 联合,这可能会导致严重的性能损失
-
写入速度很快,因为每个子类都有一个
INSERT
-
基类和子类属性可以是不可空的
缺点:
完整的应用可在 GitHub 10 上获得。
第 142 项:如何有效地使用@MappedSuperclass
你已经在第 24 项和第 87 项中看到@MappedSuperclass
在工作。
@MappedSuperclass
是一个实体级的注释,它有助于形成一个继承模型,类似于每类一个表的策略,但是有一个不是实体的基类。它没有在数据库表中具体化。基类用@MappedSuperclass
标注,可以是abstract
也可以不是。它的子类将继承它的属性,并将它们存储在自己属性旁边的子类表中。考虑图 15-7 中给出的继承层次。
图 15-7
映射超类领域模型
在Author
和Book
之间有一个单向的懒惰@ManyToOne
联想。由于Book
不是一个实体,它不支持关联;因此,Author
实体不能定义一个@OneToMany
关系。这个Author
实体可以被看作是根类,因为没有作者就没有书。Book
实体是非实体基类。Ebook
和Paperback
实体扩展了Book
实体,所以它们不需要自己的@Id
。表格关系如图 15-8 所示(注意没有book
表格)。
图 15-8
映射超类表
这里列出了Book
基类的相关代码:
@MappedSuperclass
public abstract 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")
private Author author;
// getters and setters omitted for brevity
}
@Entity
public class Ebook extends Book implements Serializable {
...
}
@Entity
public class Paperback extends Book implements Serializable {
...
}
持久数据
下面的服务方法持久化一个包含三本书的Author
,这三本书是通过Book
、Ebook
和Paperback
实体创建的:
public void persistAuthorWithBooks() {
Author author = new Author();
author.setName("Alicia Tom");
author.setAge(38);
author.setGenre("Anthology");
Paperback paperback = new Paperback();
paperback.setIsbn("002-AT");
paperback.setTitle("The beatles anthology");
paperback.setSizeIn("7.5 x 1.3 x 9.2");
paperback.setWeightLbs("2.7");
paperback.setAuthor(author);
Ebook ebook = new Ebook();
ebook.setIsbn("003-AT");
ebook.setTitle("Anthology myths");
ebook.setFormat("kindle");
ebook.setAuthor(author);
authorRepository.save(author);
paperbackRepository.save(paperback);
ebookRepository.save(ebook);
}
保存author
、paperback
和ebook
实例会触发以下 SQL 语句:
INSERT INTO author (age, genre, name)
VALUES (?, ?, ?)
Binding:[38, Anthology, Alicia Tom]
INSERT INTO paperback (author_id, isbn, title, size_in, weight_lbs)
VALUES (?, ?, ?, ?, ?)
Binding:[1, 002-AT, The beatles anthology, 7.5 x 1.3 x 9.2, 2.7]
INSERT INTO ebook (author_id, isbn, title, format)
VALUES (?, ?, ?, ?)
Binding:[1, 003-AT, Anthology myths, kindle]
写入是高效的。每个实体实例有一个INSERT
。
去拿平装书
考虑下面列出的Paperback
存储库:
@Repository
@Transactional(readOnly = true)
public interface PaperbackRepository extends JpaRepository<Paperback, Long> {
Paperback findByTitle(String title);
@Query("SELECT e FROM Paperback e JOIN FETCH e.author")
Paperback fetchByAuthorId(Long id);
}
现在,让我们通过findByTitle()
触发两个查询。第一个查询使用标识一个Ebook
的标题。第二个查询使用标识一个Paperback
的标题:
// this is a Ebook
Paperback p1 = paperbackRepository.findByTitle("Anthology myths");
// this is a Paperback
Paperback p2 = paperbackRepository.findByTitle("The beatles anthology");
两个查询触发相同的SELECT
( p1
将是null
,而p2
将获取一个Paperback
):
SELECT
paperback0_.id AS id1_2_,
paperback0_.author_id AS author_i6_2_,
paperback0_.isbn AS isbn2_2_,
paperback0_.title AS title3_2_,
paperback0_.size_in AS size_in4_2_,
paperback0_.weight_lbs AS weight_l5_2_
FROM paperback paperback0_
WHERE paperback0_.title = ?
这个查询非常简单高效。
把平装本的作者找来怎么样?这可以通过调用fetchByAuthorId()
来完成。因为这种查询方法依赖于JOIN FETCH
,所以作者与平装本在同一个SELECT
中被取出,如下所示:
SELECT
paperback0_.id AS id1_2_0_,
author1_.id AS id1_0_1_,
paperback0_.author_id AS author_i6_2_0_,
paperback0_.isbn AS isbn2_2_0_,
paperback0_.title AS title3_2_0_,
paperback0_.size_in AS size_in4_2_0_,
paperback0_.weight_lbs AS weight_l5_2_0_,
author1_.age AS age2_0_1_,
author1_.genre AS genre3_0_1_,
author1_.name AS name4_0_1_
FROM paperback paperback0_
INNER JOIN author author1_
ON paperback0_.author_id = author1_.id
该查询使用单个JOIN
并且是高效的。因为Author
没有关联,所以没有getBooks()
、getEbooks()
或getPaperbacks()
。
现在,我们来考虑一下@MappedSuperclass
的一些利弊。
优点:
-
无法查询基类
-
不允许多态查询和关联
根据经验,
@MappedSuperclass
非常适合将特定属性从基类传播到所有子类,因为对象层次结构的可见性保持在对象域级别。不要使用SINGLE_TABLE
、JOINED
或TABLE_PER_CLASS
这样的继承策略来完成这样的任务。依靠SINGLE_TABLE
、JOINED
或TABLE_PER_CLASS
结合软件设计模式(如模板、状态、策略、访问者等)。).将继承策略与软件设计模式结合起来是利用 JPA 继承的最佳选择。 -
读写速度很快
-
只要基类不需要成为一个实体,那么
@MappedSuperclass
就是每类一张表继承策略的合适替代缺点:
完整的代码可以在 GitHub 11 上找到。
正如在SINGLE_TABLE, JOINED
或TABLE_PER_CLASS
的情况下,我们可以通过创建一个由具体存储库扩展的基本存储库来避免查询方法的重复。GitHub 12 上有完整的例子。
十六、类型和 Hibernate 类型
第 143 项:如何通过 Hibernate 类型库处理 Hibernate 和不支持的类型
根据经验,努力选择最佳的数据库列类型。慢慢来,滚动您的数据库类型,因为大多数数据库都有您可以使用的特定类型。例如,MySQL 的MEDIUMINT UNSIGNED
存储 1 到 99999 范围内的整数,PostgreSQL 的money
类型存储具有固定小数精度的货币金额,cidr
类型保存 IPv4 或 IPv6 网络规范,等等。而且,尽量使用紧凑类型。这将减少索引内存占用,并允许数据库操作更大量的数据。
可以把 Hibernate 类型想象成 Java 类型(对象或原语)和 SQL 类型之间的桥梁。Hibernate ORM 自带了一组内置的受支持的类型,但是也有 Hibernate 不支持的其他 Java 类型(比如 Java 8 中引入的java.time.YearMonth
)。
*特别是对于不受支持的类型,您可以依赖 Hibernate 类型库。
Hibernate 类型库是由 Vlad Mihalcea 开发的开源项目,可以在 GitHub 1 上获得。我强烈建议您花几分钟时间来看看这个项目。你会喜欢的!
这个库提供了一套 Hibernate ORM 不支持的额外类型和实用程序。在这些类型中,有java.time.YearMonth
。让我们通过 Hibernate 类型将这种类型存储在数据库中。首先,将依赖项添加到pom.xml
文件中(对于 Maven):
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>hibernate-types-52</artifactId>
<version>2.4.3</version>
</dependency>
进一步,定义一个名为Book
的实体。注意如何通过@TypeDef
注释将java.time.YearMonth
Java 类型映射到 Hibernate 的YearMonthIntegerType
(或YearMonthDateType
)类型:
import org.hibernate.annotations.TypeDef;
import com.vladmihalcea.hibernate.type.basic.YearMonthIntegerType;
...
@Entity
@TypeDef(
typeClass = YearMonthIntegerType.class, // or, YearMonthDateType
defaultForType = YearMonth.class
)
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;
private YearMonth releaseDate;
// getters and setters omitted for brevity
}
最后,服务方法可以帮助您在数据库中持久化一个Book
实例:
public void newBook() {
Book book = new Book();
book.setIsbn("001");
book.setTitle("Young Boy");
book.setReleaseDate(YearMonth.now());
bookRepository.save(book);
}
图 16-1 显示数据库内容(查看release_date
栏)。
图 16-1
发布日期列
以及获取这个Book
的服务方法:
public void displayBook() {
Book book = bookRepository.findByTitle("Young Boy");
System.out.println(book);
}
以下是输出:
Book{id=1, title=Young Boy, isbn=001, releaseDate=2019-07}
完整的代码可以在 GitHub 2 上找到。
项目 144:如何映射区块和斑点
我们来看一下Author
实体。在它的属性中,一个作者可以有一个avatar
(一张照片)和一个biography
(几页文本)。avatar
可以认为是二进制大对象 (BLOB),而biography
可以认为是字符大对象 (CLOB)。映射二进制/字符大对象是在易用性和性能之间的权衡。
易用性(权衡性能)
遵循 JPA 规范,二进制大对象可以映射到byte[]
,而字符大对象可以映射到String
。让我们看看代码:
@Entity
public class Author implements Serializable {
...
@Lob
private byte[] avatar;
@Lob
private String biography;
...
public byte[] getAvatar() {
return avatar;
}
public void setAvatar(byte[] avatar) {
this.avatar = avatar;
}
public String getBiography() {
return biography;
}
public void setBiography(String biography) {
this.biography = biography;
}
...
}
持久化和获取avatar
和biography
很容易,如下面的服务方法(假设findByName()
是AuthorRepository
中的一个查询方法,要持久化的数据存储在两个本地文件中):
public void newAuthor() throws IOException {
Author mt = new Author();
mt.setName("Martin Ticher");
mt.setAge(43);
mt.setGenre("Horror");
mt.setAvatar(Files.readAllBytes(
new File("avatars/mt_avatar.png").toPath()));
mt.setBiography(Files.readString(
new File("biography/mt_bio.txt").toPath()));
authorRepository.save(mt);
}
public void fetchAuthor() {
Author author = authorRepository.findByName("Martin Ticher");
System.out.println("Author bio: "
+ author.getBiography());
System.out.println("Author avatar: "
+ Arrays.toString(author.getAvatar()));
}
将二进制/字符大对象映射到byte[]
和String
很容易,但是可能会带来性能损失。当您获取二进制/字符大对象时,您正在获取所有信息并将其映射到一个 Java 对象。这会导致性能下降,尤其是当信息量非常大时(例如,视频、高清图像、音频等)。).在这种情况下,最好依靠 JDBC 的高球定位器java.sql.Clob
和java.sql.Blob
,如下所述。
完整的应用可在 GitHub 3 上获得。
避免性能损失(权衡是易用性)
通过 JDBC 的 LOB 定位器Clob
和Blob
映射二进制/字符大对象支持 JDBC 驱动程序优化,例如数据流。实体映射非常简单:
@Entity
public class Author implements Serializable {
...
@Lob
private Blob avatar;
@Lob
private Clob biography;
...
public Blob getAvatar() {
return avatar;
}
public void setAvatar(Blob avatar) {
this.avatar = avatar;
}
public Clob getBiography() {
return biography;
}
public void setBiography(Clob biography) {
this.biography = biography;
}
...
}
虽然实体映射非常容易,但是持久化和获取二进制/字符大对象需要 Hibernate 特有的BlobProxy
和ClobProxy
类以及一些 I/O 代码。创建Blob
和Clob
需要这些类。下面的服务方法揭示了如何保存和获取avatar
和biography
:
public void newAuthor() throws IOException {
Author mt = new Author();
mt.setName("Martin Ticher");
mt.setAge(43);
mt.setGenre("Horror");
mt.setAvatar(BlobProxy.generateProxy(
Files.readAllBytes(new File("avatars/mt_avatar.png").toPath())));
mt.setBiography(ClobProxy.generateProxy(
Files.readString(new File("biography/mt_bio.txt").toPath())));
authorRepository.save(mt);
}
public void fetchAuthor() throws SQLException, IOException {
Author author = authorRepository.findByName("Martin Ticher");
System.out.println("Author bio: "
+ readBiography(author.getBiography()));
System.out.println("Author avatar: "
+ Arrays.toString(readAvatar(author.getAvatar())));
}
private byte[] readAvatar(Blob avatar) throws SQLException, IOException {
try (InputStream is = avatar.getBinaryStream()) {
return is.readAllBytes();
}
}
private String readBiography(Clob bio) throws SQLException, IOException {
StringBuilder sb = new StringBuilder();
try (Reader reader = bio.getCharacterStream()) {
char[] buffer = new char[2048];
for (int i = reader.read(buffer); i > 0; i = reader.read(buffer)) {
sb.append(buffer, 0, i);
}
}
return sb.toString();
}
完整的应用可在 GitHub 4 上获得。
如果二进制/字符大对象被急切地加载并且没有被使用/利用,那么处理它们会导致性能下降。例如,加载一个author
不需要同时加载avatar
和biography
。该信息可以通过项 23 和项 24 中介绍的惰性属性加载技术按需加载。
对于民族化的字符数据类型(如NCLOB
、NCHAR
、NVARCHAR
、LONGNVARCHAR
,将@Lob
替换为@Nationalized
,如下:
@Nationalized
private String biography;
第 145 项:如何有效地将 Java 枚举映射到数据库
考虑一下Author
实体和genre
属性。这个属性用一个 Java enum
来表示,如下:
public enum GenreType {
HORROR, ANTHOLOGY, HISTORY
}
现在,让我们看看将这个enum
映射到数据库的几种方法。
通过 EnumType 映射。线
一个非常简单的方法是使用@Enumerated(
EnumType.STRING)
,如下所示:
@Entity
public class Author implements Serializable {
@Enumerated(EnumType.STRING)
private GenreType genre;
...
}
但是这种方式的效率如何呢?在 MySQL 中,genre
列将是一个VARCHAR(255)
。显然,该列占据了过多的空间。现在怎么样?
@Enumerated(EnumType.STRING)
@Column(length = 9)
private GenreType genre;
九个字节的长度足以保存ANTHOLOGY
值。这应该没问题,只要你没有几百万条记录。这不太可能,但是假设你有 1500 万个作者,仅genre
一栏就需要 120+ MB。这样一点效率都没有!
通过 EnumType 映射。序数
为了提高效率,让我们从EnumType.STRING
切换到EnumType.ORDINAL
:
@Enumerated(EnumType.ORDINAL)
private GenreType genre;
这一次,在 MySQL 中,genre
列将是类型int(11)
。在 MySQL 中,INTEGER
(或INT
)类型需要四个字节。这比VARCHAR(9)
好多了。最有可能的是,你不会有超过 100 个流派,所以TINYINT
应该做这项工作:
@Enumerated(EnumType.ORDINAL)
@Column(columnDefinition = "TINYINT")
private GenreType genre;
在 MySQL 中,TINYINT
只需要一个字节来表示-128 到 127 之间的值。在这种情况下,存储 1500 万作者将需要 14mb 以上的空间。
即便如此,在某些场景中,TINYINT
可能是不够的。更大的范围,靠SMALLINT
,需要两个字节,覆盖-32768 到 32767 之间的范围。不太可能有一个有这么多价值观的enum
。
总之,依靠EnumType.ORDINAL
比依靠EnumType.STRING
更有效。尽管如此,可读性是一个代价。
完整的应用可在 GitHub 5 上获得。
将枚举映射到自定义表示
默认情况下,使用EnumType.ORDINAL
会将HORROR
链接到0
、ANTHOLOGY
链接到1
、和HISTORY
链接到2
。但是,我们假设HORROR
应该链接到10
、ANTHOLOGY
到20
、HISTORY
到30
。
一种将enum
映射到自定义表示的方法依赖于AttributeConverter
。我们在第 19 项中使用了一个AttributeConverter
,所以下面的实现应该似曾相识:
public class GenreTypeConverter
implements AttributeConverter<GenreType, Integer> {
@Override
public Integer convertToDatabaseColumn(GenreType attr) {
if (attr == null) {
return null;
}
switch (attr) {
case HORROR:
return 10;
case ANTHOLOGY:
return 20;
case HISTORY:
return 30;
default:
throw new IllegalArgumentException("The " + attr
+ " not supported.");
}
}
@Override
public GenreType convertToEntityAttribute(Integer dbData) {
if (dbData == null) {
return null;
}
switch (dbData) {
case 10:
return HORROR;
case 20:
return ANTHOLOGY;
case 30:
return HISTORY;
default:
throw new IllegalArgumentException("The " + dbData
+ " not supported.");
}
}
}
最后使用@Converter
指令 Hibernate 应用转换器:
@Entity
public class Author implements Serializable {
...
@Convert(converter = GenreTypeConverter.class)
@Column(columnDefinition = "TINYINT")
private GenreType genre;
...
}
完整的应用可在 GitHub 6 上获得。
将枚举映射到特定于数据库的枚举类型(PostgreSQL)
PostgreSQL 定义了一个可以通过CREATE TYPE
命令使用的ENUM
类型,如下例所示:
CREATE TYPE genre_info AS ENUM ('HORROR', 'ANTHOLOGY', 'HISTORY')
编写自定义类型
Hibernate 不支持这种类型(Hibernate 可以将enum
值映射到一个int
或一个String
,但是 PostgreSQL 期望值是一个Object
,所以将一个 Java enum
映射到 PostgreSQL ENUM
需要您实现一个定制的 Hibernate 类型。定义这个自定义 Hibernate 类型意味着您需要扩展 Hibernate EnumType
并覆盖nullSafeSet()
方法来形成所需的行为:
public class PostgreSQLEnumType extends EnumType {
@Override
public void nullSafeSet(PreparedStatement ps, Object obj, int index,
SharedSessionContractImplementor session)
throws HibernateException, SQLException {
if (obj == null) {
ps.setNull(index, Types.OTHER);
} else {
ps.setObject(index, obj.toString(), Types.OTHER);
}
}
}
最后,让我们用一个@TypeDef
注释注册这个类型,并把它放在一个package-info.java
文件中:
@org.hibernate.annotations.TypeDef(
name = "genre_enum_type", typeClass = PostgreSQLEnumType.class)
package com.bookstore.type;
现在,让我们使用它:
@Entity
public class Author implements Serializable {
...
@Enumerated(EnumType.STRING)
@Type(type = "genre_enum_type")
@Column(columnDefinition = "genre_info")
private GenreType genre;
...
}
坚持一个作者揭示出他们的genre
是类型genre_info
,是一个 PostgreSQL ENUM
,如图 16-2 。
图 16-2
PostgreSQL 枚举类型
完整的应用可在 GitHub 7 上获得。
使用 Hibernate 类型库
Hibernate 类型库是在项目 143 中引入的。幸运的是,这个库已经包含了 Java enum
到 PostgreSQL ENUM
类型的映射。首先,通过以下依赖项将该库添加到您的应用中:
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>hibernate-types-52</artifactId>
<version>2.4.3</version>
</dependency>
然后,在实体类级别使用@TypeDef
注释,在实体字段级别使用@Type
,如下所示:
@Entity
@TypeDef(
name = "genre_enum_type",
typeClass = PostgreSQLEnumType.class
)
public class Author implements Serializable {
...
@Enumerated(EnumType.STRING)
@Type(type = "genre_enum_type")
@Column(columnDefinition = "genre_info")
private GenreType genre;
...
}
完整的应用可在 GitHub 8 上获得。
第 146 项:如何有效地将 JSON Java 对象映射到 MySQL JSON 列
JSON 非常适合非结构化数据。
MySQL 从 5.7 版本开始增加了 JSON 类型支持。然而,Hibernate Core 没有提供适用于 JSON Java Object
和数据库 JSON 列的 JSON Type
。
幸运的是,Hibernate 类型库(你应该熟悉来自 Item 143 的这个库)填补了这个空白,并提供了两个通用的 JSON 类型——JsonStringType
和JsonBinaryType
。在 MySQL 的情况下,从 JDBC 的角度来看,JSON 类型应该表示为String
s,所以JsonStringType
是正确的选择。
让我们使用Author
实体和Book
JSON Java Object
。这里列出了Author
实体:
@Entity
@TypeDef(
name = "json", typeClass = JsonStringType.class
)
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;
@Type(type = "json")
@Column(columnDefinition = "json")
private Book book;
// getters and setters omitted for brevity
}
这里列出了Book
JSON Java Object
(这不是 JPA 实体):
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
private String title;
private String isbn;
private int price;
// getters and setters omitted for brevity
}
坚持作者
服务方法可以轻松地持久化一个作者,如下所示:
public void newAuthor() {
Book book = new Book();
book.setIsbn("001-JN");
book.setTitle("A History of Ancient Prague");
book.setPrice(45);
Author author = new Author();
author.setName("Joana Nimar");
author.setAge(34);
author.setGenre("History");
author.setBook(book);
authorRepository.save(author);
}
INSERT
的说法是:
INSERT INTO author (age, book, genre, name)
VALUES (34, '{"title":"A History of Ancient Prague",
"isbn":"001-JN","price":45}', 'History', 'Joana Nimar')
author
表如图 16-3 所示。
图 16-3
MySQL 中的 JSON
获取/更新作者
获取作者会将获取的 JSON 映射到Book
对象。例如,考虑以下查询:
public Author findByName(String name);
调用findByName()
会触发下面的SELECT
语句:
Author author = authorRepository.findByName("Joana Nimar");
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.book AS book3_0_,
author0_.genre AS genre4_0_,
author0_.name AS name5_0_
FROM author author0_
WHERE author0_.name = ?
通过取出的author
,可以调用getBook().getTitle()
、getBook().getIsbn()
或getBook().getPrice()
。调用getBook().setTitle()
、getBook().setIsbn()
或getBook().setPrice()
将触发 JSON 更新。这个UPDATE
看起来是这样的(getBook().setPrice(40)
):
UPDATE author
SET age = 34,
book = '{"title":"A History of Ancient Prague",
"isbn":"001-JN","price":40}',
genre = 'History',
name = 'Joana Nimar'
WHERE id = 1
通过查询 JSON 获取作者
MySQL 提供了基于给定的路径表达式提取部分或修改 JSON 文档的函数。其中一个功能是JSON_EXTRACT()
。它获得两个参数:要查询的 JSON 和一个路径表达式。path 语法依赖于一个前导字符$
来表示 JSON 文档,这个字符后面可选地跟着表示文档某些部分的连续选择器。有关更多细节,请查看 MySQL 文档 9 。
在WHERE
子句中调用JSON_EXTRACT()
可以通过 JPQL function()
或本地查询来完成。通过 JPQL,它看起来像下面的例子(这找到了用给定的isbn
写这本书的作者):
@Query("SELECT a FROM Author a "
+ "WHERE function('JSON_EXTRACT', a.book, '$.isbn') = ?1")
public Author findByBookIsbn(String isbn);
或者,作为本机查询:
@Query(value = "SELECT a.* FROM author a
WHERE JSON_EXTRACT(a.book, '$.isbn') = ?1",
nativeQuery = true)
public Author findByBookIsbnNativeQuery(String isbn);
调用JSON_EXTRACT()
(以及其他 JSON 特有的函数如JSON_SET()
、JSON_MERGE_
、FOO
、JSON_OBJECT()
等。)在SELECT
部分的查询可以通过原生查询或注册函数来完成,如第 79 项所示。
完整的应用可在 GitHub 10 上获得。
第 147 项:如何有效地将 JSON Java 对象映射到 PostgreSQL JSON 列
第 146 项涵盖了 MySQL JSON 类型。现在,让我们来关注一下 PostgreSQL。
PostgreSQL 从 9.2 版开始增加了 JSON 类型支持。PostgreSQL JSON 类型有json
和jsonb
。PostgreSQL JSON 类型以二进制数据格式表示,所以需要使用JsonBinaryType
(在第 146 项中,我们说过 Hibernate 类型库提供了两种通用的 JSON 类型——JsonStringType
和JsonBinaryType
)。
让我们使用Author
实体和Book
JSON Java Object
。这里列出了Author
实体:
@Entity
@TypeDef(
name = "jsonb", typeClass = JsonBinaryType.class
)
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;
@Type(type = "jsonb")
@Column(columnDefinition = "jsonb") // or, json
private Book book;
// getters and setters omitted for brevity
}
这里列出了Book
JSON Java Object
(这不是 JPA 实体):
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
private String title;
private String isbn;
private int price;
// getters and setters omitted for brevity
}
坚持作者
服务方法可以轻松地持久化一个作者,如下所示:
public void newAuthor() {
Book book = new Book();
book.setIsbn("001-JN");
book.setTitle("A History of Ancient Prague");
book.setPrice(45);
Author author = new Author();
author.setName("Joana Nimar");
author.setAge(34);
author.setGenre("History");
author.setBook(book);
authorRepository.save(author);
}
INSERT
的说法是:
INSERT INTO author (age, book, genre, name)
VALUES (34, '{"title":"A History of Ancient Prague",
"isbn":"001-JN","price":45}', 'History', 'Joana Nimar')
author
表如图 16-4 所示。
图 16-4
PostgreSQL 中的 JSON
获取/更新作者
获取作者会将获取的 JSON 映射到Book
对象。例如,考虑以下查询:
public Author findByName(String name);
调用findByName()
会触发下面的SELECT
语句:
Author author = authorRepository.findByName("Joana Nimar");
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.book AS book3_0_,
author0_.genre AS genre4_0_,
author0_.name AS name5_0_
FROM author author0_
WHERE author0_.name = ?
通过取出的author
,可以调用getBook().getTitle()
、getBook().getIsbn()
或getBook().getPrice()
。调用getBook().setTitle()
、getBook().setIsbn()
或getBook().setPrice()
将触发 JSON 更新。这个UPDATE
看起来是这样的(getBook().setPrice(40)
):
UPDATE author
SET age = 34,
book = '{"title":"A History of Ancient Prague",
"isbn":"001-JN","price":40}',
genre = 'History',
name = 'Joana Nimar'
WHERE id = 1
通过查询 JSON 获取作者
PostgreSQL 提供了两个用于查询 JSON 数据的本地操作符(更多详细信息请参见 PostgreSQL 文档 11 ):
-
->
操作符通过键返回 JSON 对象字段 -
->>
操作符通过文本返回 JSON 对象字段
作为本地操作符,它们必须在本地查询中使用。例如,获取用给定的 ISBN 写了一本书的作者的方法如下:
@Query(value = "SELECT a.* FROM author a "
+ "WHERE a.book ->> 'isbn' = ?1",
nativeQuery = true)
public Author findByBookIsbnNativeQuery(String isbn);
有时,您需要将 JSON 字段转换为适当的数据类型。例如,要将图书的price
包含在比较中,必须将其转换为INTEGER
,如下所示:
@Query(value = "SELECT a.* FROM author a "
+ "WHERE CAST(a.book ->> 'price' AS INTEGER) = ?1",
nativeQuery = true)
public Author findByBookPriceNativeQueryCast(int price);
完整的应用可在 GitHub 12 上获得。
*