Java案例如何实现分页查询?

wen java案例 12

深入解析Java分页查询:从基础原理到实战案例完全指南

关键词:Java分页查询、MyBatis分页、Spring Data JPA分页、数据库分页优化、PageHelper

Java案例如何实现分页查询?


📖 目录导读

  1. 为什么需要分页查询?核心痛点分析
  2. 分页查询的底层实现原理
  3. Java实现分页的四大主流方案
    • 1 原生JDBC分页(传统方式)
    • 2 MyBatis + PageHelper插件(最常用)
    • 3 Spring Data JPA分页(自动化方案)
    • 4 SQL手动分页(数据库特性)
  4. 实战案例:用户管理系统分页查询完整代码
  5. 分页查询的性能优化策略
  6. 高频问答:开发者最困惑的5个问题

为什么需要分页查询?核心痛点分析

在日常Java开发中,数据表动辄几十万甚至上百万条记录,假设没有分页:

  • 内存崩溃风险:一次性加载100万条记录到内存,JVM直接OOM
  • 网络传输瓶颈:前端展示2000条数据需耗时5秒以上,严重影响用户体验
  • 数据库压力激增:全表扫描消耗大量I/O资源,导致其他查询响应缓慢

真实场景:某电商后台管理系统,订单表数据量300万+,用户查询“所有订单”时,系统直接宕机,这正是分页查询必须存在的理由——只加载当前页面需要的数据,同时保留全量数据的可访问性。


分页查询的底层实现原理

分页核心公式可抽象为两个参数:

limit startIndex, pageSize
// startIndex = (currentPage - 1) * pageSize
// pageSize:每页显示条数
// currentPage:当前页码(从1开始)

数据库层原理(以MySQL为例):

  • MySQL使用LIMIT关键字实现物理分页
  • 通过OFFSET跳过指定行数,FETCH NEXT(SQL Server)或ROWNUM(Oracle)类似
  • 关键要理解:OFFSET越大,性能越差(需要跳过越多记录)

应用层原理

  • 接收前端传入的pageNumpageSize
  • 生成带LIMIT的SQL语句
  • 同时执行COUNT(*)获取总记录数
  • 将结果封装为分页对象(包含当前页数据、总条数、总页数等)

Java实现分页的四大主流方案

1 原生JDBC分页(最基础方式)

public PageResult<User> findUsersByPage(int pageNum, int pageSize) {
    // 计算起始索引
    int startIndex = (pageNum - 1) * pageSize;
    // 查询当前页数据
    String dataSql = "SELECT * FROM user LIMIT ?, ?";
    PreparedStatement ps = conn.prepareStatement(dataSql);
    ps.setInt(1, startIndex);
    ps.setInt(2, pageSize);
    List<User> list = resultSetToUserList(ps.executeQuery());
    // 查询总数
    String countSql = "SELECT COUNT(*) FROM user";
    // ...执行查询获取total
    return new PageResult<>(list, total, pageNum, pageSize);
}

缺点:需要手动处理COUNT查询,代码冗余。

2 MyBatis + PageHelper(企业最常用方案)

依赖配置

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>1.4.6</version>
</dependency>

核心用法

@Service
public class UserServiceImpl implements UserService {
    @Override
    public PageInfo<User> getUsersByPage(int pageNum, int pageSize) {
        // 开启分页,必须在查询前一行的位置
        PageHelper.startPage(pageNum, pageSize);
        // 正常的查询方法,无需添加limit
        List<User> userList = userMapper.selectAll();
        // 封装分页信息
        return new PageInfo<>(userList);
    }
}

原理:PageHelper通过拦截器在SQL执行前自动拼接LIMIT语句,同时生成COUNT查询。

关键理解:PageHelper的ThreadLocal机制 —— 每开启一个线程,分页参数自动绑定,查询结束后自动清除。

3 Spring Data JPA分页(自动化程度最高)

// Repository层
public interface UserRepository extends JpaRepository<User, Long> {
    // 方法命名查询,自动支持分页
    Page<User> findByAgeGreaterThan(int age, Pageable pageable);
    // 也可以使用@Query
    @Query("SELECT u FROM User u WHERE u.name LIKE %:name%")
    Page<User> searchByName(@Param("name") String name, Pageable pageable);
}
// Service层
public Page<User> getUsers(int pageNum, int pageSize) {
    Pageable pageable = PageRequest.of(pageNum - 1, pageSize, Sort.by("createTime").descending());
    return userRepository.findByAgeGreaterThan(18, pageable);
}

4 SQL手动分页(适合复杂查询)

当分页逻辑需要在SQL层面精细控制时:

-- MySQL 使用 LIMIT/OFFSET
SELECT * FROM orders WHERE status = 'PAID' ORDER BY create_time DESC LIMIT 10 OFFSET 20;
-- Oracle 使用 ROWNUM(三层嵌套)
SELECT * FROM (
    SELECT t.*, ROWNUM rn FROM (
        SELECT * FROM orders WHERE status = 'PAID' ORDER BY create_time DESC
    ) t WHERE ROWNUM <= 30
) WHERE rn > 20;
-- SQL Server 使用 OFFSET/FETCH(2012+)
SELECT * FROM orders ORDER BY create_time DESC OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;

实战案例:用户管理系统分页查询完整代码

1 项目结构

