Spring Boot 持久化最佳实践(三)
四、批量
第 46 项:如何批量插入 Spring Boot 风格
批处理是一种能够对INSERT、UPDATE和DELETE语句进行分组的机制,因此,它显著减少了数据库/网络往返次数。往返次数越少,性能越好。
批处理是避免由大量独立的数据库/网络往返(代表数据库中的插入、删除或更新)所导致的性能损失的完美解决方案。例如,在没有批处理的情况下,1,000 次插入需要 1,000 次单独的往返,而采用批处理且批处理大小为 30 将导致 34 次单独的往返。插入越多,批处理就越有用。
启用批处理并准备 JDBC URL
在 Spring Boot + Hibernate +(本例中为 MySQL)应用中启用批量插入支持从application.properties中的几个设置开始,接下来将讨论。
设置批量大小
批量大小可以通过spring.jpa.properties.hibernate.jdbc.batch_size属性设置。推荐值范围在 5 到 30 之间。默认值可以通过Dialect.DEFAULT_BATCH_SIZE获取。将批量大小设置为 30 可以按如下方式完成:
spring.jpa.properties.hibernate.jdbc.batch_size=30
不要混淆hibernate.jdbc.batch_size和hibernate.jdbc.fetch_size。后者用于设置 JDBC Statement.setFetchSize(),如第 45 项所述。根据经验,对于 Hibernate(导航整个结果集)和在单次数据库往返中获取整个结果集的数据库,不推荐使用hibernate.jdbc.fetch_size。所以在使用 MySQL 或者 PostgreSQL 的时候要避免。但是对于支持在多次数据库往返中获取结果集的数据库(如 Oracle)来说,这可能很有用。
MySQL 的批处理优化
对于 MySQL,有几个属性可以用来优化批处理性能。首先,有 JDBC URL 优化标志属性,rewriteBatchedStatements(这可以在 PostgreSQL 以及项目 55 中使用)。启用此属性后,SQL 语句将被重写到单个字符串缓冲区中,并发送到对数据库的单个请求中。否则,批处理语句(例如,INSERT s)如下所示:
insert into author (age, genre, name, id) values (828, 'Genre_810', 'Name_810', 810)
insert into author (age, genre, name, id) values (829, 'Genre_811', 'Name_811', 811)
...
使用此设置,这些 SQL 语句将重写如下:
insert into author (age, genre, name, id) values (828, 'Genre_810', 'Name_810', 810),(829, 'Genre_811', 'Name_811', 811),...
另一个 JDBC URL 优化标志属性是cachePrepStmts。该属性支持缓存,并与prepStmtCacheSize、prepStmtCacheSqlLimit等配合使用。如果没有此设置,缓存将被禁用。
最后,JDBC URL 优化标志属性useServerPrepStmts用于启用服务器端准备好的语句(这可能会导致显著的性能提升)。
MySQL 支持客户端(默认情况下启用)和服务器端(默认情况下禁用)预处理语句。
使用客户端准备语句时,SQL 语句在发送到服务器执行之前在客户端准备好。通过用文字值替换占位符来准备 SQL 语句。在每次执行时,客户机通过一个COM_QUERY命令发送一个准备执行的完整 SQL 语句。
设置useServerPrepStmts=true时,启用服务器准备语句。这一次,SQL 查询文本只通过一个COM_STMT_PREPARE命令从客户机发送到服务器一次。服务器准备查询并将结果(例如,占位符)发送给客户端。此外,在每次执行时,客户端将通过一个COM_STMT_EXECUTE命令向服务器发送仅用于替代占位符的文字值。此时,SQL 被执行。
大多数连接池(例如,Apache DBCP、Vibur 和 C3P0)将跨连接缓存准备好的语句。换句话说,对同一语句字符串的连续调用将使用同一个PreparedStatement实例。因此,相同的PreparedStatement被跨连接使用(被使用并返回到池的连接)以避免在服务器端准备相同的字符串。其他连接池不支持连接池级别的预准备语句缓存,而倾向于利用 JDBC 驱动程序的缓存功能(例如,HikariCP 1 )。
MySQL 驱动程序提供了客户端语句缓存,默认情况下是禁用的。可通过 JDBC 选项cachePrepStmts=true启用。一旦启用,MySQL 将为客户机和服务器准备的语句提供缓存。您可以通过以下查询获得当前缓存状态的快照:
SHOW GLOBAL STATUS LIKE '%stmt%';
这将返回如下所示的表格:
| **变量名称** | **值** | | `com_stmt_execute` | `...` | | `com_stmt_prepare` | `...` | | `prepared_stmt_count` | `...` | | `...` | `...` |请注意,较旧的 MySQL 版本不允许同时激活重写和服务器端准备语句。为了确保这些陈述仍然有效,请检查您正在使用的连接器/J 的注释。
进行这些设置后,会出现以下 JDBC URL:
jdbc:mysql://localhost:3306/bookstoredb?
cachePrepStmts=true
&useServerPrepStmts=true
&rewriteBatchedStatements=true
对于其他 RDBMS,只需移除/替换特定于 MySQL 的设置。
根据经验,如果不需要二级缓存,那么确保通过spring.jpa.properties.hibernate.cache.use_second_level_cache=false将其禁用。
为批处理插入准备实体
接下来,准备批处理插入中涉及的实体。设置分配的发生器,因为 HibernateIDENTITY发生器将导致批处理插入被禁用。Author实体如下:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private Long id;
private String name;
private String genre;
private int age;
// getters and setters omitted for brevity
}
不要补充这个:
@GeneratedValue(strategy = GenerationType.IDENTITY)
对于 Hibernate IDENTITY生成器(例如 MySQL AUTO_INCREMENT和 PostgreSQL ( BIG ) SERIAL),Hibernate 只对INSERT禁用 JDBC 批处理(作为替代,开发者可以依赖 JOOQ,它在这种情况下也支持批处理)。
另一方面,GenerationType.AUTO和 UUID 可用于插入批处理:
@Entity
public class Author implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
...
}
然而,简而言之,应该避免 UUID 标识符。在标题为“通用唯一标识符(UUID)怎么样?”的章节中的第 74 项中可以获得更多详细信息。
识别并避免内置的 saveAll(Iterable 实体)缺陷
Spring 自带saveAll(Iterable<S> entities)方法。虽然这种方法对于保存相对较小的Iterable非常方便,但是当您处理批处理时,尤其是处理大量的实体时,您需要注意几个方面:
-
开发人员无法控制当前事务中持久上下文的刷新和清除:
saveAll(Iterable<S> entities)方法将在事务提交前导致一次刷新;因此,在准备 JDBC 批处理的过程中,实体是在当前持久性上下文中累积的。对于大量的实体(大的Iterable),这会“淹没”持久性上下文,导致性能下降(例如,刷新变得缓慢)或者甚至特定于内存的错误。解决方案是将数据分块,用一个大小等于批处理大小的Iterable调用saveAll()。这样,每个Iterable都在一个单独的事务和持久性上下文中运行。您不会冒淹没持久性上下文的风险,并且在失败的情况下,回滚不会影响之前的提交。此外,您避免了不利于 MVCC(多版本并发控制 2 )和影响可伸缩性的长时间运行的事务。然而,更好的方法是在刷新-清除周期中重用持久性上下文,在开始-提交周期中重用相同的事务(您将在下一节中这样做)。 -
开发者不能靠
persist()代替merge(): 在幕后,saveAll(Iterable<S> entities)方法调用内置的save(S s)方法,后者调用EntityManager#merge()。这意味着,在触发INSERT之前,JPA 持久性提供者将触发SELECT,触发的SELECT越多,性能损失就越大。需要每个被触发的SELECT来确保数据库还没有包含与要插入的记录具有相同主键的记录(在这种情况下,Hibernate 将触发一个UPDATE而不是一个INSERT)。调用persist()而不是merge()将仅触发INSERTs。然而,向实体添加一个@Version属性将防止这些额外的SELECTs 在批处理之前被触发。 -
saveAll()方法返回一个包含持久化实体的List<S>:对于每个Iterable,saveAll()创建一个添加持久化实体的列表。如果你不需要这个列表,那么它就是免费的。例如,如果批量处理 1000 个实体,批量大小为 30,那么将创建 34 个列表。如果你不需要这些List对象,你只是白给垃圾收集器增加了更多的工作。
**通过saveAll(Iterable<S> entities)批量插入的例子可以在 GitHub 3 找到。接下来,让我们来谈谈一种能给你更多控制权的方法。
定制实现是一条可行之路
通过编写批处理的自定义实现,您可以控制和调整该过程。您向客户端公开了一个利用多种优化的saveInBatch(Iterable<S> entities)方法。这个定制实现可以依赖于EntityManager,并且有几个主要目标:
-
在每个批处理后提交数据库事务
-
用
persist()代替merge() -
不要求实体中存在
@Version以避免额外的SELECT -
不要返回持久化实体的
List -
通过名为
saveInBatch(Iterable<S>)的方法以 Spring 风格公开批处理
在我们继续之前,让我们强调一下批处理插入的最佳实践。
推荐的批量大小在 5 到 30 之间。
提交每个批处理的数据库事务(这将把当前批处理刷新到数据库)。这样,您可以避免长时间运行的事务(这不利于 MVCC 并影响可伸缩性),并且在失败的情况下,回滚不会影响之前的提交。在开始新的批处理之前,再次开始事务并清除实体管理器。这将防止托管实体的累积和可能的内存错误,内存错误是由缓慢刷新导致的性能损失。在开始提交周期中重用事务,在清除周期中重用实体管理器。
然而,如果您决定只在最后提交事务,那么在事务内部,在每一批之后显式地刷新和清除记录。通过这种方式,持久性上下文可以释放一些内存,防止内存耗尽和缓慢刷新。注意长时间运行的事务的代码。
撰写批处理存储合同
实现从包含所需方法的非存储库接口开始。这个界面用@NoRepositoryBean标注:
@NoRepositoryBean
public interface BatchRepository<T, ID extends Serializable>
extends JpaRepository<T, ID> {
<S extends T> void saveInBatch(Iterable<S> entitles);
}
编写批处理存储实现
接下来,您可以扩展SimpleJpaRepository存储库基类并实现BatchRepository。通过扩展SimpleJpaRepository,您可以通过添加所需的方法来自定义基本存储库。主要是,您扩展了特定于持久性技术的存储库基类,并使用这个扩展作为存储库代理的定制基类。请注意,您将事务传播设置为NEVER,因为您不想让 Spring 启动一个潜在的长时间运行的事务(有关 Spring 事务传播的高超指南,请查看附录 G ):
@Transactional(propagation = Propagation.NEVER)
public class BatchRepositoryImpl<T, ID extends Serializable>
extends SimpleJpaRepository<T, ID> implements BatchRepository<T, ID> {
...
@Override
public <S extends T> void saveInBatch(Iterable<S> entities) {
BatchExecutor batchExecutor
= SpringContext.getBean(BatchExecutor.class);
batchExecutor.saveInBatch(entities);
}
...
}
这个扩展有助于以 Spring 风格公开批处理插入实现。批处理发生在名为BatchExecutor的 Spring 组件中。虽然 GitHub 4 上提供了完整的代码,但是下面的方法(BatchExecutor.saveInBatch())显示了实现(注意,它从EntityManagerFactory获得EntityManager,并控制事务的开始-提交周期):
@Component
public class BatchExecutor<T> {
private static final Logger logger =
Logger.getLogger(BatchExecutor.class.getName());
@Value("${spring.jpa.properties.hibernate.jdbc.batch_size}")
private int batchSize;
private final EntityManagerFactory entityManagerFactory;
public BatchExecutor(EntityManagerFactory entityManagerFactory) {
this.entityManagerFactory = entityManagerFactory;
}
public <S extends T> void saveInBatch(Iterable<S> entities) {
EntityManager entityManager
= entityManagerFactory.createEntityManager();
EntityTransaction entityTransaction = entityManager.getTransaction();
try {
entityTransaction.begin();
int i = 0;
for (S entity : entities) {
if (i % batchSize == 0 && i > 0) {
logger.log(Level.INFO,
"Flushing the EntityManager
containing {0} entities ...", batchSize);
entityTransaction.commit();
entityTransaction.begin();
entityManager.clear();
}
entityManager.persist(entity);
i++;
}
logger.log(Level.INFO,
"Flushing the remaining entities ...");
entityTransaction.commit();
} catch (RuntimeException e) {
if (entityTransaction.isActive()) {
entityTransaction.rollback();
}
throw e;
} finally {
entityManager.close();
}
}
}
将 BatchRepositoryImpl 设置为基类
接下来,您需要指示 Spring 依赖这个定制的存储库基类。在 Java 配置中,这可以通过repositoryBaseClass属性来完成:
@SpringBootApplication
@EnableJpaRepositories(
repositoryBaseClass = BatchRepositoryImpl.class)
public class MainApplication {
...
}
测试时间
考虑使用 Spring Boot 风格的实现。首先,为Author实体定义一个经典存储库(这次,扩展BatchRepository):
@Repository
public interface AuthorRepository extends BatchRepository<Author, Long> {
}
此外,在服务中注入这个存储库,并如下调用saveInBatch():
public void batchAuthors() {
List<Author> authors = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
Author author = new Author();
author.setId((long) i + 1);
author.setName("Name_" + i);
author.setGenre("Genre_" + i);
author.setAge(18 + i);
authors.add(author);
}
authorRepository.saveInBatch(authors);
}
可能的输出将显示 1000 个作者在 34 个批次和 34 次刷新中被处理(如果您需要刷新如何工作,请参见附录 H )。见图 4-1 。
图 4-1
批量插入
根据经验,总是要确保应用(数据访问层)确实在使用批处理,并且是按预期使用的。由于批处理可能会被无声地禁用或没有正确地优化,所以不要认为它可以正常工作。最好还是依靠工具(如DataSource-Proxy;参见第 83 项,能够记录批量大小并统计执行的语句。
GitHub 5 上有源代码。如果您只想在批处理过程结束时提交,但仍想利用每次批处理后的刷新和清除,请考虑使用此代码 6 。
您可能还想检查:
-
批量插入通过
EntityManager和一个刀层 7 -
通过
JpaContext和EntityManager和 8 和批量插入
第 47 项:如何优化父子关系的批量插入
为了熟悉分批插入,在继续之前考虑阅读项目 46 。
考虑一下Author和Book实体之间的@OneToMany关联。由于级联持久存储(或全部),保存作者也保存了他们的书。如果作者和书籍的数量非常多,您可以使用批量插入技术来提高性能。
默认情况下,这将导致批处理每个作者和每个作者的图书。例如,考虑 40 个作者,每个人写了五本书。将这些数据插入数据库需要 240 次插入(40 位作者和 200 本书)。批量为 15 的这些插页应产生 17 个 JDBC 批次。为什么是 17?答案即将揭晓。
如果不使用对插入进行排序,以下 SQL 语句将按此顺序分批分组(突出显示的插入是为了直观地区分每个作者):
insert into author (age, genre, name, id) values (?, ?, ?, ?)
insert into book (author_id, isbn, title, id) values (?, ?, ?, ?)
-- 4 more
insert into author (age, genre, name, id) values (?, ?, ?, ?)
insert into book (author_id, isbn, title, id) values (?, ?, ?, ?)
-- 4 more
...
因此,有一个针对author表的插入,后面跟着五个针对book表的插入。因为有 40 个作者,所以重复 40 次。最终统计出 80 个 JDBC 批次,如图 4-2 所示。
图 4-2
不排序插入的批量插入(包括关联)
为什么是 80 批 JDBC?答案在于批处理是如何工作的。更准确地说,一个 JDBC 批处理只能针对一个表。当目标是另一个表时,当前批处理结束,并创建一个新的表。在这种情况下,定位到author表会创建一个批处理,而定位到book表会创建另一个批处理。第一批只分组一个插入,而第二批分组五个插入。所以,有 40 x 2 批。
订购插页
这个问题的解决依赖于对插页进行排序。这可以通过在以下设置中添加application.properties来实现:
spring.jpa.properties.hibernate.order_inserts=true
这一次,插页的顺序如下:
insert into author (age, genre, name, id) values (?, ?, ?, ?)
-- 14 more
insert into book (author_id, isbn, title, id) values (?, ?, ?, ?)
-- 74 more (15 x 5)
...
第一批将目标为author工作台的 15 个插入物分组。以下五个批次中的每一个批次都以book工作台为目标将 15 个插入物分组。所以,到目前为止有六批。另外六个将涵盖下一组的 15 个作者。所以,12 批。最后 10 个作者被分组到新的一批中;所以,至今有 13 个。最后 10 位作者写了 50 本书,这导致了另外四批。总共是 17 个 JDBC 批次,如图 4-3 所示。
图 4-3
带有有序插入的批量插入(包括关联)
图 4-4 所示的时间-性能趋势图揭示了订购插件可以带来实质性的好处。这里,我们将作者的数量从 5 增加到 500,同时保持每个作者的书籍数量等于 5。
图 4-4
批量插入,包括有无排序的关联
这张时间性能趋势图是在具有以下特征的 Windows 7 机器上针对 MySQL 获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。
GitHub 9 上有源代码。或者,如果您想在单个事务中运行批处理,请查看这个 GitHub10应用。
第 48 项:如何在会话级别控制批量大小
可通过spring.jpa.properties.hibernate.jdbc.batch_size在application.properties中设置应用级批量。换句话说,所有 Hibernate 会话都使用相同的批处理大小。但是,从 Hibernate 5.2 开始,您可以在会话级设置批处理大小。这允许您拥有不同批处理大小的 Hibernate 会话。
您可以通过Session.setJdbcBatchSize()方法在会话级别设置批处理大小。在 Spring Boot,访问Session意味着通过EntityManager#unwrap()从当前的EntityManager中打开它。
以下代码片段显示了在批处理插入的情况下,在会话级别设置批处理大小所需的所有部分:
private static final int BATCH_SIZE = 30;
private EntityManager entityManager = ...;
Session session = entityManager.unwrap(Session.class);
session.setJdbcBatchSize(BATCH_SIZE);
...
int i = 0;
for (S entity: entities) {
if (i % session.getJdbcBatchSize() == 0 && i > 0) {
...
}
}
...
GitHub 11 上有源代码。或者,如果您想在单个事务中运行批处理,那么请查看这个 GitHub 12 应用。
第 49 项:如何分叉连接 JDBC 配料
大多数数据库支持批量插入数百万条记录。在决定在应用级别进行批处理/批量处理之前,建议查看一下您的数据库供应商提供了哪些选项。例如,MySQL 提供了LOAD DATA INFILE,这是一个高度优化的特性,可以以很高的速度将数据从 CSV/TSV 文件直接插入到表格中。
前面的内容已经涵盖了通过批处理持久化实体的几个方面。但是在有些情况下,实体是不需要的,你必须使用 JDBC 普通批处理。例如,假设您有一个文件(citylots.json),其中包含关于 JSON 中城市地块的信息。您需要通过一个INSERT类型的语句(占位符是文件中的一行)将这个文件传输到一个数据库表(lots):
INSERT INTO lots (lot) VALUES (?)
在 Spring Boot,JDBC 配料可以通过JdbcTemplate轻松完成;更准确地说是通过JdbcTemplate.batchUpdate()方法。该方法的一个特点是将一个BatchPreparedStatementSetter实例作为第二个参数,这对于设置通过String作为第一个参数传递的PreparedStatement的文字值很有用。本质上,batchUpdate()在单个PreparedStatement上发布多个 update 语句,使用批量更新和一个BatchPreparedStatementSetter来设置值。
以下组件表示使用batchUpdate()的 JDBC 批处理实现:
@Component
public class JoiningComponent {
private static final String SQL_INSERT
= "INSERT INTO lots (lot) VALUES (?)";
private final JdbcTemplate jdbcTemplate;
public JoiningComponent(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void executeBatch(List<String> jsonList) {
jdbcTemplate.batchUpdate(SQL_INSERT,
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement pStmt, int i)
throws SQLException {
String jsonLine = jsonList.get(i);
pStmt.setString(1, jsonLine);
}
@Override
public int getBatchSize() {
return jsonList.size();
}
});
}
}
动作发生在executeBatch()方法中。接收到的jsonList被迭代,并且对于每个项目,相应地准备PreparedStatement(在setValues()中)并且发布更新。
只要jsonList不是很大,这种实现就很好。citylots.json文件有 200,000+行,因此实现需要一个长事务来迭代 200,000+项的列表并发出 200,000+更新。批量大小为 30 时,有 6,600+批要执行。即使有批处理支持,顺序执行 6,600 多个批处理也需要大量时间。
分叉连接批处理
在这种情况下,与其按顺序执行批处理,不如并发执行。Java 提供了几种可以使用的方法,比如Executors、fork/join 框架、CompletableFuture等等。在这种情况下,让我们使用 fork/join 框架。
虽然剖析 fork/join 框架已经超出了本书的范围,但是本节将快速强调几个方面:
-
fork/join 框架意味着接受一个大任务(通常,“大”意味着大量数据),并递归地将它分割(fork)成可以并行执行的较小任务(子任务)。最后,在所有子任务完成后,它们的结果被组合(连接)成一个结果。
-
在 API 术语中,可以通过
java.util.concurrent.ForkJoinPool创建一个 fork/join。 -
一个
ForkJoinPool对象操纵任务。在ForkJoinPool中执行的任务的基本类型是ForkJoinTask<V>。有三种类型的任务,但我们对RecursiveAction感兴趣,它是针对返回void的任务。 -
任务的逻辑发生在名为
compute()的abstract方法中。 -
向
ForkJoinPool提交任务可以通过很多方法完成,但是我们对invokeAll()感兴趣。该方法用于派生一组任务(例如,一个集合)。 -
通常,可用处理器(核心)的数量决定了 fork/join 并行性的级别。
基于这几点,您可以使用 fork/join 框架将一个 200,000 多项的列表派生到最多 30 项的子任务中(30 是一个批处理的大小,在application.properties中表示为一个配置属性)。此外,JoiningComponent.executeBatch()方法将执行每个子任务(批处理):
@Component
@Scope("prototype")
public class ForkingComponent extends RecursiveAction {
@Value("${jdbc.batch.size}")
private int batchSize;
@Autowired
private JoiningComponent joiningComponent;
@Autowired
private ApplicationContext applicationContext;
private final List<String> jsonList;
public ForkingComponent(List<String> jsonList) {
this.jsonList = jsonList;
}
@Override
public void compute() {
if (jsonList.size() > batchSize) {
ForkJoinTask.invokeAll(createSubtasks());
} else {
joiningComponent.executeBatch(jsonList);
}
}
private List<ForkingComponent> createSubtasks() {
List<ForkingComponent> subtasks = new ArrayList<>();
int size = jsonList.size();
List<String> jsonListOne = jsonList.subList(0, (size + 1) / 2);
List<String> jsonListTwo = jsonList.subList((size + 1) / 2, size);
subtasks.add(applicationContext.getBean(
ForkingComponent.class, new ArrayList<>(jsonListOne)));
subtasks.add(applicationContext.getBean(
ForkingComponent.class, new ArrayList<>(jsonListTwo)));
return subtasks;
}
}
最后,你需要通过ForkJoinPool启动一切:
public static final int NUMBER_OF_CORES =
Runtime.getRuntime().availableProcessors();
public static final ForkJoinPool forkJoinPool = new
ForkJoinPool(NUMBER_OF_CORES);
// fetch 200000+ lines from file
List<String> allLines = Files.readAllLines(Path.of(fileName));
private void forkjoin(List<String> lines) {
ForkingComponent forkingComponent
= applicationContext.getBean(ForkingComponent.class, lines);
forkJoinPool.invoke(forkingComponent);
}
每个批处理都将在自己的事务/连接中运行,因此您需要确保连接池(例如 HikariCP)可以提供必要数量的连接,以避免 fork/join 线程之间的争用。通常,可用处理器(核心)的数量决定了 fork/join 并行性级别(这不是一个规则;你需要对它进行基准测试)。因此,连接数应该等于或大于将执行批处理的 fork/join 线程数。例如,如果您有八个核心,那么如果您想避免空闲的 fork/join 线程,连接池必须提供至少八个连接。对于 HikariCP,您可以设置 10 个连接:
spring.datasource.hikari.maximumPoolSize=10
spring.datasource.hikari.minimumIdle=10
图 4-5 显示了分别使用一个线程、四个线程、八个线程批处理 1000、10000 和 25000 个项目时的时间性能趋势,批处理大小为 30。很明显,使用并发批处理可以大大加快进程。当然,对于特定的作业,要调优并找到线程数、连接数、批处理大小、子任务大小等的最佳值。,可以进一步优化这个实现。
图 4-5
分叉/连接和 JDBC 批量插入
图 4-5 中显示的时间-性能趋势图是在具有以下特征的 Windows 7 机器上针对 MySQL 获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。
完整的应用可在 GitHub 13 上获得。
对于复杂的批处理场景,依靠专用工具是明智的。比如春季批次 14 项目可以适当选择。
第 50 项:通过 CompletableFuture 批处理实体
这篇文章使用了“定制实现是必由之路”一节中第 46 篇文章中的代码库,所以在阅读这篇文章之前,请考虑熟悉它。
当你需要加速实体批处理过程时,考虑并发执行批处理,而不是顺序执行,就像在项目 46 中一样。Java 有几种方法,比如Executors、fork/join 框架、CompletableFuture等等。你可以像在 Item 49 中一样轻松地使用 fork/join 框架,但是为了方便起见,这次让我们使用CompletableFuture API。
虽然剖析CompletableFuture API 已经超出了本书的范围,但是下面的列表快速突出了几个方面:
-
CompletableFuture作为FutureAPI 的增强,在 JDK 8 中增加。 -
CompletableFuture提供了一个可靠的异步 API,它以大量的方法实现。 -
从这些方法中,我们感兴趣的是
CompletableFuture.allOf()。该方法允许您异步执行一系列任务,并等待它们完成。在这种情况下,任务是插入批处理。 -
你需要的另一个方法是
CompletableFuture.runAsync()。该方法可以异步运行任务,并且不返回结果。在这种情况下,任务是执行单个批处理的事务。如果您需要返回一个结果,那么您可以简单地使用supplyAsync()方法。
请记住,在第 49 项中,您创建了BatchExecutor,它在开始-提交周期中重用相同的事务。这个时候需要并发批处理,所以单个事务是不够的。换句话说,每批需要一个事务/连接。这可以通过TransactionTemplate进行整形。这里列出了改装后的BatchExecutor:
@Component
public class BatchExecutor<T> {
private static final Logger logger =
Logger.getLogger(BatchExecutor.class.getName());
@Value("${spring.jpa.properties.hibernate.jdbc.batch_size}")
private int batchSize;
private final TransactionTemplate txTemplate;
private final EntityManager entityManager;
private static final ExecutorService executor
= Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() - 1);
public BatchExecutor(TransactionTemplate txTemplate,
EntityManager entityManager) {
this.txTemplate = txTemplate;
this.entityManager = entityManager;
}
public <S extends T> void saveInBatch(List<S> entities)
throws InterruptedException, ExecutionException {
txTemplate.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
final AtomicInteger count = new AtomicInteger();
CompletableFuture[] futures = entities.stream()
.collect(Collectors.groupingBy(
c -> count.getAndIncrement() / batchSize))
.values()
.stream()
.map(this::executeBatch)
.toArray(CompletableFuture[]::new);
CompletableFuture<Void> run = CompletableFuture.allOf(futures);
run.get();
}
public <S extends T> CompletableFuture<Void> executeBatch(List<S> list) {
return CompletableFuture.runAsync(() -> {
txTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(
TransactionStatus status) {
for (S entity : list) {
entityManager.persist(entity);
}
}
});
}, executor);
}
}
注意,我们使用批处理大小将初始列表分块到一个数组CompletableFuture中。虽然这里使用的分块技术相当慢,但它非常容易编写。然而,许多其他解决方案是可用的,正如你在应用 15 中看到的。
另外,注意我们使用了一个自定义的ExecutorService。这对于控制并行度非常有用,但是您也可以跳过它。如果跳过它,那么异步任务将在从全局ForkJoinPool.commonPool()获得的线程中执行。
最后,对于 HikariCP 连接池,您可以设置 10 个连接,如下所示(这将很容易容纳用于批处理的 8 个线程):
spring.datasource.hikari.maximumPoolSize=10
spring.datasource.hikari.minimumIdle=10
图 4-6 显示了分别使用一个线程、四个线程和八个线程批处理 1000、5000 和 10000 个实体的性能趋势,批处理大小为 30。很明显,使用并发批处理可以大大加快进程。当然,对于一个特定的作业,调整并找到线程数、连接数、批处理大小、子任务大小等的最佳值。,可以进一步优化这个实现。
图 4-6
CompletableFuture 和 JPA 批量插入
图 4-6 中的时间-性能趋势图是在具有以下特征的 Windows 7 机器上针对 MySQL 获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。
完整的应用可在 GitHub 16 上获得。
对于复杂的批处理场景,依靠专用工具是明智的。比如春季批次 17 项目都可以是不错的选择。
第 51 项:如何有效地批量更新
批量更新是一个设置问题。首先,MySQL 的 JDBC URL 可以像在第 46 项中那样准备:
jdbc:mysql://localhost:3306/bookstoredb?
cachePrepStmts=true
&useServerPrepStmts=true
&rewriteBatchedStatements=true
对于其他 RDBMS,您只需删除特定于 MySQL 的设置。
您也可以通过spring.jpa.properties.hibernate.jdbc.batch_size设置批量大小。
接下来,需要考虑的主要有两个方面。
版本化实体
如果应该更新的实体是版本化的(包含一个用@Version注释的属性,用于防止丢失更新,那么确保设置了以下属性:
spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true
spring.jpa.properties.hibernate.jdbc.batch_versioned_data应该在 Hibernate 5 之前显式设置。从 Hibernate 5 开始,默认情况下启用该设置。
父子关系的批量更新
当更新影响具有级联全部/持续的父子关系时,建议通过以下设置对更新进行排序:
spring.jpa.properties.hibernate.order_updates=true
如果您不订购更新,应用将容易出现 Item 47 中描述的问题。快速提醒一下,JDBC 批处理只能针对一个表。当目标是另一个表时,当前批处理结束,并创建一个新的表。
本书附带的源代码包含两个应用。一个用于不涉及关联的批量更新(GitHub 18 ),另一个涉及关联(GitHub 19 )。两个应用都使用众所周知的实体,Author和Book。
批量更新
批量操作(删除和更新)对于修改一组记录也很有用。批量操作速度很快,但它们有三个主要缺点:
-
批量更新(和删除)可能会使持久性上下文处于过时状态(您可以在更新/删除之前刷新持久性上下文,然后在更新/删除之后关闭/清除它,以避免由潜在的未刷新或过时的实体造成的问题,从而防止这个问题)。
-
批量更新(和删除)不会受益于自动乐观锁定(例如,
@Version被忽略)。因此,不会阻止丢失更新。然而,其他查询可能会受益于乐观锁定机制。因此,建议通过显式递增版本(如果有)来通知这些更新。 -
批量删除不能利用级联删除(
CascadeType.REMOVE)或orphanRemoval。
也就是说,让我们假设Author和Book参与了一个双向懒惰的@OneToMany关联。Author持久字段是id、name、genre、age、version和books。Book持久字段是id、title、isbn、version和author。现在,让我们更新!
让我们通过将他们的age增加 1 来更新所有的作者,并通过将他们的isbn s 设置为 None 来更新书籍。不需要在持久性上下文中加载作者和书籍来执行这些更新。您可以触发两个批量操作,如下所示(注意查询是如何显式递增version):
// add this query in AuthorRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(value = "UPDATE Author a SET a.age = a.age + 1,
a.version = a.version + 1")
public int updateInBulk();
// add this query in BookRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(value = "UPDATE Book b SET b.isbn='None',
b.version=b.version + 1")
public int updateInBulk();
并且服务方法触发更新:
@Transactional
public void updateAuthorsAndBooks() {
authorRepository.updateInBulk();
bookRepository.updateInBulk();
}
触发的 SQL 语句有:
UPDATE author
SET age = age + 1,
version = version + 1
UPDATE book
SET isbn = 'None',
version = version + 1
批量操作也可以用于实体。让我们假设持久性上下文包含所有比 40 更老的作者的Author和相关联的Book。这一次,批量操作可以写成:
// add this query in AuthorRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(value = "UPDATE Author a SET a.age = a.age + 1,
a.version = a.version + 1 WHERE a IN ?1")
public int updateInBulk(List<Author> authors);
// add this query in BookRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(value = "UPDATE Book b SET b.isbn='None',
b.version = b.version + 1 WHERE b.author IN ?1")
public int updateInBulk(List<Author> authors);
并且服务方法触发更新:
@Transactional
public void updateAuthorsGtAgeAndBooks() {
List<Author> authors = authorRepository.findGtGivenAge(40);
authorRepository.updateInBulk(authors);
bookRepository.updateInBulk(authors);
}
触发的 SQL 语句如下:
UPDATE author
SET age = age + 1,
version = version + 1
WHERE id IN (?, ?, ?, ..., ?)
UPDATE book
SET isbn = 'None',
version = version + 1
WHERE author_id IN (?, ?, ..., ?)
完整的应用可在 GitHub 20 上获得。
第 52 项:如何有效地批量删除(无关联)
要批量删除 MySQL,您可以准备 JDBC URL,如 Item 46 中所述:
jdbc:mysql://localhost:3306/bookstoredb?
cachePrepStmts=true
&useServerPrepStmts=true
&rewriteBatchedStatements=true
对于其他 RDBMS,只需删除 MySQL 特有的设置即可。
通过spring.jpa.properties.hibernate.jdbc.batch_size设置批量大小(例如,设置为 30)。对于版本化的实体,将spring.jpa.properties.hibernate.jdbc.batch_versioned_data设置为true。
批处理删除可以通过几种方式有效地完成。要决定哪种方法最适合,了解批处理会影响关联以及会删除多少数据是很重要的。此项处理不影响关联的批处理删除。
考虑图 4-7 中的Author实体。
图 4-7
作者实体表
Spring Boot 公开了一堆可以用来删除记录的方法。此外,这些方法中的每一种都用于删除 100 个作者。让我们从触发批量操作的两个方法开始— deleteAllInBatch()和deleteInBatch(Iterable<T> entities)。
一般来说,请注意,批量操作比批处理更快,并且可以使用索引,但是它们不会受益于级联机制(例如,CascadeType.ALL被忽略)或自动应用级乐观锁定机制(例如,@Version被忽略)。他们对实体的修改不会自动反映在持久性上下文中。
通过内置的 deleteAllInBatch()方法删除
您可以通过经典的 Spring 存储库(AuthorRepository)从服务方法中轻松调用内置的deleteAllInBatch()方法,如下所示:
public void deleteAuthorsViaDeleteAllInBatch() {
authorRepository.deleteAllInBatch();
}
deleteAllInBatch()生成的 SQL 语句如下:
DELETE FROM author
在DataSource-Proxy(这个库在第 83 项中介绍过)的上下文中添加这个 SQL 会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:21, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["delete from author"]
Params:[()]
不使用批处理,但是已经删除了author表中的所有记录。
即使不使用批处理,这也是从数据库中删除所有记录的非常有效的方法。它需要一次数据库往返。然而,deleteAllInBatch()并不受益于自动应用级乐观锁定机制(如果该机制被启用以防止丢失更新(例如,通过@Version)),而是依赖于Query的executeUpdate()来触发批量操作。这些操作比批处理要快,但是 Hibernate 不知道删除了哪些实体。因此,持久性上下文不会相应地自动更新/同步。为了避免过时的持久性上下文,您需要决定是否需要在删除之前触发刷新操作,并在删除之后丢弃(清除或关闭)持久性上下文。例如,deleteAuthorsViaDeleteAllInBatch()并不要求任何显式的冲或清。在删除之前,没有要刷新的内容,而在删除之后,持久性上下文会自动关闭。
通过内置的 deleteInBatch(Iterable 实体)删除
deleteInBatch(Iterable<T> entities)方法也可以触发批量删除。您可以通过经典的 Spring 存储库(AuthorRepository)从服务方法中轻松调用内置的deleteInBatch(Iterable<T> entities)方法,如下所示(删除所有小于 60 的作者):
@Transactional
public void deleteAuthorsViaDeleteInBatch() {
List<Author> authors = authorRepository.findByAgeLessThan(60);
authorRepository.deleteInBatch(authors);
}
这次,deleteInBatch(Iterable<T> entities)生成的 SQL 语句如下:
DELETE FROM author
WHERE id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
...
将该 SQL 添加到DataSource-Proxy的上下文中会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:27, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["delete from author where id=? or id=? or id=? ...]
Params:[(1,12,23, ...)]
不使用批处理。Spring Boot 只是使用OR操作符将相应的id链接到WHERE子句下。
与deleteAllInBatch()完全一样,该方法通过Query的executeUpdate()触发批量操作。
不要用deleteInBatch(Iterable<T> entities)删除所有记录。对于这种情况,使用deleteAllInBatch()。如果您使用这种方法来删除一组满足给定过滤标准的记录,您就没有自动应用级乐观锁定机制的好处(防止丢失更新)。虽然这种方法非常快,但是请记住,如果生成的DELETE语句超过了最大的可接受大小/长度(例如,get 一个StackOverflowError),也很容易导致问题。通常,可接受的最大大小是很大的,但是因为您使用了批处理,所以要删除的数据量也可能很大。
与deleteAllInBatch()的情况完全一样,由您来决定是否在删除之前,您必须刷新任何未刷新的实体,而在删除之后,您必须丢弃(关闭或清除)持久性上下文。例如,deleteAuthorsViaDeleteInBatch()不需要任何显式的刷新或清除。在删除之前,没有要刷新的内容,而在删除之后,持久性上下文会自动关闭。
如果您对生成的查询的大小有疑问,您可以考虑几个备选方案。例如,您可以依靠IN操作符来编写自己的批量操作,如下所示(这将导致类型为IN (?, ..., ?)的查询:
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Author a WHERE a IN ?1")
public int deleteInBulk(List<Author> authors);
一些 RDBMS(例如,SQL Sever)在内部从IN转换为OR,而另一些则没有(例如,MySQL)。就性能而言,IN和OR非常相似,但是最好针对特定的 RDBMS 进行基准测试(例如,在 MySQL 中,IN应该比OR性能更好)。此外,在 MySQL 8 中,我依靠IN来管理 500,000 次删除而没有问题,而OR在用于 10,000 次删除时导致了StackOverflowError。
另一种方法是将获取的结果集分块以适应deleteInBatch(Iterable<T> entities)。例如,这可以通过函数式编程风格快速完成,如下所示(如果您需要优化分块过程,请考虑这个应用 21 ):
@Transactional
public void deleteAuthorsViaDeleteInBatch() {
List<Author> authors = authorRepository.findByAgeLessThan(60);
final AtomicInteger count = new AtomicInteger();
Collection<List<Author>> chunks = authors.parallelStream()
.collect(Collectors.groupingBy(c -> count.getAndIncrement() / size))
.values();
chunks.forEach(authorRepository::deleteInBatch);
}
显然,这种方法的缺点是内存中的数据重复。它也没有受益于自动乐观锁定机制(它不能防止丢失更新)。但是分块数据可以通过 fork-join、CompletableFuture或任何其他特定的 API 利用删除的并行化。您可以为每个事务传递一个数据 chuck,并以并发方式运行多个事务。例如,在 Item 49 中,您看到了如何并行化批处理插入。
或者,您可以分块获取结果集,并为每个获取的块调用deleteInBatch(Iterable<T> entities)。在这种情况下,缺点表现为每个块的额外SELECT和没有丢失更新预防。
通过内置的 deleteAll()方法删除
你可以通过一个经典的 Spring 存储库(AuthorRepository)从一个服务方法中轻松调用内置的deleteAll(Iterable<? extends T> entities)方法,如下所示(删除所有小于 60 的作者):
@Transactional
public void deleteAuthorsViaDeleteAll() {
List<Author> authors = authorRepository.findByAgeLessThan(60);
authorRepository.deleteAll(authors);
}
这次,deleteAll(Iterable<? extends T> entities)生成的 SQL 语句如下:
DELETE FROM author
WHERE id = ?
AND version = ?
在DataSource-Proxy(这个库在第 83 项中有介绍)的上下文中添加这个 SQL 会显示以下输出(查看突出显示的部分):
Name:DATA_SOURCE_PROXY, Connection:6, Time:1116, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:30
Query:["delete from author where id=? and version=?"]
Params:[(2,0),(3,0),(6,0),(11,0),(13,0),(15,0),(17,0) ...]
最后,按预期使用批处理!它受益于自动乐观锁定机制,因此也防止更新丢失。
幕后,deleteAll(Iterable<? extends T> entities、delete(T entity)依靠EntityManager.remove()。因此,持久性上下文会相应地更新。换句话说,Hibernate 将每个实体的生命周期状态从管理的转变为移除的。
您可以通过无参数调用deleteAll()通过批处理删除所有记录。这个方法在后台调用findAll()。
通过内置的删除(T 实体)方法删除
在幕后,deleteAll(Iterable<? extends T> entities)方法依赖于内置的delete(T entity)方法。没有参数的deleteAll()方法调用findAll(),在循环结果集时,它为每个元素调用delete(T entity)。另一方面,deleteAll(Iterable<? extends T> entities)循环实体并为每个元素调用delete(T entity)。
你可以通过 Spring repository ( AuthorRepository)从服务方法中轻松调用内置的delete(T entity)方法,如下所示(删除所有小于 60 的作者):
@Transactional
public void deleteAuthorsViaDelete() {
List<Author> authors = authorRepository.findByAgeLessThan(60);
authors.forEach(authorRepository::delete);
}
这次,delete(T entity)生成的 SQL 语句如下:
DELETE FROM author
WHERE id = ? AND version = ?
在DataSource-Proxy的上下文中添加该 SQL 会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:1116, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:30
Query:["delete from author where id=? and version=?"]
Params:[(2,0),(3,0),(6,0),(11,0),(13,0),(15,0),(17,0) ...]
正如所料,输出类似于使用deleteAll(Iterable<? extends T> entities)。
总之,deleteAllInBatch()和deleteInBatch(Iterable<T> entities)不使用删除批处理。因此,不需要执行特定于启用批处理的设置。它们触发批量操作,这些操作不会受益于自动乐观锁定机制(如果启用了该机制,例如通过@Version,以防止丢失更新),并且持久性上下文不会与数据库同步。建议在删除前刷新持久性上下文,并在删除后清除/关闭它,以避免任何未刷新或过时的实体造成的问题。如果开发者使用deleteAll()或deleteAll(Iterable<? extends T> entities)方法或delete(T entity)方法,则采用批处理。只要所有的记录都要被删除,最好的方法就是使用deleteAllInBatch()。在deleteInBatch(Iterable<T> entities)和deleteAll()、deleteAll(Iterable<? extends T> entities) / delete(T entity)之间做出选择是基于所有这些考虑做出的决定。
GitHub 22 上有源代码。
第 53 项:如何有效地批量删除(带关联)
要对 MySQL 进行批量删除,可以准备好 JDBC URL,如 Item 46 所示:
jdbc:mysql://localhost:3306/bookstoredb?
cachePrepStmts=true
&useServerPrepStmts=true
&rewriteBatchedStatements=true
对于其他 RDBMS,您只需删除特定于 MySQL 的设置。
通过spring.jpa.properties.hibernate.jdbc.batch_size设置批量大小(例如,设置为 30)。对于版本化的实体,将spring.jpa.properties.hibernate.jdbc.batch_versioned_data设置为true。
考虑惰性双向@OneToMany关联中涉及的Author和Book实体,如图 4-8 所示。
图 4-8
@OneToMany 表关系
删除作者也应该删除相关的书籍。例如,删除所有作者应该会自动删除所有书籍。
依赖 orphanRemoval = true
默认情况下,orphanRemoval设置为false。您可以启用它来指示 JPA 持久性提供者删除父实体中不再引用的子实体。
不要混淆orphanRemoval和CascadeType.REMOVE。他们不一样!虽然orphanRemoval负责自动移除一个被解除关联的实体实例,但是CascadeType.REMOVE并不采取行动,因为解除关联不是一个移除操作。
这里列出了重要的Author代码:
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();
接下来,让我们考虑一下 Spring Boot 删除功能。
通过内置的 deleteAllInBatch()方法删除
您可以通过经典的 Spring 存储库(AuthorRepository)从服务方法中轻松调用内置的deleteAllInBatch()方法,如下所示:
public void deleteAuthorsAndBooksViaDeleteAllInBatch() {
authorRepository.deleteAllInBatch();
}
deleteAllInBatch()生成的 SQL 语句是:
DELETE FROM author
在DataSource-Proxy的上下文中添加该 SQL(该库在第 83 项中有介绍)会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:21, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["delete from author"]
Params:[()]
批处理没有被使用,也没有受益于自动乐观锁定机制(防止丢失更新,但是author表中的所有记录都已被删除。然而,book表中的记录并没有被删除。因此,不出所料,deleteAllInBatch()没有使用orphanRemoval或级联。它只是通过Query的executeUpdate()触发一个批量删除,并且持久性上下文不与数据库同步。使用它删除所有书籍的唯一方法是显式调用它,如下所示:
@Transactional
public void deleteAuthorsAndBooksViaDeleteAllInBatch() {
authorRepository.deleteAllInBatch();
bookRepository.deleteAllInBatch();
}
即使不使用批处理和丢失更新预防机制,并且持久性上下文不与数据库同步,这也是从数据库中删除所有记录的非常有效的方法。由您来决定是否刷新(在删除之前)和关闭/清除(在删除之后)持久性上下文,以避免由任何未刷新或过时的实体造成的问题。
通过内置的 deleteInBatch(Iterable 实体)删除
deleteInBatch(Iterable<T> entities)是另一种可以触发批量删除的方法。您可以通过经典的 Spring 知识库(AuthorRepository)从服务方法中轻松调用内置的deleteInBatch(Iterable<T> entities)方法,如下所示(删除所有小于 60 的作者及其书籍):
@Transactional
public void deleteAuthorsAndBooksViaDeleteInBatch() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authorRepository.deleteInBatch(authors);
}
这次,deleteInBatch(Iterable<T> entities)生成的 SQL 语句如下:
DELETE FROM author
WHERE id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
...
在DataSource-Proxy的上下文中添加该 SQL 会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:27, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["delete from author where id=? or id=? or id=? ...]
Params:[(1,12,23, ...)]
同样,不使用批处理和丢失更新防止机制,但是所有小于 60 的作者都已被删除。然而,book表中的相关记录并没有被删除。因此,deleteInBatch(Iterable<T> entities)没有利用orphanRemoval或级联。它只是通过Query的executeUpdate()触发一个批量删除,并且持久性上下文不与数据库同步。使用它删除所有书籍的唯一方法是显式调用它,如下所示:
@Transactional
public void deleteAuthorsAndBooksViaDeleteInBatch() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authorRepository.deleteInBatch(authors);
authors.forEach(a -> bookRepository.deleteInBatch(a.getBooks()));
}
这一次,每删除一个作者,就会多一个DELETE来删除关联的书籍。这是一个 N+1 问题。添加的 Ns 越多,效率就越低。最终,您可以通过将所有作者的书加入到一个列表中并将该列表传递给deleteInBatch(Iterable<T> entities))来解决这个 N+1 问题:
DELETE FROM book
WHERE id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
此外,请记住,如果生成的DELETE语句超过了可接受的最大大小,这种方法很容易导致问题。关于这一点的更多细节在第 52 项中。
通过内置的 deleteAll(Iterable extends T>实体)和 delete(T 实体)方法删除
你可以通过一个经典的 Spring 存储库(AuthorRepository)从一个服务方法中轻松调用内置的deleteAll(Iterable<? extends T> entities)方法,如下所示(删除所有小于 60 的作者和相关书籍):
@Transactional
public void deleteAuthorsAndBooksViaDeleteAll() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authorRepository.deleteAll(authors);
}
同样的事情可以通过delete(T entity)完成,如下所示:
@Transactional
public void deleteAuthorsAndBooksViaDeleteAll() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authors.forEach(authorRepository::delete);
}
这两种方法都产生相同的 SQL 语句(请注意通过查询中出现的version起作用的乐观锁定机制):
DELETE FROM book
WHERE id = ?
AND version = ?
-- since each author has written 5 books, there will be 4 more DELETEs here
DELETE FROM author
WHERE id = ?
AND version = ?
这些 SQL 语句对每个应该删除的作者重复。在DataSource-Proxy的上下文中添加这些 SQL 语句会显示以下输出(检查突出显示的部分并记住,对于每个被删除的作者,有两个批处理):
Name:DATA_SOURCE_PROXY, Connection:6, Time:270, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:5
Query:["delete from book where id=? and version=?"]
Params:[(1,0),(2,0),(3,0),(4,0),(5,0)]
Name:DATA_SOURCE_PROXY, Connection:6, Time:41, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:1
Query:["delete from author where id=? and version=?"]
Params:[(1,0)]
最后,使用批处理,但不是很优化。使用批处理是因为CascadeType.ALL,其中包含了CascadeType.REMOVE。为了确保每个Book从管理到移除的状态转换,每个Book都有一个DELETE语句。但是批处理已经将这些DELETE语句分组到一个批处理中。
尽管如此,问题还是表现在批次的数量上。没有对DELETE语句进行排序,这导致了比该任务所需更多的批处理。请记住,一个批处理只能针对一个表。将book和author表作为目标会产生下面的语句:删除 10 个作者,每个人有 5 本书,需要 10 x 2 个批处理。你需要 20 个批次,因为每个作者在他自己的批次中被删除,而他的五本书在另一个批次中被删除。以下方法将优化批次数量。
首先,代码:
@Transactional
public void deleteAuthorsAndBooksViaDelete() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authors.forEach(Author::removeBooks);
authorRepository.flush();
// or, authorRepository.deleteAll(authors);
authors.forEach(authorRepository::delete);
}
看看这些粗线。代码通过助手方法removeBooks()将所有的Book从它们对应的Author中分离出来,如下所示(该方法在Author中):
public void removeBooks() {
Iterator<Book> iterator = this.books.iterator();
while (iterator.hasNext()) {
Book book = iterator.next();
book.setAuthor(null);
iterator.remove();
}
}
此外,代码显式(手动)刷新持久性上下文。orphanRemoval=true该进场了。由于此设置,所有取消关联的书籍都将被删除。生成的DELETE语句是批处理的(如果orphanRemoval设置为false,将执行一堆更新而不是删除)。最后,代码通过deleteAll(Iterable<? extends T> entities)或delete(T entity)方法删除所有的Author。因为所有的Book都是分离的,所以Author删除也将利用批处理。
与之前的方法相比,这一次的批次数量要少得多。请记住,当删除 10 个作者和相关书籍时,需要 20 个批次。依靠这种方法只会产生三个批次。
首先执行删除所有相关书籍的批处理(因为每个作者有五本书,所以有 10 个作者 x 5 本书记录要删除):
Name:DATA_SOURCE_PROXY, Connection:6, Time:1071, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:30
Query:["delete from book where id=? and version=?"]
Params:[(1,0),(2,0),(3,0),(4,0),(5,0),(6,0),(7,0),(8,0), ... ,(30,0)]
Name:DATA_SOURCE_PROXY, Connection:6, Time:602, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:20
Query:["delete from book where id=? and version=?"]
Params:[(31,0),(32,0),(33,0),(34,0),(35,0),(36,0), ... ,(50,0)]
此外,执行删除 10 个作者的批处理:
Name:DATA_SOURCE_PROXY, Connection:6, Time:432, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:10
Query:["delete from author where id=? and version=?"]
Params:[(1,0),(2,0),(3,0),(4,0),(5,0),(6,0),(7,0),(8,0),(9,0),(10,0)]
GitHub 23 上有源代码。
依靠 SQL,依靠删除级联
ON DELETE CASCADE是使用 SQL 级联删除的 SQL 指令。
ON DELETE CASCADE是一个特定于数据库的操作,在删除父行时删除数据库中的子行。您可以通过 Hibernate 特有的@OnDelete注释添加这个指令,如下所示:
@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE},
mappedBy = "author", orphanRemoval = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private List<Book> books = new ArrayList<>();
请注意,层叠(CascadeType)效果减少为PERSIST和MERGE。此外,orphanRemoval被设置为false(或者,简单地删除它,因为false是默认的)。这意味着这种方法不涉及 JPA 实体状态传播或实体移除事件。这种方法依赖于数据库自动操作,因此,持久性上下文不会相应地同步。让我们看看通过每个内置的删除机制会发生什么。
@OnDelete的出现将如下改变author表:
ALTER TABLE book
ADD CONSTRAINT fkklnrv3weler2ftkweewlky958
FOREIGN KEY (author_id) REFERENCES author (id)
ON DELETE CASCADE
在 MySQL 的情况下,如果spring.jpa.properties.hibernate.dialect被设置为使用 InnoDB 引擎,则ON DELETE CASCADE被考虑如下:
spring.jpa.properties.hibernate.dialect= org.hibernate.dialect.MySQL5InnoDBDialect
或者,对于 MySQL 8:
org.hibernate.dialect.MySQL8Dialect
通过内置的 deleteAllInBatch()方法删除
您可以通过经典的 Spring 存储库(AuthorRepository)从服务方法中轻松调用内置的deleteAllInBatch()方法,如下所示:
public void deleteAuthorsAndBooksViaDeleteAllInBatch() {
authorRepository.deleteAllInBatch();
}
deleteAllInBatch()生成的 SQL 语句如下:
DELETE FROM author
在DataSource-Proxy的上下文中添加该 SQL 会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:21, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["delete from author"]
Params:[()]
不使用批处理,丢失更新不会被阻止,但是被触发的批量操作将触发数据库级联删除。因此,book表中的行也被删除。当从author和book表中删除所有行时,这是一种非常有效的方法。
通过内置的 deleteInBatch(Iterable 实体)删除
你可以通过一个经典的 Spring 存储库(AuthorRepository)从一个服务方法中轻松调用内置的deleteInBatch(Iterable<T> entities)方法,如下所示(删除所有小于 60 的作者和他们的书):
@Transactional
public void deleteAuthorsAndBooksViaDeleteInBatch() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authorRepository.deleteInBatch(authors);
}
这次,deleteInBatch(Iterable<T> entities)生成的 SQL 语句如下:
DELETE FROM author
WHERE id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
OR id = ?
...
在DataSource-Proxy的上下文中添加该 SQL 会显示以下输出:
Name:DATA_SOURCE_PROXY, Connection:6, Time:27, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["delete from author where id=? or id=? or id=? ...]
Params:[(1,12,23, ...)]
不使用批处理,不阻止丢失更新,但是触发的批量操作将触发数据库级联删除。来自book表的相关行也被删除。这是一种非常有效的方法。唯一需要注意的是避免超过查询最大接受大小的DELETE字符串语句。
通过内置的 deleteAll(Iterable extends T>实体)和 delete(T 实体)方法删除
你可以通过一个经典的 Spring 存储库(AuthorRepository)从一个服务方法中轻松调用内置的deleteAll(Iterable<? extends T> entities)方法,如下所示(删除所有小于 60 的作者和相关书籍):
@Transactional
public void deleteAuthorsAndBooksViaDeleteAll() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authorRepository.deleteAll(authors);
}
同样的事情可以通过delete(T entity)完成,如下所示:
@Transactional
public void deleteAuthorsAndBooksViaDelete() {
List<Author> authors = authorRepository.fetchAuthorsAndBooks(60);
authors.forEach(authorRepository::delete);
}
这两种方法产生相同的 SQL 语句:
DELETE FROM author
WHERE id = ?
AND version = ?
-- this DELETE is generated for each author that should be deleted
这些 SQL 语句对每个应该删除的作者重复。在DataSource-Proxy的上下文中添加这些 SQL 语句会显示以下输出(查看突出显示的部分):
Name:DATA_SOURCE_PROXY, Connection:6, Time:35, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:6
Query:["delete from author where id=? and version=?"]
Params:[(5,0),(6,0),(7,0),(8,0),(9,0),(10,0)]
使用批处理,并通过乐观锁定机制防止Author的更新丢失!此外,删除作者将触发数据库级联删除。来自book表的相关行也被删除。这一次,实体状态转换和数据库自动操作混合在一起。因此,持久性上下文是部分同步的。同样,这是非常高效的。
GitHub 24 上有源代码。
第 54 项:如何批量提取关联
第 39 项描述了如何通过JOIN FETCH获取同一个查询中与其父查询的关联(尤其是集合)。此外, Item 7 描述了 JPA 2.1 @NamedEntityGraph的强大功能,可用于避免 N+1 问题和解决惰性加载问题,而 Item 43 通过 SQL JOIN处理抓取关联。
Hibernate 允许您通过 Hibernate 特有的@BatchSize注释批量获取关联。然而,在考虑@BatchSize之前,建议评估前面提到的方法。掌握所有这些方法将有助于你做出明智的决定。
现在,让我们继续看@BatchSize,让我们来看一个通过例子学习的技巧。考虑双向懒惰@OneToMany关联中涉及的Author和Book实体。图 4-9 显示了一个数据快照,有助于跟踪和更好地理解查询的结果集。
图 4-9
数据快照
集合级别的@BatchSize
查看Author实体源代码:
@Entity
public class Author implements Serializable {
...
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
@BatchSize(size = 3)
private List<Book> books = new ArrayList<>();
...
}
与books相关联的集合被标注为@BatchSize(size = 3)。这意味着 Hibernate 应该一次为三个Author实体初始化books集合。在检查 SQL 语句之前,让我们考虑下面的服务方法,它在集合级利用了@BatchSize:
@Transactional(readOnly = true)
public void displayAuthorsAndBooks() {
List<Author> authors = authorRepository.findAll();
for (Author author : authors) {
System.out.println("Author: " + author.getName());
System.out.println("No of books: "
+ author.getBooks().size() + ", " + author.getBooks());
}
}
这个方法通过一个SELECT查询获取所有的Author实体。此外,调用第一个Author实体的getBooks()方法将触发另一个SELECT查询,该查询初始化前一个SELECT查询返回的前三个Author实体的集合。这是@BatchSize在集合级的效果。
因此,首先SELECT获取所有的Author:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
第一个Author调用getBooks()将触发以下SELECT:
SELECT
books0_.author_id AS author_i4_1_1_,
books0_.id AS id1_1_1_,
books0_.id AS id1_1_0_,
books0_.author_id AS author_i4_1_0_,
books0_.isbn AS isbn2_1_0_,
books0_.title AS title3_1_0_
FROM book books0_
WHERE books0_.author_id IN (?, ?, ?)
Hibernate 使用一个IN子句有效地引用三个实体作者的标识符(这是批处理的大小)。输出如下所示:
Author: Mark Janel
No of books: 1, [Book{id=4, title=The Beatles Anthology, isbn=001-MJ}]
Author: Olivia Goy
No of books: 2, [Book{id=5, title=Carrie, isbn=001-OG}, Book{id=6,
title=House Of Pain, isbn=002-OG}]
Author: Quartis Young
No of books: 0, []
到达第四个作者*,乔安娜·尼玛尔*将需要一个新的SELECT用于下一批Book s。这个SELECT的结果集如下:
Author: Joana Nimar
No of books: 3, [Book{id=1, title=A History of Ancient Prague, isbn=001-JN}, Book{id=2, title=A People's History, isbn=002-JN}, Book{id=3, title=History Day, isbn=003-JN}]
Author: Alicia Tom
No of books: 1, [Book{id=7, title=Anthology 2000, isbn=001-WT}]
Author: Katy Loin
No of books: 0, []
到达最后一个作者沃斯巨魔,将需要一个新的SELECT。没有足够的数据来填充另一批Book;因此,不需要IN条款:
SELECT
books0_.author_id AS author_i4_1_1_,
books0_.id AS id1_1_1_,
books0_.id AS id1_1_0_,
books0_.author_id AS author_i4_1_0_,
books0_.isbn AS isbn2_1_0_,
books0_.title AS title3_1_0_
FROM book books0_
WHERE books0_.author_id = ?
输出如下所示:
No of books: 0, []
确保不要误解@BatchSize在集合级的工作方式。不要认为集合级别的大小为 n 的@BatchSize将在集合中加载 n 个项目(例如,书籍)。它加载了 n 个集合。Hibernate 不能截断集合(当我们讨论JOIN FETCH的分页时,会在第 97 项中解决这个问题)。
类/实体级别的@BatchSize
查看Author实体源代码:
@Entity
@BatchSize(size = 3)
public class Author implements Serializable {
...
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();
...
}
用@BatchSize(size = 3)对Author实体进行了注释。这意味着当获取一本书时,Hibernate 应该初始化三个引用的authors。换句话说,如果我们遍历所有的书,并且在没有@BatchSize存在的情况下对每本书调用getAuthor(),那么 Hibernate 将执行四个SELECT语句来检索被代理的所有者(有七本书,但是其中一些书有相同的作者,因此某些SELECT语句将命中持久上下文而不是数据库)。在Author实体级存在@BatchSize的情况下执行相同的操作,将导致两个SELECT语句。
在检查一些 SQL 语句之前,让我们考虑下面的服务方法,它在实体级利用了@BatchSize:
@Transactional(readOnly = true)
public void displayBooksAndAuthors() {
List<Book> books = bookRepository.findAll();
for (Book book : books) {
System.out.println("Book: " + book.getTitle());
System.out.println("Author: " + book.getAuthor());
}
}
这个方法通过一个SELECT查询获取所有的Book实体。此外,调用第一个Book实体的getAuthor()方法将触发另一个SELECT查询,该查询初始化前一个SELECT查询返回的前三个Book实体的关联。这就是@BatchSize在实体层面的效果。
因此,第一个SELECT获取所有的Book:
SELECT
book0_.id AS id1_1_,
book0_.author_id AS author_i4_1_,
book0_.isbn AS isbn2_1_,
book0_.title AS title3_1_
FROM book book0_
此外,调用第一个Book的getAuthor(将触发下面的SELECT:
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id IN (?, ?, ?)
Hibernate 使用一个IN子句有效地引用三个实体作者的标识符(这是批处理的大小)。输出如下所示:
Book: A History of Ancient Prague
Author: Author{id=4, name=Joana Nimar, genre=History, age=34}
Book: A People's History
Author: Author{id=4, name=Joana Nimar, genre=History, age=34}
Book: History Day
Author: Author{id=4, name=Joana Nimar, genre=History, age=34}
Book: The Beatles Anthology
Author: Author{id=1, name=Mark Janel, genre=Anthology, age=23}
Book: Carrie
Author: Author{id=2, name=Olivia Goy, genre=Horror, age=43}
Book: House Of Pain
Author: Author{id=2, name=Olivia Goy, genre=Horror, age=43}
到达下一本书, 选集】2000 ,将需要一个新的SELECT用于下一批Author s。没有足够的数据来填充另一批Author,因此不需要IN子句:
SELECT
author0_.id AS id1_0_0_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = ?
输出如下所示:
Book: Anthology 2000
Author: Author{id=5, name=Alicia Tom, genre=Anthology, age=38}
一般来说,注意在大小为 n 的集合级别使用@BatchSize一次将初始化多达 n 个惰性集合。另一方面,在实体级使用大小为 n 的@BatchSize将一次初始化多达 n 个惰性实体代理。
显然,批量加载关联的实体比一个一个地加载要好(这样,可以避免潜在的 N+1 问题)。然而,在使用@BatchSize之前,准备好反对在您的特定情况下使用 SQL JOIN、JPA JOIN FETCH或实体图的论据。
完整的应用可在 GitHub 25 上获得。
第 55 项:为什么在通过 Hibernate 批处理插入时避免 PostgreSQL (BIG)SERIAL
在 PostgreSQL 中,使用GenerationType.IDENTITY将禁用 Hibernate 插入批处理。(BIG ) SERIAL的行为“几乎”像 MySQL 的AUTO_INCREMENT。换句话说,当使用插入批处理时,要避免以下情况:
@Entity
public class Author implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
}
PostgreSQL 的(BIG ) SERIAL是一个用于模拟标识列的语法糖表达式。在这个仿真的背后,PostgreSQL 使用了一个数据库序列。
这个问题的一个解决方案是依靠GenerationType.AUTO。在 PostgreSQL 中,GenerationType.AUTO设置会拾取SEQUENCE生成器;因此,批处理插入将按预期工作。以下代码工作正常:
@Entity
public class Author implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
...
}
优化标识符提取过程
这一次,批处理插入机制工作正常,但是对于每次插入,Hibernate 必须在一次单独的数据库往返中获取它的标识符。如果在一个批处理中有 30 个插入,则需要 30 次数据库往返来获取 30 个标识符,如下例所示:
select nextval ('hibernate_sequence')
select nextval ('hibernate_sequence')
-- 28 more
...
insert into author (age, genre, name, id) values (?, ?, ?, ?)
insert into author (age, genre, name, id) values (?, ?, ?, ?)
...
通常,当插入数量相当大时(例如,10,000 个插入),使用批处理插入。对于 10,000 次插入,有 10,000 次额外的数据库往返,这意味着性能损失。您可以通过 hi/lo 算法消除这种性能损失,该算法可以生成内存中的标识符( Item 66 )。更好的是伟大的 pooled 或 pooled-lo 算法(第 67 项)。您可以采用高/低算法,如下所示:
@Entity
public class Author implements Serializable {
@Id
@GeneratedValue(
strategy = GenerationType.SEQUENCE,
generator = "hilo"
)
@GenericGenerator(
name = "hilo",
strategy = "org.hibernate.id.enhanced.SequenceStyleGenerator",
parameters = {
@Parameter(name = "sequence_name", value = "hilo_sequence"),
@Parameter(name = "initial_value", value = "1"),
@Parameter(name = "increment_size", value = "1000"),
@Parameter(name = "optimizer", value = "hilo")
}
)
private Long id;
...
}
这一次,1,000 的增量意味着 hi/lo 可以生成 1,000 个内存中标识符。因此,对于 10,000 次插入,只需要 10 次数据库往返来获取标识符。显然,您可以通过调整increment_size来进一步优化这一点。
通过重写批处理插入优化批处理
Item 46 介绍了针对 MySQL 的reWriteBatchedInserts优化,并表示这种优化也可以用于 PostgreSQL。启用该属性后,SQL 语句将被重写到单个字符串缓冲区中,并在单个请求中发送到数据库。
在依赖 HikariCP 的 Spring Boot 应用中,您可以通过application.properties设置reWriteBatchedInserts:
spring.datasource.hikari.data-source-properties.reWriteBatchedInserts=true
该设置也可以通过编程实现:
PGSimpleDataSource ds = ...;
ds.setReWriteBatchedInserts(true);
完整的应用可在 GitHub 26 上获得。
Footnotes 1https://github.com/brettwooldridge/HikariCP#statement-cache
2
https://vladmihalcea.com/how-does-mvcc-multi-version-concurrency-control-work/
3
hibernate pringb booth batch insertsj 对等存储库
4
hibernate pringb ootsbatch inserts pringbatch style batch drowning
5
hibernate pringb booth 插入 entity manager viaj paccontext
6
hibernate pringb ootsbatch inserts pringbatch style batch drowning
7
hibernate pringb booth batch inserts pringstyle
8
hibernate pringb booth batch insert entity manager
9
hibernate pringb booth batch inserter deratchpertra action
10
hibernate pringb booth batch insert der
11
hibernate pringb ootsbatch insertsv iasssionperan sacrifice
12
hibernate pringb ootsbatch insertsv iassession
13
hibernate pringb bootbatchjsonfile forkjroin
14
font category = " non proportional ">https://spring . io/projects/spring-batch
15
块列表
16
hibernate pringb booth batch insertsc complex future
17
font category = " non proportional ">https://spring . io/projects/spring-batch
18
hibernate pringb booth batch updateor deringlesentity
19
hibernate pringb booth batch update der
20
hibernate spring ootbull updates
21
块列表
22
hibernate pringb bootbatchdeletesi ngine tity
23
hibernate pringb booth batch deleteor pharemove
24
hibernate pringb ootsbatchdeleteca 过期
25
hibernate pringb otloadbatch assment
26
hibernate pringb bootbatchingdse rial
**
五、集合
第 56 项:如何联接获取@ElementCollection 集合
特别是当定义一个单向的一对多关联到一个Basic类型(例如String)或者Embeddable类型时,JPA 有一个简单的解决方案,就是@ElementCollection。这些类型被映射到一个单独的表中,可以通过@CollectionTable进行定制。假设一个网上书店购物车通过ShoppingCart实体映射,可嵌入的Book通过@ElementCollection映射,如图 5-1 所示。
图 5-1
@ElementCollection 表关系
相关部分是@ElementCollection映射:
@Entity
public class ShoppingCart implements Serializable {
...
@ElementCollection(fetch = FetchType.LAZY) // lazy is default
@CollectionTable(name = "shopping_cart_books",
joinColumns = @JoinColumn(name = "shopping_cart_id"))
private List<Book> books = new ArrayList<>();
...
}
默认情况下,books是延迟加载的。有时,建模某些功能需求可能需要程序急切地获取books属性。显然,在实体级切换到FechType.EAGER是必须避免的代码气味。
解决方案来自于JOIN FETCH,它可以像用于关联一样用于@ElementCollection。换句话说,下面两个 JPQL 查询使用JOIN FETCH在获取ShoppingCart的同一个SELECT中获取books:
@Repository
public interface ShoppingCartRepository
extends JpaRepository<ShoppingCart, Long> {
@Query(value = "SELECT p FROM ShoppingCart p JOIN FETCH p.books")
ShoppingCart fetchShoppingCart();
@Query(value = "SELECT p FROM ShoppingCart p
JOIN FETCH p.books b WHERE b.price > ?1")
ShoppingCart fetchShoppingCartByPrice(int price);
}
调用fetchShoppingCart()将触发下面的 SQL:
SELECT
shoppingca0_.id AS id1_1_,
shoppingca0_.owner AS owner2_1_,
books1_.shopping_cart_id AS shopping1_0_0__,
books1_.genre AS genre2_0_0__,
books1_.isbn AS isbn3_0_0__,
books1_.price AS price4_0_0__,
books1_.title AS title5_0_0__
FROM shopping_cart shoppingca0_
INNER JOIN shopping_cart_books books1_
ON shoppingca0_.id = books1_.shopping_cart_id
调用fetchShoppingCartByPrice()将触发下面的 SQL:
SELECT
shoppingca0_.id AS id1_1_,
shoppingca0_.owner AS owner2_1_,
books1_.shopping_cart_id AS shopping1_0_0__,
books1_.genre AS genre2_0_0__,
books1_.isbn AS isbn3_0_0__,
books1_.price AS price4_0_0__,
books1_.title AS title5_0_0__
FROM shopping_cart shoppingca0_
INNER JOIN shopping_cart_books books1_
ON shoppingca0_.id = books1_.shopping_cart_id
WHERE books1_.price > ?
GitHub 1 上有源代码。
第 57 项:如何 DTO 一个@ElementCollection
此项假设网上书店购物车通过ShoppingCart实体映射,可嵌入的Book通过@ElementCollection映射,如图 5-2 所示。
图 5-2
@ElementCollection 表关系
相关部分是@ElementCollection映射:
@Entity
public class ShoppingCart implements Serializable {
...
@ElementCollection(fetch = FetchType.LAZY) // lazy is default
@CollectionTable(name = "shopping_cart_books",
joinColumns = @JoinColumn(name = "shopping_cart_id"))
private List<Book> books = new ArrayList<>();
...
}
此外,目标是获取一个只读数据的结果集,其中包含来自shopping_cart的owner,以及来自shopping_cart_books的title和price(收集表)。因为它是只读数据,一个JOIN和 DTO 将完成这项工作。由于JOIN和弹簧投影对于@ElementCollection工作良好,解决方案依赖于以下投影:
public interface ShoppingCartDto {
public String getOwner();
public String getTitle();
public int getPrice();
}
该投影可以在存储库中进一步使用,如下所示:
@Repository
public interface ShoppingCartRepository
extends JpaRepository<ShoppingCart, Long> {
@Query(value = "SELECT a.owner AS owner, b.title AS title,
b.price AS price FROM ShoppingCart a JOIN a.books b")
List<ShoppingCartDto> fetchShoppingCart();
@Query(value = "SELECT a.owner AS owner, b.title AS title,
b.price AS price FROM ShoppingCart a JOIN a.books b
WHERE b.price > ?1")
List<ShoppingCartDto> fetchShoppingCartByPrice(int price);
}
调用fetchShoppingCart()将触发下面的 SQL(注意只选择了owner、title和price):
SELECT
shoppingca0_.owner AS col_0_0_,
books1_.title AS col_1_0_,
books1_.price AS col_2_0_
FROM shopping_cart shoppingca0_
INNER JOIN shopping_cart_books books1_
ON shoppingca0_.id = books1_.shopping_cart_id
调用fetchShoppingCartByPrice()将触发下面的 SQL:
SELECT
shoppingca0_.owner AS col_0_0_,
books1_.title AS col_1_0_,
books1_.price AS col_2_0_
FROM shopping_cart shoppingca0_
INNER JOIN shopping_cart_books books1_
ON shoppingca0_.id = books1_.shopping_cart_id
WHERE books1_.price > ?
注意@ElementCollection不是实体关联类型,即使你可能这么认为。主要是,你会在下一项看到,@ElementCollection充当单向@OneToMany ( 项 2 )。因此,它遭受同样的性能损失。最佳实践建议您使用@ElementCollection来表示基本类型(例如,整数或字符串)或可嵌入类型,而不是实体类。
GitHub 2 上有源代码。
第 58 项:为什么以及何时将@OrderColumn 与@ElementCollection 一起使用
这个项目假设一个在线书店购物车是通过ShoppingCart实体映射的,可嵌入的Book是通过@ElementCollection映射的,如下面的代码所示:
@Entity
public class ShoppingCart implements Serializable {
...
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String owner;
@ElementCollection
@CollectionTable(name = "shopping_cart_books",
joinColumns = @JoinColumn(name = "shopping_cart_id"))
@Column(name="title")
private List<String> books = new ArrayList<>();
// getters and setters omitted for brevity
}
该实体通过两个表进行映射(shopping_cart和shopping_cart_books)。图 5-3 表示数据的快照(基本上,有一个购物车,里面有三本书)。
图 5-3
数据快照(@ElementCollection)
该实体的存储库包含一个通过所有者名称获取ShoppingCart的查询:
@Repository
@Transactional(readOnly = true)
public interface ShoppingCartRepository
extends JpaRepository<ShoppingCart, Long> {
ShoppingCart findByOwner(String owner);
}
此外,应用运行几个查询(三个INSERT和三个DELETE)来:
-
将一本书添加到当前购物车的开头
-
将一本书添加到当前购物车的末尾
-
将一本书添加到当前购物车的中间
-
从购物车中取出第一本书
-
从购物车中取出最后一本书
-
从推车中取出中间的书
以下每个场景都从图 5-3 中的数据快照开始。
为了将一本新书添加到当前购物车中(INSERT a book),Hibernate 需要删除shopping_cart_books中的所有内容,然后重新插入值,包括新书。例如,下面的方法将在books的开头添加一本新书:
@Transactional
public void addToTheBeginning() {
ShoppingCart cart = shoppingCartRepository.findByOwner("Mark Juno");
cart.getBooks().add(0, "Modern history");
}
调用此方法将产生以下 SQL 语句套件。第一,把所有的书都删掉;第二,它们被重新插入,包括新书:
DELETE FROM shopping_cart_books
WHERE shopping_cart_id = ?
Binding: [1]
INSERT INTO shopping_cart_books (shopping_cart_id, title)
VALUES (?, ?)
Binding: [1, Modern history]
INSERT INTO shopping_cart_books (shopping_cart_id, title)
VALUES (?, ?)
Binding: [1, A History of Ancient Prague]
INSERT INTO shopping_cart_books (shopping_cart_id, title)
VALUES (?, ?)
Binding: [1, Carrie]
INSERT INTO shopping_cart_books (shopping_cart_id, title)
VALUES (?, ?)
Binding: [1, The Beatles Anthology]
每个INSERT必须从@CollectionTable中删除Entity的所有记录,然后重新插入。
类似地,以下在末尾和中间插入一本书的尝试将产生一串 SQL 语句,如前所述:
@Transactional
public void addToTheEnd() {
ShoppingCart cart = shoppingCartRepository.findByOwner("Mark Juno");
cart.getBooks().add("The last day");
}
@Transactional
public void addInTheMiddle() {
ShoppingCart cart = shoppingCartRepository.findByOwner("Mark Juno");
cart.getBooks().add(cart.getBooks().size() / 2, "Middle man");
}
从books中删除一本书也没有效率。与INSERT的情况一样,每次删除都需要删除shopping_cart_books中的所有内容,然后重新插入所有值。例如,以下方法将删除第一本书:
@Transactional
public void removeFirst() {
ShoppingCart cart = shoppingCartRepository.findByOwner("Mark Juno");
cart.getBooks().remove(0);
}
调用此方法将产生以下 SQL 语句套件。第一,把所有的书都删掉;二是全部重新插入,删除的书除外:
DELETE FROM shopping_cart_books
WHERE shopping_cart_id = ?
Binding: [1]
INSERT INTO shopping_cart_books (shopping_cart_id, title)
VALUES (?, ?)
Binding: [1, Carrie]
INSERT INTO shopping_cart_books (shopping_cart_id, title)
VALUES (?, ?)
Binding: [1, The Beatles Anthology]
每个DELETE必须从@CollectionTable中删除Entity的所有记录,然后重新插入。
类似地,以下从末尾和中间删除一本书的尝试将产生一串 SQL 语句,如您之前所见:
@Transactional
public void removeLast() {
ShoppingCart cart = shoppingCartRepository.findByOwner("Mark Juno");
cart.getBooks().remove(cart.getBooks().size() - 1);
}
@Transactional
public void removeMiddle() {
ShoppingCart cart = shoppingCartRepository.findByOwner("Mark Juno");
cart.getBooks().remove(cart.getBooks().size() / 2);
}
需要频繁更新的集合会导致明显的性能损失。最好依靠显式的一对多关联。另一方面,需要很少(或不需要)更新的集合是@ElementCollection的一个很好的候选,因为它不代表外键方。
GitHub 3 上有源代码。
通过@OrderColumn 优化@ElementCollection
一个@OrderColumn可以用来在任何集合映射上定义一个顺序List。将@OrderColumn添加到@ElementCollection是在某些INSERT和DELETE中反映的优化。相关代码修改如下:
@Entity
public class ShoppingCart implements Serializable {
...
@ElementCollection
@OrderColumn(name = "index_no")
@CollectionTable(name = "shopping_cart_books",
joinColumns = @JoinColumn(name = "shopping_cart_id"))
@Column(name="title")
private List<String> books = new ArrayList<>();
...
}
@OrderColumn的存在反映在shopping_cart_books表中的一个新列(index_no)中,如图 5-4 所示。
图 5-4
数据快照(@ElementCollection 和@OrderColumn)
因此,为了惟一地标识每一行,@OrderColumn在目标表中被映射为一个新列。现在,让我们看看@OrderColumn如何优化@ElementCollection。以下每个场景都从图 5-4 所示的数据快照开始。
将一本书添加到当前购物车的开头
将一本书(现代史)添加到当前购物车的开头将触发以下 SQL 语句(在每个 SQL 语句下是一个绑定参数列表):
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [Modern History, 1, 0]
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [A History of Ancient Prague, 1, 1]
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [Carrie, 1, 2]
INSERT INTO shopping_cart_books (shopping_cart_id, index_no, title)
VALUES (?, ?, ?)
Binding: [1, 3, The Beatles Anthology]
将一本新书添加到books(在索引 0 处)的开头会将现有书籍向下推一个位置。这发生在内存中,并通过一组UPDATE语句刷新到数据库中。每个现有的行都有一个相应的UPDATE语句。最后,在这些更新完成后,最后一本书通过一个INSERT语句被重新插入。图 5-5 为插近代史书前后的shopping_cart_books台(左侧)。
图 5-5
在开头插入(@ElementCollection 和@OrderColumn)
没有@OrderColumn,应用触发了五条 SQL 语句(一条DELETE和四条INSERT)。通过@OrderColumn,应用触发了四条 SQL 语句(三条UPDATE和一条INSERT)。
将一本书添加到当前购物车的末尾
将一本书(最后一天)添加到当前购物车的末尾将触发以下 SQL 语句:
INSERT INTO shopping_cart_books (shopping_cart_id, index_no, title)
VALUES (?, ?, ?)
Binding: [1, 3, The Last Day]
添加到集合的末尾不会影响它的顺序;因此,单个INSERT就可以完成这项工作。这比没有@OrderColumn的情况好多了。
没有@OrderColumn,应用触发了五条 SQL 语句(一条DELETE和四条INSERT)。通过@OrderColumn,应用触发了一个INSERT语句。
将一本书添加到当前购物车的中间
将一本书(中间人)添加到当前购物车的中间将触发以下 SQL 语句:
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [Middle Man, 1, 1]
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [Carrie, 1, 2]
INSERT INTO shopping_cart_books (shopping_cart_id, index_no, title)
VALUES (?, ?, ?)
Binding: [1, 3, The Beatles Anthology]
将一本新书添加到books的中间会将位于集合中间和结尾之间的所有现有书籍向下推一个位置。这发生在内存中,并通过一组UPDATE语句刷新到数据库中。每一行都有一个相应的UPDATE语句。最后,最后一本书通过一个INSERT语句被重新插入。图 5-6 为插入中间人书之前(左侧)和之后(右侧)的shopping_cart_books工作台。
图 5-6
在中间插入(@ElementCollection 和@OrderColumn)
没有@OrderColumn,应用触发了五条 SQL 语句(一条DELETE和四条INSERT)。通过@OrderColumn,应用触发了三个 SQL 语句(两个UPDATE和一个INSERT)。
从当前购物车中移除第一本书
从当前购物车中删除第一本书( A History of 古布拉格)将触发以下 SQL 语句:
DELETE FROM shopping_cart_books
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [1, 2]
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [The Beatles Anthology, 1, 1]
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [Carrie, 1, 0]
从books(索引 0 处)移除第一本书会将所有现有的书上移一个位置。这发生在内存中,并通过一组在通过DELETE语句删除最后一行后触发的UPDATE语句刷新到数据库中。图 5-7 为删除古布拉格历史书前后的shopping_cart_books表(左侧)。
图 5-7
移除第一本书(@ElementCollection 和@OrderColumn)
没有@OrderColumn,应用触发了三个 SQL 语句(一个DELETE和两个INSERT)。使用@OrderColumn,应用也触发了三条 SQL 语句(一条DELETE和两条UPDATE)。
从当前购物车中移除最后一本书
从当前购物车中删除最后一本书( The Beatles 选集)将触发以下 SQL 语句:
DELETE FROM shopping_cart_books
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [1, 2]
从收藏的末尾删除一本书不会影响它的顺序;因此,单个DELETE就可以完成这项工作。这比没有@OrderColumn的情况好多了。
没有@OrderColumn,应用触发了三个 SQL 语句(一个DELETE和两个INSERT)。通过@OrderColumn,应用触发了一个DELETE语句。
从当前购物车中取出一本书
从当前购物车中间移除一本书( Carrie )将触发以下 SQL 语句:
DELETE FROM shopping_cart_books
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [1, 2]
UPDATE shopping_cart_books
SET title = ?
WHERE shopping_cart_id = ?
AND index_no = ?
Binding: [The Beatles Anthology, 1, 1]
从books的中间移除一本书会将位于集合的中间和末端之间的现有书向上推一个位置。这发生在内存中,并通过一个DELETE和一组UPDATE语句刷新到数据库中。首先,删除最后一行。第二,位于表的末端和中间的每一行都被更新。图 5-8 为移除载体书之前(左侧)和之后(右侧)的shopping_cart_books工作台。
图 5-8
从中间移除(@ElementCollection 和@OrderColumn)
没有@OrderColumn,应用触发了三个 SQL 语句(一个DELETE和两个INSERT)。使用@OrderColumn,应用也触发了两条 SQL 语句(一条DELETE和一条UPDATE)。
最后的结论是,当操作发生在集合末尾附近时,@OrderColumn可以减轻一些性能损失(例如,在集合末尾添加/删除)。位于添加/移除条目之前的所有元素基本上保持不变,因此如果应用影响靠近集合尾部的行,性能损失可以忽略。
根据经验,当数据变化很少,并且添加新实体的目的只是映射外键侧时,元素集合是一个合适的选择。否则,一对多关联是更好的选择。
注意,单向@OneToMany和@ManyToMany以及双向@ManyToMany与@ElementCollection属于同一把伞。
GitHub 4 上有源代码。
第 59 项:如何合并实体集合
此项说明了合并实体集合的一种好方法。
首先,假设Author和Book参与了一个双向懒惰的@OneToMany关联。领域模型如图 5-9 所示。
图 5-9
双向@一对一关系
在代码中,Author类如下所示:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String genre;
private int age;
@OneToMany(cascade = CascadeType.ALL,
mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();
public void addBook(Book book) {
this.books.add(book);
book.setAuthor(this);
}
public void removeBook(Book book) {
book.setAuthor(null);
this.books.remove(book);
}
// getters and setters omitted for brevity
}
并且Book实体看起来如下:
@Entity
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String isbn;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
// getters and setters omitted for brevity
@Override
public boolean equals(Object obj) {
if(obj == null) {
return false;
}
if (this == obj) {
return true;
}
if (getClass() != obj.getClass()) {
return false;
}
return id != null && id.equals(((Book) obj).id);
}
@Override
public int hashCode() {
return 2021;
}
}
数据库已经填充了图 5-10 中的实体。
图 5-10
数据快照(合并前)
现在,让我们获取与给定的Author记录相关联的Book个实体中的List(例如, Joana Nimar )。通过一个JOIN可以很容易地获取Author和一个关联的Book,如下所示:
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
@Query(value = "SELECT b FROM Book b JOIN b.author a WHERE a.name = ?1")
List<Book> booksOfAuthor(String name);
}
调用booksOfAuthor("Joana Nimar")将触发以下SELECT:
SELECT
book0_.id AS id1_1_,
book0_.author_id AS author_i4_1_,
book0_.isbn AS isbn2_1_,
book0_.title AS title3_1_
FROM book book0_
INNER JOIN author author1_
ON book0_.author_id = author1_.id
WHERE author1_.name = ?
这个SELECT返回的List<Book>包含三本书。
此时,List<Book>处于脱离状态;因此,让我们将它存储在一个名为detachedBooks的变量中:
Book{id=1, title=A History of Ancient Prague, isbn=001-JN}
Book{id=2, title=A People's History, isbn=002-JN}
Book{id=4, title=Carrie, isbn=007-JN}
接下来,让我们对该集合执行以下修改(因为该集合处于分离状态,所以修改不会自动传播到数据库):
-
将第一本书的书名从古布拉格史更新为古罗马史:
-
取出第二本书:
detachedBooks.get(0).setTitle("A History of Ancient Rome");
- 添加新书(100 分钟内的历史):
detachedBooks.remove(1);
Book book = new Book();
book.setTitle("History In 100 Minutes");
book.setIsbn("005-JN");
detachedBooks.add(book);
显示修改后的detachedBooks集合会显示以下内容(查看最后一本新书,它有一个null id):
Book{id=1, title=A History of Ancient Rome, isbn=001-JN}
Book{id=4, title=Carrie, isbn=007-JN}
Book{id=null, title=History In 100 Minutes, isbn=005-JN}
合并分离的集合
这一项的最后一步是使用尽可能少的数据库往返次数来合并分离的集合。首先,开发者必须获取Author和关联的Book。这可以通过JOIN FETCH轻松完成:
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query(value="SELECT a FROM Author a JOIN FETCH a.books
WHERE a.name = ?1")
Author authorAndBooks(String name);
}
调用authorAndBooks()触发下面的SELECT(从数据库中取出作者和相关书籍):
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_,
books1_.author_id AS author_i4_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.title AS title3_1_1_,
books1_.author_id AS author_i4_1_0__,
books1_.id AS id1_1_0__
FROM author author0_
INNER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.name = ?
考虑返回的Author存储在名为author的变量中。
接下来,让我们将detachedBooks设置为author!首先,让我们快速消除不好的方法。
混合管理的和分离的实体是一种导致错误的糟糕组合。所以,试图以author.setBooks(detachedBooks)的身份去做某件事,根本行不通。另一方面,分离作者,设置detachedBooks,然后合并作者也可以,但是会导致合并过程产生额外的SELECT查询。这个额外的SELECT可以通过使用手动合并来避免。
手动合并需要三个步骤:
-
移除传入集合中不再存在的现有数据库行(
detachedBooks)。首先,过滤掉不在detachedBooks的author的书。第二,每一本在detachedBooks找不到的author书都要撤下如下: -
更新在传入集合中找到的现有数据库行(
detachedBooks)。首先过滤新书(newBooks)。这些书在detachedBooks有,但在author书里没有。第二步,过滤detachedBooks,得到在detachedBooks而不在newBooks的书籍。这些是应该更新的书籍,如下所示:
List<Book> booksToRemove = author.getBooks().stream()
.filter(b -> !detachedBooks.contains(b))
.collect(Collectors.toList());
booksToRemove.forEach(b -> author.removeBook(b));
- 最后,添加在传入集合中找到的、在当前结果集中找不到的行(
newBooks):
List<Book> newBooks = detachedBooks.stream()
.filter(b -> !author.getBooks().contains(b))
.collect(Collectors.toList());
detachedBooks.stream()
.filter(b -> !newBooks.contains(b))
.forEach((b) -> {
b.setAuthor(author);
Book mergedBook = bookRepository.save(b);
author.getBooks().set(
author.getBooks().indexOf(mergedBook), mergedBook);
});
newBooks.forEach(b -> author.addBook(b));
将这三个步骤粘合在一个服务方法中会产生以下结果:
@Transactional
public void updateBooksOfAuthor(String name, List<Book> detachedBooks) {
Author author = authorRepository.authorAndBooks(name);
// Remove the existing database rows that are no
// longer found in the incoming collection (detachedBooks)
List<Book> booksToRemove = author.getBooks().stream()
.filter(b -> !detachedBooks.contains(b))
.collect(Collectors.toList());
booksToRemove .forEach(b -> author.removeBook(b));
// Update the existing database rows which can be found
// in the incoming collection (detachedBooks)
List<Book> newBooks = detachedBooks.stream()
.filter(b -> !author.getBooks().contains(b))
.collect(Collectors.toList());
detachedBooks.stream()
.filter(b -> !newBooks.contains(b))
.forEach((b) -> {
b.setAuthor(author);
Book mergedBook = bookRepository.save(b);
author.getBooks().set(
author.getBooks().indexOf(mergedBook), mergedBook);
});
// Add the rows found in the incoming collection,
// which cannot be found in the current database snapshot
newBooks.forEach(b -> author.addBook(b));
}
测试时间
调用updateBooksOfAuthor()可以如下进行:
updateBooksOfAuthor("Joana Nimar", detachedBooks);
除了获取作者和相关书籍的SELECT之外,触发的 SQL 语句有:
INSERT INTO book (author_id, isbn, title)
VALUES (?, ?, ?)
Binding: [2, 005-JN, History In 100 Minutes]
UPDATE book
SET author_id = ?,
isbn = ?,
title = ?
WHERE id = ?
Binding: [2, 001-JN, A History of Ancient Rome, 1]
DELETE FROM book
WHERE id = ?
Binding: [2]
图 5-11 显示了数据的当前快照。
图 5-11
数据快照(合并后)
搞定了。完整的代码可以在 GitHub 5 上找到。
你可能会认为这个案例是 ?? 的一个角落案例。获取子实体集合并独立于关联的父实体使用它们不是一项日常任务。更常见的是获取父实体和相关联的子实体集合,修改处于分离状态的集合,并合并父实体。在这种情况下,将使用CascadeType.ALL,产生的 SQL 语句与您预期的完全一样。
hibernate pringb ootscolec ionjoinfetch
2
hibernate pringb otdtelement col 选择
3
hibernate pringb ooteelement colec ionnoordercum n
4
hibernate pringb ootscolec ionwithordercoll umn
5
hibernate pringb otmerge collections ons