Spring Boot 持久化最佳实践(四)
六、连接和事务
第 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.timeoutHibernate 特有的提示,相当于来自org.hibernate.query.Query的setTimeout()(超时以秒为单位指定): -
通过
javax.persistence.query.timeoutJPA 提示,相当于来自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 将准备事务上下文,并将为其获取数据库连接。
仅此而已!
Footnotes 1hibernate pringb bootdelayconnect on
2
3
4
hibernate pringb ootponimo nalizado
5
hibernate pringb ottransaction ti meout
6
仓库中的 Hibernate 跳 ootTransactional】
七、标识符
第 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_valuehi是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_valuehi为 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()返回一个常数;因此,对于nullid,我们依赖于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断言具有非nullID 的对象和存储在 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;
}
}
此时,连接表有了一个实体,多对多关联已经转换为两个双向的一对多关联。
Footnotes 1hibernate pringb ootbautogenerator 型
2
3
4
5
6
hibernate pringb otlombokesa ndhashcode
7
hibernate pringb oonaturalidimpl
8
https://hibernate.atlassian.net/browse/HHH-7964
9
https://hibernate.atlassian.net/browse/HHH-7964
10
hibernate pringb otnaturalidcch e
11
hibernate pringb 欧特 ReferenceNatu ralId
12
hibernate pringb oortreturn 生成 edKeys
13
hibernate pringb bootcustomsenc 再生器
14
15
hibernate pringb otcompositeekeyi dcclass
16
https://www.ietf.org/rfc/rfc4122.txt
17
http://mysql.rjweb.org/doc.php/uuid
18
19
hibernate pringb otassigned uuid
20
https://www.ietf.org/rfc/rfc4122.txt
21
22
hibernate pringb oocompositeekeye mbddblemaprel
*