做了几年 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 OFFSETOracle 用 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.xSELECT * FROM user ORDER BY id LIMIT ?, ?参数是 offset 和 size写法简单深分页性能下降明显Oracle 11g 及以下SELECT * FROM (SELECT t.*, ROWNUM rn FROM (...) t WHERE ROWNUM ?) WHERE rn ?ROWNUM 在排序之后生成否则分页顺序会错乱需要三层嵌套Oracle 12cSELECT * FROM user ORDER BY id OFFSET ? ROWS FETCH NEXT ? ROWS ONLY语法更简洁但老库升级需要评估兼容性如果把方言直接写在每个 DAO 方法里将来切换数据库时要改的 SQL 量会非常大。更好的做法是在分页工具层统一处理。2.4 为什么 Spring JDBC 没有内置分页插件MyBatis-Plus 有PaginationInnerInterceptorSpring 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.xMySQL 5.7 或 8.0Maven 3.6IDEA 或 Eclipse3.2 Maven 依赖创建一个 Spring Boot 项目核心依赖只需要两个spring-boot-starter-jdbc和数据库驱动。!-- 文件路径pom.xml -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- 如果使用 Oracle引入 Oracle JDBC 驱动groupId 和 version 以你实际使用的数据库为准 -- !-- dependency groupIdcom.oracle.database.jdbc/groupId artifactIdojdbc8/artifactId scoperuntime/scope /dependency -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 ListUser 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 最小为 1pageSize 限制在 1 到 100 之间。为什么要限制因为如果不限制一旦前端传一个 pageSize100000你的数据库瞬间就会被拉爆这是接口层最常见的漏洞之一。5.2 分页结果对象 PageResult// 文件路径src/main/java/com/example/demo/page/PageResult.java import java.util.Collections; import java.util.List; public class PageResultT { private ListT list; private long total; private int pageNum; private int pageSize; private int pages; public PageResult() { } public PageResult(ListT 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 ListT getList() { return list; } public void setList(ListT 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; } }不管是什么实体分页结果的结构都是一样的。用泛型PageResultT的好处是整个项目的分页方法返回类型统一前端拿到 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 调用 DAODAO 使用 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 PageResultUser 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()); ListUser 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 PageResultUser 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 PageResultUser 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?pageNum1pageSize5预期返回 JSON 格式如下{ list: [ { id: 1, name: zhangsan, email: zhangsanexample.com }, { id: 2, name: lisi, email: lisiexample.com } ], total: 100, pageNum: 1, pageSize: 5, pages: 20 }验证点有三个第一list里的数据确实是从第 1 条开始的第二total和数据库里的总条数一致第三pages等于(total pageSize - 1) / pageSize。再试一次翻页curl http://localhost:8080/api/users?pageNum3pageSize5如果第二页和第一页的数据没有重叠列表查询就是正确的。7.3 单元测试除了手动 curl还可以写一个简单的单元测试。// 文件路径src/test/java/com/example/demo/UserDaoTest.java SpringBootTest class UserDaoTest { Autowired private UserDao userDao; Test void testFindPage() { PageResultUser 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() { PageResultUser 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 排序字段白名单如果你允许前端通过参数指定排序字段比如sortFieldemail、sortOrderdesc那么一定要做白名单校验。否则前端传入sortField(select 1 from dual)之类的值即使你用的是 PreparedStatement排序字段拼接进 ORDER BY 那一段同样有注入风险。更稳的做法是后端维护一个允许排序的字段映射表前端传 fieldKey后端映射成真实列名private static final MapString, 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 PageResultUser 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 的 StopWatchStopWatch stopWatch new StopWatch(pageQuery); stopWatch.start(count); Long total jdbcTemplate.queryForObject(countSql, Long.class); stopWatch.stop(); stopWatch.start(list); ListUser 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 性能、接口设计和前后端协作。把这些细节处理好你的接口在数据量翻了十倍之后仍然能保持稳定这种能力才是真正拉开普通开发者和资深开发者差距的地方。