
先说一个我在技术群里看到过很多次的问题Hibernate 还有人用吗打出这句话的人多半是在多表关联查询里被 N1 问题折磨过转头去用了 MyBatis 或者干脆手写 SQL。但今天要聊的 fetch join恰恰就是 Hibernate 解决这类查询性能问题最核心的武器之一。一个能熟练使用 fetch join 的开发者和只会用getXxx()的地板式访问关联对象的开发者写出来的接口性能可以差几十倍。这篇文章我会从 N1 问题的根源讲起把 fetch join 的语法、运行时行为、典型坑位、改造案例一次说透。适合正在用 JPA/Hibernate 写业务代码的 Java 开发者尤其是那些已经在生产环境遇到过接口慢、SQL 多、集合加载不全这类问题的人。1. 为什么需要 fetch join先从 N1 这个老大难说起1.1 懒加载是 N1 的温床Hibernate 的关联默认是懒加载LAZY的你查出一个School对象时它的students集合只是一个空壳代理等你真正调用school.getStudents()的那一刻Hibernate 才会再去数据库补发一条 SQL。这个设计的初衷是好的避免每次查主表都把关联数据全量拖出来。但它埋了一个大坑在循环里访问每个对象的关联属性时就会产生“查一次主表 每条记录再查一次关联表”的 SQL 风暴也就是经典的 N1 问题。很多新手以为懒加载没问题是因为自己写单条查询感觉不到。一旦牵扯到列表页、报表、导出N1 会直接让你的数据库连接池被拖垮。而且更隐蔽的是这种问题在测试环境数据量小的时候完全看不出来上线后数据量一上来才爆发。1.2 一次列表请求背后的 101 条 SQL给你一个非常生活化的场景教育系统里展示学校列表每个学校下面要显示学生人数。ListSchool schools entityManager .createQuery(select s from School s, School.class) .getResultList(); for (School school : schools) { System.out.println(学生数量: school.getStudents().size()); }假设数据库里有 100 个学校这段代码会执行多少条 SQL答案是 101 条第 1 条查学校列表第 100 条是每个学校访问students集合时各自的懒加载查询。虽然每条 SQL 都很快但 101 次网络往返乘以生产环境的并发量数据库和业务线程都在空转。我见过一个真实案例一个导出报表的接口平时 QPS 不高但每次导出都能把数据库 CPU 打满。打开慢查询日志一看全是类似select * from student where school_id ?的短查询一条也就 1ms 左右但一次导出要循环发出几千条。这就是 N1 最恶心的地方——单条无害量变引发质变。1.3 一句话认识 fetch joinfetch join 的定位非常明确它是写在 JPQL/HQL 查询里的一个查询级抓取指令让 Hibernate 在一条 SQL 里把主实体和它关联的对象一起查回来并把关联对象放进当前 Session 的持久化上下文一级缓存里。这样后续再怎么访问关联对象Hibernate 都会直接从缓存里拿不再发 SQL。select s from School s left join fetch s.students上面这行 HQL 就是 fetch join 的最小形态。它和普通 join 最大的区别在于普通 join 只是 SQL 层面的表关联用来做条件过滤、投影并不会改变 Hibernate 对关联对象的加载行为而 join fetch 是明确告诉 Hibernate——这条查询里把s.students这个集合也一并初始化了。2. fetch join 怎么写语法、语义与运行时行为拆解2.1 最小可运行的 join fetch 写法先给一个完整的实体模型后面所有案例都基于它Entity Table(name school) public class School { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String name; OneToMany(mappedBy school) private ListStudent students new ArrayList(); } Entity Table(name student) public class Student { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String name; private Integer score; ManyToOne(fetch FetchType.LAZY) JoinColumn(name school_id) private School school; }现在把刚才那段循环代码改成 fetch joinListSchool schools entityManager .createQuery( select distinct s from School s left join fetch s.students, School.class) .getResultList(); for (School school : schools) { System.out.println(学生数量: school.getStudents().size()); }开 SQL 日志后你会看到查询只剩一条select s1_0.id, s1_0.name, st1_0.school_id, st1_0.id, st1_0.name, st1_0.score from school s1_0 left join student st1_0 on st1_0.school_id s1_0.id之后循环访问getStudents()时日志里不会再出现任何 SQL。这就是 fetch join 的效果一次查询全部加载。2.2 join fetch 与普通 join 的本质区别很多初学者会把join fetch和join搞混这俩在 HQL 里的语义完全不同。普通 join 在 HQL 里通常只出现在两种情况一是作为 where 条件的一部分二是在 select 里做投影。它不负责加载关联对象。比如// 普通 join只筛选有学生的学校但学生的数据不会加载到实体里 select s from School s join s.students st where st.score 60执行完这条查询后返回的School.students依然是懒加载状态。如果你在业务代码里访问它Hibernate 还是会补发 SQL。这一点坑了很多人——以为写了 join 就避免了 N1结果日志一开SQL 一条没少。而 fetch join 的语义是“抓取”它告诉 Hibernate 把关联对象的数据库行也查出来并组装到当前实体的合适位置。对多对一关联ManyToOne/OneToOne来说结果集行数不会变只是把引用的目标对象填充好对一对多关联OneToMany/ManyToMany来说SQL 结果集会因为 join 而膨胀这正是后面要讲的 distinct 和分页坑的来源。2.3 distinct 到底去掉了什么因为一对多集合的 fetch join 是左连接如果一个学校有 10 个学生SQL 结果集里就会出现 10 行相同学校信息的记录。Hibernate 在组装对象时如果发现同一个School主键出现了多次就会把这几行里不同的Student挂到同一个School.students集合里。但如果不用 distinctHibernate 返回的ListSchool里会包含重复的 School 对象引用。所以常规写法里都要加 distinctselect distinct s from School s left join fetch s.students这里有个细节值得注意HQL 里的 distinct 语义是“按根实体去重”不是普通 SQL 里“按整行去重”的概念。Hibernate 5.x 时代HQL 里的 distinct 大多在应用层做内存去重Hibernate 6 在不同版本里对是否把 DISTINCT 下推到 SQL 层有所调整。无论底层行为怎么变你只需要记住distinct 在这里的作用是保证返回的School不重复代价是数据库或应用层需要额外做一次去重处理。如果数据量特别大也可以像下面这样在应用层手动去重ListSchool schools entityManager .createQuery(select s from School s left join fetch s.students, School.class) .getResultList(); // 用 Map 或 Set 按 id 去重 MapLong, School schoolMap new LinkedHashMap(); for (School school : schools) { schoolMap.putIfAbsent(school.getId(), school); } ListSchool result new ArrayList(schoolMap.values());2.4 为什么一条 SQL 就够了持久化上下文的作用fetch join 之所以能一次搞定秘密在于 Hibernate 的持久化上下文Persistence Context。当一条查询返回时所有出现在结果集中的实体——不管是根实体还是 fetch 出来的关联实体——都会被登记到当前 Session 的一级缓存里并标记好状态。之后再访问school.getStudents()Hibernate 会先检查这个集合的状态如果发现它已经被本次查询初始化过了就直接返回内存里的集合对象不再发起数据库查询。这个行为和 Hibernate 的一级缓存Session.find()去重机制是同一套逻辑只是 fetch join 把“提前加载”的过程揉进了同一批 SQL 里。理解这一点很重要fetch join 不是把数据放到什么神秘的地方而是通过 SQL join把原本需要后续多次懒加载才能拿到的对象一次性铺在持久化上下文里。所以它只对当前 Session 生效一旦 Session 关闭这个缓存也没了。这也是它和二级缓存跨 Session 的缓存的本质区别。2.5 多个关联同时 fetch 的行数膨胀问题实际业务里我们经常要同时带出多个关联比如查学校的同时带学生列表、再带教师列表。很多人下意识这样写select s from School s left join fetch s.students left join fetch s.teachers结果会抛一个异常MultipleBagFetchException。这个坑太经典了我单独在下一节讲。这里先铺垫一个概念同时 fetch 多个一对多集合时SQL 结果集的行数按两个集合数量乘积膨胀。假设一个学校有 20 个学生、15 个教师join 之后就是 300 行而一个学校本来只需要一行。如果同时 fetch 三个集合膨胀更夸张。所以性能上要有个基本判断fetch join 适合解决的问题是“关联少、数据可控”的场景不适合把所有关联无脑都 fetch 出来。对象图越深、越宽单条 SQL 的复杂度越高最终收益反而越低。3. fetch join 的三个大坑集合分页、MultipleBag 与过滤陷阱3.1 集合 fetch 分页听起来合理实际全乱这是生产环境里最隐蔽的问题之一。假设我们要分页展示学校列表每页 10 条还要带出学生ListSchool schools entityManager .createQuery(select s from School s left join fetch s.students, School.class) .setFirstResult(0) .setMaxResults(10) .getResultList();你预期的行为SQL 里先分页查 10 个学校再 join 出这 10 个学校的学生。实际上 Hibernate 的做法完全不一样它会在日志里打出一条警告大意是firstResult/maxResults specified with collection fetch; applying in memory然后把 join 之后的完整结果集一次性加载到内存在内存里做分页。这个行为在数据量大时是灾难。假设 school 表 1 万条、student 表 5 万条你本来只想取 10 条数据Hibernate 却把 5 万行 join 结果全查出来然后在 Java 内存里切出 10 个 School。数据库 IO 和堆内存都被白白消耗了。而且内存分页的结果也经常不符合直觉由于一个 School 会展开成多行对应多个学生分页切出来的 10 行可能只包含 3 个不同 School导致这一页数据“缺失”。这个问题没有直接的 fetch join 解法正确做法一般是先分页查出主表 id再针对这些 id 做 join fetch// 第一步分页查主表 id ListLong ids entityManager .createQuery(select s.id from School s order by s.id, Long.class) .setFirstResult(0) .setMaxResults(10) .getResultList(); // 第二步按 id 集合 fetch此时不再分页 ListSchool schools entityManager .createQuery( select distinct s from School s left join fetch s.students where s.id in :ids, School.class) .setParameter(ids, ids) .getResultList();两步加起来也只有两条 SQL整体性能比内存分页好一个数量级。如果你用的还是ManyToMany或者中间表结构的关联这个套路同样适用。3.2 MultipleBagFetchException为什么 Hibernate 直接拒绝前面提到同时 fetch 两个 List 集合时抛MultipleBagFetchException很多同学第一次看到这个异常会一脸懵明明 SQL 能 join为什么 Hibernate 直接拒绝原因要从 Java 集合语义说起。这里的List在 Hibernate 里被称为 bag它是一个允许重复、没有显式顺序的集合。当两条 join 结果里出现同一个学生因为教师集合 join 导致行数翻倍时Hibernate 无法判断这个学生应该算作“重复出现了一次”还是“集合里本来就有两个相同的引用”。bag 的语义无法消除这种歧义Hibernate 为了保证数据正确性直接选择抛异常。如果你把其中一个 List 改成 Set或者给 List 加上OrderColumnHibernate 就能通过唯一性和排序信息正确装配不再抛异常。但要知道即使不抛异常两个集合同时 fetch 依然会产生巨大的笛卡尔积。我个人的建议是能不这么干就不这么干。优先考虑把查询拆开一条查询 fetch 学生另一条查询独立 fetch 教师然后在应用层按 School 的 id 手动组装。3.3 给 fetch 出来的关联加过滤条件的坑另一个常见误用是想查“有及格学生的学校”同时把及格学生也带出来于是写成select distinct s from School s left join fetch s.students st where st.score 60这个写法可能不报错但结果非常微妙。如果某个学校只有不及格的学生那么 join 后这个学校没有任何行满足score 60它在结果集里就消失了——看起来合理。但如果某个学校有 10 个学生其中 3 个及格、7 个不及格那么查询返回的School.students集合里只会包含那 3 个及格的学生。问题在于Hibernate 认为这个集合是“已初始化”的因为经过了 fetch所以后续访问school.getStudents()时不会再去数据库补全你拿到的就是一份残缺的集合。这比 N1 更难排查因为代码里看不到任何异常只有业务数据不对劲。正确的姿势是把“过滤”和“抓取”分开。要么用普通 join 只查合格的学校 id再对 id 集合做完整 fetch要么使用 DTO 投影只选取需要的字段不触碰实体集合的完整性。在我做过的项目里凡是需要“部分子集”的场景一律不走实体集合 fetch改为投影查询。4. 实操案例一次完整的 N1 到 fetch join 改造4.1 场景与实体设计一个简单的学校成绩报表拿一个我调试过的接口举例报表模块需要展示所有学校及其学生的成绩信息要求返回学校名称、学生姓名、学生分数。数据量不算大——200 个学校、每个学校平均 50 个学生约 1 万条学生记录。最初的实现很直白先查所有学校再循环查学生。代码和文章开头那个示例基本一样只是多了些字段拼接。跑一次接口耗时稳定在 2.8 秒左右日志里 SQL 数量 201 条。这在本地开发环境还能忍放到测试环境压测50 并发直接把数据库连接池拖到报警。4.2 复现 N1日志和实际耗时我当时用两个手段确认问题一是打开 Hibernate 的 SQL 日志hibernate.show_sqltrue二是在项目里临时接了一个 SQL 执行计数工具或者直接用数据库的 slow log。日志长这样注意看重复模式Hibernate: select s1_0.id,s1_0.name from school s1_0 Hibernate: select st1_0.school_id,st1_0.id,st1_0.name,st1_0.score from student st1_0 where st1_0.school_id 1 Hibernate: select st1_0.school_id,st1_0.id,st1_0.name,st1_0.score from student st1_0 where st1_0.school_id 2 Hibernate: select st1_0.school_id,st1_0.id,st1_0.name,st1_0.score from student st1_0 where st1_0.school_id 3 ...接口总耗时的构成里95% 以上是这 200 次循环查询的网络往返和连接获取开销。单条学生查询只要 0.3ms但 200 次乘上并发连接池就受不了了。4.3 改造一步到位join fetch 的效果改动其实很小核心就是把查询改成 fetch join并加上 distinctListSchool schools entityManager .createQuery( select distinct s from School s left join fetch s.students order by s.id, School.class) .getResultList();重新跑接口日志里只剩一条 join 查询Hibernate: select distinct s1_0.id, s1_0.name, st1_0.school_id, st1_0.id, st1_0.name, st1_0.score from school s1_0 left join student st1_0 on st1_0.school_id s1_0.id order by s1_0.id接口耗时从 2.8 秒降到了 180ms 左右SQL 数量从 201 条降到 1 条。这个量级的提升根本不需要什么性能调优魔法就是少发了 200 次无意义的重复查询。这也是 Hibernate 被人骂和被夸奖的分水岭明白 fetch join 的人觉得 ORM 真香不明白的人觉得 ORM 是性能毒瘤。4.4 替代方案与组合拳EntityGraph、BatchSize、拆分查询fetch join 不是唯一的方案而且它也有局限性。这里我把几个常用替代方案一并说清楚方便你根据场景选。第一个是 JPA 标准的EntityGraph它不修改 JPQL 字符串而是以查询提示hint的方式声明抓取路径EntityGraphSchool graph entityManager.createEntityGraph(School.class); graph.addAttributeNodes(students); ListSchool schools entityManager .createQuery(select s from School s, School.class) .setHint(jakarta.persistence.fetchgraph, graph) .getResultList();用 Spring Data JPA 的话更简单直接在 Repository 方法上加注解public interface SchoolRepository extends JpaRepositorySchool, Long { EntityGraph(attributePaths students) Query(select s from School s) ListSchool findAllWithStudents(); }EntityGraph 的好处是抓取路径可以动态组合同一个查询可以通过不同 hint 达到不同加载深度业务代码里更干净。缺点是它的实现也是靠 SQL join遇到集合分页、多集合 fetch 时和 join fetch 有一样的坑。第二个是BatchSize专门治“循环里逐个懒加载”的问题Entity public class School { OneToMany(mappedBy school) BatchSize(size 20) private ListStudent students new ArrayList(); }加了BatchSize后当你第一次访问某个学校的 students 时Hibernate 不会只查这一个学校的而是把当前 Session 里已加载的学校 id 收集起来用一条in查询批量初始化最多 20 个学校的集合。这样 200 个学校循环访问SQL 数量从 201 条变成 1 10 条。它不需要改任何查询语句是存量项目最快的止血方案。第三个是拆分查询手动组装适合需要过滤部分子集、或者同时 fetch 多个集合的场景// 查询一只取学校列表 ListSchool schools entityManager .createQuery(select s from School s, School.class) .getResultList(); ListLong schoolIds schools.stream().map(School::getId).toList(); // 查询二一次取所有学生并按学校分组 ListStudent students entityManager .createQuery( select st from Student st where st.school.id in :ids, Student.class) .setParameter(ids, schoolIds) .getResultList(); MapLong, ListStudent studentMap students.stream() .collect(Collectors.groupingBy(st - st.getSchool().getId())); // 查询三需要其他集合就再来一次最后在应用层组装这样做的 SQL 数量最少、开销最可控但代码会多一些。我的经验是项目前期优先用 fetch join 和 EntityGraph等发现某个查询的 join 过于复杂、或者需要过滤子集合时再切换成拆分查询方案。5. 常见问题速查与生产环境排坑技巧5.1 报错与解决方案速查表现象根因解决方案日志出现大量相同 SQL接口响应慢N1循环访问懒加载关联使用 join fetch / BatchSize / EntityGraph访问school.getStudents()抛LazyInitializationExceptionSession 已关闭懒加载无法执行在事务内访问或直接 join fetch日志提示firstResult/maxResults specified with collection fetch; applying in memory集合 fetch join 与分页叠加先分页查 id再按 id 做 join fetchMultipleBagFetchException同时 fetch 两个 List 集合把 List 改 Set或拆分多条查询手动组装集合里的元素少了一截且没有报错fetch join 的 where 条件过滤了关联数据集合场景不要过滤子集改用投影或拆分查询join fetch 后返回的 List 里学校重复一对多 join 产生笛卡尔积行加 distinct或在应用层去重5.2 确认 SQL 数量的三种手段排查 N1 最核心的能力是能看到程序实际发了多少 SQL、发了什么 SQL。最简单的方式是本地开发打开 Hibernate 配置spring.jpa.show-sqltrue spring.jpa.properties.hibernate.format_sqltrue但show-sql是打印到控制台的生产环境一般不会开。更好的办法是接一个 SQL 语句监听工具比如 datasource-proxy、p6spy或者直接用数据库自带的慢查询日志。我实际项目里最常用的是给数据源包一层 proxy统计每个接口执行期间的 SQL 条数和总耗时这样不用翻日志就能精确定位是哪个接口在刷 SQL。判断标准很简单一个列表接口的 SQL 数量应该和主表数据量级无关。如果你发现某个接口的 SQL 条数随行数线性增长基本可以断定 N1 没跑。5.3 我在生产环境使用 fetch join 的几条铁律踩过足够多坑之后我给自己定了这么几条使用规则你可以直接拿去用。第一条fetch join 是查询级策略不要为了省事去改实体映射为EAGER。全局 EAGER 会让所有查询都背上关联加载的开销即使你根本不需要那些数据这是典型的因小失大。第二条集合类关联最多同时 fetch 一个。两个就很难看了三个以上基本等于自杀。多对一和一对多混合 fetch 没问题比如“学校 学生列表 学校地址多对一”是可以的但不要“学生列表 教师列表”同时 fetch。第三条fetch join 和分页不要同时出现。这不是语法错误而是行为不达预期的大坑。牢记“先分页查 id再 fetch 实体”这个两步走套路。第四条不要在 fetch 出来的集合上加 where 条件。如果确实需要过滤子集请用投影或拆分查询不要贪图省事污染集合的完整性。第五条改造 N1 时先用日志确认问题再动手。有时候加个BatchSize就能解决大部分问题不一定非要用 fetch join——别为了炫技把简单查询搞复杂。最后我个人的体会是Hibernate 的性能问题九成不是框架本身慢而是使用姿势埋下的雷。fetch join 就像一把双刃剑用对了能把 N1 直接斩干净用错了也能给你整出内存分页、集合残缺、笛卡尔积膨胀这些更隐蔽的故障。回到开头那个问题Hibernate 还有人用吗只要 JPA 还是 Java 世界的默认持久化规范Hibernate 就不会没人用Spring Boot 全家桶现在默认跑的依然是它。真正让 Hibernate 在网上被吐槽的不是它不好用而是很多团队把懒加载、N1、缓存失效这些锅全扣到框架头上。你真正需要的不是换框架而是搞清楚 fetch join 这种核心工具的边界然后放心地把它用在合适的地方。