
在Hibernate里做分页第一反应都是setFirstResult()配合setMaxResults()这套API从Hibernate 2.x用到现在的Hibernate 6.x可以说是最基础也最常用的操作之一。但如果你只停留在“能翻页”这个层面后面会遇到一堆坑count数不准、SQL莫名复杂、一对多关联分页直接变成内存分页、Oracle方言生成的SQL看不懂……这篇文章我就把这几年做Hibernate分页的完整思路、底层原理和排错过程一次性说透。先说清楚适用人群正准备用Hibernate做列表页、后台管理系统的Java开发者或者已经从MyBatis转到Hibernate/Spring Data JPA的老司机。不管你是新手还是老鸟这篇文章的目标是让你看完之后不仅能写出正确分页还能解释清楚为什么这么写以及出了问题去哪排查。1. 先搞清楚一件事Hibernate分页到底是物理分页还是逻辑分页1.1 物理分页与逻辑分页的本质区别经常在群里见到有人问“Hibernate分页是不是就是把所有数据查出来再内存截取”这种疑问不奇怪因为很多ORM框架早期确实这么干过。物理分页和逻辑分页完全是两码事物理分页SQL层面就带了LIMIT、OFFSET或ROWNUM这类数据库专有语法数据库只把当前页需要的那几条数据返回给应用。逻辑分页不管页面要多少条先把满足条件的所有记录从数据库捞出来放到Java内存里再通过List.subList()这类方式截取一段。逻辑分页在数据量小的时候挺好用比如几千行配置数据翻页也就无所谓了。但一旦数据量来到几十万、上百万条逻辑分页就非常难受——数据库到应用之间要传输全量数据内存里也要维持全量对象这还不算每条记录关联的嵌套对象创建开销。我见过一个生产事故就是有人用逻辑分页查50万行数据列表页直接卡死GC连续Full GC。Hibernate默认走的是物理分页路径。当你调用setMaxResults()和setFirstResult()之后Hibernate会把它们翻译成底层数据库能识别的分页SQL真正只取需要的行。判断当前是不是物理分页有个笨办法打开hibernate.show_sqltrue看控制台打印的SQL里有没有limit、offset、rownum等关键字没有就是内存分页。1.2 为什么Hibernate默认选择物理分页早年Hibernate设计分页机制时数据库方言Dialect体系已经非常成熟。Hibernate允许针对每种数据库实现专门的LimitHandler所以它在框架层面就具备生成物理分页SQL的能力。选物理分页几乎是唯一合理的选择原因很直接第一数据传输量和内存占用都小。一页20条数据库就只返回20条第二查询时间相对可控。很多场景下配合索引分页查询能命中索引下推之类的优化避免全表扫描第三语义清晰开发者不用自己拼数据库方言。当然这不是说Hibernate绝对不会做逻辑分页。当它发现当前数据库不支持分页语法就必须退回内存分页这种情况在老旧数据库或特定方言配置缺失时会出现。现代主流数据库基本都有分页支持所以正常项目里物理分页是绝对主流。1.3 什么情况会退化成内存分页这里必须提醒一个常见的坑fetch join一对多集合时Hibernate很可能“故意”退化成内存分页。原因是setMaxResults()如果在SQL层加上LIMIT会对主表行数进行限制但假如同一行主表记录关联了10条子表记录SQL结果集展开后可能是按子表行数来计数的这样截取出来的“10条主记录”可能其实只有2条主记录语义完全错乱。Hibernate对这个问题的处理策略是在日志里打一条警告HHH000104: firstResult/maxResults specified with collection fetch; applying in memory意思是检测到集合抓取分页改为内存模式。你没看错Hibernate是主动选择内存分页来保证语义正确。但后果就是如果这一页关联的数据特别庞大内存压力会明显上升甚至出现MemoryLimitExceededException。遇到过这种场景的兄弟应该知道改法通常是拆成两步先用分页查询主表ID列表再用IN批量查子表数据或者干脆放弃join抓取改用batch_size加载、NamedEntityGraph这类次级方案。后面第4章我会专门写一个完整示例。2. 三种主流写法从HQL到Criteria再到Hibernate 62.1 最经典的HQL写法setFirstResult与setMaxResultsHQL分页是最直观、也最常见的老写法。假设有一张图书表我要按书名模糊查询并分页代码如下// 页码从0开始每页20条 int pageNo 0; int pageSize 20; try (Session session sessionFactory.openSession()) { QueryBook query session.createQuery( from Book b where b.title like :kw order by b.id, Book.class); query.setParameter(kw, % keyword %); query.setFirstResult(pageNo * pageSize); query.setMaxResults(pageSize); ListBook books query.list(); }setFirstResult(int)控制从第几行开始取setMaxResults(int)控制最多取多少行。这里有一个容易踩的惯性问题很多人习惯页号从1开始于是写成setFirstResult((pageNo - 1) * pageSize)这没有错但要和前端约定好。我见过接口文档写“页码从0开始”前端从1传结果第二页永远和第一页重复排查了半天。很多项目里HQL通常不会只查实体本身可能只查某些字段QueryTuple query session.createQuery( select b.id, b.title, b.price from Book b where b.publisher :pub order by b.id, Tuple.class); query.setParameter(pub, publisher); query.setFirstResult(10); query.setMaxResults(20);这种写法叫“标量查询”或“部分字段查询”好处是减少完整实体加载的开销。唯一要注意的是类型是Tuple而不是Object[]取值时tuple.get(0)、tuple.get(title)都行。老项目里用Object[]的写法也能跑但Hibernate 6以后更推荐Tuple。2.2 动态查询首选Criteria API附带count写法条件不固定时字符串拼HQL特别容易漏空格、少括号。Criteria APIJPA的CriteriaBuilder在动态场景下更稳。Hibernate 5.2完全支持JPA的Criteria写法Hibernate 6下就是同一套API。写个带关键字、价格区间、状态的动态分页查询public PageResultBook searchBooks(String keyword, BigDecimal minPrice, BigDecimal maxPrice, int pageNo, int pageSize, Session session) { CriteriaBuilder cb session.getCriteriaBuilder(); // 1. 拼条件 CriteriaQueryBook query cb.createQuery(Book.class); RootBook root query.from(Book.class); ListPredicate predicates new ArrayList(); if (keyword ! null !keyword.isBlank()) { predicates.add(cb.like(root.get(title), % keyword %)); } if (minPrice ! null) { predicates.add(cb.greaterThanOrEqualTo(root.get(price), minPrice)); } if (maxPrice ! null) { predicates.add(cb.lessThanOrEqualTo(root.get(price), maxPrice)); } query.where(predicates.toArray(new Predicate[0])); query.orderBy(cb.asc(root.get(id))); // 2. 查总数 CriteriaQueryLong countQuery cb.createQuery(Long.class); RootBook countRoot countQuery.from(Book.class); countQuery.select(cb.count(countRoot)); // count条件必须重新设置不能复用上面的root if (!predicates.isEmpty()) { countQuery.where(predicatesToArray(countRoot, cb, keyword, minPrice, maxPrice)); } Long total session.createQuery(countQuery).getSingleResult(); // 3. 查当前页数据 QueryBook query session.createQuery(cq); query.setFirstResult(pageNo * pageSize); query.setMaxResults(pageSize); ListBook rows query.getResultList(); return PageResult.of(total, rows); }许多人会尝试把RootBook在数据查询和count查询之间复用实际容易出问题因为CriteriaQuery.from()和where()都绑定在特定query实例上强行复用会和count语义冲突。最稳妥的做法是像上面一样count查询重新create一个Root条件重新构造一遍。如果项目用的还是老的org.hibernate.CriteriaHibernate 5之前的原生API写法是session.createCriteria(Book.class).add(Restrictions.like(title, % keyword %))再调setFirstResult和setMaxResults。但Hibernate 6已经移除这套原生Criteria API还在维护老项目的朋友建议尽早迁移到JPA Criteria。2.3 Hibernate 6.x的新分页APIPage与getResultList(Page)从Hibernate 6.2开始官方推荐了一套新的分页方式用Page对象封装页码和大小。以Hibernate 6.2为例Page page Page.page(20).withFirstResult(40); ListBook books session.createSelectionQuery( from Book where title like :kw order by id, Book.class) .setParameter(kw, % keyword %) .getResultList(page);Page.page(20)表示每页20条withFirstResult(40)表示从第41行开始。整体比之前的两个set方法简洁不少也更容易在Service层传递。老接口setFirstResult/setMaxResults在6.x早期还是可以用的只是部分方法被标了Deprecated。如果你用的是Spring Data JPA 3 Hibernate 6Pageable会映射到这里的分页逻辑上原理是相通的。新API还有一个额外好处SelectionQuery接口支持getResultCount()这种更直接的计数方式这个我只能说不同版本差异比较大我没法断言所有版本都有。所以新项目建议直接看当前Hibernate版本的Javadoc把Page、SelectionQuery、MutationQuery这几个类翻一遍比网上抄旧代码靠谱。3. 分页SQL到底是怎么拼出来的方言机制拆解3.1 Dialect与LimitHandler的分工Hibernate分页SQL的生成不是靠写死一套模板而是通过方言机制。每个数据库方言类比如MySQLDialect、PostgreSQLDialect、OracleDialect内部都注册了一个LimitHandler这个组件负责把setFirstResult/setMaxResults转成最终的分页子句。整个流程大致是解析HQL/JPQL生成语义上“没有分页”的SQL。会话工厂根据配置的hibernate.dialect或者底层DatabaseMetaData选中方言。执行查询前LimitHandler介入把原始SQL包装成带分页语法的SQL。预编译参数化时分页参数offset和limit会作为绑定参数拼到SQL里避免SQL注入。你不需要背这些类名但理解这层机制对排查问题很有帮助。比如同一个应用从MySQL迁移到OracleHQL一点不用改底层SQL会自动从limit换成Oracle的rownum或offset ... fetch靠的就是方言切换。如果你发现分页SQL风格和预期不符第一反应应该是查方言版本和LimitHandler实现而不是去翻业务代码。3.2 MySQL、PostgreSQL、Oracle分页SQL的真实形态为了让你对“方言翻译”有直观感受我把同一条HQL在三种数据库下生成的SQL形态列出来原始HQL就一句/* HQL */ select b from Book b order by b.idMySQL 8使用标准LIMITselect b.id, b.title, b.price from book b order by b.id limit ?, ?PostgreSQL风格是LIMIT和OFFSET分开select b.id, b.title, b.price from book b order by b.id limit ? offset ?Oracle旧版本12c之前用三层嵌套的ROWNUMselect * from ( select inner_.*, rownum as rn from ( select b.id, b.title, b.price from book b order by b.id ) inner_ where rownum ? ) where rn ?Oracle 12c及以后可以用标准OFFSET/FETCHselect b.id, b.title, b.price from book b order by b.id offset ? rows fetch next ? rows only看到MySQL和PostgreSQL生成的SQL你可能会想“这不就是普通SQL吗”没错对支持LIMIT的数据库来说Hibernate只是把参数填进去而已。但Oracle的旧方言就很能体现Hibernate的设计它要保证外层WHERE ROWNUM和内层RN同时生效生成逻辑比较复杂。这也是为什么很多老系统一升级数据库版本顺便把Hibernate版本升级一下分页性能就可能有变化——因为新方言可能切换到更现代的OFFSET/FETCH语法。3.3 为什么分页必须配order by排序稳定性问题分页查询不写order by等于把自己交给数据库的“物理顺序”。MySQL里通常意味着主键顺序或索引扫描顺序但这并不是强保证。一旦数据分布变化、优化器选择不同索引或并行执行下一页数据可能和上一页出现重叠或缝隙。我实际踩过一回一个订单列表分页用户在第1页看到单号1001往下翻到第2页又看到1001客户直接反馈“数据重复”。排查之后发现查询里只有where status 1没有任何排序。数据库默认按主键物理顺序返回但这种顺序在分页场景下压根不可靠。正确做法是至少给一个唯一性排序字段query.orderBy(cb.asc(root.get(status)), cb.asc(root.get(id)));status作为业务排序id作为稳定排序的兜底。这样即使业务排序字段有大量相同值最终顺序也不会乱。如果你用HQL就写成order by b.status asc, b.id asc。4. 实战一个带条件查询的分页接口完整实现4.1 需求与表结构以图书管理为例。表结构就三张book图书、author作者、book_author多对多关联表book表字段有id、title、price、publisher、status。需求是前端传入关键字、价格区间、状态后端返回分页后的图书列表每一条Book里要带该书的作者列表。很多项目做到“带作者列表”就会自然写出这样的查询// 危险写法后面会解释 from Book b left join fetch b.authors a where b.title like :kw然后一执行就遇到警告HHH000104: firstResult/maxResults specified with collection fetch; applying in memory。如果你没注意日志功能看起来正常但数据量一大就卡死。这就是4.2节要解决的问题。4.2 Service层编写count与数据列表双查询我建议的实现方法是第一步普通分页查询Book的主表字段不join抓取作者集合第二步拿到当前页Book ID集合再用一条in查询把作者关联关系查出来手动组装。public PageResultBookVO pageBooks(String keyword, BigDecimal minPrice, BigDecimal maxPrice, int pageNo, int pageSize, Session session) { CriteriaBuilder cb session.getCriteriaBuilder(); // 1. count查询 CriteriaQueryLong countCq cb.createQuery(Long.class); RootBook countRoot countCq.from(Book.class); ListPredicate countPreds buildPredicates(cb, countRoot, keyword, minPrice, maxPrice); countCq.select(cb.count(countRoot)).where(countPreds.toArray(new Predicate[0])); long total session.createQuery(countCq).getSingleResult(); // 2. 当前页主表查询不做集合fetch CriteriaQueryBook pageCq cb.createQuery(Book.class); RootBook pageRoot pageCq.from(Book.class); ListPredicate pagePreds buildPredicates(cb, pageRoot, keyword, minPrice, maxPrice); pageCq.select(pageRoot) .where(pagePreds.toArray(new Predicate[0])) .orderBy(cb.asc(pageRoot.get(id))); QueryBook pageQuery session.createQuery(pageCq); pageQuery.setFirstResult(pageNo * pageSize); pageQuery.setMaxResults(pageSize); ListBook books pageQuery.getResultList(); // 3. 批量查询当前页作者关联避免N1 ListLong ids books.stream().map(Book::getId).toList(); MapLong, ListAuthor authorMap findAuthorsByBookIds(session, ids); // 4. 组装VO ListBookVO voList books.stream().map(book - { BookVO vo new BookVO(); vo.setId(book.getId()); vo.setTitle(book.getTitle()); vo.setPrice(book.getPrice()); vo.setAuthors(authorMap.getOrDefault(book.getId(), List.of())); return vo; }).toList(); return new PageResult(total, voList); }findAuthorsByBookIds的实现加了个join fetch查询作者但因为查询范围是当前页ID集合行数很短尽管join后结果集有重复也可以接受private MapLong, ListAuthor findAuthorsByBookIds(Session session, ListLong bookIds) { if (bookIds null || bookIds.isEmpty()) { return Map.of(); } String hql select distinct b from Book b left join fetch b.authors a where b.id in :ids; ListBook books session.createQuery(hql, Book.class) .setParameter(ids, bookIds) .list(); MapLong, ListAuthor result new HashMap(); for (Book b : books) { result.put(b.getId(), new ArrayList(b.getAuthors())); } return result; }这个方案避开了集合fetch分页导致的“内存分页”问题绝大部分真实业务场景都用这套路子。代价是多一条SQL但换来的是稳定可控的执行计划和内存占用值得。4.3 一对多关联的分页陷阱fetch join会导致内存分页再深入说下为什么fetch join 分页会出问题。看这个HQLfrom Book b left join fetch b.authors a where b.status 1假设第1页最多取20条Book。数据库在执行时book表里有50条记录满足条件其中20条Book各有3个作者。如果直接在SQL层写LIMIT 20这条SQL在join fetch后返回的行数可能远超20——因为每个作者都占一行。结果就是SQL的limit限制的不是“主表20行”而是“结果集展开后20行”导致只返回了部分Book的部分作者语义错乱。Hibernate为了避免这种错误检测到这种组合时干脆不走物理分页改成先查出所有满足条件的主表数据再在内存中截取20条。这也就是HHH000104警告的含义。我实测过当Book表20万行、关联作者平均3行时这个查询的内存结果集直接膨胀到数十万对象内存翻车几乎是必然的。解决思路不外乎三种不要一次性fetch集合改用BatchSize或Fetch(SUBSELECT)。分页查主表ID再批量查关联子表。用NamedEntityGraph或者DTO投影代替。第2种就是我上面代码用的方案也是我目前在项目里最推荐的方式。它把“分页不稳定”问题从根本上消解了——主表分页永远只作用于单表SQL关联数据后续用IN再取。5. 常见问题与排查技巧实录5.1 count计数总是慢先看三件套再谈优化分页接口一般都是两条SQL一个count一个查列表。很多项目优化重点全放在列表SQL上结果接口还是慢一看数据库慢日志count反而成了最大的瓶颈。count慢的原因通常就三个第一count条件里的字段没有索引。模糊查询like %keyword%本身就很难走索引如果条件里还有status、category_id等字段优先给它们建组合索引。第二多表join导致count基数变大。Hibernate会把HQL里的隐式关联都翻译成join如果你count的根实体是多对多的一端join会重复计数很多人不得不用select count(distinct b)。distinct一旦出现count性能就要打折扣。更合理的做法是count查询里去掉一切不必要的join只留下过滤条件。第三大偏移量。经典问题用户翻到第1000页limit 19980, 20数据库仍然要扫描前19980行。这种场景下优化空间有限常规方案是“前端禁止深翻页”或“改为基于游标/ID的分页”——下一批数据从上一批最后一条id开始往后查而不是从头数offset。排查count问题时直接把Hibernate打印的count SQL拿到数据库执行一遍看EXPLAIN基本能定位80%的问题。5.2 “分页失效”场景回顾MyBatis Plus分页失效与Hibernate的区别网上时不时看到“mybatisplus分页失效”相关热搜这个背景也值得顺带聊一聊。MyBatis Plus的分页依赖PaginationInnerInterceptor拦截器失效原因通常集中在几个地方拦截器没注册或顺序不对、Page对象没有作为查询方法参数传入、传入的Page参数被当成普通条件、结果类型不匹配导致拦截器没有触发分页改写。Hibernate的分页是框架内置流程不依赖这类外部拦截器所以基本不存在“忘了加拦截器导致分页失效”的说法。你只要调用了setFirstResult/setMaxResults或getResultList(page)分页逻辑就会执行。如果你发现Hibernate分页“失效”先检查两件事第一是不是没有执行到查询方法。比如缓存命中、短路逻辑直接返回了之前的数据。第二是不是出现了HHH000104警告集合fetch导致内存分页。这种情况打印SQL里看不到limit会让人觉得分页没生效。另外要留神Hibernate二级缓存也可能让你误以为分页失效。如果查询结果被缓存只有完全相同的分页参数和条件才会命中缓存参数一变就会重新查正常情况下不会导致“翻页重复”。真正遇到翻页重复优先查排序稳定性。5.3 顺手排雷大偏移量分页与N1问题先补充一个经常被忽略的细节firstResult参数不能是负数这很容易理解。但是很多数据库方言对offset大小有限制尤其是32位整数上限。如果某个页面业务异常传入一个超大页号pageNo * pageSize可能直接溢出成负数抛IllegalArgumentException。我建议在Controller或Service入口统一做参数校验页号上限可以设置成1000这样保守的值同时给前端一个明确提示。再说N1问题。分页本身不会制造N1但只要你在翻页后遍历每本书去查作者天然会变成N1。以一个20条的分页列表来讲等于是1次列表查询20次作者查询总共21次SQL。如果每本书又关联评论、标签SQL数量会指数膨胀。常见解法是前面提到的“按页ID批量查子表”String hql select a from Author a join a.books b where b.id in :ids; ListAuthor authors session.createQuery(hql, Author.class) .setParameter(ids, bookIds) .list();然后在Java层按bookId分组。控制往返次数稳定在2~3条SQL比Hibernate自己的一对多集合加载和N1都高效得多。如果是Spring Data JPA项目直接定义EntityGraph(attributePaths authors)配合Pageable也能达到类似效果。另一个小坑是集合属性上的BatchSize。很多人以为加了BatchSize(size 30)就万事大吉它确实能把20次SQL合并成1次WHERE id IN (...)但对“分页”本身没有任何优化作用。它解决的是集合加载问题不是分页SQL生成问题。这点要分清楚别搞混。最后我个人的排查顺序是先看日志有没有HHH000104没有就确认SQL里有物理分页关键字再看count和列表两条SQL的执行计划最后把主表ID一旦拿到立刻用批量IN查询关联数据。这套顺序帮我解决过好几起分页性能事故写下来供你参考。