☰
Hibernate分页原理与实战:从基础用法到深分页优化
2026/10/6 5:13:12 网站建设 项目流程

“Hibernate还有人用吗?”这个问题,我在网上看到过很多次,每次回答都挺矛盾的。一方面Spring Boot默认的JPA实现底层就是Hibernate,你只要写过Spring Data JPA,就避不开这个框架;另一方面被MyBatis惯坏了的同学,总觉得Hibernate太重、太玄、出了bug不知道去哪查。我的观点很直接:不管你现在用什么ORM,Hibernate怎么处理分页、为什么这么处理,都是值得搞明白的基础功。尤其是你手上恰好有个老项目还在用Hibernate 4.2,或者面试时被问到“Hibernate分页到底怎么做的”,这篇东西能给你一个完整的思路。

文章我不会只甩几种API的用法,那没意思。我想把分页实现的方式、底层SQL的变化、深翻页的坑、以及不同框架分页失效的原因串起来聊一次,很多内容是我在实际项目里踩过的,代码也是可以直接抄的。

1. Hibernate分页的两种基础姿势

1.1 setFirstResult和setMaxResults怎么组合

Hibernate里最经典的分页就是在这两个方法上做文章,道理特别简单:

  • setFirstResult(n):设置结果集从第几条开始,对应SQL里的offset。
  • setMaxResults(size):设置最多返回多少条,对应SQL里的limit。
  • 两者搭配就是limit offset, size,也就是第几页的几条数据。

给你一段老项目里最常见的HQL写法,Hibernate 4.2到6.x基本通用:

int pageNo = 2; // 第2页 int pageSize = 20; // 每页20条 Query query = session.createQuery("select u from User u where u.status = :status order by u.id"); query.setParameter("status", 1); query.setFirstResult((pageNo - 1) * pageSize); query.setMaxResults(pageSize); List<User> list = query.list();

这段代码对应到MySQL,方言会帮你翻译成:

select ... from user where status = 1 order by id limit 20, 20;

这里有个细节值得讲:Hibernate之所以能做到“不写方言SQL也能分页”,是因为它内置了数据库方言(Dialect)。比如MySQL方言生成limit ,,Oracle方言生成三层嵌套的rownum写法,SQL Server方言则生成row_number() over(...)。这其实已经帮你屏蔽了不同数据库的差异,但代价是它没法像手写SQL那样极限地针对某一种数据库做优化。

真正开发中我建议你记住一个原则:能用Hibernate的查询对象(Query、Criteria)就不用手写分页SQL,因为方言适配会少操心很多;等你真遇到性能瓶颈,再单独把那一条SQL拿出来手工优化。

1.2 用Criteria怎么分页

在Hibernate 4.2那个年代,项目里还有一种极其常见的方式:Criteria分页。代码长得这样:

Criteria criteria = session.createCriteria(User.class); criteria.add(Restrictions.eq("status", 1)); criteria.addOrder(Order.asc("id")); criteria.setFirstResult((pageNo - 1) * pageSize); criteria.setMaxResults(pageSize); List<User> list = criteria.list();

这种写法本质和HQL没区别,底层生成的SQL也一样。唯一的差别是代码风格,Criteria更面向对象,不用写字符串HQL,重构字段名的时候不容易漏改。

但要提醒一点,Hibernate 5.2以后把原来的Criteria标记为过时了,官方推荐的是JPA标准的CriteriaQuery。如果你是在新项目里,应该用下面这种方式:

CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<User> cq = cb.createQuery(User.class); Root<User> root = cq.from(User.class); cq.where(cb.equal(root.get("status"), 1)); cq.orderBy(cb.asc(root.get("id"))); TypedQuery<User> query = entityManager.createQuery(cq); query.setFirstResult((pageNo - 1) * pageSize); query.setMaxResults(pageSize); List<User> list = query.getResultList();

