ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

SpringJDBC分页实战:多数据库方言适配与深分页性能优化

SpringJDBC分页实战:多数据库方言适配与深分页性能优化 SpringJDBC 系列写到第 14 篇这次单独把分页拎出来讲。原因很简单很多项目从 MyBatis-Plus 切到 SpringJDBC 之后卡住的第一件事往往就是分页。MyBatis-Plus 有 Page 对象和分页插件调用时自动拼接 LIMITSpringJDBC 没有这些现成组件SQL 要自己写总条数要自己查分页结果对象要自己封装。代价是代码量多一些但好处是每一步都透明可控不会出现 MyBatis-Plus 在子查询或自定义 SQL 场景下分页失效之类的意外。SpringJDBC 分页本质上只需要解决两件事拼对当前数据库的分页方言 SQL以及维护一个统一的分页返回结构。本文按一条完整落地路径来写先做一个基于 JdbcTemplate 的 MySQL 分页查询再封装通用 PageResult 和 NamedParameterJdbcTemplate 动态条件分页接着处理 Oracle 12c、Oracle 11g、PostgreSQL、SQL Server 的分页方言差异最后给出 REST 分页接口、批量分页扫描任务以及分页和索引的优化方案。内容偏实战适合正在使用 SpringJdbcTemplate 或 NamedParameterJdbcTemplate 做后端接口的团队也适合从 MyBatis-Plus 迁移到原生 JDBC 的开发者直接对照着改代码。1. 核心能力速览能力项说明项目主题SpringJDBC 分页实现系列第 14 篇核心 APIJdbcTemplate、NamedParameterJdbcTemplate、RowMapper分页方式显式 SQL 拼接不依赖第三方分页插件数据库支持MySQL、Oracle12c/11g、PostgreSQL、SQL Server按方言适配动态条件使用 MapSqlParameterSource 参数绑定避免 SQL 注入批量任务通过分页循环扫描实现支持大数据量分批处理接口能力可封装 REST 分页接口返回统一 PageResult 结构前置依赖spring-jdbc / spring-boot-starter-jdbc 对应数据库驱动学习成本低掌握 JdbcTemplate 用法即可上手从这张表可以看出来SpringJDBC 分页并没有像 MyBatis-Plus 那样的“自动分页魔法”它的分页是显式 SQL。写进 SQL 的 LIMIT、OFFSET、ROWNUM 是什么数据库就执行什么结果完全可以预期。这既是它的优点也是它的成本每个数据库的方言差异都需要自己处理尤其当项目同时使用 MySQL 和 Oracle 时分页 SQL 的维护要格外注意。2. 适用场景与使用边界SpringJDBC 分页适合中小团队、内部管理系统、报表后台以及那些对 SQL 可控性要求高、不希望框架替自己生成复杂查询的项目。JdbcTemplate 本身足够轻量一个 Repository 类、一个 RowMapper、一段拼好的 SQL就能完成一个稳定的分页接口排查问题也方便SQL 报错直接定位到具体语句即可。不适合的场景也很明确。如果业务模型非常复杂需要处理多级嵌套对象、懒加载、级联保存SpringJDBC 手写映射的成本会直线上升这时候 JPA 或 MyBatis 可能更合适。另外在数据量达到百万、千万级别时裸用 LIMIT 加 OFFSET 的深分页方案无论用什么框架都会慢SpringJDBC 也不会例外这时候需要换成分页扫描或游标分页的思路。还有一个容易被忽略的边界是权限与合规。分页接口通常会暴露列表数据和总条数如果列表里包含用户手机号、身份证、邮箱等敏感字段后端必须做权限校验和字段裁剪不能只依赖前端隐藏列。任何涉及个人信息、版权素材的分页导出或批量任务都要先确认数据来源合法、用途在授权范围内测试环境建议使用脱敏数据避免把生产数据直接用于联调。3. 环境准备与前置条件本节以 Spring Boot 3.x 为例JDK 17、MySQL 8.x其他版本按自己项目的实际环境调整。首先要引入 spring-boot-starter-jdbc 和 MySQL 驱动依赖Spring Boot 2.x 对应的 MySQL 驱动坐标是 mysql:mysql-connector-java这里给出的是 3.x 的写法。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency数据源配置写在 application.yml 里注意数据库名、账号密码替换成自己的值字符集和时区参数建议保留避免中文乱码和日期偏差。spring: datasource: url: jdbc:mysql://127.0.0.1:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver本文统一使用一张 sys_user 用户表做演示建表语句如下。底层是 InnoDB主键 id 自增created_at 建了普通索引后面讲分页和索引时会用到这个索引。CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(64) NOT NULL COMMENT 用户名, email VARCHAR(128) DEFAULT NULL COMMENT 邮箱, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态 1-正常 0-禁用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), KEY idx_created_at (created_at) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 用户表;环境准备阶段还要确认一件事目标数据库到底用哪个方言。如果项目是 MySQL 单库后面的方言章节可以跳过如果项目同时对接 MySQL 和 Oracle建议提前把分页 SQL 的拼接逻辑抽成独立方法否则每个 Repository 里都会散落各种方言判断后面维护成本很高。4. 基础分页实现JdbcTemplate 写第一个分页查询4.1 准备实体类和 RowMapper先建一个 User 实体这里用 Lombok 的 Data 简化 getter/setter。实体字段与表字段一一对应createdAt 使用 LocalDateTime 类型。Data public class User { private Long id; private String username; private String email; private Integer status; private LocalDateTime createdAt; }JdbcTemplate 查询结果需要手动映射到对象RowMapper 是分页代码里最常见的伙伴。每个字段从 ResultSet 里取出后 set 进对象注意下划线字段名和驼峰属性的对应关系。Component public class UserRowMapper implements RowMapperUser { Override public User mapRow(ResultSet rs, int rowNum) throws SQLException { User user new User(); user.setId(rs.getLong(id)); user.setUsername(rs.getString(username)); user.setEmail(rs.getString(email)); user.setStatus(rs.getInt(status)); user.setCreatedAt(rs.getObject(created_at, LocalDateTime.class)); return user; } }4.2 LIMIT 分页查询与总数统计MySQL 的分页语法是LIMIT offset, pageSize其中 offset 等于(pageNum - 1) * pageSize。注意 pageNum 从 1 开始如果前端传 0 或负数要在入口处做归一化否则 offset 会算出负值SQL 直接报错。Repository public class UserRepository { private final JdbcTemplate jdbcTemplate; public UserRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } public ListUser selectPage(int pageNum, int pageSize) { int offset (pageNum - 1) * pageSize; String sql SELECT id, username, email, status, created_at FROM sys_user ORDER BY id DESC LIMIT ?, ?; return jdbcTemplate.query(sql, new UserRowMapper(), offset, pageSize); } public long count() { Long total jdbcTemplate.queryForObject( SELECT COUNT(*) FROM sys_user, Long.class); return total null ? 0L : total; } }这里使用了 JdbcTemplate 的可变参数重载query(String sql, RowMapperT rowMapper, Object... args)LIMIT 的两个占位符分别传入 offset 和 pageSize由 PreparedStatement 参数绑定完成不拼接用户输入。分页查询和总数统计是两个独立的 SQL这是 SpringJDBC 分页最基础也最重要的结构一个查列表一个查 COUNT两个 SQL 的 WHERE 条件必须保持一致否则就会出现总条数和列表对不上的问题。5. 通用分页封装PageResult 动态条件分页5.1 定义 PageResult 分页结果对象每个分页接口最终都要向前端返回页码、每页大小、总条数、总页数和当前页数据五个信息直接把这五个字段封装成一个泛型类所有接口共用。totalPages 通过(total pageSize - 1) / pageSize向上取整得到注意 pageSize 必须先校验大于 0避免除零异常。public class PageResultT { private final int pageNum; private final int pageSize; private final long total; private final int totalPages; private final ListT records; public PageResult(int pageNum, int pageSize, long total, ListT records) { this.pageNum pageNum; this.pageSize pageSize; this.total total; this.records records null ? Collections.emptyList() : records; this.totalPages (int) ((total pageSize - 1) / pageSize); } public static T PageResultT of(int pageNum, int pageSize, long total, ListT records) { return new PageResult(pageNum, pageSize, total, records); } public int getPageNum() { return pageNum; } public int getPageSize() { return pageSize; } public long getTotal() { return total; } public int getTotalPages() { return totalPages; } public ListT getRecords() { return records; } }5.2 NamedParameterJdbcTemplate 动态条件分页基础 LIMIT 分页只能查全表实际业务里分页列表几乎都带搜索条件。用 JdbcTemplate 拼动态 WHERE 会非常痛苦因为问号占位符的顺序很容易错位。换成 NamedParameterJdbcTemplate 之后条件用:参数名绑定MapSqlParameterSource 负责统一管理参数顺序不再影响结果。下面的查询对象 UserQuery 包含分页参数和两个可选过滤条件username 使用模糊匹配status 使用精确匹配两个条件都可能为空。Data public class UserQuery { private int pageNum 1; private int pageSize 10; private String username; private Integer status; }Repository 里先拼接 WHERE 条件再分别执行 COUNT 和列表查询。注意列表 SQL 末尾拼接了LIMIT :offset, :pageSizeoffset 和 pageSize 在查询之前才加入参数集合这样 COUNT 执行时不会带着这两个多余参数逻辑更清晰。Repository public class UserRepository { private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; public UserRepository(NamedParameterJdbcTemplate namedParameterJdbcTemplate) { this.namedParameterJdbcTemplate namedParameterJdbcTemplate; } public PageResultUser selectPageByCondition(UserQuery query) { int pageNum Math.max(query.getPageNum(), 1); int pageSize Math.min(Math.max(query.getPageSize(), 1), 200); int offset (pageNum - 1) * pageSize; MapSqlParameterSource params new MapSqlParameterSource(); StringBuilder where new StringBuilder( WHERE 1 1 ); if (StringUtils.hasText(query.getUsername())) { where.append( AND username LIKE :username ); params.addValue(username, % query.getUsername() %); } if (query.getStatus() ! null) { where.append( AND status :status ); params.addValue(status, query.getStatus()); } String countSql SELECT COUNT(*) FROM sys_user where; Long total namedParameterJdbcTemplate.queryForObject(countSql, params, Long.class); String dataSql SELECT id, username, email, status, created_at FROM sys_user where ORDER BY id DESC LIMIT :offset, :pageSize; params.addValue(offset, offset); params.addValue(pageSize, pageSize); ListUser records namedParameterJdbcTemplate.query(dataSql, params, new UserRowMapper()); return PageResult.of(pageNum, pageSize, total null ? 0L : total, records); } }这里的WHERE 1 1不是性能问题InnoDB 优化器会直接忽略这个恒真条件它只是让后续条件拼接时不用判断“是否是第一个条件”而已。模糊查询使用%拼接在参数值上而不是拼在 SQL 里依然走参数绑定避免注入风险。5.3 泛型分页工具方法如果项目中有大量分页查询可以把“COUNT 列表 LIMIT 拼接”的逻辑抽成一个泛型工具方法。调用方只需要传 COUNT SQL、列表 SQL、参数集合和 RowMapper分页的公共流程全部收口到一处后续如果要统一加日志、限流、分页参数校验只改这一个方法即可。public final class PaginationUtils { private PaginationUtils() { } public static T PageResultT queryByMysql( NamedParameterJdbcTemplate jdbcTemplate, String countSql, String dataSql, MapSqlParameterSource params, RowMapperT rowMapper, int pageNum, int pageSize) { Long total jdbcTemplate.queryForObject(countSql, params, Long.class); int offset (pageNum - 1) * pageSize; MapSqlParameterSource pageParams new MapSqlParameterSource(); pageParams.addValues(params.getValues()); pageParams.addValue(offset, offset); pageParams.addValue(pageSize, pageSize); String pageSql dataSql LIMIT :offset, :pageSize; ListT records jdbcTemplate.query(pageSql, pageParams, rowMapper); return PageResult.of(pageNum, pageSize, total null ? 0L : total, records); } }调用方式如下调用方只需要关心业务 SQL 和 RowMapper分页本身不感知具体业务。这里 dataSql 末尾不要写分号因为工具方法要追加 LIMIT 子句。String countSql SELECT COUNT(*) FROM sys_user WHERE status :status; String dataSql SELECT id, username, email, status, created_at FROM sys_user WHERE status :status ORDER BY id DESC; MapSqlParameterSource params new MapSqlParameterSource(status, 1); PageResultUser page PaginationUtils.queryByMysql( namedParameterJdbcTemplate, countSql, dataSql, params, new UserRowMapper(), 2, 10);6. 多数据库方言适配MySQL、Oracle、PostgreSQL、SQL ServerSpringJDBC 本身不屏蔽数据库方言分页 SQL 要按数据库类型切换。最稳妥的做法是定义一套方言策略对每种数据库生成对应的分页 SQL。下面先逐个看各数据库的分页写法再给出统一适配思路。6.1 MySQL 分页写法MySQL 使用LIMIT offset, pageSize或LIMIT pageSize OFFSET offset前者在 Java 代码里更常见。LIMIT 两个参数都可以用占位符绑定本文第 4、5 节已经给出了完整示例这里不再重复。MySQL 分页需要注意两点一是 ORDER BY 的字段必须有索引支撑否则大数据量下会走文件排序二是不建议在一条 SQL 里用 SQL_CALC_FOUND_ROWS 统计总数大多数场景下单独执行 COUNT 更清晰。6.2 Oracle 分页12c 的 OFFSET FETCH 与 11g 的 ROWNUMOracle 分页是所有方言里最需要小心的。Oracle 12c 及以上版本提供了标准的OFFSET ... ROWS FETCH NEXT ... ROWS ONLY写法和 SQL Server 的语法接近使用占位符绑定即可SELECT id, username, email, status, created_at FROM sys_user ORDER BY id DESC OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;但很多生产库还在用 Oracle 11g11g 不支持 OFFSET FETCH只能用 ROWNUM 两层子查询。ROWNUM 是结果集生成后才编号的所以不能直接写WHERE ROWNUM 20需要先用内层子查询限定最大行号再在外层过滤起始行号SELECT * FROM ( SELECT t.*, ROWNUM AS rn FROM ( SELECT id, username, email, status, created_at FROM sys_user ORDER BY id DESC ) t WHERE ROWNUM 30 ) WHERE rn 20;Java 侧拼接 Oracle 11g 分页时offset 和 endRow 是后端用整数计算出来的必须保证这两个值来自 int 类型不能把前端传来的原始字符串直接拼进 SQL。虽然这里有字符串拼接但因为数值已经经过后端校验和类型转换注入风险可以控制住。int offset (pageNum - 1) * pageSize; int endRow offset pageSize; String sql SELECT * FROM ( SELECT t.*, ROWNUM AS rn FROM ( SELECT id, username, email, status, created_at FROM sys_user ORDER BY id DESC ) t WHERE ROWNUM endRow ) WHERE rn offset;6.3 PostgreSQL 与 SQL Server 分页写法PostgreSQL 使用LIMIT pageSize OFFSET offset和 MySQL 的LIMIT offset, pageSize参数顺序不同写代码时最容易搞混。建议在方言工具类里把两种写法分开封装避免在业务代码里来回调整参数顺序。SELECT id, username, email, status, created_at FROM sys_user ORDER BY id DESC LIMIT 10 OFFSET 20;SQL Server 2012 及以上版本使用OFFSET ... ROWS FETCH NEXT ... ROWS ONLY这个语法和 Oracle 12c 很像但有一个强制要求必须配合 ORDER BY 使用否则 SQL 直接报错。如果业务上确实没有排序字段至少要按主键排一下这也是分页查询的通用要求。SELECT id, username, email, status, created_at FROM sys_user ORDER BY id DESC OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;6.4 方言适配思路项目只用一个数据库时不需要过度设计。但如果你所在团队同时维护 MySQL 和 Oracle 两套环境建议把方言差异收敛到一个接口后面。可以定义一个 PaginationDialect 接口不同数据库分别实现Repository 层根据当前数据源类型选择对应实现业务代码只依赖接口。public interface PaginationDialect { String buildPageSql(String sql, long offset, int pageSize); }public class MySqlDialect implements PaginationDialect { Override public String buildPageSql(String sql, long offset, int pageSize) { return sql LIMIT offset , pageSize; } }public class Oracle12cDialect implements PaginationDialect { Override public String buildPageSql(String sql, long offset, int pageSize) { return sql OFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY; } }实际项目中可以把 dbType 放在配置中心或数据源元数据里启动时注入对应的方言 Bean。这里给的实现故意把 offset 和 pageSize 直接拼进 SQL原因前面说过Oracle 11g 的 ROWNUM 子查询对占位符支持不稳定如果是 MySQL 和 PostgreSQL建议还是用参数绑定把参数放进 MapSqlParameterSource。7. 接口 API 与批量任务7.1 REST 分页接口参数设计REST 分页接口的参数命名建议统一避免每个接口风格不一致。最常见的约定是 pageNum 从 1 开始、pageSize 默认 10通过 RequestParam 接收并给出默认值和上限保护。下面是一个基于 UserRepository 的分页接口示例url 路径为 GET /api/users。RestController RequestMapping(/api/users) public class UserController { private final UserRepository userRepository; public UserController(UserRepository userRepository) { this.userRepository userRepository; } GetMapping public PageResultUser list(RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize, RequestParam(required false) String username, RequestParam(required false) Integer status) { UserQuery query new UserQuery(); query.setPageNum(pageNum); query.setPageSize(pageSize); query.setUsername(username); query.setStatus(status); return userRepository.selectPageByCondition(query); } }接口层要做的第一件事是参数校验。pageNum 小于 1 时归一到 1pageSize 超过 200 时直接截断到 200防止有人用一个特别大的 pageSize 把整个表一次性拉出来。参数校验放在 Repository 入口统一处理Controller 就不用每个方法都写一遍。7.2 分页返回结构与前端页码显示PageResult 序列化成 JSON 后结构如下。前端表格组件只需要绑定 total 和 records 两个字段就能完成基础渲染totalPages 用于计算总页数pageNum 和 pageSize 用于回显当前页码和每页大小。{ pageNum: 1, pageSize: 10, total: 153, totalPages: 16, records: [] }需要注意前端拿到 total 和 totalPages 后页码显示逻辑要跟后端一致。比如前端用 Element UI 的 el-pagination直接绑定 total 即可如果前端用 hiprint 这类打印组件输出带页码的表格totalPages 正好用来计算总页数再由打印组件在每页底部渲染“第 1 页 / 共 16 页”这样的页码后端不需要额外做导出分页。换句话说只要后端把 totalPages 字段稳定返回前端打印分页页码如何显示的问题就归结为前端组件配置不再需要后端配合改接口。7.3 批量分页扫描任务分页不只在接口里用很多定时任务、同步任务也要用分页扫描大表。核心写法是一个 while 循环每页取一批数据处理完后判断是否还有下一页直到当前页超过总页数为止。下面是一个用户同步任务的示例。Component public class UserSyncJob { private final UserRepository userRepository; public UserSyncJob(UserRepository userRepository) { this.userRepository userRepository; } public void syncAllUsers() { int pageNum 1; int pageSize 500; while (true) { PageResultUser page userRepository.selectPageByCondition( buildDefaultQuery(pageNum, pageSize)); if (page.getRecords().isEmpty()) { break; } for (User user : page.getRecords()) { processUser(user); } if (pageNum page.getTotalPages()) { break; } pageNum; } } private UserQuery buildDefaultQuery(int pageNum, int pageSize) { UserQuery query new UserQuery(); query.setPageNum(pageNum); query.setPageSize(pageSize); return query; } private void processUser(User user) { // 具体的同步逻辑建议单条失败单独记录避免中断整个批次 } }批量任务有两个容易踩的坑。第一是单次 pageSize 不要太大500 条左右是一个比较稳妥的量级一次拉几万条虽然也能跑但内存和事务时间都会明显上升。第二是 while 循环必须设置退出条件建议同时依赖 records 为空和 pageNum 超过 totalPages 两个判断防止因为统计条件不一致导致死循环。如果每页处理逻辑涉及外部接口调用还要给单条处理加 try-catch 和失败重试不能因为一个脏数据把整个批次打断。8. 性能观察分页和索引的关键点8.1 深分页为什么慢MySQL 执行LIMIT 100000, 10时并不是直接跳过前面的 100000 行而是先扫描出前 100010 行再丢弃前 100000 行返回最后 10 行。数据量越大OFFSET 越大数据库扫描和丢弃的无效行就越多响应时间会肉眼可见地变慢。这就是分页和索引最常见的问题OFFSET 深分页。当用户翻到第 100 页之后接口变慢往往不是因为服务器性能差而是因为这一条 SQL 本身在做大量无效扫描。解决深分页的思路有两个方向限制翻页深度或者换用游标分页。面向 C 端用户的列表通常只需要前几页可以设置最大可访问页码阻止用户无限翻页。后台管理系统需要导出大量数据时则优先考虑游标分页。8.2 游标分页替代大 OFFSET游标分页的核心思想是记住上次查询的最后一条记录的主键下一页查询时用WHERE id :lastId代替 OFFSET每次查询都能直接走主键索引定位起点不再扫描之前的数据。完成 1 到 100 页的遍历游标分页的开销几乎恒定。public ListUser selectPageByCursor(Long lastId, int pageSize) { String sql SELECT id, username, email, status, created_at FROM sys_user WHERE id :lastId ORDER BY id DESC LIMIT :pageSize; MapSqlParameterSource params new MapSqlParameterSource(); params.addValue(lastId, lastId null ? Long.MAX_VALUE : lastId); params.addValue(pageSize, pageSize); return namedParameterJdbcTemplate.query(sql, params, new UserRowMapper()); }游标分页的代价是没有“跳页”能力前端只能做“下一页”按钮不能直接跳到第 50 页。因此它更适合 feed 流、消息列表、日志扫描和批量同步任务不适合传统后台管理系统的表格分页。如果你的需求是普通表格分页还是要回到 LIMIT 加合理索引的路线。8.3 分页查询的索引设计分页查询的索引设计要同时考虑 WHERE 条件和 ORDER BY 字段。最基础的规则是排序字段要有索引。比如ORDER BY id DESC直接走主键索引性能很好ORDER BY created_at DESC就要依赖 idx_created_at。如果排序字段没有索引MySQL 会对查询结果做 filesort数据量一大就会变慢。当 WHERE 和 ORDER BY 同时存在时比如WHERE status 1 ORDER BY id DESC优先建一个(status, id)的联合索引让过滤和排序同时走索引。如果 SELECT 的字段能全部覆盖进索引还可以把(status, id, username, email, created_at)建成覆盖索引查询时完全不需要回表性能提升最明显代价是索引空间增大、写入变慢需要权衡。8.4 COUNT 统计怎么优化分页接口每次请求都要执行一次 COUNT数据量大时 COUNT 本身也会成为瓶颈。InnoDB 的 COUNT(*) 在没有 WHERE 条件时也要扫描全表无法像 MyISAM 那样直接返回缓存的行数。常见优化手段包括给 COUNT 语句加上 WHERE 条件限定让统计范围变小对超大表使用近似统计或维护一张独立的计数表高频接口对总数做短时间缓存比如 30 秒内不重复统计。如果业务上只要求“加载更多”而不是精确分页也可以直接去掉 COUNT改成前端下拉加载的方式查询性能会明显改善。9. 常见问题与排查方法问题现象可能原因排查方式解决方案总条数与列表数据对不上COUNT 和数据 SQL 的 WHERE 条件不一致对比两个 SQL 的条件拼接逻辑用同一个 StringBuilder 生成 WHERE避免两处条件漂移pageNum 传 0 或负数导致 SQL 报错参数未归一化offset 算出负数查看接口日志中的 SQL 参数入口处统一 Math.max(pageNum, 1)Oracle 11g 分页报 ORA-00918 列歧义ROWNUM 子查询里 SELECT * 与业务字段重名查看具体 SQL 和业务表字段外层显式列出列名或给 t.* 加别名从 MyBatis-Plus 迁移后以为分页失效对 SpringJDBC 机制理解偏差没有分页插件检查 SQL 中是否真的包含 LIMIT显式拼接分页 SQL参考第 4、5 节MySQL LIMIT 参数偶发语法异常数据库驱动版本过旧或 URL 参数配置问题查看完整异常栈确认驱动版本升级 mysql-connector-j 并检查 URL 参数翻到深页之后接口明显变慢OFFSET 过大深分页导致无效扫描EXPLAIN 查看扫描行数限制最大页数或改用游标分页每个分页请求都很慢但列表 SQL 单独执行很快COUNT 全表统计开销大在 MySQL 慢查询日志中看 COUNT 耗时缓存总数、缩小查询范围或改为加载更多批量任务内存飙升或卡死单页拉取条数过大、无退出条件、事务过大观察任务日志中打印的页码和内存曲线单页 500 条以内分批提交循环设置双退出条件数据库服务器内存持续偏高涉及非分页缓冲池占用过高连接数突增、大排序、驱动层资源未释放检查数据库连接数、慢查询和排序操作限制连接池大小、优化分页 SQL、避免一次拉取大结果集分页接口返回慢但接口没报错排序字段无索引走 filesortEXPLAIN 查看 Extra 列是否出现 filesort为排序字段或联合条件创建索引排查分页问题最有效的工具是 EXPLAIN。对分页 SQL 执行EXPLAIN SELECT ...重点看 type、rows 和 Extra 三列。type 出现 ALL 说明全表扫描rows 数值特别大说明扫描行数远超返回行数Extra 出现 filesort 说明排序字段缺索引。这三个信号基本能覆盖大部分分页性能问题。10. 最佳实践与使用建议把分页参数校验固化成一套公共规则。每次分页查询都重复写一遍Math.max(pageNum, 1)和Math.min(pageSize, 200)会非常啰嗦建议放在 PageQuery 基类或分页工具方法里统一处理。这样即使有人新写了一个分页接口忘了校验公共层也能兜住。COUNT 语句和列表语句必须共用同一套 WHERE 条件生成逻辑。最安全的做法是在 Repository 内部用一个方法生成 WHERECOUNT 和列表 SQL 都引用它。如果两个地方各自拼一次条件后续加一个过滤字段时漏改其中一处就会出现总条数和列表不一致的线上事故。排序字段必须白名单化。分页接口经常支持前端传入排序字段如果直接把前端参数拼进 ORDER BY等于把 SQL 注入的口子打开了。正确的做法是让前端传排序字段的枚举值后端用一个 Map 映射到真实列名映射不到就用默认排序。分页接口涉及敏感数据时还要确认当前用户对这批数据有访问权限不能只靠前端控制按钮显示。批量分页任务要有日志、有上限、有失败补偿。每处理完一页打一条日志记录当前页码和剩余条数while 循环设置最大页数保护单条失败先记错误日志重试几次仍失败就丢进失败队列不要让一个异常把整个任务打断。第一次跑批量任务时先用测试环境小数据量验证一轮确认分页条件正确、退出条件生效再放到生产上跑。11. 总结与下一步SpringJDBC 分页并没有真正的难点核心就是三件事拼对当前数据库的分页方言 SQL、维护一份和数据查询完全一致的总数统计、使用统一的分页返回对象。把这套“三件套”在项目里固定下来分页基本不会再出问题。最容易踩的坑集中在三处MySQL 和 Oracle 的方言差异、COUNT 条件与列表条件不一致、深分页导致的性能劣化本文对应的代码和排查方法都可以直接对照使用。这篇文章是 SpringJDBC 系列的第 14 篇重点是单表分页。下一步可以继续讨论多表 JOIN 场景下的分页、分页与事务的配合、读写分离架构下分页的注意事项以及批量写入时如何控制事务粒度。如果你的项目还在用裸 JdbcTemplate 拼分页 SQL建议先把 PageResult 和方言策略抽出来后续所有分页接口都能复用同一套结构省下来的维护成本会非常明显。
返回列表