做了几年 Java 开发,你会慢慢发现一个规律:用惯了 MyBatis-Plus 或 Spring Data JPA 的开发者,第一次回到 Spring JDBC 的 JdbcTemplate 时,最容易卡住的往往不是连接池配置,也不是事务管理,而是分页。
MyBatis-Plus 有现成的分页插件,Spring Data JPA 有 Pageable 接口,唯独 JdbcTemplate 什么都没有。你需要在 SQL 里手动加 LIMIT,手动查 count,再手动组装一个分页结果对象。网上很多博客只给一句“加个 LIMIT 就行了”,可一旦落到真实项目里,你会发现事情远没有这么简单:数据库方言怎么兼容?count 查询要不要单独优化?深分页为什么越来越慢?分页参数怎么避免 SQL 注入?
这篇文章我会从零开始,把 Spring JDBC 分页这件事讲透。你会看到一个可以直接落地到项目里的通用分页封装,包含分页参数对象、结果对象、MySQL 和 Oracle 方言处理、完整示例代码,以及所有我在实际项目中踩过的坑。读完你至少能解决两个问题:第一,在原生 Spring JDBC 项目里不再为分页发愁;第二,知道分页背后的性能瓶颈到底在哪里。
1. 这篇文章真正要解决的问题
先说一个很现实的情况:Spring JDBC 的核心定位是轻量、直接、SQL 可控。它不像 MyBatis-Plus 那样内置了完整的分页能力,也不像 Hibernate 那样在底层帮你生成分页方言。官方敢这么做,是因为分页本身不是 Spring JDBC 的职责范围,它把“分页怎么写”这件事完全交给了开发者。
这带来一个矛盾:如果你只在某一个查询里分页,手写一句 LIMIT 很简单。但一个真实项目通常有成百上千个查询需要分页,每个都手写 LIMIT、手写 count、手写结果组装,代码会变得非常难看,而且很难保证行为一致。
这篇文章要解决的,就是下面三个核心问题:
第一,分页参数和分页结果怎么抽象。你需要 PageRequest 和 PageResult 这样的封装对象,才能让所有 DAO 层方法保持统一签名,而不是每个方法都接收五六个散落的参数。
第二,多数据库方言怎么处理。MySQL、PostgreSQL 用 LIMIT OFFSET,Oracle 用 ROWNUM。如果公司未来要从 MySQL 迁移到 Oracle,或者一个项目里本身就有多种数据源,你需要在 SQL 拼接这一层做方言隔离。
第三,分页性能怎么保障。分页看起来只是“查一页数据”,但随着页码变大,传统 LIMIT 写法会越来越慢。这个坑很多初学者不知道,等到线上报表接口超时了才反应过来。
什么样的读者最应该读这篇文章?如果你正在维护一个 Spring Boot + JdbcTemplate 的老项目,或者你的项目因为轻量要求不能引入 MyBatis-Plus 这类重量级 ORM,这篇文章可以直接帮你省掉半天的设计时间。
2. Spring JDBC 分页的核心概念与思路
在动手写代码之前,先搞清楚分页在 Spring JDBC 项目里到底是由哪些部分组成的。
2.1 JdbcTemplate 在分页里扮演什么角色
JdbcTemplate 是 Spring JDBC 提供的一个工具类,它封装了 JDBC 连接获取、Statement 创建、异常转换、资源释放这些繁琐步骤。你只需要给它一条 SQL,以及一组参数,它就能执行查询并把结果映射成对象。
在做分页时,JdbcTemplate 主要做两件事:一是执行分页列表查询,二是执行 count 统计查询。它本身不关心你怎么拼 SQL,这恰恰是它灵活的地方。
2.2 分页的本质:列表查询 + count 查询
一次完整的分页请求,本质上由两个查询组成:
第一个查询是 count 查询,拿到符合条件的总记录数。这一步通常用SELECT COUNT(*) FROM 表 WHERE 条件实现,注意不要带上 ORDER BY 和 LIMIT。
第二个查询是列表查询,从某个位置开始取固定数量的数据。这一步才需要数据库方言,比如 MySQL 的LIMIT ? OFFSET ?,Oracle 的 ROWNUM 三层嵌套。
这两个查询执行完毕之后,还需要根据 total 和 pageSize 计算总页数 pages,最后把 list、total、pageNum、pageSize、pages 一起返回给前端。前端拿到这些字段之后才能正确渲染分页组件、表格页码和打印页码。
2.3 MySQL 与 Oracle 的分页语法差异
这里用一个表格展示最常用的两种方言,你就能直观感受到为什么需要一个方言层做隔离。
| 数据库 | 分页 SQL 示例 | 特点 |
|---|---|---|
| MySQL 5.x/8.x | SELECT * FROM user ORDER BY id LIMIT ?, ? | 参数是 offset 和 size,写法简单;深分页性能下降明显 |
| Oracle 11g 及以下 | SELECT * FROM (SELECT t.*, ROWNUM rn FROM (...) t WHERE ROWNUM <= ?) WHERE rn > ? | ROWNUM 在排序之后生成,否则分页顺序会错乱;需要三层嵌套 |
| Oracle 12c+ | SELECT * FROM user ORDER BY id OFFSET ? ROWS FETCH NEXT ? ROWS ONLY | 语法更简洁,但老库升级需要评估兼容性 |
如果把方言直接写在每个 DAO 方法里,将来切换数据库时要改的 SQL 量会非常大。更好的做法是在分页工具层统一处理。
2.4 为什么 Spring JDBC 没有内置分页插件
MyBatis-Plus 有PaginationInnerInterceptor,Spring Data JPA 有Pageable。Spring JDBC 没有这些,原因不是技术做不到,而是设计理念不同。Spring JDBC 希望保持“你写 SQL,我帮你执行”的纯粹性,而不是在框架层猜测你的分页意图。
这意味着什么?意味着如果你在 Spring JDBC 项目里想要分页,必须自己设计一套方案。这件事既是坏事也是好事:坏处是初始代码量多一点;好处是你对 SQL 完全可控,不会被框架生成的分页 SQL 坑到。
在实际项目中,如果项目里已经引入了 MyBatis-Plus,直接用它的分页插件完全没问题。但如果你只用 Spring JDBC,为了一个分页功能硬塞一个 MyBatis-Plus 进去,引入的成本和依赖复杂度是很不划算的。手写一套轻量分页封装,长期来看反而更干净。
3. 环境准备与前置条件
本篇文章的示例代码基于 Spring Boot,数据库默认使用 MySQL。整个思路对 Oracle、PostgreSQL 同样适用,只需要在方言层做调整。
3.1 环境清单
建议环境如下,具体版本请以你当前项目为准:
- JDK 8 或更高版本
- Spring Boot 2.x 或 3.x
- MySQL 5.7 或 8.0
- Maven 3.6+
- IDEA 或 Eclipse
3.2 Maven 依赖
创建一个 Spring Boot 项目,核心依赖只需要两个:spring-boot-starter-jdbc和数据库驱动。
<!-- 文件路径:pom.xml --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 如果使用 Oracle,引入 Oracle JDBC 驱动,groupId 和 version 以你实际使用的数据库为准 --> <!-- <dependency> <groupId>com.oracle.database.jdbc</groupId> <artifactId>ojdbc8</artifactId> <scope>runtime</scope> </dependency> --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>注意:如果你的 Spring Boot 版本较老,MySQL 驱动的依赖坐标可能是mysql:mysql-connector-java,新版本统一成了com.mysql:mysql-connector-j。具体坐标以你的 Spring Boot 版本解析结果为准。
3.3 数据源配置
在application.yml中配置数据源。
# 文件路径:src/main/resources/application.yml server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver如果你的数据库账号密码不方便写死在配置里,可以用环境变量注入,比如${DB_PASSWORD}。
3.4 建表与测试数据
示例使用一张简单的user表。
-- 文件路径:src/main/resources/schema.sql CREATE TABLE IF NOT EXISTS `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL, `email` VARCHAR(100) );建议插一批测试数据进去,至少几十条,这样分页效果才明显。
4. Spring JDBC 分页的基础实现:先跑通再说
这里是整个分页开发的第一阶段。先不要追求架构设计,直接用最朴素的方式跑通一个分页流程,验证环境没问题,再用第二阶段去优化。
4.1 创建实体类
// 文件路径:src/main/java/com/example/demo/entity/User.java public class User { private Long id; private String name; private String email; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } }4.2 基础版 DAO
// 文件路径:src/main/java/com/example/demo/dao/UserDao.java @Repository public class UserDao { private final JdbcTemplate jdbcTemplate; public UserDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public List<User> findPage(int pageNum, int pageSize) { int offset = (pageNum - 1) * pageSize; String sql = "SELECT id, name, email FROM user ORDER BY id LIMIT ? OFFSET ?"; return jdbcTemplate.query(sql, (rs, rowNum) -> { User user = new User(); user.setId(rs.getLong("id")); user.setName(rs.getString("name")); user.setEmail(rs.getString("email")); return user; }, pageSize, offset); } public long count() { String sql = "SELECT COUNT(*) FROM user"; Long total = jdbcTemplate.queryForObject(sql, Long.class); return total == null ? 0L : total; } }这段代码有两个关键点。
第一,分页参数是通过?占位符传入的,而不是直接拼字符串。为什么强调这一点?因为如果写成"SELECT ... LIMIT " + pageSize + " OFFSET " + offset,虽然 LIMIT 后面的参数是数字,看起来不容易注入,但长期来看这种拼接习惯非常危险,一旦条件部分也改成拼接,就会把整个查询暴露在 SQL 注入风险之下。参数化查询是必须遵守的底线。
第二,RowMapper 用 lambda 表达式实现,每次查询都会把ResultSet的每一行映射成 User 对象。你也可以用BeanPropertyRowMapper自动映射,但它依赖数据库字段名和 Java 属性名保持一致,复杂查询时不如手写 RowMapper 直观。
4.3 基础版的局限
这个基础版能跑通,但距离工程可用还差很远:
- 每个 DAO 方法都要写一遍 count、offset 计算、RowMapper,代码重复严重。
- 参数不校验,pageNum 传 0 或负数时会得到错误结果。
- 方言写死在 SQL 里,换数据库要改所有 DAO。
- 返回结果只有 list,没有 total、pages,前端很难做分页组件。
基础版解决的是“从无到有”的问题,接下来要解决“从有到优”的问题。
5. 设计一个可复用的通用分页封装
分页封装的核心思路是:把“分页参数”和“分页结果”抽象成两个通用对象,把“方言差异”隔离在接口后面,然后把 count 查询和列表查询的通用流程抽出来。
5.1 分页请求对象 PageRequest
// 文件路径:src/main/java/com/example/demo/page/PageRequest.java public class PageRequest { private int pageNum = 1; private int pageSize = 10; public PageRequest() { } public PageRequest(int pageNum, int pageSize) { this.pageNum = Math.max(pageNum, 1); this.pageSize = Math.min(Math.max(pageSize, 1), 100); } public int getPageNum() { return pageNum; } public int getPageSize() { return pageSize; } public int getOffset() { return (pageNum - 1) * pageSize; } }这里做了两层约束:pageNum 最小为 1,pageSize 限制在 1 到 100 之间。为什么要限制?因为如果不限制,一旦前端传一个 pageSize=100000,你的数据库瞬间就会被拉爆,这是接口层最常见的漏洞之一。
5.2 分页结果对象 PageResult
// 文件路径:src/main/java/com/example/demo/page/PageResult.java import java.util.Collections; import java.util.List; public class PageResult<T> { private List<T> list; private long total; private int pageNum; private int pageSize; private int pages; public PageResult() { } public PageResult(List<T> list, long total, int pageNum, int pageSize) { this.list = list == null ? Collections.emptyList() : list; this.total = total; this.pageNum = pageNum; this.pageSize = pageSize; this.pages = pageSize == 0 ? 0 : (int) ((total + pageSize - 1) / pageSize); } public List<T> getList() { return list; } public void setList(List<T> list) { this.list = list; } public long getTotal() { return total; } public void setTotal(long total) { this.total = total; } public int getPageNum() { return pageNum; } public void setPageNum(int pageNum) { this.pageNum = pageNum; } public int getPageSize() { return pageSize; } public void setPageSize(int pageSize) { this.pageSize = pageSize; } public int getPages() { return pages; } public void setPages(int pages) { this.pages = pages; } }不管是什么实体,分页结果的结构都是一样的。用泛型PageResult<T>的好处是,整个项目的分页方法返回类型统一,前端拿到 JSON 的结构也统一,不会出现 A 接口返回total、B 接口返回count这种混乱情况。
计算总页数时有一个细节:(total + pageSize - 1) / pageSize。这个公式可以避免浮点数计算误差。
5.3 方言抽象接口 PageDialect
不同数据库的分页语法不一样,所以把“拼接分页 SQL”这件事抽象成接口。
// 文件路径:src/main/java/com/example/demo/page/PageDialect.java public interface PageDialect { String buildPageSql(String sql, int pageNum, int pageSize); }接口只有一个方法:传入原始查询 SQL、页码、每页大小,返回拼好分页语法的 SQL。
5.4 MySQL 方言实现
// 文件路径:src/main/java/com/example/demo/page/MySqlPageDialect.java public class MySqlPageDialect implements PageDialect { @Override public String buildPageSql(String sql, int pageNum, int pageSize) { int offset = (pageNum - 1) * pageSize; return sql + " LIMIT " + pageSize + " OFFSET " + offset; } }等等,前面刚说了要用参数化查询,这里为什么又出现字符串拼接?
这里拼接的是 SQL 语句片段本身,不是用户输入的数据。pageNum 和 pageSize 在进入 PageRequest 时已经做了整型校验,而且是 DAO 内部计算出来的值,不是前端原始字符串。所以这样做是安全的。真正需要参数化的用户数据是 WHERE 条件里的那些值。
5.5 Oracle 方言实现
// 文件路径:src/main/java/com/example/demo/page/OraclePageDialect.java public class OraclePageDialect implements PageDialect { @Override public String buildPageSql(String sql, int pageNum, int pageSize) { int end = pageNum * pageSize; int start = (pageNum - 1) * pageSize + 1; return "SELECT * FROM (SELECT t.*, ROWNUM rn FROM (" + sql + ") t WHERE ROWNUM <= " + end + ") WHERE rn >= " + start; } }Oracle 的 ROWNUM 分页是新手最容易写错的地方。核心原因是:ROWNUM 是行号,它在 ORDER BY 排序之前就会分配。如果你直接在SELECT * FROM user WHERE ROWNUM <= 10 ORDER BY id上做分页,取到的行是排序前的前 10 行,再排一次序,数据顺序就错了。
所以正确的做法是三层嵌套:
- 最内层:原始 SQL,包含 WHERE 和 ORDER BY。
- 中间层:给排序后的结果生成 ROWNUM,并截断到 end 行。
- 最外层:再按 ROWNUM 过滤掉 start 之前的行。
如果你的项目数据库是 Oracle 12c 及以上,也可以使用OFFSET ? ROWS FETCH NEXT ? ROWS ONLY这种新语法,语法更接近标准 SQL,但要注意驱动版本和生产库版本是否支持。
5.6 方言工厂
有了方言接口和实现类之后,还需要一个工厂来根据数据库类型创建对应的方言实例。
// 文件路径:src/main/java/com/example/demo/page/PageDialectFactory.java public class PageDialectFactory { public static PageDialect getDialect(String databaseType) { if ("oracle".equalsIgnoreCase(databaseType)) { return new OraclePageDialect(); } return new MySqlPageDialect(); } }这个工厂可以做得更复杂,比如通过 Spring 的 ApplicationContext 注入多个 Bean 然后按数据库类型选择。但在这个示例里,简单的静态工厂就够了。
6. 完整示例代码实现:DAO + Service + Controller
前面把分页组件设计好了,接下来用一个完整示例把它们组装起来。整个流程是:Controller 接收请求参数,Service 调用 DAO,DAO 使用 PageDialect 拼接分页 SQL,最后返回 PageResult。
6.1 DAO 层:使用通用封装
// 文件路径:src/main/java/com/example/demo/dao/UserDao.java @Repository public class UserDao { private final JdbcTemplate jdbcTemplate; private final PageDialect pageDialect; public UserDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; this.pageDialect = PageDialectFactory.getDialect("mysql"); } public PageResult<User> findPage(PageRequest pageRequest) { String baseSql = "SELECT id, name, email FROM user ORDER BY id"; String countSql = "SELECT COUNT(*) FROM user"; Long total = jdbcTemplate.queryForObject(countSql, Long.class); if (total == null || total == 0) { return new PageResult<>(Collections.emptyList(), 0, pageRequest.getPageNum(), pageRequest.getPageSize()); } String pageSql = pageDialect.buildPageSql(baseSql, pageRequest.getPageNum(), pageRequest.getPageSize()); List<User> list = jdbcTemplate.query(pageSql, (rs, rowNum) -> { User user = new User(); user.setId(rs.getLong("id")); user.setName(rs.getString("name")); user.setEmail(rs.getString("email")); return user; }); return new PageResult<>(list, total, pageRequest.getPageNum(), pageRequest.getPageSize()); } }注意total == 0时的提前返回。这样做可以省掉一次无意义的列表查询,同时避免 Oracle 方言在无数据时执行复杂嵌套查询。
如果你有 WHERE 条件,count 和列表查询的 SQL 都要带上相同的 WHERE 条件。否则会出现总数对不上、列表内容错位的问题。这里为了示例简洁,没有加条件,实际项目里你可能会这样写:
String condition = " WHERE name LIKE ?"; String countSql = "SELECT COUNT(*) FROM user" + condition; String pageSql = pageDialect.buildPageSql( "SELECT id, name, email FROM user" + condition + " ORDER BY id", pageRequest.getPageNum(), pageRequest.getPageSize());记住:WHERE 条件中的参数值必须通过 JdbcTemplate 的重载方法传入,不能拼进 SQL。
6.2 Service 层:业务逻辑隔离
// 文件路径:src/main/java/com/example/demo/service/UserService.java @Service public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao = userDao; } public PageResult<User> listUsers(int pageNum, int pageSize) { PageRequest pageRequest = new PageRequest(pageNum, pageSize); return userDao.findPage(pageRequest); } }把 DAO 和 Controller 隔离开,是为了将来如果要做权限过滤、数据脱敏或者缓存,不用改 DAO 和 Controller 两侧的代码。
6.3 Controller 层:接收前端分页参数
// 文件路径:src/main/java/com/example/demo/controller/UserController.java @RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping public PageResult<User> list(@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize) { return userService.listUsers(pageNum, pageSize); } }这里用@RequestParam接收pageNum和pageSize,并为两个参数都提供了默认值。即使前端不传分页参数,接口也能正常工作。前端的分页组件、表格组件、打印模板需要页码时,统一使用 PageResult 里的total、pageNum、pages字段,这样前后端对“分页状态”的理解就是一致的。
6.4 启动类
// 文件路径:src/main/java/com/example/demo/DemoApplication.java @SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }7. 运行结果与效果验证
代码写完,下面进入验证阶段。
7.1 启动应用
在项目根目录执行:
mvn spring-boot:run看到Started DemoApplication的日志就表示启动成功。
7.2 请求分页接口
打开终端,执行 curl 命令请求第一页数据:
curl "http://localhost:8080/api/users?pageNum=1&pageSize=5"预期返回 JSON 格式如下:
{ "list": [ { "id": 1, "name": "zhangsan", "email": "zhangsan@example.com" }, { "id": 2, "name": "lisi", "email": "lisi@example.com" } ], "total": 100, "pageNum": 1, "pageSize": 5, "pages": 20 }验证点有三个:第一,list里的数据确实是从第 1 条开始的;第二,total和数据库里的总条数一致;第三,pages等于(total + pageSize - 1) / pageSize。
再试一次翻页:
curl "http://localhost:8080/api/users?pageNum=3&pageSize=5"如果第二页和第一页的数据没有重叠,列表查询就是正确的。
7.3 单元测试
除了手动 curl,还可以写一个简单的单元测试。
// 文件路径:src/test/java/com/example/demo/UserDaoTest.java @SpringBootTest class UserDaoTest { @Autowired private UserDao userDao; @Test void testFindPage() { PageResult<User> result = userDao.findPage(new PageRequest(1, 10)); System.out.println("total = " + result.getTotal()); System.out.println("pages = " + result.getPages()); System.out.println("size = " + result.getList().size()); Assertions.assertTrue(result.getTotal() > 0); Assertions.assertFalse(result.getList().isEmpty()); } @Test void testPageOutOfRange() { PageResult<User> result = userDao.findPage(new PageRequest(999, 10)); Assertions.assertTrue(result.getList().isEmpty()); } }运行mvn test,如果测试通过,说明分页封装在正常页码和越界页码下都能正确工作。
7.4 失败时按什么顺序排查
如果接口报错或者返回结果不对,建议按下面的顺序排查:
- 看启动日志有没有数据源连接错误。如果连不上数据库,多半是
application.yml的 URL、账号或密码写错了。 - 看 SQL 有没有语法错误。把
pageDialect.buildPageSql()拼接出的 SQL 打出来,拿到数据库客户端里手动执行一遍。 - 看 SQL 参数顺序。
LIMIT ? OFFSET ?对应的参数顺序是 pageSize 在前、offset 在后,颠倒之后页面数据会错乱。 - 看 RowMapper 的字段映射。如果返回的 JSON 里全是 null,检查数据库字段名和
rs.getXXX("字段名")是否完全一致。
8. 常见问题与排查思路
在这一部分,我把 Spring JDBC 分页项目里最常见的问题整理成一张表,方便你直接对照排查。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 返回的 total 是 0,但数据库里明明有数据 | count SQL 带有 LIMIT,或者 count 查询条件写错 | 打印 count SQL 手动执行 | count SQL 只保留 WHERE,去掉 ORDER BY / LIMIT |
| 第一页数据正常,第二页和第一页重复 | offset 计算错误,页码从 0 开始 | 检查 PageRequest.getOffset() 逻辑 | 明确offset = (pageNum - 1) * pageSize,统一从前端约定页码从 1 开始 |
| Oracle 分页数据顺序错乱 | ROWNUM 在 ORDER BY 前分配 | 打印三层嵌套 SQL 手动执行 | 使用SELECT * FROM (SELECT t.*, ROWNUM rn FROM (原SQL) t WHERE ROWNUM <= ?) WHERE rn >= ? |
| 分页查询越来越慢,翻到后面几页明显卡顿 | LIMIT 深分页扫描大量数据 | 用 EXPLAIN 查看执行计划 | 改用游标分页 / keyset pagination,或优化索引覆盖 |
| 接口报 SQL 语法错误 | 方言拼接的 SQL 在目标数据库不支持 | 在数据库客户端手动执行拼接结果 | 根据数据库版本选择正确方言,比如 Oracle 12c 用 OFFSET FETCH 或 ROWNUM |
| 前端页码在打印时和表格显示不一致 | 前后端对 pageNum 起始值理解不同 | 检查请求参数和返回 JSON | 统一 pageNum 从 1 开始,total、pages 语义约定写入接口文档 |
| 用 MyBatis-Plus 的项目分页失效 | 没有注册分页拦截器或配置顺序错误 | 查看是否配置了 PaginationInnerInterceptor | 这和 Spring JDBC 手写分页不同,MyBatis-Plus 依赖拦截器,需要单独注册并放在 MybatisPlusInterceptor 中 |
| 有人通过 pageSize 传超大值导致数据库压力过大 | 缺少参数校验 | 查看日志中接收到的 pageSize | 在 PageRequest 构造器或 Controller 层限制 pageSize 最大值,建议 1 到 100 |
| 分页查询结果与条件查询总数不一致 | 列表 SQL 和 count SQL 的 WHERE 条件不同步 | 对比两条 SQL 的条件部分 | 抽出一个基础条件 SQL,列表查询和 count 查询共用同一个条件拼接方法 |
其中有一个问题值得展开说:深分页慢。
传统分页写法是LIMIT 100000, 20,数据库在执行时会先扫描前面的 100000 行,然后丢掉,只取最后 20 行。这属于典型的“越翻越慢”。
如果产品确实需要支持翻到很后面,有几种优化思路:
第一种是延迟关联。先用覆盖索引查出主键id,再和原表做 JOIN 取完整记录:
SELECT u.id, u.name, u.email FROM user u INNER JOIN (SELECT id FROM user ORDER BY id LIMIT 100000, 20) t ON u.id = t.id ORDER BY u.id;第二种是游标分页,也就是 keyset pagination,核心思路是不再使用 LIMIT,而是记住上一页最后一条记录的某个连续字段值:
SELECT id, name, email FROM user WHERE id > ? ORDER BY id LIMIT 20;这个方案在数据量极大的场景下性能非常好,但代价是前端分页组件通常要从“页码式”改成“加载更多”或“上一页/下一页”式。两种方案没有绝对的好坏,需要根据产品形态来取舍。
9. Spring JDBC 分页的最佳实践与工程建议
最后一部分,把我在实际项目中沉淀下来的工程建议整理成清单。这些建议不一定每一条都适合你的项目,但值得在开发前先过一遍。
9.1 分页参数统一入口与校验
所有分页参数必须通过PageRequest这个统一对象传递,不要在 Controller 里接收三个、DAO 里又传五个。统一入口的好处是只要在 PageRequest 里做一次校验,全项目生效。pageSize 上限建议根据你的数据量和查询复杂度来制定,一般 100 足够。
9.2 排序字段白名单
如果你允许前端通过参数指定排序字段,比如sortField=email、sortOrder=desc,那么一定要做白名单校验。否则前端传入sortField=(select 1 from dual)之类的值,即使你用的是 PreparedStatement,排序字段拼接进 ORDER BY 那一段同样有注入风险。
更稳的做法是:后端维护一个允许排序的字段映射表,前端传 fieldKey,后端映射成真实列名:
private static final Map<String, String> SORT_FIELD_MAP = Map.of( "createTime", "create_time", "name", "name", "id", "id" );前端传的 key 不在映射表里,就直接用默认排序字段,而不是拼到 SQL 里。
9.3 count 查询的优化位置
当查询条件很复杂、关联表很多时,SELECT COUNT(*) FROM 表可能非常慢,因为它要把所有 JOIN 都执行一遍。可以考虑单独写一个轻量 count SQL,只从主表取数,条件是能覆盖主表字段即可。
另外,如果列表查询是一次性报告类场景,而且报表结果是异步生成的,完全可以把 count 结果缓存起来,几秒钟的延迟对报表用户来说完全无感。
9.4 列表查询使用只读事务
分页查询本身就是只读操作,建议在 Service 方法上加上只读事务,减少数据库锁的开销:
@Transactional(readOnly = true) public PageResult<User> listUsers(int pageNum, int pageSize) { PageRequest pageRequest = new PageRequest(pageNum, pageSize); return userDao.findPage(pageRequest); }这只是一个很小的优化,但在高并发查询场景下,能让数据库优化器少做很多不必要的 undo 日志记录。
9.5 不要忘记索引
分页查询的 ORDER BY 字段必须有索引。很多人写了ORDER BY create_time LIMIT 10 OFFSET 100,但create_time字段根本没建索引,导致每翻一页数据库就要做一次文件排序。
最理想的情况是:查询条件里的 WHERE 字段和 ORDER BY 字段能组成一个联合索引,这样数据库可以直接通过索引顺序扫描获取数据,中间不产生临时文件和排序操作。
如果使用延迟关联分页,确保联合索引能覆盖 WHERE、ORDER BY 和 SELECT 的 id,这样内层子查询就不需要回表。
9.6 前端页码语义统一
很多接口问题不是后端代码写错了,而是前后端对页码的约定不一致。建议在接口文档里写清楚:
- pageNum 从 1 开始。
- pageSize 是每页条数。
- total 是总条数。
- pages 是总页数。
前端不管是表格组件、分页器,还是打印模板里的页码,都使用后端返回的字段。比如在 vue-plugin-hiprint 这类打印工具里,分页页码最好也由后端返回的总记录数驱动,而不是前端根据本地数据长度自己推算,否则一旦后端做了过滤或权限裁剪,打印出来的页码就会和列表页对不上。
9.7 日志与慢查询监控
分页接口应该打印执行时间和 SQL,方便定位慢查询。一个简洁的写法是在 DAO 层用 Spring 的 StopWatch:
StopWatch stopWatch = new StopWatch("pageQuery"); stopWatch.start("count"); Long total = jdbcTemplate.queryForObject(countSql, Long.class); stopWatch.stop(); stopWatch.start("list"); List<User> list = jdbcTemplate.query(pageSql, rowMapper); stopWatch.stop(); log.info("count cost = {} ms, list cost = {} ms, SQL = {}", stopWatch.getTotalTimeMillis() - stopWatch.getLastTaskInfo().getTimeMillis(), stopWatch.getLastTaskInfo().getTimeMillis(), pageSql);生产环境建议配合慢查询日志使用。MySQL 的 slow_query_log、Oracle 的 AWR 报告都可以用来发现分页性能问题,然后再针对具体 SQL 做优化。
9.8 用多少能力,引多少依赖
有些团队看到分页麻烦,第一反应是引入 MyBatis-Plus 或者 PageHelper。如果你已经有这些依赖,当然没问题。但如果项目里只有 JdbcTemplate,为了分页单独引入一个 ORM 框架,会带来配置、AOP、缓存、事务等一系列隐形成本。
手写分页封装的成本其实很低,核心代码加起来不到两百行。对于大多数业务系统来说,这套方案足够用,而且 SQL 完全可控,出了问题一眼就能看出来。只有当团队里分页场景极其复杂、查询条件动态拼接工作量太大时,才值得重新评估是否引入 ORM 框架。
10. 总结与下一步
这篇文章从 Spring JDBC 没有内置分页这个痛点出发,把分页拆成了 count 查询、列表查询、方言处理、结果组装四个部分,并提供了一个可以直接复制到项目里的通用分页封装。它不是网上那种只贴一个LIMIT就算完的分页,而是考虑了参数校验、方言兼容、深分页限制和前端页码语义的工程化方案。
你可以先按照文章第 4 节把最基础的分页跑通,然后在自己的项目里逐步引入 PageRequest、PageResult 和 PageDialect。建议把这一套沉淀成团队内部的基础组件,放在公共模块里,后续再做查询分页时就不需要每个 DAO 重复写分页逻辑了。
下一步值得继续深挖的方向有三个:一是 keyset / 游标分页的具体落地,适合大数据量场景;二是分页查询如何结合数据库索引与执行计划做调优;三是 Spring JDBC 的命名参数模板 NamedParameterJdbcTemplate,它在动态查询条件较多时比 JdbcTemplate 更顺手,可以和分页封装组合使用。
分页看起来是一件小事,但它同时涉及数据库语法、SQL 性能、接口设计和前后端协作。把这些细节处理好,你的接口在数据量翻了十倍之后仍然能保持稳定,这种能力才是真正拉开普通开发者和资深开发者差距的地方。