有人管这套叫“类型安全的查询”,但实际开发里用的人并不多。我这里写出来不是让你新项目都用它,而是提醒你:如果你在网上搜Hibernate分页教程,会看到大量老代码,你得能分清哪些是4.2时代的写法,哪些是现在该用的写法,否则复制到新工程里直接编译都过不去。

1.3 别把getFetchSize和setMaxResults搞混

顺着上面继续说一个我面试时经常问人的点。有个词叫setFetchSize,它和setMaxResults长得像,但完全是两码事。

  • setMaxResults是真正的分页条数,SQL层面会生成limit之类的东西。
  • setFetchSize是每次从数据库游标获取多少行,本质上是个传输批量大小,不影响最终结果集数量,更多影响内存占用和网络往返次数。

举个例子:

Query query = session.createQuery("select u from User u"); query.setFetchSize(100); // 每次从游标拉100行 query.setMaxResults(1000); // 最多返回1000条

这两个组合在一起,实际是让JDBC驱动一次少拿点数据,避免一次把上千上万行全堆到内存里。特别是做大数据量导出的时候,setFetchSize配合ScrollableResults能明显降低JVM内存压力。

我用过一个真实的场景:一张订单表50万数据,后台需要导出全部数据生成报表。如果直接list()出来,Hibernate会把50万条记录连同实体对象一次性加载进内存,GC直接被拍死。后来改成setFetchSize(500)加上游标遍历,内存占用掉了一个量级。所以记住,分页给用户看的场景用setMaxResults,大批量遍历的场景把setFetchSize用起来。

2. 工程里更常用的方式:Pageable与Spring Data JPA

2.1 PageRequest让分页代码收敛

如果你用的是Spring Boot + Spring Data JPA,你会发现很多人根本不直接写Query对象,而是用Pageable。这其实是站在Hibernate肩膀上做的一层封装,底层还是Hibernate在跑。

最标准的用法是:

Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.ASC, "id")); Page<User> page = userRepository.findAll(pageable); List<User> users = page.getContent(); long total = page.getTotalElements(); int totalPages = page.getTotalPages();

Repository方法都不用写实现,Spring Data会根据方法名自动生成查询:

public interface UserRepository extends JpaRepository<User, Long> { Page<User> findByStatus(Integer status, Pageable pageable); }

这个时候Hibernate实际做了什么?它不只执行了一条查询列表的SQL,还会自动执行一条select count(*) from user where status = ?去计算总条数,然后封装成Page对象。这个“自动count”很方便,也是后面我要说的性能隐患点之一。

2.2 count查询怎么优化

自动count查询最省事,但一般在数据量上来以后会变成第一个性能瓶颈。你想想,列表页就那么20条数据,结果它先去全表count一遍,遇到大表且没有适合的索引,这条count可能比列表查询还慢。

解决办法是在@Query注解里单独指定countQL:

@Query( value = "select u from User u where u.status = :status", countQuery = "select count(u) from User u where u.status = :status" ) Page<User> findByStatus(@Param("status") Integer status, Pageable pageable);

注意这里有个细节:countQuery里的实体别名和主查询的QL要保持一致。Spring Data JPA要求count语句的HQL语法和主查询差不多,不然运行时会报错。我自己就翻过车,主查询用了u,count查询写成了select count(user) from User user,结果启动直接报“Cannot resolve symbol”。

如果你硬要复杂查询,比如多个left join,那count语句尽量只count主表的根实体,别把关联表也带进去,否则生成的SQL会把关联表也join一遍,白白增加开销。

2.3 一对多关联分页要格外小心

这块算是Hibernate分页里最阴间的部分。很多人给列表查询加上left join fetch:

Query query = session.createQuery( "select distinct u from User u left join fetch u.orders o order by u.id" ); query.setFirstResult(0); query.setMaxResults(10); List<User> list = query.list();