src/main/java/com/example/demo/
├── controller/UserController.java
├── service/UserService.java
├── mapper/UserMapper.java
├── entity/User.java
└── common/PageResult.java

2 分页统一返回对象

public class PageResult<T> {
    private List<T> list;      // 当前页数据
    private long total;        // 总记录数
    private int pageNum;       // 当前页码
    private int pageSize;      // 每页大小
    private int pages;         // 总页数
    private boolean isFirst;   // 是否为第一页
    private boolean isLast;    // 是否为最后一页
    public PageResult(List<T> list, long total, int pageNum, int pageSize) {
        this.list = list;
        this.total = total;
        this.pageNum = pageNum;
        this.pageSize = pageSize;
        this.pages = (int) (total % pageSize == 0 ? total / pageSize : total / pageSize + 1);
        this.isFirst = pageNum == 1;
        this.isLast = pageNum >= pages;
    }
}

3 Controller层完整代码

@RestController
@RequestMapping("/api/users")
public class UserController {
    @Autowired
    private UserService userService;
    @GetMapping
    public Result<PageResult<UserDto>> getUserList(
            @RequestParam(defaultValue = "1") Integer pageNum,
            @RequestParam(defaultValue = "10") Integer pageSize,
            @RequestParam(required = false) String keyword) {
        // 参数校验
        if (pageNum < 1 || pageSize < 1 || pageSize > 100) {
            return Result.error("分页参数非法");
        }
        PageResult<UserDto> result = userService.queryPage(pageNum, pageSize, keyword);
        return Result.success(result);
    }
}

分页查询的性能优化策略

1 避免“深分页”问题

  • 现象:当pageNum=100000时,LIMIT 999990, 10需要扫描100万行
  • 解决方案
    • 子查询优化SELECT * FROM user WHERE id >= (SELECT id FROM user ORDER BY id LIMIT 999990, 1) LIMIT 10
    • 游标分页:基于上一页最后一条记录的ID,WHERE id > lastId LIMIT 10
    • 限制最大页数:前端只允许查询前100页

2 COUNT查询优化

  • 使用COUNT(*)而非COUNT(1)(MySQL内部优化等价)
  • 为经常作为条件的字段建立索引
  • 考虑使用EXPLAIN分析COUNT执行计划

3 排序优化

  • 排序字段必须建立索引
  • 避免对text、blob类型字段排序
  • 多字段排序时,索引与排序顺序一致

4 缓存策略

  • 对于实时性要求不高的数据,缓存COUNT结果
  • 使用Redis缓存常用分页查询结果,设置过期时间

高频问答:开发者最困惑的5个问题

❓ Q1:PageHelper的原理是什么?为什么调用startPage后必须紧接着查询?

答案:PageHelper使用ThreadLocal存储分页参数。startPage()将参数存入当前线程,MyBatis的Executor执行器拦截到SQL时,检查ThreadLocal中是否有分页参数,有则自动拦截并拼接LIMIT,如果中间插入其他查询,分页参数会被耗用,导致异常。

❓ Q2:使用Spring Data JPA时,pageNum为什么从0开始?

答案:这是Spring Data JPA的底层设计,遵循Spring JDBC规范,在PageRequest.of(int page, int size)中,page参数从0开始。最佳实践:在Controller层将前端传入的pageNum减1后传入。

❓ Q3:分页查询一定要查COUNT吗?如何优化?

答案:不一定,如果前端只需要“加载更多”而非分页器,可以使用游标分页(基于ID或时间戳),不查COUNT,但传统分页需要COUNT来显示总页数,优化方案:

  • 使用EXPLAIN估算COUNT(不精确)
  • 使用Redis缓存COUNT结果
  • 使用MySQL的information_schema估算(仅适用于精确不高的场景)

❓ Q4:百万级数据分页,使用ORDER BY会导致性能问题吗?

答案:会,索引排序是关键:

-- 错误:无法使用索引排序
SELECT * FROM user WHERE status = 1 ORDER BY create_time DESC LIMIT 0,10;
-- 正确:必须建立 (status, create_time) 复合索引

如果索引无法覆盖,考虑使用覆盖索引只返回主键,再回表查询。

❓ Q5:分页查询结果需要返回总条数吗?为什么?

答案:需要,但视场景而定,原因:

  • 总条数>100万:返回总条数可能不精确(COUNT慢),可改为显示“共100万+”条
  • 非必须场景:如Instagram无限滚动模式,使用游标偏移替代pageNum,无需总条数
  • 建议:业务允许时,后端缓存COUNT结果,避免每次都查

分页查询是Java后端开发中最常见也最容易出错的功能之一,从原生JDBC到Spring Boot集成,开发者需要理解:

  • 两种分页方式:逻辑分页(不推荐)和物理分页(推荐)
  • 工具的选择:PageHelper适合MyBatis生态,JPA自带分页适合新项目
  • 性能的平衡:不要盲目依赖框架,理解SQL层面的优化原理

最佳实践建议

  1. 新项目优先使用Spring Data JPA的分页方案
  2. 已有MyBatis项目使用PageHelper,注意ThreadLocal问题
  3. 面对大数据量时,考虑游标分页替代传统OFFSET分页
  4. 始终为排序字段建立复合索引

最后记住:没有万能的方案,只有最合适的针对业务场景的权衡

抱歉,评论功能暂时关闭!