分页查询是Java后端开发中最常见的需求之一。无论是管理后台的用户列表,还是电商平台的商品展示,只要涉及大量数据的展示,就离不开分页。一次把几十万条记录全部查出来放到内存里,不仅会让接口响应变慢,还可能直接导致内存溢出。本文将从数据库语句和Java逻辑实现两个层面,详细讲解分页查询的完整实现过程。

一、分页的底层原理与数据库分页语句
分页的本质是:根据当前页码和每页条数,计算出需要跳过的记录数,然后从数据库中只取出这一段数据。假设每页显示pageSize条,当前是第pageNum页,那么跳过的记录数就是(pageNum - 1) * pageSize。理解了这个公式,所有的分页实现都只是围绕它做封装。
不同数据库的分页语法差异较大。MySQL使用limit子句,这是最简洁的方式:
-- 跳过20条,取10条,即查询第3页(每页10条) SELECT * FROM t_user ORDER BY id LIMIT 20, 10;
Oracle在12c之前的版本没有limit,需要借助rownum伪列来实现,写法上要嵌套一层子查询:
SELECT * FROM (
SELECT t.*, ROWNUM rn FROM t_user t WHERE ROWNUM <= 30
) WHERE rn > 20;
需要注意的是,ROWNUM的过滤条件如果写成ROWNUM > 20放在外层是无效的,因为ROWUM是在取数据的过程中逐行赋值的,先过滤再编号,所以大于条件必须放在子查询外面,通过别名rn来判断。PostgreSQL的写法和MySQL类似,使用LIMIT 10 OFFSET 20。SQL Server则可以用OFFSET ... FETCH或者ROW_NUMBER()窗口函数。写分页SQL时一定要配合ORDER BY,否则数据库不保证返回顺序,翻页时可能出现重复或遗漏数据的问题。
二、JDBC手工分页的完整实现
在不使用任何框架的场景下,可以用原生JDBC实现分页。核心逻辑分三步:第一步校验并规范化分页参数,第二步执行count语句查询总记录数,第三步执行带limit的分页语句查出当前页数据。
public class PageResult<T> {
private long total; // 总记录数
private int pageNum; // 当前页码
private int pageSize; // 每页条数
private int totalPages; // 总页数
private List<T> list; // 当前页数据
// 计算总页数:向上取整
public int calcTotalPages() {
if (pageSize <= 0) return 0;
return (int) ((total + pageSize - 1) / pageSize);
}
}
参数校验是不可省略的环节。前端传来的pageNum可能是0或者负数,pageSize可能传一个特别大的值甚至被恶意传入超大数字拖垮数据库,所以服务端必须做兜底:
int pageNum = Math.max(1, paramMap.get("pageNum"));
int pageSize = paramMap.get("pageSize");
if (pageSize < 1) pageSize = 10;
if (pageSize > 100) pageSize = 100; // 限制单页最大条数
long offset = (long)(pageNum - 1) * pageSize;
String sql = "SELECT id, username, email FROM t_user ORDER BY id LIMIT ?, ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setLong(1, offset);
ps.setLong(2, pageSize);
ResultSet rs = ps.executeQuery();
这里有一个容易踩的坑:offset的计算要用long类型。如果pageNum很大而pageSize也较大,(pageNum - 1) * pageSize用int相乘可能溢出变成负数,导致SQL报错。手工分页的好处是完全可控、没有框架依赖,适合小型项目或者学习原理,缺点是代码重复度高,每个列表接口都要写一遍类似的逻辑。
三、MyBatis与PageHelper插件分页
在MyBatis项目中,最流行的做法是引入PageHelper插件。它基于拦截器机制,在SQL执行前自动改写语句加上limit,并自动执行一条count查询,开发者只需要写普通的查询SQL即可。
先在pom.xml中加入依赖,然后在查询方法前调用PageHelper.startPage():
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version>
</dependency>
// 紧跟其后的第一条查询会被自动分页 PageHelper.startPage(pageNum, pageSize); List<User> users = userMapper.selectUserList(queryParam); PageInfo<User> pageInfo = new PageInfo<>(users); // pageInfo中包含total、pageNum、pageSize、pages、list等完整分页信息 return pageInfo;
使用PageHelper有几个注意点。第一,startPage()必须紧挨着查询语句,中间不能插入其他SQL操作,因为它是基于ThreadLocal实现的,如果后面的查询没有消费掉这个分页参数,参数会残留并影响到下一次查询。第二,如果列表查询带有复杂的left join,自动生成的count语句可能较慢,可以通过PageHelper.startPage(pageNum, pageSize, false)关闭count查询,或者手写优化后的count语句。第三,mapper中的SQL不需要写任何limit相关内容,保持单表纯净的查询条件,插件会自动拼接。
四、Spring Data JPA与深分页优化
如果项目使用Spring Data JPA,分页支持开箱即用。只需要在Repository接口中声明返回Page<T>类型的方法,并传入Pageable参数:
public interface UserRepository extends JpaRepository<User, Long> {
Page<User> findByUsernameContaining(String keyword, Pageable pageable);
}
// 调用时构造Pageable,页码从0开始
PageRequest pageable = PageRequest.of(pageNum - 1, pageSize, Sort.by("id").descending());
Page<User> page = userRepository.findByUsernameContaining(keyword, pageable);
long total = page.getTotalElements();
需要特别留意JPA的页码是从0开始的,而前端习惯传从1开始的页码,转换时记得减一,这是实际项目中经常出现的off-by-one问题的来源。
最后谈谈深分页的性能问题。当翻到很靠后的页码时,例如LIMIT 1000000, 10,数据库实际上要先扫过前100万条记录再丢弃,越往后翻越慢。常见的优化方案有两种:一是利用上一页最后一条记录的id做游标式查询,写成WHERE id > lastId LIMIT 10,利用主键索引直接定位,速度几乎不受页码影响;二是先用覆盖索引查出当前页的id集合,再回表取完整数据:
SELECT u.* FROM t_user u
INNER JOIN (
SELECT id FROM t_user ORDER BY id LIMIT 1000000, 10
) t ON u.id = t.id;
这种方式把大量扫描限制在索引范围内,回表只查10条记录,性能提升明显。对于无限滚动的信息流场景,推荐直接采用游标分页(cursor-based pagination),彻底放弃页码概念。
五、方案选型建议
综合来看,三种实现方式各有适用场景:原生JDBC适合理解原理和极简项目;MyBatis配合PageHelper是国内主流方案,灵活度高,对复杂SQL友好;Spring Data JPA适合快速开发的Crud类项目,代码量最少。无论选择哪种方案,都要记住三个原则:分页参数必须服务端校验并限制上限,查询必须带确定的排序字段,深分页场景要考虑游标优化。把这些细节处理好,分页功能才能既好用又稳定。
Java分页查询数据库分页PageHelper修改时间:2026-09-15 13:34:40