表面上看起来预期是“查出10个用户,每个用户带上他的订单”。但实际效果会让你傻眼:Hibernate发现你要fetch一个集合属性,同时又要分页,它不能直接在SQL里用limit了,因为left join之后一个用户对应多行订单,限制行数会导致用户数量不完整。于是它选择先把所有匹配的数据查询出来,然后到内存里自己裁切分页。

这就导致两个后果:

  1. 分页没有真正下推到数据库,数据量大时性能极差。
  2. 因为内存分页针对的是实体集合,还需要distinct去重,最终返回的条数经常和你期望的不一致。

我在一个报表项目里遇到过一模一样的问题:接口返回20条列表,但这20条里有些用户的orders数据特别多,Hibernate日志打出一句firstResult/maxResults specified with collection fetch; applying in memory,然后整页数据慢到三秒以上。

这种场景的正确解法是先分页查出主表id,再根据id批量加载关联集合:

// 第一步:查出当前页的用户id List<Long> ids = session.createQuery( "select u.id from User u order by u.id") .setFirstResult(0) .setMaxResults(20) .list(); // 第二步:用in查询关联数据 List<User> users = session.createQuery( "select distinct u from User u left join fetch u.orders where u.id in :ids order by u.id") .setParameter("ids", ids) .list();

虽然不是最优雅,但至少分页SQL真正下推到了数据库,而且两种查询加起来最多查两次,比起全量载入内存再分页,完全是另一个数量级。

2.4 为什么网上总说MyBatis Plus分页失效

顺带说个热词里很多人关注的问题:MyBatis Plus分页失效。其实这个话题和Hibernate没有直接关系,但把它放在这里对比着看很有意义,因为分页失效的本质原因很多时候是“分页插件没有被正常拦截执行”。

MyBatis Plus的分页是依赖拦截器做SQL改写,在select语句后面拼limit。失效常见原因:

  • 拦截器没注册到MyBatis配置里。
  • 自定义的拦截器和分页拦截器执行顺序不对,导致分页拦截时SQL已经被包装或者直接短路return了。
  • 直接new Page(1, 10)之后没把Page对象作为参数传进去,而是用了普通List接收。
  • 把分页插件加到了MyBatis的plugin列表里,但版本和MyBatis不兼容,SQL没被解析重写。

Hibernate这边分页失效的原因不太一样,它不是靠SQL改写,而是靠方言生成SQL,所以Hibernate的“分页失效”更常见于:

  • 手写了原生SQL,但没有用addEntity或正确返回类型。
  • 用了老版本Criteria且方言配置错误,导致limit拼不出来。
  • 在一对多fetch分页时,走了“内存分页”,产生性能问题而不是数量失效。

一句话总结两边的坑:MyBatis Plus靠插件拦截,配置不当会失效;Hibernate靠方言和查询API,使用不当会失真、失性能。

3. 大数据量下的深分页优化

3.1 深分页为什么慢

很多系统的分页死在同一个地方:用户翻到第100页、第10000页时接口变慢。

Hibernate生成的SQL在MySQL里通常是limit 1000000, 20,意思是跳过100万条,取20条。数据库并不是“跳过”就完事,它得把前100万条全扫一遍然后扔掉。数据量小的时候没事,数据量一大,这一大段扫描时间就变得不可接受。

Oracle那边更夸张,经典的rownum分页在深翻页的时候一样要扫描大量数据。所以只要你是基于offset分页,不管Hibernate还是MyBatis,深分页慢是必然,框架只是翻译SQL的,翻不了本质。

3.2 用id游标替代offset

如果要继续优化深分页,常见的方向是“keyset分页”,也叫“seek分页”,核心思路是记住上一页最后一条数据的id,下一页直接用id条件往后找:

Long lastId = 10000L; // 上一页最后一条记录的id Query query = session.createQuery( "select u from User u where u.id > :lastId order by u.id"); query.setParameter("lastId", lastId); query.setMaxResults(20); List<User> list = query.list();

对应SQL是:

select ... from user where id > 10000 order by id limit 20;

