Spring Boot 持久化最佳实践(六)
十二、模式
第 92 项:如何在 Spring Boot 设立飞行通道
对于生产来说,不要依赖hibernate.ddl-auto(或对应方)将模式 DDL 导出到数据库。只需移除(禁用)hibernate.ddl-auto或将其设置为validate,并依靠 Flyway 或 Liquibase。本文介绍了将 Flyway 设置为 Spring Boot 数据库迁移工具的几个方面。
本节包含 MySQL 和 PostgreSQL 的应用。
在这种情况下,重要的是要知道术语数据库、模式、和目录在 MySQL 中表示相同的东西,而在 PostgreSQL 中,一个数据库与一个目录相同,并且可以有多个模式(两个同名的表可以位于同一个数据库中,但位于不同的模式)。
最快的 Flyway 设置(MySQL 和 PostgreSQL)
通过向项目添加一个 Flyway 依赖项,可以实现具有默认设置的最快设置。对于 Maven,将pom.xml添加到以下依赖项中:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
Spring Boot 有一种国旗叫做spring.flyway.enabled。该标志位默认设置为true;因此,当 Spring Boot 获得关于 Flyway 存在的确认时,它将依赖 Flyway 的默认设置来迁移数据库。
默认情况下,Spring Boot 在classpath:db/migration路径中查找 SQL 文件(可通过spring.flyway.locations配置)。文件名称应遵循飞行路线命名惯例(如V1.1__ Description .sql)。此外,开发人员可以在该位置添加 Flyway 回调对应的 SQL 文件(如afterMigrate.sql、beforeClean.sql等)。).将相应地考虑这些文件。
GitHub 1 上有一个使用 Flyway 和 MySQL 的启动应用。
MySQL 数据库是通过 JDBC URL 的特定参数createDatabaseIfNotExist=true创建的。Flyway 将连接到 JDBC URL 中的这个数据库,并对其运行 SQL 文件。
而且在 GitHub 2 上也有使用 Flyway 和 PostgreSQL 的启动应用。
这个应用依赖于默认的postgres数据库和一个public模式。SQL 文件根据该模式执行。
如果CREATE TABLE中使用的表名与实体名不同(例如对于Author实体,表名应该是author),您必须使用@Table(name=" table name ")来指示 JPA 相应的表。例如,对于名为author_history的表,实体名称应该是AuthorHistory,或者在实体级别将@Table指定为@Table(name="author_history")。
指示 Flyway 创建数据库
这一次,您将指示 Flyway 代表您创建 MySQL 数据库。
Flyway 不是为创建数据库而设计的(例如,执行CREATE DATABASE语句)。它被设计为连接到一个现有的(空的或非空的)数据库,并且一旦连接建立,就对这个数据库执行所有给定的脚本。然而,Flyway 可以通过CREATE SCHEMA创建模式。
主要是,可以通过spring.flyway.schemas设置指示 Flyway 更新模式。如果有多个模式,它们的名称应该用逗号分隔。如果模式不存在,Flyway 将自动创建它们。
关系型数据库
在 MySQL 中, schema 相当于数据库;因此,Flyway 可以创建一个 MySQL 数据库。完成这项任务需要三个步骤:
-
从 JDBC URL 中删除数据库名称:
-
通过
spring.flyway.schemas指示 Flyway 更新(并且,因为它不存在,所以创建)数据库:
spring.datasource.url=jdbc:mysql://localhost:3306/
- 通知实体数据库名称,如下例所示:
spring.flyway.schemas=bookstoredb
@Entity
@Table(schema = "bookstoredb") // or @Table(catalog = "bookstoredb")
public class Author implements Serializable {
...
}
搞定了。现在,Flyway 将代表您创建bookstoredb数据库。
完整的应用可在 GitHub 3 上获得。
一种数据库系统
与 MySQL 相比,PostgreSQL 的情况有所不同,因为一个 PostgreSQL 数据库可以有多个模式。这一次,创建模式不会导致创建数据库。它会导致创建一个模式。
在 PostgreSQL 中,连接总是指向某个数据库。切换到另一个数据库需要新的连接。Flyway 连接到一个现有的数据库,CREATE SCHEMA(通过spring.flyway.schemas触发)将在这个数据库中创建一个模式。
实现此行为的步骤如下:
-
在 JDBC URL 中指定要连接的数据库(例如,方便的默认
postgres数据库或您自己的数据库) -
通过
spring.flyway.schemas指示 Flyway 更新(并且,因为它不存在,所以创建)数据库:
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
- 通知实体数据库名称,如下例所示:
spring.flyway.schemas=bookstore
@Entity
@Table(schema = "bookstore")
public class Author implements Serializable {
...
}
搞定了。现在 Flyway 将代表您创建bookstore模式。完整的应用可在 GitHub 4 上获得。
结果对比如图 12-1 所示。
图 12-1
MySQL 和 PostgreSQL 中的模式
通过@FlywayDataSource 设置 Flyway
Flyway 可以通过前缀为spring.flyway.*的 Spring Boot 属性进行配置,并放在application.properties文件中。另一种方法是使用@FlywayDataSource注释和 Flyway-fluent API。
在这种情况下,DataSource很可能也是以编程方式配置的;因此,我们来考虑下面的 MySQL DataSource(更多详情在第 86 项):
@Bean(name = "dataSource")
public HikariDataSource dataSource() {
HikariDataSource hds = new HikariDataSource();
hds.setJdbcUrl("jdbc:mysql://localhost:3306/bookstoredb
?createDatabaseIfNotExist=true");
hds.setUsername("root");
hds.setPassword("root");
hds.setConnectionTimeout(50000);
hds.setIdleTimeout(300000);
hds.setMaxLifetime(900000);
hds.setMaximumPoolSize(8);
hds.setMinimumIdle(8);
hds.setPoolName("MyPool");
hds.setConnectionTestQuery("select 1 from dual");
hds.setAutoCommit(false);
return hds;
}
接下来,必须将这个DataSource传递给 Flyway。为此,您定义一个方法,该方法接收DataSource作为参数,用@FlywayDataSource对其进行注释,并如下实现它:
@FlywayDataSource
@Bean(initMethod = "migrate")
public Flyway flyway(@Qualifier("dataSource")
HikariDataSource dataSource) {
return Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration") // this path is default
.load();
}
Flyway 将连接到在dataSource的 JDBC URL 中找到的数据库,并对其运行来自classpath:db/migration的 SQL 文件。
请随意探索 fluent API,看看可以定制哪些设置。
在 GitHub 上可以找到 MySQL 5 和 PostgreSQL 6 的完整应用。
飞行路线和多模式
可以设置 Flyway 来迁移同一供应商或不同供应商的多个模式。对于这样的例子,请考虑:
-
在 MySQL 7 中自动创建和迁移两个数据库
-
在 PostgreSQL 8 中自动创建并迁移两个数据库
-
自动创建和迁移两个
DataSource(MySQL 和 PostgreSQL) 9
第 93 项:如何通过 schema-*生成两个数据库。sql 并将实体与它们匹配
对于生产来说,不要依赖hibernate.ddl-auto(或对应方)将模式 DDL 导出到数据库。只需移除(禁用)hibernate.ddl-auto或将其设置为validate并依靠schema-*.sql,或者,对于生产,依靠 Flyway ( Item 92 )或 Liquibase。
依靠schema-*.sql有助于避免从 JPA 注释生成模式。但是,它没有版本,所以不支持模式迁移。在schema-*.sql中,开发人员可以调用应用每次运行时将执行的 SQL 语句。
例如,下面的schema-mysql.sql包含特定于 MySQL 的 DDL 语句,用于创建两个数据库和两个表(authorsdb数据库中的author表和booksdb数据库中的book表):
CREATE DATABASE IF NOT EXISTS authorsdb;
CREATE TABLE IF NOT EXISTS authorsdb.author
(
id BIGINT(20) NOT NULL auto_increment,
age INT(11) NOT NULL,
genre VARCHAR(255) DEFAULT NULL,
name VARCHAR(255) DEFAULT NULL,
PRIMARY KEY (id)
);
CREATE DATABASE IF NOT EXISTS booksdb;
CREATE TABLE IF NOT EXISTS booksdb.book
(
id BIGINT(20) NOT NULL auto_increment,
isbn VARCHAR(255) DEFAULT NULL,
title VARCHAR(255) DEFAULT NULL,
PRIMARY KEY (id)
);
要指示 Spring Boot 执行来自schema-mysql.sql的 DDL 语句,只需将application.properties添加到以下设置中:
spring.datasource.initialization-mode=always
spring.datasource.platform=mysql
spring.datasource.initialization-mode的可能值有always、embedded和never。虽然always和never非常清楚,但是embedded值(这是默认值)指示 Spring Boot 只有在依赖嵌入式数据库(例如 H2)时才初始化模式。
此外,在application.properties中,在没有显式数据库的情况下设置 JDBC URL:
spring.datasource.url=jdbc:mysql://localhost:3306
此外,Author实体应该被显式地映射到authorsdb.author表,而Book实体应该被映射到booksdb.book表。为此,用@Table(schema="authorsdb")标注Author实体,用@Table(schema="booksdb")标注Book实体:
@Entity
@Table(schema="authorsdb")
public class Author implements Serializable {
...
}
@Entity
@Table(schema="booksdb")
public class Book implements Serializable {
...
}
仅此而已!现在,您可以像往常一样使用AuthorRepository和BookRepository。来自AuthorRepository的查询方法将针对authorsdb触发,而来自BookRepository的查询方法将针对booksdb触发。
GitHub 10 上有完整的应用。
如果您想通过 Hibernate 导入 SQL 脚本文件,那么您必须使用hibernate.hbm2ddl.import_files属性。简单地传递要加载的文件作为这个属性的值。或者,您可以通过 JPA 2.1 模式生成特性来实现。要加载脚本,只需使用javax.persistence.sql-load-script-source属性。GitHub 11 上有完整的例子。
hibernate pringb ooflywaymysqlqu ick
2
hibernate pringb 欧塔佛莱迪·SQL quick
3
hibernate pringb otflywaymysqlda tabase
4
hibernate pringb 欧塔佛莱迪耶 SqlSchema
5
hibernate pringb 欧塔 FlywayMySQLPr og
6
hibernate pringb otflywayposter SQL rog
7
hibernate pringb otflywaymysqltw o tabases
8
hibernate pringb 欧塔佛莱迪耶 SqlTwoSchemas
9
hibernate pringb otflywaytwo vendors
10
hibernate pringb oomatchentities totable totable as
11
hibernate pringb oostschema generation ion
十三、分页
项目 94:偏移分页何时以及为什么会成为性能损失
偏移分页非常流行,Spring Boot(更准确地说是 Spring Data Commons)通过Page和SliceAPI 提供了对它的支持。但是,随着项目的发展和数据的积累,依赖偏移分页可能会导致性能下降,即使在项目开始时这并不是一个问题。
处理偏移量分页意味着您可以忽略在达到期望的偏移量之前丢弃 n 条记录所带来的性能损失。更大的 n 会导致显著的性能损失。另一个代价是计算记录总数所需的额外的SELECT(特别是如果您需要计算每个获取的页面)。虽然键集(seek)分页可能是一种可行的方法(作为一种不同的方法),但可以优化偏移分页以避免这种额外的SELECT,如项目 95 和项目 96 中所述。因此,如果您熟悉这个主题,并且您所需要的只是一个优化的偏移分页,您可以简单地跳转到项目 95 和项目 96 。好,现在我们继续...
对于相对较小的数据集,offset 和 keyset 提供了几乎相同的性能。但是,您能保证数据集不会随时间增长吗?或者您能控制增长过程吗?大多数企业从少量数据开始,但当成功加速时,数据量也会快速增加。
偏移量和键集索引扫描
索引扫描输入偏移量将遍历索引范围,从开头到指定的偏移量。基本上,偏移量表示在将记录包含在结果中之前必须跳过的记录数,这意味着还必须进行计数。在 offset 中,根据必须获取和跳过的数据量(请记住,表通常会快速“增长”),这种方法可能会导致性能显著下降。偏移方法将遍历已经显示的记录。见图 13-1 。
图 13-1
偏移量中的索引扫描与键集分页
另一方面,键集中的索引扫描将只遍历所需的值,从上一个值开始(它跳过这些值,直到上一个值被取出)。在键集中,随着表记录的增加,性能几乎保持不变。
偏移分页的利弊
考虑偏移量分页的以下优点和缺点。
缺点:
-
插入可能会造成页面偏差
-
每次,它都从头开始对行进行编号
-
它应用一个过滤器来删除不必要的行
-
如果偏移量大于排序结果中的行数,则不返回任何行
优点:
-
它可以获取任意页面
反对使用偏移分页的一个重要参考和令人信服的论据是使用索引,LUKE! 1 网站。我强烈建议您花点时间看看 Markus Winand 的这个精彩演示 2 ,它涵盖了调优分页 SQL 的重要主题,比如在 offset 和 keyset 分页中使用索引和行值(PostgreSQL 支持)。
在开始分页实现之前,建议至少考虑以下两个方面:
-
有时不需要为每个页面触发一个
SELECT COUNT(插入/删除很少发生)。在这种情况下,最好定期触发SELECT COUNT或者只触发一次。 -
最好使用强大的过滤功能,而不是返回大量的页面。想想你最后一次浏览一个网站的几个页面是什么时候,如果你不记得了,这意味着你的客户的行为是一样的。他们更喜欢润色他们的过滤器,而不是浏览大量的页面。因此,提高你的过滤器,直到返回的结果适合几个页面。
Spring Boot 胶印分页
如果获胜者是偏移分页,那么 Spring Boot 通过Page API 提供了对偏移分页的内置支持。考虑图 13-2 中Author实体对应的author表。
图 13-2
作者实体表
下面的例子依赖于Author实体和AuthorRepository存储库来形成一种实现分页的简单方法。首先,至少有五种方法可以获取结果集,如下所示:
如果您需要在接受参数Pageable的查询方法中取消分页,那么只需传递参数Pageable.unpaged()。
- 在没有显式排序的情况下调用内置的
findAll(Pageable)(不建议使用**):**
*** 调用带有排序的内置findAll(Pageable):
authorRepository.findAll(PageRequest.of(page, size));
- 使用 Spring 数据查询构建器机制在存储库中定义新方法:
authorRepository.findAll(PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "price")));
- 使用 JPQL 和
@Query,有和没有显式SELECT COUNT:
Page<Author> findByName(String name, Pageable pageable);
Page<Author> queryFirst10ByName(String name, Pageable pageable);
- 使用本地查询和
@Query,有和没有显式SELECT COUNT:
@Query(value = "SELECT a FROM Author a WHERE a.genre = ?1",
countQuery = "SELECT COUNT(*) FROM Author a WHERE a.genre = ?1")
public Page<Author> fetchByGenreExplicitCount(
String genre, Pageable pageable);
@Query("SELECT a FROM Author a WHERE a.genre = ?1")
public Page<Author> fetchByGenre(String genre, Pageable pageable);
@Query(value = "SELECT * FROM author WHERE genre = ?1",
countQuery = "SELECT COUNT(*) FROM author WHERE genre = ?1",
nativeQuery = true)
public Page<Author> fetchByGenreNativeExplicitCount(
String genre, Pageable pageable);
@Query(value = "SELECT * FROM author WHERE genre = ?1",
nativeQuery = true)
public Page<Author> fetchByGenreNative(String genre, Pageable pageable);
此外,支持Author分页所需的传统存储库将扩展PagingAndSortingRepository,如下所示:
@Repository
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
}
接下来,服务方法可以按年龄升序获取页面Author,如下所示:
public Page<Author> fetchNextPage(int page, int size) {
return authorRepository.findAll(PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age")));
}
接下来,从控制器调用它,如下所示:
@GetMapping("/authors/{page}/{size}")
public Page<Author> fetchAuthors(@PathVariable int page,
@PathVariable int size) {
return bookstoreService.fetchNextPage(page, size);
}
下面是一个可能的请求及其输出(获取包含五个作者和一个带有细节的pageable元素的第一个页面):
http://localhost:8080/authors/1/5
{
"content":[
{
"id":22,
"age":24,
"name":"Kemal Ilias",
"genre":"History"
},
{
"id":28,
"age":24,
"name":"Sandra Ostapenco",
"genre":"History"
},
{
"id":16,
"age":24,
"name":"Joana Leonte",
"genre":"History"
},
{
"id":46,
"age":24,
"name":"Alp Ran",
"genre":"History"
},
{
"id":12,
"age":26,
"name":"Katre Mun",
"genre":"Anthology"
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageNumber":1,
"pageSize":5,
"offset":5,
"paged":true,
"unpaged":false
},
"totalPages":11,
"totalElements":51,
"last":false,
"numberOfElements":5,
"first":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":1,
"size":5,
"empty":false
}
获取这个结果需要两条 SQL 语句(第二条SELECT对记录进行计数,并在每次获取页面时触发):
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
ORDER BY author0_.age ASC
LIMIT 5, 5
SELECT
Count(author0_.id) AS col_0_0_
FROM author author0_
有时不需要为每个页面触发一个SELECT COUNT,因为新的插入或删除非常罕见;因此,行数在很长时间内保持固定。在这种情况下,只在获取第一页时触发一次SELECT COUNT,并依靠Slice或List而不是Page进行分页。或者您可以定期触发SELECT COUNT(例如,每 15 分钟、每 10 页等)。).
在分页的情况下,确定性排序顺序是强制性的。因此,不要忘记ORDER BY子句。
请注意,如果您将对象添加到控制器中,Spring 可以派生出Pageable对象。请求的参数遵循以下约定:
-
page请求参数指示要检索的页面(缺省值为 0) -
size请求参数指示要检索的页面的大小(缺省值是 20) -
sort请求参数将排序属性表示为property, property (,ASC|DESC)(默认为升序)
下面是一个控制器端点示例:
@GetMapping("/authors")
public Page<Author> fetchAuthors(Pageable pageable) {
return bookstoreService.fetchNextPagePageable(pageable);
}
以下是第 1 页的请求,大小为 3,按名称降序排列:
http://localhost:8080/authors?page=1&size=3&sort=name,desc
或者按姓名降序和流派升序排序:
http://localhost:8080/authors?page=1&size=3&sort=name,desc&sort=genre,asc
GitHub 3 上有源代码。
在决定哪种分页类型最合适之前,请考虑阅读这一整章。最有可能的是,本文中介绍的方法最容易导致性能损失,所以只将其作为下一步工作的里程碑。接下来的两个项目——项目 95 和项目 96——讨论偏移分页的优化。
更准确地说,通过COUNT(*) OVER()窗口函数和SELECT COUNT子查询,尽量避免额外的SELECT COUNT。
图 13-3 中显示的时间-性能趋势图强调了COUNT(*) OVER()往往比使用两个SELECT语句或一个SELECT COUNT子查询执行得更好。另一方面,一个SELECT COUNT子查询似乎并没有比触发两个SELECT语句带来更大的好处。这是因为应用和数据库运行在同一台机器上。如果您通过网络访问数据库,那么触发两个SELECT语句会增加两次网络开销,而SELECT COUNT子查询只会增加一次网络开销。在图 13-3 中,我们假设author表有 100 万条记录,我们尝试获取 100 个实体的页面。更准确地说,我们获取第一页(0)、第 5000 页和第 9999 页。
图 13-3
使用偏移分页选择实体
图 13-3 中显示的时间性能趋势图是在具有以下特征的 Windows 7 机器上针对 MySQL 获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。
在 MySQL 中,为了对记录进行计数,还需要使用SQL_CALC_FOUND_ROWS查询修饰符和附带的FOUND_ROWS()函数。这种方法在本书中被跳过,因为它在 MySQL 8.0.17 中被标记为不推荐使用,并将在未来的 MySQL 版本中被删除。然而,一篇关于SQL_CALC_FOUND_ROWS性能的有趣论文可以在这里找到 4 。
第 95 项:如何优化 COUNT(*) OVER 和 Page 的偏移分页
第 94 项强调了偏移分页的两个潜在性能损失:遍历已经显示的记录和每页两个单独的SELECT语句(两次数据库往返)。一个SELECT获取数据,另一个计算记录总数。此外,让我们尝试只在一次查询(一次数据库往返)中获取通过这两个SELECT获得的信息。这样,消除了第二次往返所增加的性能损失。作为一个权衡,获取数据的SELECT也需要一些时间来计数。
COUNT(*) OVER()窗口化聚合
COUNT(*) OVER()是COUNT()集合函数和OVER()子句的组合,它将窗口函数与其他函数区分开来。OVER指定聚合函数的窗口子句。
当 RDBMS 支持窗口函数(例如 MySQL 8)时,可以使用COUNT(*) OVER()窗口聚合来消除获取记录总数所需的第二次数据库往返。如果你的 RDBMS 不支持窗口函数,考虑使用SELECT COUNT子查询的项目 96 。
您可以通过本地查询编写一个COUNT(*) OVER()查询。COUNT(*) OVER()是获取数据的查询的一部分。它的目的是统计记录的总数。此外,每个提取的数据页可以是实体页(但仅当有计划修改它们时)或 DTO 页(对于只读数据)。让我们来看看如何去取一只Page< dto >。
页数为页<dto
一个毫无创意的获取Page< dto >的方法如下:
public Page<AuthorDto> findAll(Pageable pageable) {
Page<Author> page = authorRepository.findAll(pageable);
return new PageImpl<AuthorDto>(AuthorConverter/Mapper.convert/map(
page.getContent()), pageable, page.getTotalElements());
}
一些开发人员声称前面的例子是正确的,这是基于各种或多或少正确的原因。然而,在您做出决定之前,请考虑阅读弗拉德·米哈尔恰的推文 5 ,它反对这种反模式。Vlad 说:“不要提取实体,只使用映射器来创建 dto。这是非常低效的,但我一直看到这种反模式得到推广。”
虽然该方法返回一个Page<AuthorDto>,但是当调用authorRepository.findAll()时,它仍然将数据提取到持久性上下文中。因为内置的findAll()用@Transactional(readOnly = true)注释,所以持久化上下文不会保存水合状态。因此,实体将以只读模式加载。
最好避免以将数据转换为 dto 为唯一目的,将数据作为只读实体获取。在这种情况下,DTO 包含实体的所有属性(它是实体的镜像)。大多数时候,我们提取属性的子集(web 应用中的常见场景),这意味着我们只从实体中提取所需的属性到 dto,而丢弃其余的。获取超过需要的数据是一种不好的做法。因此,在这两种情况下,提取实体的唯一目的是使用 dto 映射器,这会导致性能下降。
一个Page< dto >需要一个 DTO;因此,您定义了一个弹簧投影(DTO ),其中包含与应该提取的数据相对应的 getters。在这种情况下,是Author实体的age和name:
public interface AuthorDto {
public String getName();
public int getAge();
@JsonIgnore
public long getTotal();
}
检查突出显示的两行代码。需要使用getTotal()来映射COUNT(*) OVER()的结果。这不是Author实体的财产。而且,它用@JsonIgnore进行了注释,因为它不会在发送给客户端的 JSON 响应中被序列化。它将在PageImpl的构造函数中被用来创建一个Page<AuthorDto>。但是,在此之前,这里列出了获取数据的 JPQL 查询和单次数据库往返中的记录总数(也可以使用WHERE子句):
@Repository
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT name, age, COUNT(*) OVER() AS total FROM author", nativeQuery = true)
List<AuthorDto> fetchAll(Pageable pageable);
}
注意,该查询没有显式设置排序和限制结果集所需的ORDER BY和LIMIT子句。然而,使用传递的Pageable,它包含页面、大小和排序信息,可以很好地完成这项工作。这个Pageable将根据给定的大小、页面和排序信息,向生成的 SQL 语句添加缺失的ORDER BY和LIMIT子句。用两个整数替换Pageable对象并将ORDER BY age LIMIT ?1, ?2添加到查询中不会有问题。
LIMIT子句由MySQL和PostgreSQL识别。SQL Server 支持SELECT TOP子句,Oracle 使用ROWNUM或ROWS FETCH NEXT n ROWS ONLY。
调用fetchAll()将触发以下 SQL 语句:
SELECT
name,
age,
COUNT(*) OVER() AS total
FROM author
ORDER BY age ASC
LIMIT ? ?
通过getTotal()存储COUNT(*) OVER()结果。由于fetchAll()返回一个List<AuthorDto>,它必须被转换成一个Page<AuthorDto>。服务方法创建一个Pageable并调用fetchAll()。fetchAll()的结果用于通过下面的PageImpl构造函数创建一个Page<AuthorDto>:
public PageImpl(List<T> content, Pageable pageable, long total)
服务方法非常简单:
public Page<AuthorDto> fetchNextPage(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age"));
List<AuthorDto> authors = authorRepository.fetchAll(pageable);
Page<AuthorDto> pageOfAuthors = new PageImpl(authors, pageable,
authors.isEmpty() ? 0 : authors.get(0).getTotal());
return pageOfAuthors;
}
REST 控制器端点可以如下调用fetchNextPage()方法:
@GetMapping("/authors/{page}/{size}")
public Page<AuthorDto> fetchAuthors(
@PathVariable int page, @PathVariable int size) {
return bookstoreService.fetchNextPage(page, size);
}
下面是一些可能的 JSON 输出(注意,记录总数是 51):
http://localhost:8080/authors/1/3
{
"content":[
{
"age":23,
"name":"Wuth Troll"
},
{
"age":23,
"name":"Nagir Turok"
},
{
"age":24,
"name":"Alp Ran"
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":3,
"pageNumber":1,
"offset":3,
"paged":true,
"unpaged":false
},
"totalPages":17,
"totalElements":51,
"last":false,
"numberOfElements":3,
"first":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":1,
"size":3,
"empty":false
}
GitHub 6 上有源代码。
您可以很容易地只获取数据(没有pageable元素)作为List<AuthorDto>,如这个应用 7 。
页面为页面<实体 >
虽然Page< dto >非常适合只读数据的分页,但是Page< entity >更适合将被修改的实体。
提取实体不会映射COUNT(*) OVER()结果。实体定义了它的属性集(id、age、name和genre,但是它没有一个特殊的属性来表示数据库中记录的总数。要解决这个问题,至少有一种方法可以进一步讨论。
使用专用属性
映射由COUNT(*) OVER()返回的记录总数的一种方法是在相应的实体中添加一个专用属性。该属性可以映射到不可插入的或不可更新的列,如下例所示(total属性没有设置器):
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int age;
private String name;
private String genre;
@Column(insertable = false, updatable = false)
long total;
...
public long getTotal() {
return total;
}
}
此外,在AuthorRepository中,您可以依赖包含COUNT(*) OVER()的原生 SQL,如下所示:
@Repository
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT id, name, age, genre, COUNT(*) OVER() AS total
FROM author", nativeQuery = true)
List<Author> fetchAll(Pageable pageable);
}
调用fetchAll()将触发下面的SELECT语句(注意,有一个单独的查询用于获取一页数据作为List<Author>):
SELECT
id,
name,
age,
genre,
COUNT(*) OVER() AS total
FROM author
ORDER BY age ASC
LIMIT ?, ?
调用fetchAll()的服务方法负责准备Page<Author>,如下所示:
public Page<Author> fetchNextPage(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age"));
List<Author> authors = authorRepository.fetchAll(pageable);
Page<Author> pageOfAuthors = new PageImpl(authors, pageable,
authors.isEmpty() ? 0 : authors.get(0).getTotal());
return pageOfAuthors;
}
源代码可在 GitHub 8 上获得。应用公开了一个类型为http://localhost:8080/authors/{page}/{size}的 REST 端点。返回的结果是一个 JSON,如下例所示(在author表中有 51 条记录,这由total字段公开):
http://localhost:8080/authors/1/3
{
"content":[
{
"id":7,
"age":23,
"name":"Wuth Troll",
"genre":"Anthology"
},
{
"id":48,
"age":23,
"name":"Nagir Turok",
"genre":"Anthology"
},
{
"id":46,
"age":24,
"name":"Alp Ran",
"genre":"History"
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":3,
"pageNumber":1,
"offset":3,
"paged":true,
"unpaged":false
},
"totalPages":17,
"totalElements":51,
"last":false,
"numberOfElements":3,
"first":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":1,
"size":3,
"empty":false
}
您可以很容易地只获取数据(没有pageable元素)作为List<Author>,如这个应用 9 中所示。
第 96 项:如何使用 SELECT COUNT 子查询和 Page 优化偏移量分页
第 94 项强调了偏移分页的两个潜在性能损失:遍历已经显示的记录和每页两个单独的SELECT语句(两次数据库往返)。一个SELECT获取数据,另一个对记录进行计数。让我们试着只在一个查询中触发这两个SELECT(一次数据库往返)。这样就消除了第二次往返的性能损失。作为一个权衡,获取数据的SELECT也会消耗一些时间来计数。
选择计数子查询
如果您的 RDBMS 不支持窗口函数(例如,版本 8 之前的 MySQL ),使用SELECT COUNT子查询来消除获取记录总数所需的第二次数据库往返。如果你的 RDBMS 支持窗口函数,考虑第 95 条中的方法。
您可以通过本地查询或 JPQL 编写一个SELECT COUNT子查询。作为一个子查询,这个SELECT COUNT嵌套在获取数据的SELECT中,其目的是计算记录总数。此外,每个提取的数据页可以是一个实体页(但只有在计划修改它们时)或一个 dto 页(对于只读数据)。让我们来看看如何获取一个Page< dto >。
页数为页<dto
获取Page< dto >的一个没有创意的方法如下:
public Page<AuthorDto> findAll(Pageable pageable) {
Page<Author> page = authorRepository.findAll(pageable);
return new PageImpl<AuthorDto>(AuthorConverter/Mapper.convert/map(
page.getContent()), pageable, page.getTotalElements());
}
虽然该方法返回一个Page<AuthorDto>,但是当调用authorRepository.findAll()时,它仍然将数据提取到持久性上下文中。这将是一种更好的方法,可以避免以只读实体的形式获取数据,然后将其转换为 DTO。此外,在这种情况下,DTO 包含实体的所有属性(它是实体的镜像)。提取属性的一个子集会强加被丢弃的数据,这些数据是毫无目的地获取的。
一个Page< dto >需要一个 DTO;因此,您需要定义一个 Spring projection (DTO ),其中包含与应该获取的数据相对应的 getters。在这种情况下,是Author实体的age和name:
public interface AuthorDto {
public String getName();
public int getAge();
@JsonIgnore
public long getTotal();
}
检查突出显示的两行代码。需要使用getTotal()来映射SELECT COUNT子查询的结果。这不是Author实体的财产。而且,它用@JsonIgnore进行了注释,因为它不会在发送给客户端的 JSON 响应中被序列化。它将在PageImpl的构造函数中被用来创建一个Page<AuthorDto>。但是,在此之前,在一次数据库往返中获取数据和记录总数的 JPQL 查询如下所示:
@Repository
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT a.name as name, a.age as age, "
+ "(SELECT count(a) FROM Author a) AS total FROM Author a")
List<AuthorDto> fetchAllJpql(Pageable pageable);
}
请注意,fetchAllJpql()接受了一个类型为Pageable的参数(一个包装了关于一页数据的元数据的对象,比如页面大小、总元素数、页码、排序等。).JPQL 没有提供限制查询的机制;因此,明确添加LIMIT(或其对应方)是不可能的。这通常通过在Query上使用setMaxResults()方法来实现。然而,使用Pageable可以很好地完成这项工作。传递的Pageable将把ORDER BY和LIMIT子句添加到生成的 SQL 语句中。
调用fetchAllJpql()将触发以下 SQL 语句:
SELECT
author0_.name AS col_0_0_,
author0_.age AS col_1_0_,
(SELECT COUNT(author1_.id)
FROM author author1_)
AS col_2_0_
FROM author author0_
ORDER BY author0_.age ASC
LIMIT ? ?
通过以下本机查询可以获得相同的效果:
@ repository public interface author repository 扩展 PagingAndSortingRepository{ @ Query(value = " SELECTt . total,姓名,作者年龄,“+ "(SELECT count(*) AS total FROM author) AS t" , nativeQuery = true) List<AuthorDto> fetchAllNative(Pageable pageable);}
SELECT COUNT子查询结果通过getTotal()存储。由于fetchAllJqpl()返回一个List<AuthorDto>,它必须被转换成一个Page<AuthorDto>。一个服务方法创建Pageable并调用fetchAllJpql()。fetchAllJpql()的结果用于通过下面的PageImpl构造函数创建一个Page<AuthorDto>:
public PageImpl(List<T> content, Pageable pageable, long total)
服务方法非常简单:
public Page<AuthorDto> fetchNextPageJpql(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age"));
List<AuthorDto> authors = authorRepository.fetchAllJpql(pageable);
Page<AuthorDto> pageOfAuthors = new PageImpl(authors, pageable,
authors.isEmpty() ? 0 : authors.get(0).getTotal());
return pageOfAuthors;
}
REST 控制器端点可以调用fetchNextPageJpql()方法,如下所示:
@GetMapping("/authors/{page}/{size}")
public Page<AuthorDto> fetchAuthorsJpql(
@PathVariable int page, @PathVariable int size) {
return bookstoreService.fetchNextPageJpql(page, size);
}
可能的输出是下面的 JSON(注意,记录总数是 51):
http://localhost:8080/authors/1/3
{
"content":[
{
"age":23,
"name":"Tylor Ruth"
},
{
"age":23,
"name":"Wuth Troll"
},
{
"age":24,
"name":"Kemal Ilias"
}
],
"pageable":{
"sort":{
"unsorted":false,
"sorted":true,
"empty":false
},
"pageSize":3,
"pageNumber":1,
"offset":3,
"paged":true,
"unpaged":false
},
"totalPages":17,
"totalElements":51,
"last":false,
"numberOfElements":3,
"first":false,
"sort":{
"unsorted":false,
"sorted":true,
"empty":false
},
"number":1,
"size":3,
"empty":false
}
GitHub 10 上有源代码。
您可以很容易地只获取数据(没有pageable元素)作为List<AuthorDto>,如这个应用 11 。
页面为页面<实体 >
虽然Page< dto >非常适合对只读数据进行分页,但是Page< entity >更适合将要被修改的实体。
提取实体不会映射SELECT COUNT子查询结果。实体定义了一组属性,但是它没有表示数据库中记录总数的特殊属性。要解决这个问题,至少有一种方法,下面讨论。
使用额外的属性
映射由SELECT COUNT子查询返回的记录总数的一种方法是向相应的实体添加一个额外的属性。该属性可以映射到不可插入或可更新的列,如下例所示(total属性没有设置器):**
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int age;
private String name;
private String genre;
@Column(insertable = false, updatable = false)
long total;
...
public long getTotal() {
return total;
}
}
此外,在AuthorRepository中,您可以依赖包含SELECT COUNT子查询的原生 SQL,如下所示:
@Repository
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT t.total, id, name, age, genre FROM author, "
+ "(SELECT count(*) AS total FROM author) AS t",
nativeQuery = true)
List<Author> fetchAll(Pageable pageable);
}
调用fetchAll()将触发下面的SELECT语句(注意,有一个单独的查询用于获取一页数据作为List<Author>):
SELECT
t.total,
id,
name,
age,
genre
FROM author,
(SELECT COUNT(*) AS total
FROM author) AS t
ORDER BY age ASC
LIMIT ?, ?
调用fetchAll()的服务方法负责准备Page<Author>,如下所示:
public Page<Author> fetchNextPage(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age"));
List<Author> authors = authorRepository.fetchAll(pageable);
Page<Author> pageOfAuthors = new PageImpl(authors, pageable,
authors.isEmpty() ? 0 : authors.get(0).getTotal());
return pageOfAuthors;
}
源代码可在 GitHub 12 上获得。应用公开了一个类型为http://localhost:8080/authors/{page*}/{size}*的 REST 端点。返回的结果是一个 JSON,如下例所示(在author表中有 51 条记录,这由total字段公开):
http://localhost:8080/authors/1/3
{
"content":[
{
"id":25,
"age":23,
"name":"Tylor Ruth",
"genre":"Anthology"
},
{
"id":7,
"age":23,
"name":"Wuth Troll",
"genre":"Anthology"
},
{
"id":22,
"age":24,
"name":"Kemal Ilias",
"genre":"History"
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":3,
"pageNumber":1,
"offset":3,
"paged":true,
"unpaged":false
},
"totalPages":17,
"totalElements":51,
"last":false,
"numberOfElements":3,
"first":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":1,
"size":3,
"empty":false
}
您可以很容易地只获取数据(没有pageable元素)作为List<Author>,如这个应用 13 。
项目 97:如何使用联接提取和可分页
考虑双向懒惰@OneToMany关联中涉及的两个实体(Author和Book)。您可以通过(LEFT ) JOIN FETCH ( 第 39 项和第 41 项)在同一个查询中高效地获取相关书籍的作者。您可以通过组合(LEFT ) JOIN FETCH和Pageable对结果集应用分页。但是尝试实现这种组合会导致异常。例如,考虑以下查询:
@Transactional(readOnly = true)
@Query(value = "SELECT a FROM Author a
JOIN FETCH a.books WHERE a.genre = ?1")
Page<Author> fetchWithBooksByGenre (String genre, Pageable pageable);
调用fetchWithBooksByGenre()会导致以下异常:
org.hibernate.QueryException: query specified join fetching,but the owner of the fetched association was not present in the select list [FromElement {explicit, not a collection join, fetch join, fetch non-lazy properties, classAlias = null, role = com.bookstore.entity.Author.books, tableName = book,tableAlias = books1_, origin = author author0_, columns = {author0_.id, className = com.bookstore.entity.Book}}]
这个异常的主要原因是来自 Spring 数据的缺失计数查询。
如果您实际上不需要一个Page(例如,您不关心记录的总数等等),那么只需用Slice或List替换Page。这将消除这一例外。
您可以通过countQuery元素添加缺少的SELECT COUNT,如下所示:
@Transactional
@Query(value = "SELECT a FROM Author a
LEFT JOIN FETCH a.books WHERE a.genre = ?1",
countQuery = "SELECT COUNT(a) FROM Author a WHERE a.genre = ?1")
Page<Author> fetchWithBooksByGenre(String genre, Pageable pageable);
或者您可以通过一个特别的实体图来添加它,如下所示(关于实体图的更多细节可以在项目 7 、项目 8 和项目 9 中找到):
@Transactional
@EntityGraph(attributePaths = {"books"},
type = EntityGraph.EntityGraphType.FETCH)
@Query(value = "SELECT a FROM Author a WHERE a.genre = ?1")
Page<Author> fetchWithBooksByGenre(String genre, Pageable pageable);
这一次,调用这些fetchWithBooksByGenre()方法之一会产生以下 SQL 语句:
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_,
books1_.author_id AS author_i4_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.title AS title3_1_1_,
books1_.author_id AS author_i4_1_0__,
books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.genre = ?
ORDER BY author0_.name ASC
SELECT
COUNT(author0_.id) AS col_0_0_
FROM author author0_
WHERE author0_.genre = ?
注意,分页发生在内存中(这些 SQL 语句中没有数据库分页)。此外,这将作为类型HHH000104:的消息发出
firstResult/maxResults specified with collection fetch; applying in memory!.
依赖于内存中的分页会导致性能下降,尤其是当提取的集合很大时。因此,使用这段代码时要格外小心。为了理解和固定HHH000104,请考虑第 98 项。
另一方面,让我们获取所有相关作者的书籍。例如:
@Transactional(readOnly = true)
@Query(value = "SELECT b FROM Book b
LEFT JOIN FETCH b.author WHERE b.isbn LIKE ?1%",
countQuery = "SELECT COUNT(b) FROM Book b WHERE b.isbn LIKE ?1%")
Page<Book> fetchWithAuthorsByIsbn(String isbn, Pageable pageable);
@Transactional(readOnly = true)
@EntityGraph(attributePaths = {"author"},
type = EntityGraph.EntityGraphType.FETCH)
@Query(value = "SELECT b FROM Book b WHERE b.isbn LIKE ?1%")
Page<Book> fetchWithAuthorsByIsbn(String isbn, Pageable pageable);
这两个查询都会触发以下查询:
SELECT
book0_.id AS id1_1_0_,
author1_.id AS id1_0_1_,
book0_.author_id AS author_i4_1_0_,
book0_.isbn AS isbn2_1_0_,
book0_.title AS title3_1_0_,
author1_.age AS age2_0_1_,
author1_.genre AS genre3_0_1_,
author1_.name AS name4_0_1_
FROM book book0_
LEFT OUTER JOIN author author1_
ON book0_.author_id = author1_.id
WHERE book0_.isbn LIKE ?
ORDER BY book0_.title ASC LIMIT ?
SELECT
COUNT(book0_.id) AS col_0_0_
FROM book book0_
WHERE book0_.isbn LIKE ?
这次分页是由数据库完成的,这比在内存中好得多。
完整的应用可在 GitHub 14 上获得。
第 98 项:如何修复 HHH000104
在第 97 项中,您看到HHH000104是一个警告,告诉您内存中正在对结果集进行分页。例如,考虑双向惰性@OneToMany关联中的Author和Book实体以及以下查询:
@Transactional
@Query(value = "SELECT a FROM Author a
LEFT JOIN FETCH a.books WHERE a.genre = ?1",
countQuery = "SELECT COUNT(a) FROM Author a WHERE a.genre = ?1")
Page<Author> fetchWithBooksByGenre(String genre, Pageable pageable);
调用fetchWithBooksByGenre()工作正常,除了发出以下警告:HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!显然,从性能角度来看,在内存中分页并不好。
一般来说,不可能用 Hibernate ORM/JPA 注释来限制获取的集合的大小。例如,不可能限制@OneToMany集合的大小。Hibernate 不能操作集合的子集,因为它必须管理整个集合实体的状态转换。
在这种情况下,Hibernate 不能简单地使用 SQL 级别的分页来截断结果集,因为它容易截断一些Book行。这可能导致一个Author只有一个Book的子集。这就是为什么分页是在内存中完成的,Hibernate 可以控制整个结果集。不幸的是,特别是对于大的结果集,这会导致严重的性能损失。
由于HHH000104是作为警告报告的,所以在日志中很有可能会遗漏。从 Hibernate 5.2.13 开始,如果hibernate.query.fail_on_pagination_over_collection_fetch属性被启用,HHH000104被报告为异常。在 Spring Boot,该属性可以在application.properties中启用,如下所示:
spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true
这一次,丢失HHH000104是不可能的,因此确保您总是在您的项目中启用该属性。
进一步,让我们看看如何修复HHH000104并在数据库上执行分页。
提取受管实体
您可以将结果集作为一个Page、Slice或List来获取。
正在获取页面
首先,让我们关注Page并获取给定流派作者的 id:
@Transactional(readOnly = true)
@Query(value = "SELECT a.id FROM Author a WHERE a.genre = ?1")
Page<Long> fetchPageOfIdsByGenre(String genre, Pageable pageable);
进一步,让我们获取这些作者的书籍(获取的 id):
@Transactional(readOnly = true)
@QueryHints(value = @QueryHint(name = HINT_PASS_DISTINCT_THROUGH,
value = "false"))
@Query(value = "SELECT DISTINCT a FROM Author a
LEFT JOIN FETCH a.books WHERE a.id IN ?1")
List<Author> fetchWithBooks(List<Long> authorIds);
或者,您可以依赖实体图:
@Transactional(readOnly = true)
@EntityGraph(attributePaths = {"books"},
type = EntityGraph.EntityGraphType.FETCH)
@QueryHints(value = @QueryHint(name = HINT_PASS_DISTINCT_THROUGH,
value = "false"))
@Query(value = "SELECT DISTINCT a FROM Author a WHERE a.id IN ?1")
List<Author> fetchWithBooksEntityGraph(List<Long> authorIds);
服务方法可以如下调用这两个查询(在调用fetchWithBooks()之前,建议您确保pageOfIds.getContent()不为空):
@Transactional
public Page<Author> fetchAuthorsWithBooksByGenre(int page, int size) {
Pageable pageable = PageRequest.of(
page, size, Sort.by(Sort.Direction.ASC, "name"));
Page<Long> pageOfIds = authorRepository
.fetchPageOfIdsByGenre("Anthology", pageable);
List<Author> listOfAuthors = authorRepository
.fetchWithBooks(pageOfIds.getContent());
Page<Author> pageOfAuthors = new PageImpl(
listOfAuthors, pageable, pageOfIds.getTotalElements());
return pageOfAuthors;
}
同样,你可以调用fetchWithBooksEntityGraph()。
注意,service-method 用@Transactional进行了注释,这意味着将以读写模式获取实体。如果需要只读实体,那么就添加@Transactional(readOnly=true)。
触发的 SQL 语句如下(这与实体图的用法相同):
SELECT
author0_.id AS col_0_0_
FROM author author0_
WHERE author0_.genre = ?
ORDER BY author0_.name ASC LIMIT ? ?
SELECT
COUNT(author0_.id) AS col_0_0_
FROM author author0_
WHERE author0_.genre = ?
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_,
books1_.author_id AS author_i4_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.title AS title3_1_1_,
books1_.author_id AS author_i4_1_0__,
books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.id IN (?, ?, ?, ?)
以下是 JSON 输出示例:
{
"content":[
{
"id":1,
"name":"Mark Janel",
"genre":"Anthology",
"age":23,
"books":[
{
"id":3,
"title":"The Beatles Anthology",
"isbn":"001-MJ"
},
{
"id":8,
"title":"Anthology From Zero To Expert",
"isbn":"002-MJ"
},
{
"id":9,
"title":"Quick Anthology",
"isbn":"003-MJ"
}
]
},
{
"id":6,
"name":"Merci Umaal",
"genre":"Anthology",
"age":31,
"books":[
{
"id":7,
"title":"Ultimate Anthology",
"isbn":"001-MU"
},
{
"id":10,
"title":"1959 Anthology",
"isbn":"002-MU"
}
]
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":2,
"pageNumber":0,
"offset":0,
"paged":true,
"unpaged":false
},
"totalElements":4,
"totalPages":2,
"last":false,
"numberOfElements":2,
"first":true,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":0,
"size":2,
"empty":false
}
此外,您可以优化实现,以避免使用单独的SELECT COUNT进行偏移分页。一种快速的方法是使用COUNT(*) OVER()进行本地查询,如下所示:
@Transactional(readOnly = true)
@Query(value = "SELECT a.id AS id, COUNT(*) OVER() AS total
FROM Author a WHERE a.genre = ?1",
nativeQuery = true)
List<Tuple> fetchTupleOfIdsByGenre(String genre, Pageable pageable);
服务方法应该处理List<Tuple>,以便提取作者的 id 和元素的总数:
@Transactional
public Page<Author> fetchPageOfAuthorsWithBooksByGenreTuple(
int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "name"));
List<Tuple> tuples = authorRepository.fetchTupleOfIdsByGenre(
"Anthology", pageable);
List<Long> listOfIds = new ArrayList<>(tuples.size());
for(Tuple tuple: tuples) {
listOfIds.add(((BigInteger) tuple.get("id")).longValue());
}
List<Author> listOfAuthors
= authorRepository.fetchWithBooksJoinFetch(listOfIds);
Page<Author> pageOfAuthors = new PageImpl(listOfAuthors, pageable,
((BigInteger) tuples.get(0).get("total")).longValue());
return pageOfAuthors;
}
这一次,去掉了附加的SELECT COUNT;因此,您可以将三个SELECT语句减少到两个。
获取切片
依靠Slice也是一种选择。使用Slice而不是Page消除了对这个额外的SELECT COUNT查询的需要,并返回页面(记录)和一些元数据,但不返回记录总数。当您需要 Slice 提供的元数据但不需要记录总数时,或者当您通过只执行一次的单独的SELECT COUNT获取记录总数时,这很有用。当插入/删除从不触发或很少触发时,通常会这样做。在这种情况下,记录的数量在页面导航之间不会改变,所以没有必要为每个页面触发一个SELECT COUNT。
这需要三至两条 SQL 语句。下面是基于Slice的实现:
@Transactional(readOnly = true)
@Query(value = "SELECT a.id FROM Author a WHERE a.genre = ?1")
Slice<Long> fetchSliceOfIdsByGenre(String genre, Pageable pageable);
@Transactional
public Slice<Author> fetchAuthorsWithBooksByGenre(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "name"));
Slice<Long> pageOfIds = authorRepository
.fetchSliceOfIdsByGenre("Anthology", pageable);
List<Author> listOfAuthors = authorRepository
.fetchWithBooks(pageOfIds.getContent());
Slice<Author> sliceOfAuthors = new SliceImpl(
listOfAuthors, pageable, pageOfIds.hasNext());
return sliceOfAuthors;
}
这将只触发两个 SQL SELECT语句。您没有关于总行数的信息,但是您知道是否有更多的页面。示例 JSON 如下所示(检查last元素):
{
"content":[
{
"id":1,
"name":"Mark Janel",
"genre":"Anthology",
"age":23,
"books":[
{
"id":3,
"title":"The Beatles Anthology",
"isbn":"001-MJ"
},
{
"id":8,
"title":"Anthology From Zero To Expert",
"isbn":"002-MJ"
},
{
"id":9,
"title":"Quick Anthology",
"isbn":"003-MJ"
}
]
},
{
"id":6,
"name":"Merci Umaal",
"genre":"Anthology",
"age":31,
"books":[
{
"id":7,
"title":"Ultimate Anthology",
"isbn":"001-MU"
},
{
"id":10,
"title":"1959 Anthology",
"isbn":"002-MU"
}
]
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":2,
"pageNumber":0,
"offset":0,
"paged":true,
"unpaged":false
},
"numberOfElements":2,
"first":true,
"last":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":0,
"size":2,
"empty":false
}
获取列表
我们也可以作为一个List<Author>来获取数据。当您不需要由Page或Slice提供的任何元数据时,这很有用:
@Transactional(readOnly = true)
@Query(value = "SELECT a.id FROM Author a WHERE a.genre = ?1")
List<Long> fetchListOfIdsByGenre(String genre, Pageable pageable);
这一次,您使用Pageable只是为了通过 Spring help 添加用于排序和分页的 SQL 子句。特别是在分页时,Spring 会根据方言选择合适的 SQL 子句(例如,对于 MySQL,它会添加LIMIT)。移除Pageable并使用原生查询也是一种选择。
调用fetchListOfIdsByGenre()的服务方法如下:
@Transactional
public List<Author> fetchListOfAuthorsWithBooksByGenre(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "name"));
List<Long> listOfIds = authorRepository.fetchListOfIdsByGenre(
"Anthology", pageable);
List<Author> listOfAuthors
= authorRepository.fetchWithBooksJoinFetch(listOfIds);
return listOfAuthors;
}
这将触发以下两个SELECT语句:
SELECT
author0_.id AS col_0_0_
FROM author author0_
WHERE author0_.genre = ?
ORDER BY author0_.name
ASC LIMIT ? ?
SELECT
author0_.id AS id1_0_0_,
books1_.id AS id1_1_1_,
author0_.age AS age2_0_0_,
author0_.genre AS genre3_0_0_,
author0_.name AS name4_0_0_,
books1_.author_id AS author_i4_1_1_,
books1_.isbn AS isbn2_1_1_,
books1_.title AS title3_1_1_,
books1_.author_id AS author_i4_1_0__,
books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
ON author0_.id = books1_.author_id
WHERE author0_.id IN (?, ?)
和一个示例结果集的 JSON 表示:
[
{
"id":3,
"name":"Quartis Young",
"genre":"Anthology",
"age":51,
"books":[
{
"id":5,
"title":"Anthology Of An Year",
"isbn":"001-QY"
}
]
},
{
"id":5,
"name":"Pyla Lou",
"genre":"Anthology",
"age":41,
"books":[
{
"id":6,
"title":"Personal Anthology",
"isbn":"001-KL"
}
]
}
]
GitHub 15 上有完整的应用。
第 99 项:如何实现 Slice findAll()
Spring Boot 提供了基于偏移量的内置分页机制,该机制返回一个Page或Slice。每个 API 代表一个数据页面和一些页面元数据。主要区别在于,Page包含记录的总数,而Slice只能判断是否还有另一页可用。对于Page,Spring Boot 提供了一个findAll()方法,它可以接受一个Pageable和/或一个Specification或Example作为参数。为了创建一个包含记录总数的Page,这个方法触发了一个SELECT COUNT额外的查询,紧接着这个查询用于获取当前页面的数据。这可能导致性能损失,因为每次请求页面时都会触发SELECT COUNT查询。为了避免这个额外的查询,Spring Boot 提供了一个更宽松的 API,即Slice API。使用Slice而不是Page消除了对这个额外的SELECT COUNT查询的需要,并返回页面(记录)和一些页面元数据,但不返回记录总数。因此,虽然Slice不知道记录的总数,但它仍然可以知道在当前页之后是否还有另一页可用,或者这是最后一页。问题是Slice对于包含 SQL WHERE子句的查询(包括那些使用内置在 Spring 数据中的查询构建器机制的查询)工作良好,但是对于findAll()就不行了。这个方法仍然会返回一个Page而不是一个Slice,所以对于Slice<T> findAll(...)会触发SELECT COUNT查询。
快速实施
获取所有数据的Slice的快速解决方案包括定义一个依赖于显式查询(JPQL)和Pageable对象的方法。
提取切片<实体 >
考虑将这个方法命名为fetchAll()。您可以将它添加到一个存储库中,如下所示(AuthorRepository是对应于Author实体的存储库):
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT a FROM Author a")
Slice<Author> fetchAll(Pageable pageable);
}
调用fetchAll()将触发如下的单个SELECT查询:
SELECT
author0_.id AS id1_0_,
author0_.age AS age2_0_,
author0_.genre AS genre3_0_,
author0_.name AS name4_0_
FROM author author0_
ORDER BY author0_.age ASC
LIMIT ? ?
调用fetchAll()的服务方法可以编写如下:
public Slice<Author> fetchNextSlice(int page, int size) {
return authorRepository.fetchAll(PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age")));
}
考虑类型为localhost:8080/authors/{ page }/{ size }的 REST 端点和author表中总共 51 条记录。对大小为 3 的第二页的请求可以作为localhost:8080/authors/1/3被触发,结果(作为 JSON)如下:
{
"content":[
{
"id":7,
"age":23,
"name":"Wuth Troll",
"genre":"Anthology"
},
{
"id":25,
"age":23,
"name":"Tylor Ruth",
"genre":"Anthology"
},
{
"id":16,
"age":24,
"name":"Joana Leonte",
"genre":"History"
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":3,
"pageNumber":1,
"offset":3,
"paged":true,
"unpaged":false
},
"numberOfElements":3,
"first":false,
"last":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":1,
"size":3,
"empty":false
}
没有关于记录总数的信息。但是,"last": false表示这不是最后一页。
正在获取切片<dto
考虑以下 Spring Boot 投影(DTO):
public interface AuthorDto {
public String getName();
public int getAge();
}
考虑将这个方法命名为fetchAllDto()。您可以将它添加到一个存储库中,如下所示(AuthorRepository是对应于Author实体的存储库):
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT a.name as name, a.age as age FROM Author a")
Slice<AuthorDto> fetchAllDto(Pageable pageable);
}
调用fetchAllDto()将触发如下的单个SELECT查询:
SELECT
author0_.name AS col_0_0_,
author0_.age AS col_1_0_
FROM author author0_
ORDER BY author0_.age ASC
LIMIT ? ?
调用fetchAllDto()的服务方法可以编写如下:
public Slice<AuthorDto> fetchNextSlice(int page, int size) {
return authorRepository.fetchAllDto(PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, "age")));
}
考虑类型为localhost:8080/authors/{ page }/{ size }的 REST 端点和author表中总共 51 条记录。对大小为 3 的第二页的请求可以作为localhost:8080/authors/1/3被触发,结果(作为 JSON)如下:
{
"content":[
{
"age":23,
"name":"Wuth Troll"
},
{
"age":23,
"name":"Tylor Ruth"
},
{
"age":24,
"name":"Joana Leonte"
}
],
"pageable":{
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"pageSize":3,
"pageNumber":1,
"offset":3,
"paged":true,
"unpaged":false
},
"numberOfElements":3,
"first":false,
"last":false,
"sort":{
"sorted":true,
"unsorted":false,
"empty":false
},
"number":1,
"size":3,
"empty":false
}
GitHub 16 上有源代码。
Slice findAll(可分页可分页)的实现
将方法名保存为findAll意味着您必须创建它的自定义实现。首先,编写一个abstract类,并如下定义findAll():
@Repository
@Transactional(readOnly = true)
public abstract class SlicePagingRepositoryImplementation<T> {
@Autowired
private EntityManager entityManager;
private final Class<T> entityClass;
public SlicePagingRepositoryImplementation(Class<T> entityClass) {
this.entityClass = entityClass;
}
public Slice<T> findAll(Pageable pageable) {
return findAll(pageable, entityClass);
}
...
findAll(Pageable, Class<T>)是一个负责构建查询的private方法。一种简单的方法如下:
private Slice<T> findAll(Pageable pageable, Class<T> entityClass) {
final String sql = "SELECT e FROM " + entityClass.getSimpleName() + " e";
TypedQuery<T> query = entityManager.createQuery(sql, entityClass);
return this.readSlice(query, pageable);
}
最后,readSlice()是一个private方法,负责通过SliceImpl和给定的query创建一个Slice<T>:
private Slice<T> readSlice(final TypedQuery<T> query,
final Pageable pageable) {
query.setFirstResult((int) pageable.getOffset());
query.setMaxResults(pageable.getPageSize() + 1);
final List<T> content = query.getResultList();
boolean hasNext = content.size() == (pageable.getPageSize() + 1);
if (hasNext) {
content.remove(content.size() - 1);
}
return new SliceImpl<>(content, pageable, hasNext);
}
完整的实现可在 GitHub 17 上获得。除此之外,还有其他几个实现,如下所示:
-
实现 18 基于
CriteriaBuilder而不是硬编码的 SQL -
实现 19 让你提供一个
Sort -
实现 20 个 ,允许你提供一个
Sort和Specification -
实现 21 ,允许你提供一个
Sort、LockModeType、QueryHints和Specification -
从
SimpleJpaRepository实现 22 覆盖Page<T> readPage(...)方法
项目 100:如何实现键集分页
建议在继续之前阅读第 94 项。
对于大型数据集,偏移分页会带来严重的性能损失,特别是遍历已经显示的记录以达到所需的偏移。在这种情况下,最好依靠键集分页,它在不断增长的数据中保持“恒定”的时间。下面列出了键集分页的优点和缺点。
缺点:
-
无法获取任意页面
-
编写
WHERE子句并不(总是)容易
优点:
-
搜索上一页的最后一项
-
仅提取以下行
-
无限滚动机制
-
插入不会造成页面偏差
另一个要考虑的缺点是,Spring Boot 不提供对键集分页的内置支持。实际上,依赖偏移量而不是键集分页的主要原因是缺少工具支持。
考虑Author实体对应的author表,如图 13-4 所示。
图 13-4
作者实体表
目标是实现键集分页,以实体和 DTO 的形式获取作者。
选择一列作为最近访问的记录/行(如id列),并在WHERE和ORDER BY子句中使用该列。依托id列的习惯用法如下(多列排序遵循同样的思路):
SELECT ...
FROM ...
WHERE id < {last_seen_id}
ORDER BY id DESC
LIMIT {how_many_rows_to_fetch}
或者,像这样:
SELECT ...
FROM ...
WHERE ...
AND id < {last_seen_id}
ORDER BY id DESC
LIMIT {how_many_rows_to_fetch}
例如,在这种情况下应用第一个习惯用法可能会导致以下本机查询(第一个查询获取实体;第二个获取 DTO):
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query(value = "SELECT * FROM author AS a WHERE a.id < ?1
ORDER BY a.id DESC LIMIT ?2", nativeQuery = true)
List<Author> fetchAll(long id, int limit);
@Query(value = "SELECT name, age FROM author AS a WHERE a.id < ?1
ORDER BY a.id DESC LIMIT ?2", nativeQuery = true)
List<AuthorDto> fetchAllDto(long id, int limit);
}
在分页的情况下,确定的排序顺序是强制性的,所以不要忘记ORDER BY子句。
LIMIT子句由MySQL和PostgreSQL识别。SQL Server 支持SELECT TOP子句,Oracle 使用ROWNUM或ROWS FETCH NEXT n ROWS ONLY。
AuthorDto是一个简单的 Spring Boot 投影:
public interface AuthorDto {
public String getName();
public int getAge();
}
此外,服务方法可以调用fetchAll()和fetchAllDto(),如下所示:
public List<Author> fetchNextPage(long id, int limit) {
return authorRepository.fetchAll(id, limit);
}
public List<AuthorDto> fetchNextPageDto(long id, int limit) {
return authorRepository.fetchAllDto(id, limit);
}
REST 控制器端点如localhost:8080/authors/{ id }/{ limit }可以帮助你测试这些服务方法。例如,通过localhost:8080/authors/5/3调用fetchNextPage()将输出以下内容:
[
{
"id":4,
"age":34,
"name":"Joana Nimar",
"genre":"History"
},
{
"id":3,
"age":51,
"name":"Quartis Young",
"genre":"Anthology"
},
{
"id":2,
"age":43,
"name":"Olivia Goy",
"genre":"Horror"
}
]
图 13-5 所示的时间-性能趋势图显示,键集分页比偏移量分页快得多。考虑有 100 万条记录的author表。我们获取第一页(0)、第 5000 页和第 9999 页,共 100 条记录。
图 13-5
失调与键集的关系
GitHub 23 上有源代码。
第 101 项:如何向键集分页添加下一页按钮
建议在继续之前阅读第项 100 。
键集分页不依赖于元素的总数。但是,通过一点小技巧,对客户机的响应可以包含一条信息,表明是否有更多的记录要获取。客户端可以使用该信息来显示下一页按钮。例如,localhost:8080/authors/5/3这样的 REST 控制器端点将返回三条记录(ID4、 3 和 2 ),但是在author表中还有一条记录(ID 1 )。响应的last元素表示这不是最后一页:
{
"authors":[
{
"id":4,
"age":34,
"name":"Joana Nimar",
"genre":"History"
},
{
"id":3,
"age":51,
"name":"Quartis Young",
"genre":"Anthology"
},
{
"id":2,
"age":43,
"name":"Olivia Goy",
"genre":"Horror"
}
],
"last":false
}
因此,您可以通过localhost:8080/authors/2/3获取下一页。这一次,响应将包含一条记录(ID 1 ),并且last元素是true。这意味着这是最后一页,因此应禁用下一页按钮:
{
"authors":[
{
"id":1,
"age":23,
"name":"Mark Janel",
"genre":"Anthology"
}
],
"last":true
}
但是,如何添加last元素呢?首先,您定义一个类,该类对获取的数据和额外的元素进行分组(在本例中是last,但是可以添加更多的元素):
public class AuthorView {
private final List<Author> authors;
private final boolean last;
public AuthorView(List<Author> authors, boolean last) {
this.authors = authors;
this.last = last;
}
public List<Author> getAuthors() {
return authors;
}
public boolean isLast() {
return last;
}
}
此外,服务方法获取limit + 1记录并如下确定last的值:
public AuthorView fetchNextPage(long id, int limit) {
List<Author> authors = authorRepository.fetchAll(id, limit + 1);
if (authors.size() == (limit + 1)) {
authors.remove(authors.size() - 1);
return new AuthorView(authors, false);
}
return new AuthorView(authors, true);
}
最后,您修改 REST 控制器端点以返回List<AuthorView>而不是List<Author>:
@GetMapping("/authors/{id}/{limit}")
public AuthorView fetchAuthors(
@PathVariable long id, @PathVariable int limit) {
return bookstoreService.fetchNextPage(id, limit);
}
搞定了。GitHub 24 上有源代码。它还包括 DTO 案件。
第 102 项:如何通过 ROW_NUMBER()实现分页
到目前为止,分页主题已经在几篇文章中讨论过了。另一种获取页面数据的方法是使用ROW_NUMBER()窗口函数,这是在项目 119 中引入的。如果你不熟悉ROW_NUMBER(),最好把这一项推迟到你看完第 119 项之后。
考虑众所周知的Author实体和下面的 DTO:
public interface AuthorDto {
public String getName();
public int getAge();
}
下面的本地查询是一个通过ROW_NUMBER()获取页面作者的例子:
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository
extends PagingAndSortingRepository<Author, Long> {
@Query(value = "SELECT * FROM (SELECT name, age, "
+ "ROW_NUMBER() OVER (ORDER BY age) AS row_num "
+ "FROM author) AS a WHERE row_num BETWEEN ?1 AND ?2",
nativeQuery = true)
List<AuthorDto> fetchPage(int start, int end);
}
或者,如果您还需要获取总行数,那么使用total字段和使用COUNT(*) OVER()窗口函数的查询来丰富 d to,如下所示:
public interface AuthorDto {
public String getName();
public int getAge();
public long getTotal();
}
@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query(value = "SELECT * FROM (SELECT name, age, "
+ "COUNT(*) OVER() AS total, "
+ "ROW_NUMBER() OVER (ORDER BY age) AS row_num FROM author) AS a "
+ "WHERE row_num BETWEEN ?1 AND ?2",
nativeQuery = true)
List<AuthorDto> fetchPage(int start, int end);
}
完整的应用可在 GitHub 25 上获得。
Footnotes 1https://use-the-index-luke.com/no-offset
2
3
4
https://www.percona.com/blog/2007/08/28/to-sql_calc_found_rows-or-not-to-sql_calc_found_rows/
5
https://twitter.com/vlad_mihalcea/status/1207887006883340288
6
hibernate pringb bootpagedtooffs pagination wf
7
hibernate pringgb 东向字节 paging wf
8
hibernate pringb ootpageentityofset page text racolumwf
9
hibernate pringb 东列表蒂米什图定页文本 racolumwf
10
hibernate pringb bootpagedtooffset 分页
11
hibernate pringb otlistdtooffset 分页
12
hibernate pringb bootpageentity yoff set action text racolumn
13
hibernate pringb 东列表蒂米什图设置页面文本 racolm
14
hibernate pringb oojoinfetchage able
15
16
hibernate pringb ootbar 片剂冲裁剂全部
17
hibernate pringb ootball 植入物骨架
18
hibernate pringb ootbladelrit riabuilder
19
hibernate pringb ootbar 重写重建者 t
20
hibernate pringb ootbladelrit rebuildersortan ds spec
21
hibernate pringb ootbladelritte rebuilders ortan dsspec dzquerryhints
22
hibernate pringb ootbladelrit 重建 jparerepository
23
24
hibernate pringb bootectpagitat ionnextpage
25
hibernate pringb bootinactitionrow number
**