
“Hibernate还有人用吗”这个问题我在网上看到过很多次每次回答都挺矛盾的。一方面Spring Boot默认的JPA实现底层就是Hibernate你只要写过Spring Data JPA就避不开这个框架另一方面被MyBatis惯坏了的同学总觉得Hibernate太重、太玄、出了bug不知道去哪查。我的观点很直接不管你现在用什么ORMHibernate怎么处理分页、为什么这么处理都是值得搞明白的基础功。尤其是你手上恰好有个老项目还在用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); ListUser 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); ListUser list criteria.list();这种写法本质和HQL没区别底层生成的SQL也一样。唯一的差别是代码风格Criteria更面向对象不用写字符串HQL重构字段名的时候不容易漏改。但要提醒一点Hibernate 5.2以后把原来的Criteria标记为过时了官方推荐的是JPA标准的CriteriaQuery。如果你是在新项目里应该用下面这种方式CriteriaBuilder cb entityManager.getCriteriaBuilder(); CriteriaQueryUser cq cb.createQuery(User.class); RootUser root cq.from(User.class); cq.where(cb.equal(root.get(status), 1)); cq.orderBy(cb.asc(root.get(id))); TypedQueryUser query entityManager.createQuery(cq); query.setFirstResult((pageNo - 1) * pageSize); query.setMaxResults(pageSize); ListUser 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 JPA2.1 PageRequest让分页代码收敛如果你用的是Spring Boot Spring Data JPA你会发现很多人根本不直接写Query对象而是用Pageable。这其实是站在Hibernate肩膀上做的一层封装底层还是Hibernate在跑。最标准的用法是Pageable pageable PageRequest.of(0, 20, Sort.by(Sort.Direction.ASC, id)); PageUser page userRepository.findAll(pageable); ListUser users page.getContent(); long total page.getTotalElements(); int totalPages page.getTotalPages();Repository方法都不用写实现Spring Data会根据方法名自动生成查询public interface UserRepository extends JpaRepositoryUser, Long { PageUser findByStatus(Integer status, Pageable pageable); }这个时候Hibernate实际做了什么它不只执行了一条查询列表的SQL还会自动执行一条select count(*) from user where status ?去计算总条数然后封装成Page对象。这个“自动count”很方便也是后面我要说的性能隐患点之一。2.2 count查询怎么优化自动count查询最省事但一般在数据量上来以后会变成第一个性能瓶颈。你想想列表页就那么20条数据结果它先去全表count一遍遇到大表且没有适合的索引这条count可能比列表查询还慢。解决办法是在Query注解里单独指定countQLQuery( value select u from User u where u.status :status, countQuery select count(u) from User u where u.status :status ) PageUser findByStatus(Param(status) Integer status, Pageable pageable);注意这里有个细节countQuery里的实体别名和主查询的QL要保持一致。Spring Data JPA要求count语句的HQL语法和主查询差不多不然运行时会报错。我自己就翻过车主查询用了ucount查询写成了select count(user) from User user结果启动直接报“Cannot resolve symbol”。如果你硬要复杂查询比如多个left join那count语句尽量只count主表的根实体别把关联表也带进去否则生成的SQL会把关联表也join一遍白白增加开销。2.3 一对多关联分页要格外小心这块算是Hibernate分页里最阴间的部分。很多人给列表查询加上left join fetchQuery 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); ListUser list query.list();表面上看起来预期是“查出10个用户每个用户带上他的订单”。但实际效果会让你傻眼Hibernate发现你要fetch一个集合属性同时又要分页它不能直接在SQL里用limit了因为left join之后一个用户对应多行订单限制行数会导致用户数量不完整。于是它选择先把所有匹配的数据查询出来然后到内存里自己裁切分页。这就导致两个后果分页没有真正下推到数据库数据量大时性能极差。因为内存分页针对的是实体集合还需要distinct去重最终返回的条数经常和你期望的不一致。我在一个报表项目里遇到过一模一样的问题接口返回20条列表但这20条里有些用户的orders数据特别多Hibernate日志打出一句firstResult/maxResults specified with collection fetch; applying in memory然后整页数据慢到三秒以上。这种场景的正确解法是先分页查出主表id再根据id批量加载关联集合// 第一步查出当前页的用户id ListLong ids session.createQuery( select u.id from User u order by u.id) .setFirstResult(0) .setMaxResults(20) .list(); // 第二步用in查询关联数据 ListUser 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); ListUser 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生成的典型分页SQLMySQLlimit offset, sizePostgreSQLlimit size offset offsetOracle三层嵌套子查询 rownumSQL 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)查了一次id1的用户然后分页查询返回的结果集里恰好包含id2的用户Hibernate会怎么做它会先用SQL把数据查出来组装结果集时发现id2的实体已经存在一级缓存里就会直接用缓存里的对象替换查询出来的对象而不会重新填充。这个行为在简单场景下感受不到但在一些复杂映射、字段延迟加载、或者跨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”警告看到了你就能立刻反应过来该改代码方向了。