写完这个系列上一期,本来想歇几天,结果翻到工作群里的一个截图,又有人一头扎进“接口慢、SQL一大堆”的坑里。于是就有了这篇“Java Day n+1!”,它既是这个系列的第 n+1 篇,也是专门聊 Java 世界里各种“+1”问题的一篇。要说 Java 开发者最熟悉的 +1,肯定不是数学里的自增,而是数据库访问中的 N+1 查询。一条列表查出来 N 条记录,结果又补发了 N 条关联查询,接口延迟直接起飞。这个问题我在好几个项目里都碰到过,今天把原理、排查手段、三种主流解法、一个完整排查过程,以及一个容易被忽略的“+1”变体,一次性讲透。不管你是刚接触 JPA 的新手,还是写过几年业务代码的老手,这篇文章都可以直接当查漏补缺的清单用。
1. N+1查询为什么是Java开发绕不过去的坑
1.1 先看一个最典型的N+1查询示例
假设有一个订单实体,每个订单下面挂着一组订单项,数据库里是主表和明细表的关系。JPA 里通常这样建模:
@Entity @Table(name = "t_order") public class OrderEntity { @Id private Long id; private String buyerNick; private BigDecimal totalAmount; @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) private List<OrderItemEntity> items = new ArrayList<>(); }订单项实体里维护一个指向订单的外键,业务侧要查“已支付订单列表”,顺带拿到每个订单下的商品件数。最直观的写法就是查完订单,再循环订单取 items:
List<OrderEntity> orders = orderRepository.findByStatus("PAID"); for (OrderEntity order : orders) { int itemCount = order.getItems().size(); // 组装 DTO 用 }就这样一段“看着人畜无害”的代码,落到数据库上会变成这样的查询模式:
select * from t_order where status = 'PAID'; select * from t_order_item where order_id = 101; select * from t_order_item where order_id = 102; select * from t_order_item where order_id = 103; -- N 条订单就补 N 条查询这就是 N+1 查询:第一条 SQL 查出 N 条主记录,后续每条记录又触发一条关联 SQL,总查询次数是 1 + N。当订单列表有 200 条数据时,数据库要承受 201 次查询。在数据库连接池数量有限、网络开销和 SQL 解析开销都不小的情况下,接口响应时间会肉眼可见地涨上去。
N+1 这个叫法在不同团队里也有叫“查列表变成查字典”“循环查库”的,本质都一样:本该一次拿到的关联数据,被拆成了一连串单点查询。最麻烦的是,它不是必现错误,只有数据量上来、并发上来的时候才暴露,所以特别容易在开发环境“测不出来”,一上线就出事。
1.2 一个懒加载引发的连锁问题
要理解 N+1,得先搞清楚 JPA 里懒加载(LAZY)的设计意图。懒加载的意思是:关联属性在查询主实体时先不加载,等到真正“被使用”的时候,再通过代理对象从数据库补查。
这个设计本身没问题,它的初衷是避免一次性把整棵对象树全部查出来。比如订单详情页只需要查订单本身,那就没必要把 500 个订单项也一起加载。问题是懒加载有一个隐藏的触发条件:它必须在持久化上下文(也叫 EntityManager 会话)仍然存活时执行。如果事务已经结束、会话已经关闭,你去访问一个还没加载的懒加载集合,框架就会抛异常。
很多团队为了省事,直接在 Controller 层把实体返回给前端序列化,再开启 OpenSessionInView 让会话在视图渲染阶段保持存活,保证序列化时不抛异常。这样做的代价是:序列化器在遍历实体的关联属性时,每遇到一个还没有加载的集合,就悄悄发一条 SQL。表面看着接口只是“查询一次”,实际底下已经打了上百条 SQL。等到数据库连接池被占满、接口动不动几百毫秒甚至几秒才反应过来时,问题早就被放大到很难查了。
所以我对懒加载的态度经常是:懒加载本身不是坏事,坏的是“在循环里无意识地触碰懒加载属性”,或者说“在会话边界之外还允许透明加载”。前者产生 N+1,后者把 N+1 藏得更深。排查的时候,第一步不是看代码怎么写的,而是先确认数据访问层到底发了多少条 SQL。
1.3 3步查清接口里的N+1热点
排查 N+1,最快的方法不是翻代码,而是让接口把 SQL 日志如实打印出来,然后数 SQL 条数。我常用的三步:
第一步,把数据访问层的 SQL 日志打开。如果是 JPA 实现,可以临时开 show-sql 和格式化输出,日志级别调到 DEBUG。生产环境不建议长期开,压测环境或灰度环境开一把。
# SQL 日志落到文件后,统计 select 条数 grep -i "^select" app-sql.log | wc -l第二步,对一个接口发起一次请求,观察日志里的 select 条数。如果明明一个列表接口,日志里却出现几十上百条“长得一样、只是条件里的 id 不同”的 SQL,那基本可以断定已经踩进 N+1。
第三步,用调用链或堆栈把这条 SQL 映射回代码。很多 SQL 跟踪工具可以打印触发 SQL 的调用堆栈,也可以直接看慢查询日志。找到 SQL 之后,往上层找是哪一行代码访问了懒加载属性,这一行往往就在循环体里,或者在 DTO 序列化器里。
注意:不要一看到 N+1 就把 OpenSessionInView 关掉,也不要直接禁止所有懒加载。正确做法是先确认 SQL 是从哪个代码路径触发的,再决定用哪种方式优化。强制 EAGER 加载同样会带来严重性能问题,后面提到。
2. 数据访问层的N+1常规解法与选型
2.1 JOIN FETCH:最直接但容易踩笛卡尔积
第一种解法是在查询里主动把关联数据一次性取出来,这就是 JOIN FETCH。用法是在 JPQL 或 Criteria 查询里显式声明:
@Query("select distinct o from OrderEntity o " + "left join fetch o.items " + "where o.status = :status") List<OrderEntity> findWithItemsByStatus(@Param("status") String status);执行后框架会生成一条带 LEFT JOIN 的 SQL,把订单和订单项在数据库层关联好,然后在内存里组装成对象图。这样一来,访问 order.getItems() 时不会再触发懒加载,因为集合已经被填充。
这个方案直接有效,但有两个很经典的坑。
第一个坑是笛卡尔积。如果你一次 JOIN FETCH 两个集合,比如订单同时有 items 和 tags 两个一对多集合,查询结果的行数就变成“订单数 × items 数 × tags 数”。本来 100 条订单,每个订单 5 个订单项、3 个标签,结果返回 1500 行,内存里还会重复组装一堆对象。正确姿势是一次查询只 FETCH 一个集合,或者先分页拿主记录 ID,再分集合批量加载。
第二个坑是分页。JOIN FETCH 和 Pageable 一起用的时候,很多 JPA 实现对一对多集合没法在数据库端直接分页,它会先把所有数据加载到内存,再截取“第一页”,并且日志里会打印类似“firstResult/maxResults specified with collection fetch; applying in memory”的警告。数据量小无所谓,数据量大直接 OOM。
对应到明显出问题的场景,我个人习惯是先避开“一对多集合 + 分页 + JOIN FETCH”的组合,改成两步查询:第一步查符合条件的实体 ID 分页,第二步用where id in :ids再次查询并 JOIN FETCH 集合,最后在内存里把顺序整理回去。两步都是有限结果集,既不会内存分页,也不会让 SQL 爆炸。
2.2 @BatchSize:代码侵入最小的兜底方案
如果历史代码已经写得很烂,到处都在访问懒加载集合,而你又没有时间立刻重构所有查询,@BatchSize 是性价比最高的兜底。
它的含义是:当需要初始化多个实体的懒加载集合时,不再每个实体单独发一条 SQL,而是把“当前持久化上下文里所有还没初始化同类集合的实体”攒到一起,用一条IN查询批量加载。这相当于把原本的 1 + N 条 SQL 变成 1 + ceil(N / size) 条。
代码层面有两种配法。一种是在实体上直接标:
import org.hibernate.annotations.BatchSize; @Entity @BatchSize(size = 50) public class OrderEntity { // ... }另一种是在配置文件里设置全局默认值:
spring.jpa.properties.hibernate.default_batch_fetch_size: 50全局配置是我更推荐的方式,因为你不用改动任何实体,对一个模块里所有懒加载关联都生效。批量大小一般取 50 到 100 之间,太小了效果不明显,太大了会让单条 SQL 的IN列表过长。
这个方案有一个购买点要重新强调:它不是把查询次数降到 1,而是把 N 次降到 N/50 次,所以它只是“止血”,不是“根治”。如果关联数据只在部分场景需要,依然会造成不必要的传输开销。但它非常适合放在重构的前置阶段,先稳住接口不被打垮,再慢慢把热点查询改成投影或 JOIN FETCH。
2.3 DTO投影:给接口减负的正统做法
所谓投影查询,就是直白地告诉数据库:“我只要订单号、买家昵称、商品件数、下单时间,其他一概不需要。”这在 JPA 里可以通过 constructor expression 或接口投影来完成。
@Query("select new com.demo.api.vo.OrderListVO(" + "o.id, u.nick, count(i.id), o.createTime) " + "from OrderEntity o " + "left join o.items i " + "left join UserEntity u on u.id = o.userId " + "where o.status = :status " + "group by o.id, u.nick, o.createTime") List<OrderListVO> findOrderList(String status);上面这种写法,SQL 会直接聚合出每一笔订单对应的商品件数,返回的就是一个按需投影的视图对象。它既不会触发懒加载,也不会把 items 集合整个拖出来,数据库返回给应用的字段数量大幅减少,序列化阶段也不会因为关联属性而手滑打 SQL。对于接口列表页和报表类场景,这是我优先考虑的做法。
对应的投影 DTO 是一个普通 POJO,有对应构造器即可,结构稳定、字段明确。代价是放弃实体缓存和实体管理的便利,并且 SQL 要自己维护,字段别名和类型得对得上。另外要注意,如果投影 DTO 里不小心又写了关联对象的 getter,序列化时照样可能触发懒加载,所以投影对象的字段一定要“扁平、基本类型、无关联”。
三种方案选型没有一个绝对正确答案。下面这张表是我在团队里推动 Code Review 时的默认参考:
| 方案 | 改动成本 | SQL 次数 | 典型适用场景 | 最大风险 |
|---|---|---|---|---|
| JOIN FETCH | 低 | 1 条主查询 + 0 条懒加载补查 | 单层集合、详情页、无大结果集 | 笛卡尔积、内存分页 |
| @BatchSize | 极低 | 1 + ceil(N/B) | 历史代码兜底、批量页面 | 仍有额外 SQL,结果集会偏大 |
| DTO 投影 | 中 | 0 或 1 | 列表页、报表、字段精简场景 | 字段映射需手动维护 |
3. 从N+1联想到JVM里的n+1次对象new
3.1 循环new百万次对象,内存却没爆?
数据库里的 N+1 解决了,我一直觉得“+1”这个后缀很有意思,因为 JVM 里面也藏着一个“n+1”的变体:循环里反复 new 对象的场景。
看一段很普通的代码:
public class AllocationDemo { private static long calc(int count) { long sum = 0L; Point p; for (int i = 0; i < count; i++) { p = new Point(i, i + 1); sum += p.x + p.y; } return sum; } }如果纯按“Java 对象都分配在堆上”这个最朴素的理解,这个循环要创建 count 个 Point 对象,全部进堆,等 GC 来回收。可实际运行时会发现,当 count 到百万甚至千万级别,Young GC 的次数并没有线性上涨,甚至比预期少很多。这是因为 HotSpot VM 的 JIT 编译器在编译热点方法时,会做一项叫逃逸分析的优化。
逃逸分析判断的是“这个对象会不会逃出当前方法的作用域”。在这个例子里,p 对象只在循环里被创建、使用,既没有返回给调用方,也没有被放进集合或静态变量,编译器就能确定它没有逃逸。既然没有逃逸,它就可以不被当成一个真正的堆对象,而是被拆散成几个标量(也就是 x 和 y 两个独立的局部变量),直接在寄存器或栈上参与计算。这种技术叫标量替换。循环跑了 100 万次,实际可能一次堆分配都没有发生。
这里也解释了很多初学者对 JVM 内存模型的一个误解:Java 语言规范规定对象的“可见行为”,并不强制要求每个 new 对象都必须在堆上留下痕迹。只要外部观察不到差异,JIT 怎么优化都行。逃逸分析就是钻了这个空子,把本应发生 N 次堆分配的循环,变成了“0 次堆分配 + N 次标量计算”。
3.2 逃逸分析的三个“不逃逸”前提
逃逸分析不是什么地方都能生效,它有三个很具体的前提。
第一,对象本身不能逃逸。什么是逃逸?把新建对象作为方法返回值返回出去,是逃逸;把对象引用放进成员变量或静态容器,是逃逸;把对象作为参数传给另一个可能不被内联的方法,也可能被判定为逃逸。一句话:对象的引用一旦可能被外部拿到,编译器就不敢做标量替换。
第二,这段代码必须成为热点方法。逃逸分析发生在 JIT 编译阶段,不是解释执行阶段。一个只被调用几次的小方法,JIT 可能根本不值得去分析;但在循环几百万次的场景里,方法快速变热,C2 编译器才会投入成本做深度分析。
第三,方法内联要配合得上。标量替换的对象往往被拆分到调用方法里,如果相关方法体巨大、调用关系复杂,编译器也会放弃。小方法、纯计算、无副作用、无反射和代理调用,是最容易被优化的形态。
所以实战中你会发现,一个被 Spring 管理、经过 CGLIB 代理、返回值被放进集合又在多层调用之间传递的“业务对象”,基本别想走逃逸分析的便宜。逃逸分析适合处理的是循环内临时构造的小对象,比如坐标点、金额计算中间量、字符串拼接的中间容器,而不是那些生命周期长、跨层传递的实体。
3.3 怎么确认标量替换真的生效了
如果你看完上面对比,想知道自己的项目里逃逸分析到底有没有生效,最直接的办法是做个对照实验,而不是去翻 JVM 内部日志。
一种做法是跑一个固定的压测用例,分别用-XX:+DoEscapeAnalysis(默认开启)和-XX:-DoEscapeAnalysis跑两轮,对比 GC 日志里的 Young GC 次数和平均停顿。如果开启状态下 GC 次数明显更少,说明大量对象被优化掉了。另一种做法是通过 JFR 采样分配,在 JFR 里看分配热点对象数量,开启和关闭对比,也能看出差异。
发布时间较早的 JDK 提供过一些打印逃逸分析结果的内部参数,比如 PrintEliminateAllocations,但这些参数未必在正式发布的构建里稳定可用。对普通开发来说,不必执着于看到“某一行对象被消除”的证明,只需记住:HotSpot VM 里-XX:+DoEscapeAnalysis、-XX:+EliminateAllocations、-XX:+EliminateLocks这些优化默认就是打开的。你需要做的不是手动开启,而是别傻乎乎地去写一堆来路不明的大对象复用来“替 JVM 操心”。过度复用对象反而带来线程安全风险,把代码可读性搞得一塌糊涂,这是本末倒置。
注意:逃逸分析的优化对象是“没有逃逸”的对象,而不是所有 new 出来的对象。如果你为了让对象不逃逸,强行把本该共享的业务对象复制几百份,内存压力反而更大。先写清晰正确的代码,再关注 JIT 怎么优化,顺序不能反。
4. 模拟项目X订单接口N+1排查实录
4.1 现象和第一轮排查
去年做一个内部订单中心系统时(这里用模拟项目 X 代称),我们接到反馈,订单列表接口越来越慢,前端分页翻页都开始卡。监控里看到 P99 响应时间从平时的 220ms 一路爬到 1.6s,数据库连接池 active 连接数也明显升高。DBA 那边的慢查询日志里干干净净,没有特别离谱的单条慢 SQL,但总耗时就是降不下来。
当时我判断问题不在单条 SQL,而在“SQL 数量”。于是先在压测环境模拟同样场景,开启 SQL 日志,对一个列表接口执行一次请求,数了一下日志:第一条是查订单主表,后面跟着 97 条长得几乎一样的查订单项 SQL。这已经是教科书式的 N+1 了。
基本情况是:100 条订单记录里,97 个订单在后续流程里访问了 order.getItems(),于是补发了 97 条关联查询。单条 SQL 只要几毫秒,可 97 条在连接池里排队,连接被逐个占住,响应时间就全耗在等待上。
4.2 定位到代码里的那句懒加载
我沿着日志里的 SQL 调用栈往上层找,最终定位到订单组装服务里的这段代码:
List<OrderEntity> orders = orderRepository.findByStatus("PAID"); List<OrderListVO> result = new ArrayList<>(); for (OrderEntity order : orders) { OrderListVO vo = new OrderListVO(); vo.setOrderNo(order.getOrderNo()); vo.setBuyerNick(order.getBuyer().getNick()); // 下面这行是触发点 vo.setItemCount(order.getItems().size()); result.add(vo); }问题很清楚:为了拿到“商品件数”,代码直接遍历懒加载集合,每次遍历都触发一次关联查询。更隐蔽的是,这里把实体对象直接暴露给组装层,加上会话在接口返回后还没关闭,序列化阶段又会把一些没显式使用的懒字段也加载一遍。SQL 数量被进一步放大。
第一期修复我选的是最低风险的 @BatchSize。在配置里设置默认批量大小后,同一请求里的懒加载集合被合并成 1 到 2 条 IN 查询,SQL 数量直接从 98 条降到 2 条,接口 P99 降到了 450ms 左右。这个修复只花了几分钟,却先稳住了线上。
正式版本里,我把列表查询改成了 DTO 投影。由于页面只需要订单号、买家昵称、商品件数、支付金额、下单时间这几个字段,完全没必要把 OrderEntity 和 items 集合加载到内存。投影查询把关联和聚合都交给数据库,最终只落一条 SQL。
4.3 优化落地与前后对比
优化前的代码逻辑是“多查 + 后过滤”,优化后的逻辑是“按需取 + 一次性成型”。改造完成后,我用同样 100 条订单的压测样例重新跑了一轮,对比结果如下:
| 指标 | 优化前 | 第一轮 @BatchSize | 最终 DTO 投影 |
|---|---|---|---|
| SQL 条数 | 1 + 97 | 1 + 1 | 1 |
| 响应 P99 | 1600ms | 450ms | 85ms |
| 单页返回数据量 | 约 1.8MB | 约 1.1MB | 约 280KB |
| 代码侵入性 | 无 | 极小 | 中 |
这个数据很能说明问题:N+1 消耗的不只是数据库时间,还有网络传输、连接池排队、GC 压力和序列化耗时。把 98 条 SQL 减到 1 条,响应时间降了将近 95%,这比加机器、调缓存参数都来得直接。
这件事之后,我们团队在接口设计上立了一条规矩:列表接口禁止在 Service 层循环里主动访问懒加载集合;返回前端的数据一律走 VO,不允许直接返回实体;批量查询默认配置好 batch fetch size 作为兜底。后来同类问题在别的新接口里再没出现过,这条规范功不可没。
5. N+1问题常见坑位与排查心得
5.1 排查中问得最多的4个为什么
很多人照着上面的方案改了,还是会撞到各种“看起来改了又不生效”的怪事。这里把我被问得最多的四个问题整理成一张速查表,可以收藏备用。
| 现象 | 最容易忽略的原因 | 检查方向 |
|---|---|---|
| 用了 JOIN FETCH,还是打了 N 条 SQL | 另一个懒加载关联在 DTO 序列化时被触发 | 优先检查序列化阶段的 getter,避免实体直接返回前端 |
| 配置了 @BatchSize,但日志里没有 IN 查询 | 实体不是在当前持久化上下文里加载的;或者配置被覆盖 | 确认是否还在同一事务/会话内访问懒集合 |
| JOIN FETCH 加上分页后结果不对,日志有警告 | 一对多集合无法在数据库端分页,走了内存分页 | 改成先查 ID 分页再批量加载 |
| 使用 DTO 投影后,接口还是慢 | 投影对象里仍有关联字段 getter,或者查询本身缺少索引 | 检查投影 DTO 字段是否都是基本类型,执行计划是否走索引 |
我自己踩得最多的是第一条。有时候代码里明明已经 JOIN FETCH 了 items,但页面上额外展示的“买家昵称”来自另一个懒加载关联,于是 JPA 照旧补查。这个问题只看 SQL 日志非常隐蔽,因为补查的 SQL 会在 FETCH 之后出现,而不是出现在循环阶段。所以排查 N+1 时,不是找到一条重复 SQL 就完事,而是把所有重复出现的 SQL 一股脑找出来,逐个回到代码里确认触发点。
5.2 几条实战里的长期习惯
这些习惯不一定写在框架文档里,但能在关键时刻省你很多时间。
第一条,先看 SQL 次数,再谈代码优化。不管接口多慢,先把日志数清楚,N+1 是“数量问题”,跟单条慢 SQL 是两回事,解决思路完全不同。
第二条,所有列表接口强制返回 VO,禁止直接把实体交给前端。这是从源头避免懒加载被序列化器“偷摸”触发的最有效手段。实体留在 Service 内部,数据复制成 VO 的过程要显式、可控。
第三条,配置 OpenSessionInView 为 false。如果暂时不能关,那就要更严格地限制实体外传,否则懒加载会在请求结束后的视图层疯狂补查,问题会变得极其难查。
第四条,批量大小真的不用贪多。设成 100 已经能让 99% 的场景满意,设成 1000 反而会造成一条巨大的 IN 查询,数据库不一定吃得消。
第五条,Code Review 的时候多问一句“这个 getter 会不会触发懒加载”。很多 N+1 都是在一个不经意的 getXxx() 里埋下的,Review 时盯住循环里的关联访问,能提前拦下大部分问题。
最后分享一个我在实际项目里经常用的土办法。排查 N+1 时,先把日志里的 SQL 取出来,按相同 SQL 的出现次数从高到低排,出现次数最多的那一条,往往就是风暴点。改它之前,先想清楚“业务到底需要多少字段”,再决定是批量加载还是走投影。数据库里的 N+1 和 JVM 里的逃逸分析优化,一个在做减法,一个在做“无中生有”,本质都是在提醒我们:不要被代码的表象骗了,真正决定 Java 应用性能的,常常是那些看不见的执行路径。
N+1 这东西,一次排查带来的收益可能比加十台机器都明显。希望这篇“Java Day n+1”能让你下次遇到这类问题的时候,少走两步弯路。