数据库可以利用主键索引直接定位到10000,然后往后取20条,完全不需要扫描之前的百万行。这是目前大数据量翻页里最实用的优化手段之一。

当然它也有代价:这种分页只支持“上一页/下一页”,不能再直接跳转到任意指定页码,因为没有了offset概念。所以在产品设计上,如果只需要“加载更多”这种交互,keyset分页是首选。

还有一个变种是把排序字段和id组合起来做游标,比如按createTime + id排序,游标条件就是(create_time, id) > (:lastTime, :lastId)。这个在HQL里写起来稍微复杂一点,需要借助row value语法,不同数据库兼容性也不同,实在不行可以写原生SQL。

3.3 ScrollableResults该什么时候用

Hibernate的ScrollableResults经常被误当成“分页神器”,其实它不是做页面分页的,它更像是一个数据库游标,适合用来遍历大量数据:

ScrollableResults results = session.createQuery("select u from User u order by u.id") .setFetchSize(100) .scroll(ScrollMode.FORWARD_ONLY); while (results.next()) { User user = (User) results.get(0); // 处理一条数据,比如写入Excel、发送MQ消息 } results.close();

注意几个要点:

  • ScrollMode.FORWARD_ONLY表示只能向前滚动,这是最高效的游标模式。你要是用ScrollMode.SCROLL_INSENSITIVE,那就得数据库游标支持双向滚动,很多场景没必要。
  • setFetchSize是配合这个场景用的,不设的话默认结果集可能全量进入内存。
  • 游标长时间打开会占用数据库连接,处理完一定要close(),最好放在finally里。

到底什么时候用?我的建议是:需要处理几万到几十万行记录、不适合用分页接口反复查询时,用ScrollableResults。比如导出报表、批量修复、定时任务全量扫描。如果在来返回给前端分页,它就不是合适的选择。

3.4 不同数据库下的SQL差异

Hibernate方言屏蔽了大部分差异,但有时候DBA会把慢SQL直接拉给你看,你得能看懂Hibernate到底生成了什么。这里给个常见对照表:

数据库Hibernate生成的典型分页SQL
MySQLlimit offset, size
PostgreSQLlimit size offset offset
Oracle三层嵌套子查询 +rownum
SQL Serverrow_number() over (order by ...)外包一层

之前在Oracle老版本上有个经典问题:rownum是在order by之前赋值的,所以不能直接对排序后的结果集做分页,必须三层嵌套把排序后的结果包一层再取rownum。Hibernate帮你处理了这些,但从另外一个角度看,如果你手写原生SQL想达到和Hibernate相同的性能,就必须知道每个数据库的写法差异。

热词里的“oracle分页”,其实说的就是这种经典写法:

select * from ( select t.*, rownum rn from ( select * from user order by id ) t where rownum <= 40 ) where rn > 20;

能看懂这个结构,再回看Hibernate给Oracle生成的SQL,你就能完全掌控它分页到底是怎么工作的。

4. 常见问题排查与避坑实录

4.1 为什么分页返回条数总是不对

我把这个放到排查篇的第一条,因为太常见了。

现象:list返回的条数和setMaxResults设置的不一样,有时候多有时候少。

常见原因:

  • 实体映射中配置了级联fetch = FetchType.EAGER的集合,分页时Hibernate发现不能直接limit,于是走内存分页。返回的“实体数”和“结果行数”对不上。
  • 同一个查询里既用了distinct又用了分页,但关联方向是一对多,distinct作用在关联行上导致去重后的实体数量改变。
  • 在同一个session里持久化了一些新实体,分页查询时命中了一级缓存,导致某些实体的状态被覆盖或引用错乱。

排查方法简单粗暴:把hibernate.show_sql打开,看生成的SQL里有没有limit;再看Hibernate控制台有没有输出firstResult/maxResults specified with collection fetch这句警告。有这句警告,问题基本就定位到了。

4.2 分页总数和列表数据对不上

这种情况也很常见。列表数据和数据库总数不一致,先别急着怀疑Hibernate,原因通常出在两个地方。

一个是“并发数据变更”。用户打开列表时总数是100,翻了一页后别人删了5条,列表自然少几条。这个不是Hibernate的问题,是所有分页方案都避免不了的一致性问题,看业务是否能接受。

另一个是“集合fetch去重导致总数虚高”。前面说过一对多关联fetch时,Hibernate内存分页会对主实体去重,最后页面上的条数比count查询出来的少。尤其涉及多对多、一对多混合查询时,count总数是数据库行数(含关联行),而列表返回的是去重后的实体数,两边天然对不上。

解决办法:把列表查询和count查询分开设计,列表用两步查询保证主实体唯一,count只统计主表行数。这也是我对所有团队的建议:列表接口里不要轻易在一条HQL里随便加集合fetch。

4.3 一级缓存带来的“假分页”

Hibernate的一级缓存是session级别的,平时大家关注得不多,但在批量分页循环处理场景里会出事。

比如你在一个事务里先通过session.get(User.class, 1L)查了一次id=1的用户,然后分页查询返回的结果集里恰好包含id=2的用户,Hibernate会怎么做?

它会先用SQL把数据查出来,组装结果集时发现id=2的实体已经存在一级缓存里,就会直接用缓存里的对象替换查询出来的对象,而不会重新填充。这个行为在简单场景下感受不到,但在一些复杂映射、字段延迟加载、或者跨session交互的场景里,会表现为“数据像是没刷新”。

排查办法:确认是否在同一个session里先对同一条记录做过操作。如果是,要么控制session生命周期,要么用session.clear()清掉一级缓存。

4.4 手写SQL分页时最容易踩的坑

最后再补一点原生SQL分页的坑。用createSQLQuery写分页时,很多人这样写:

SQLQuery query = session.createSQLQuery("select * from user where status = :status order by id"); query.addEntity(User.class); query.setFirstResult(0); query.setMaxResults(20);

看起来没什么问题,但如果在SQL里直接写的列顺序和实体映射顺序不一致,Hibernate组装实体时会非常脆弱。建议手写SQL时尽量加上明确的列名:

SQLQuery query = session.createSQLQuery( "select id, name, status, create_time from user where status = :status order by id"); query.addEntity(User.class);

还有一点,createSQLQuery配合分页时Hibernate方言依然会帮你生成limit,但如果你SQL里自己加了limit,那就会导致双重限制,下一页查询的条数可能直接变成0。

5. 我个人的分页实践建议

如果让我总结一下这些年干活的经验,分页这块其实就三条:

第一,小业务、接口内部使用、数据量可控,直接无脑用Pageable加自动count,不折腾。多数管理系统一个页面几百条数据,没必要为性能提前写一堆复杂逻辑。

第二,遇到一对多关联列表,尽量别用集合fetch分页。要么改两步查询先拿id再查详情,要么在DTO层自己组装数据。这个决策能帮你避开Hibernate里最折磨人的那批问题。

第三,数据量一旦到了百万级且用户会深翻页,赶紧考虑keyset分页或者手动限制最大可查询页码,别让用户真的翻到第10000页。这不是技术限制,是产品层面要做出的取舍。

Hibernate确实有自己的复杂度,但理解了分页背后的SQL生成逻辑、缓存机制、以及各种fetch模式,它并没有网上说的那么吓人。很多时候你踩到的坑不是框架在为难你,而是你还没看清它背后到底做了什么。

最后再分享一个小技巧:排查Hibernate分页问题时,最快的方式永远是打开show_sql和format_sql,眼睛盯着实际生成的SQL,而不是盯着Hibernate的Java报错。Hibernate在日志里给你的提示比报错信息诚实得多,尤其那句显眼的“in memory”警告,看到了你就能立刻反应过来该改代码方向了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询