ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring JDBC分页实战:从JdbcTemplate到Page封装与优化

Spring JDBC分页实战:从JdbcTemplate到Page封装与优化 之前在业务迭代中做列表页分页很多同事第一反应都是直接上 MyBatis-Plus 的Page或者写一条LIMIT ? OFFSET ?就完事。一旦项目里用的是 Spring JDBC 体系JdbcTemplate、NamedParameterJdbcTemplate很多人反而会愣一下Spring JDBC 没有内置 Page 对象分页到底应该怎么写是手动拼 SQL还是自己封装一个分页工具类这篇文章从 Spring JDBC 最基础的分页写法讲起逐步拆解参数绑定、分页对象封装、DAO 层实践最后给出一套完整的 Spring Boot 分页接口示例并整理分页场景下的索引优化和常见坑点。内容覆盖入门到项目落地后端开发者可以直接参考复用。1. 背景与核心概念分页是 Web 系统里最基础、也最容易写错的功能之一。它的本质是数据库表里的数据量很大不能把全部结果一次性返回给前端而是按每页固定条数切片返回。Spring JDBC 是 Spring 框架对 JDBC 的轻量封装核心类是JdbcTemplate。它帮我们处理了连接的获取与释放、SQL 预编译、参数绑定、结果集映射等重复工作但有一个关键点需要明确Spring JDBC 本身没有提供类似 MyBatis-PlusPage那样开箱即用的分页对象。这并不意味着 Spring JDBC 不支持分页。分页在数据库层面的本质就是 SQL 方言差异-- MySQL SELECT * FROM user LIMIT 10 OFFSET 20; -- PostgreSQL SELECT * FROM user LIMIT 10 OFFSET 20; -- Oracle 12c SELECT * FROM user OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY; -- SQL Server SELECT * FROM user ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;Spring JDBC 只负责“把 SQL 发出去、把参数绑进去、把结果映射回来”至于这个 SQL 是不是分页 SQL它不关心。所以使用 Spring JDBC 做分页核心工作就是你自己的写分页 SQL绑定分页参数设计分页结果对象。实际项目里Spring JDBC 最常见的分页方案有三种方案做法适用场景方案一手动绑定参数每次查询都写LIMIT ? OFFSET ?手动计算 offset简单查询、快速实现方案二封装分页工具类写一个PageResult/PageRequest配合 DAO 层统一处理项目里大量列表页方案三继承JdbcTemplate或结合方言类参考 Spring Data 的Pageable思路自己做方言适配企业级中后台系统为了便于理解先把流程拆成几步前端传入当前页码pageNum和每页条数pageSize。后端将页码转换为数据库需要的offset和limit。执行分页 SQL 查询当前页数据。执行 COUNT 查询获取总条数。组装成分页结果对象返回给前端。下面从环境准备开始完整走一遍。2. 环境准备与版本说明本节以大多数项目使用的 Spring Boot Maven 环境为例。版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路不一定和你的版本完全一致。2.1 基础环境JDK 8 或 JDK 17Maven 3.6Spring Boot 2.x 或 3.xMySQL 5.7 或 8.xIDEIntelliJ IDEA 或 Eclipse如果你用的是 Spring Boot 3.x需要注意javax包名已经迁移到jakarta下面示例的依赖坐标不变但部分代码包名会有区别。2.2 创建项目结构建议的包结构如下src/main/java/com/example/paging/ ├── PagingApplication.java ├── controller/ │ └── UserController.java ├── service/ │ └── UserService.java ├── dao/ │ └── UserDao.java ├── entity/ │ └── User.java └── common/ ├── PageRequest.java └── PageResult.java2.3 Maven 依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies如果使用 Spring Boot 2.xMySQL 驱动坐标通常是mysql:mysql-connector-java如果使用 Spring Boot 3.x新坐标是com.mysql:mysql-connector-j。这一点在引入依赖时要注意区分。2.4 application.yml 配置spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver如果你只是本地练习可以暂时不使用连接池spring-boot-starter-jdbc默认会引入 HikariCP。3. 核心语法与原理拆解3.1 LIMIT 与 OFFSET 的关系MySQL 分页最核心的语法是SELECT * FROM table ORDER BY id LIMIT pageSize OFFSET offset;其中LIMIT pageSize表示最多返回的行数。OFFSET offset表示跳过多少行。偏移量的计算方式是int offset (pageNum - 1) * pageSize;这里有个常见误区前端传的pageNum通常从 1 开始但数据库的OFFSET是从 0 开始计数。如果直接把pageNum传给OFFSET第一页数据会被跳过。用一个小例子说明// 第 1 页每页 10 条 // pageNum 1, pageSize 10 // offset (1 - 1) * 10 0 SELECT * FROM user ORDER BY id LIMIT 10 OFFSET 0; // 第 2 页每页 10 条 // offset (2 - 1) * 10 10 SELECT * FROM user ORDER BY id LIMIT 10 OFFSET 10;3.2 JdbcTemplate 参数绑定Spring JDBC 的分页 SQL 依然走?占位符机制因此完全不需要担心拼接字符串带来的 SQL 注入问题。String sql SELECT id, name, age FROM user ORDER BY id LIMIT ? OFFSET ?; ListUser users jdbcTemplate.query(sql, new BeanPropertyRowMapper(User.class), pageSize, offset);这里BeanPropertyRowMapper会按数据库字段名到 Java 属性名的驼峰映射规则自动完成结果映射。例如数据库字段create_time会映射到 Java 属性的createTime前提是项目中开启了map-underscore-to-camel-case或BeanPropertyRowMapper本身支持下划线转驼峰。Spring JDK 8 以后默认支持这个转换建议了解即可。3.3 COUNT 查询分页功能除了查当前页数据还必须返回总条数total前端才能计算总页数。String countSql SELECT COUNT(1) FROM user; Integer total jdbcTemplate.queryForObject(countSql, Integer.class);这里有一个工程层面的重要建议COUNT 查询不要带ORDER BY不要带LIMIT。因为 COUNT 只需要统计数量排序和限制条数都是多余开销。如果需要条件查询两个 SQL 的条件部分必须保持一致String condition WHERE name LIKE ?; String countSql SELECT COUNT(1) FROM user condition; String listSql SELECT id, name, age FROM user condition ORDER BY id LIMIT ? OFFSET ?;条件部分不一致会导致列表条数和总条数对不上。4. 完整实战案例Spring Boot Spring JDBC 分页接口下面通过一个完整的用户列表分页接口演示从建表到前端联调的全过程。4.1 初始化数据库表CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL, age INT DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;插入一些测试数据INSERT INTO user (name, age) VALUES (张三, 18), (李四, 20), (王五, 22), (赵六, 25), (钱七, 28), (孙八, 30), (周九, 32), (吴十, 35);4.2 编写分页基础类分页对象应该包含两个部分PageRequest请求参数包含pageNum、pageSize。PageResult返回结果包含total、list、pageNum、pageSize、totalPages。// 文件路径src/main/java/com/example/paging/common/PageRequest.java package com.example.paging.common; public class PageRequest { private int pageNum 1; private int pageSize 10; public int getOffset() { return (pageNum - 1) * pageSize; } // getter / setter 省略 }// 文件路径src/main/java/com/example/paging/common/PageResult.java package com.example.paging.common; import java.util.List; public class PageResultT { private long total; private ListT list; private int pageNum; private int pageSize; private int totalPages; public PageResult(long total, ListT list, int pageNum, int pageSize) { this.total total; this.list list; this.pageNum pageNum; this.pageSize pageSize; this.totalPages (int) Math.ceil((double) total / pageSize); } // getter / setter 省略 }getOffset()方法放在PageRequest里可以避免每个 DAO 方法都重复计算偏移量这是个很实用的小设计。4.3 编写实体类// 文件路径src/main/java/com/example/paging/entity/User.java package com.example.paging.entity; import java.time.LocalDateTime; public class User { private Integer id; private String name; private Integer age; private LocalDateTime createTime; // getter / setter 省略 }4.4 编写 DAO 层DAO 层负责最基础的数据访问逻辑。这里注意一个问题BeanPropertyRowMapper是org.springframework.jdbc.core.BeanPropertyRowMapper不要和 MyBatis 的RowMapper混淆。// 文件路径src/main/java/com/example/paging/dao/UserDao.java package com.example.paging.dao; import com.example.paging.common.PageRequest; import com.example.paging.common.PageResult; import com.example.paging.entity.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.BeanPropertyRowMapper; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.List; Repository public class UserDao { Autowired private JdbcTemplate jdbcTemplate; private static final String BASE_COLUMN id, name, age, create_time; private static final String BASE_TABLE user; public PageResultUser selectPage(PageRequest pageRequest) { // 1. 查询总条数 String countSql SELECT COUNT(1) FROM BASE_TABLE; Integer total jdbcTemplate.queryForObject(countSql, Integer.class); // 2. 查询当前页数据 String listSql SELECT BASE_COLUMN FROM BASE_TABLE ORDER BY id LIMIT ? OFFSET ?; ListUser users jdbcTemplate.query( listSql, new BeanPropertyRowMapper(User.class), pageRequest.getPageSize(), pageRequest.getOffset() ); return new PageResult(total, users, pageRequest.getPageNum(), pageRequest.getPageSize()); } }这里最重要的是一行代码jdbcTemplate.query(listSql, new BeanPropertyRowMapper(User.class), pageRequest.getPageSize(), pageRequest.getOffset());query方法后面的可变参数会按顺序绑定到 SQL 的?占位符上先绑定LIMIT ?再绑定OFFSET ?顺序一定不能反。4.5 编写 Service 层Service 层做参数校验和业务逻辑组织。// 文件路径src/main/java/com/example/paging/service/UserService.java package com.example.paging.service; import com.example.paging.common.PageRequest; import com.example.paging.common.PageResult; import com.example.paging.dao.UserDao; import com.example.paging.entity.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; Service public class UserService { Autowired private UserDao userDao; public PageResultUser listUsers(int pageNum, int pageSize) { if (pageNum 1) { pageNum 1; } if (pageSize 1 || pageSize 100) { pageSize 10; } PageRequest pageRequest new PageRequest(); pageRequest.setPageNum(pageNum); pageRequest.setPageSize(pageSize); return userDao.selectPage(pageRequest); } }pageSize限制上限是很有必要的。如果不做限制调用方传入一个极大的值比如pageSize 9999999数据库会一次性查出来大量数据直接拖垮服务。4.6 编写 Controller 层// 文件路径src/main/java/com/example/paging/controller/UserController.java package com.example.paging.controller; import com.example.paging.common.PageResult; import com.example.paging.entity.User; import com.example.paging.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/user) public class UserController { Autowired private UserService userService; GetMapping(/page) public PageResultUser page( RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize) { return userService.listUsers(pageNum, pageSize); } }4.7 启动项目并验证启动 Spring Boot 应用后访问http://localhost:8080/user/page?pageNum1pageSize4预期返回{ total: 8, list: [ {id: 1, name: 张三, age: 18, createTime: 2025-01-01T10:00:00}, {id: 2, name: 李四, age: 20, createTime: 2025-01-01T10:00:01}, {id: 3, name: 王五, age: 22, createTime: 2025-01-01T10:00:02}, {id: 4, name: 赵六, age: 25, createTime: 2025-01-01T10:00:03} ], pageNum: 1, pageSize: 4, totalPages: 2 }再访问第二页http://localhost:8080/user/page?pageNum2pageSize4可以看到返回的是第 5 到第 8 条数据。分页功能和预期一致。5. 分页查询的进阶写法5.1 带查询条件的分页实际项目里几乎没有不带查询条件的列表页。这里以按姓名模糊查询为例。public PageResultUser selectPageByName(PageRequest pageRequest, String name) { String condition WHERE name LIKE ?; String countSql SELECT COUNT(1) FROM BASE_TABLE condition; String listSql SELECT BASE_COLUMN FROM BASE_TABLE condition ORDER BY id LIMIT ? OFFSET ?; Integer total jdbcTemplate.queryForObject(countSql, Integer.class, % name %); ListUser users jdbcTemplate.query( listSql, new BeanPropertyRowMapper(User.class), % name %, pageRequest.getPageSize(), pageRequest.getOffset() ); return new PageResult(total, users, pageRequest.getPageNum(), pageRequest.getPageSize()); }这里有一个小细节LIKE ?的?需要传% 关键字 %而LIMIT ?和OFFSET ?传的是整数。参数顺序必须和 SQL 里占位符顺序一致先条件参数再分页参数。如果项目里条件字段越来越多建议使用NamedParameterJdbcTemplate它可以用命名参数替代?可读性更好尤其在参数很多的时候不容易搞错顺序。// NamedParameterJdbcTemplate 示例 String sql SELECT id, name, age FROM user WHERE name LIKE :name ORDER BY id LIMIT :limit OFFSET :offset; MapSqlParameterSource params new MapSqlParameterSource(); params.addValue(name, % name %); params.addValue(limit, pageRequest.getPageSize()); params.addValue(offset, pageRequest.getOffset()); ListUser users namedParameterJdbcTemplate.query(sql, params, new BeanPropertyRowMapper(User.class));这两个模板可以共存于一个 DAO 中JdbcTemplate处理简单 SQLNamedParameterJdbcTemplate处理多条件动态 SQL分工明确。5.2 NamedParameterJdbcTemplate 分页如果你更喜欢命名参数的方式可以在配置类中手动创建 Bean// 文件路径src/main/java/com/example/paging/config/JdbcConfig.java Configuration public class JdbcConfig { Bean public NamedParameterJdbcTemplate namedParameterJdbcTemplate(JdbcTemplate jdbcTemplate) { return new NamedParameterJdbcTemplate(jdbcTemplate); } }它的核心优势是参数和 SQL 分离调试多条件查询时能少很多无意义的数位排查。6. 常见问题与排查思路分页功能写起来不难但一旦数据量上来问题就变得非常具体。下面整理几个高频现象和解决思路。问题现象常见原因解决思路第一页正常第二页以后数据重复或缺失OFFSET计算错误把pageNum当成offset使用(pageNum - 1) * pageSize报错Parameter index out of range参数数量和?占位符数量不一致按 SQL 顺序核对参数分页数据条数正确但总条数不对COUNT SQL 和查询 SQL 条件不一致统一提取公共条件片段数据量 10 万以后越往后翻越慢LIMIT ? OFFSET ?在大偏移量下扫描效率低优化方案见下文BeanPropertyRowMapper映射部分字段为空数据库字段和 Java 属性命名对不上检查是否为createTime和create_time可自定义 RowMapper6.1 复制粘贴即可运行的排查清单遇到分页问题按下面顺序排查先打印最终执行的 SQL看LIMIT和OFFSET的真实值。确认pageNum是否从 1 开始offset是否执行了(pageNum - 1) * pageSize。对比 COUNT SQL 和 LIST SQL 的 WHERE 条件。检查参数绑定顺序条件参数 → LIMIT → OFFSET。检查数据库字段映射。6.2 MyBatis-Plus 分页失效的对比提醒很多从 MyBatis-Plus 转过来的开发者会习惯性地找类似PageHelper的插件或者认为框架层面会自动拼接LIMIT。Spring JDBC 没有这个能力也没有分页拦截器。你必须自己写LIMIT。这是 Spring JDBC 分页和 MyBatis-Plus 分页最大的区别。MyBatis-Plus 分页失效通常是因为没有配置分页插件PaginationInnerInterceptor而 Spring JDBC 则是从一开始就没有“自动分页”这个概念。两种技术思路完全不同不要混淆。6.3 Oracle 分页差异如果你的项目连接的是 Oracle 数据库分页 SQL 不能使用 MySQL 的LIMIT写法。Oracle 12c 及以上版本推荐SELECT * FROM user ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;Oracle 11g 及以下版本通常使用ROWNUM三层嵌套SELECT * FROM ( SELECT t.*, ROWNUM rn FROM ( SELECT * FROM user ORDER BY id ) t WHERE ROWNUM 30 ) WHERE rn 20;从工程角度建议在 DAO 层把数据库方言差异隔离开不要让业务层感知到不同数据库的 SQL 差异。项目如果有跨库迁移需求优先统一到标准写法。7. 分页查询的性能优化与最佳实践7.1 大偏移量问题LIMIT 1000000, 10为什么慢因为 MySQL 需要扫描并丢弃前面的 1000000 行才能返回最后 10 行。即使这些行不需要返回数据库也付出了扫描成本。优化方式一延迟关联也叫 延迟 join先只查主键再用主键关联回原表SELECT u.id, u.name, u.age FROM user u INNER JOIN ( SELECT id FROM user ORDER BY id LIMIT 1000000, 10 ) tmp ON u.id tmp.id ORDER BY u.id;子查询只查主键列可以走覆盖索引代价小很多再通过主键回表取完整数据。优化方式二游标分页基于上次位置对于“加载更多”类场景不需要页码只需要记住上一页最后一条记录的 IDSELECT * FROM user WHERE id #{lastId} ORDER BY id LIMIT 10;这种方式无论翻到第几页性能都很快缺点是无法直接跳页。适合小程序、App 信息流列表不适合后台管理系统的页码跳转。7.2 分页与索引分页查询涉及排序时索引的作用非常关键。SELECT * FROM user ORDER BY id LIMIT 10 OFFSET 20;主键id本身有索引所以按id排序走主键索引性能不错。但如果按create_time排序SELECT * FROM user ORDER BY create_time LIMIT 10 OFFSET 20;没有索引时MySQL 需要先排序再分页这个过程叫filesort数据量大时很慢。建议在create_time上建索引ALTER TABLE user ADD INDEX idx_create_time (create_time);另外如果查询条件里有WHERE age 18 ORDER BY id可以考虑联合索引ALTER TABLE user ADD INDEX idx_age_id (age, id);联合索引的字段顺序有讲究等值条件字段放前面排序字段放后面这样索引可以同时覆盖“过滤”和“排序”两个动作。7.3 COUNT 查询优化分页接口每次都要执行 COUNT。SELECT COUNT(1)在 MyISAM 引擎下很快因为引擎会存储总行数但 InnoDB 需要实时扫描统计表越大越慢。几条工程建议条件字段尽量走索引COUNT 查询的 WHERE 条件和列表查询保持一致。如果 COUNT 超过 100ms考虑做计数缓存例如 Redis 缓存总条数。对于超大表的管理后台可以放弃精确 COUNT前端改为“加载更多”模式本质上是游标分页避免每次都做全表 COUNT。7.4 参数校验与防注入分页参数必须校验pageNum最小为 1。pageSize必须限制合理范围例如 1 到 100。排序字段不要直接拼 SQL。如果前端可以传排序字段千万不要直接拼接// 危险写法orderBy 直接拼进 SQL String sql SELECT * FROM user ORDER BY orderBy LIMIT ? OFFSET ?;正确做法是维护一个白名单private static final MapString, String ORDER_BY_MAP new HashMap(); static { ORDER_BY_MAP.put(id, id); ORDER_BY_MAP.put(createTime, create_time); ORDER_BY_MAP.put(age, age); } String orderByColumn ORDER_BY_MAP.getOrDefault(orderBy, id); String sql SELECT * FROM user ORDER BY orderByColumn LIMIT ? OFFSET ?;7.5 分页对象与前端对接前端组件通常需要这四个字段total、list、pageNum、pageSize。有些组件还需要totalPages比如 Element UI 的el-pagination根据比例计算即可。如果前端要求字段名是records、current、size建议后端不要为了迁就前端改字段名而是在 VO 层做一次字段映射转换。保持后端分页对象统一工程维护成本更低。7.6 生产环境注意事项分页查询必须强调合法授权用户只能查看自己权限范围内的数据注意在 SQL 中带上数据权限条件。涉及生产环境变更时大表加索引要评估锁表时间优先使用pt-online-schema-change等在线变更工具。分页 SQL 上线前利用EXPLAIN确认执行计划避免出现全表扫描。日志记录时不要打印完整的 SQL 参数防止敏感信息泄露。8. 总结与下一步学习路线通过本文你已经掌握了 Spring JDBC 分页的完整链路包括LIMIT/OFFSET参数计算、JdbcTemplate参数绑定、Page 对象设计、COUNT 查询组合以及带条件查询的写法也知道了不同数据库方言的分页写法差异。接下来可以继续学习NamedParameterJdbcTemplate的批量操作与动态 SQL 构造。Spring Boot 统一返回结果封装与全局异常处理。索引优化、慢查询日志分析。MyBatis 和 Spring JDBC 在分页设计上的异同。如果你正在做分页相关功能建议从最简单的方案开始先保证功能正确数据量增长后再考虑延迟关联和游标分页。动手写一个完整的用户列表分页接口把LIMIT参数、COUNT 查询、PageResult 这三个点跑通比看十遍文档更有用。
返回列表