【Kotlin + Spring Boot 4 从零到架构师】第 21 篇:N+1 问题与 fetch join、EntityGraph
本系列定位:零基础入门,从 Kotlin 语法一路到 Spring Boot 4 高级架构(DDD + Modulith),适合 Java 开发者转型,也适合纯新手系统学习。
本篇你将学到
- 什么是 N+1 问题,它为什么是性能杀手
- 三种解决方案:fetch join、@EntityGraph、@BatchSize
- 每种方案的适用场景与优缺点
- 如何检测 N+1 问题
学完本篇,你将能识别和消除项目中的 N+1 查询,显著提升接口响应速度。
一、N+1 问题详解
1.1 什么是 N+1
当你查询一个列表(N 条记录),每条记录又关联了懒加载的实体时,Hibernate 会执行 1 条查列表的 SQL + N 条查关联的 SQL = N+1 条 SQL。
1.2 代码复现
kotlin
@Entity
@Table(name = "orders")
class Order(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
var user: User? = null,
@Column(name = "total_amount")
var totalAmount: BigDecimal = BigDecimal.ZERO
)
// Repository
interface OrderRepository : JpaRepository<Order, Long> {
// 普通查询
fun findAllByStatus(status: String): List<Order>
}
kotlin
// Service
@Service
class OrderService(
private val orderRepository: OrderRepository
) {
fun getOrderSummaries(status: String): List<OrderSummary> {
val orders = orderRepository.findAllByStatus(status) // 第 1 条 SQL
return orders.map { order ->
OrderSummary(
orderId = order.id!!,
// ← 访问懒加载的 user 属性,触发额外 SQL!
username = order.user?.username ?: "未知",
totalAmount = order.totalAmount
)
}
// 如果有 100 个订单,这里会执行 100 条额外 SQL!
}
}
控制台 SQL 日志:
sql
-- 第 1 条:查列表
SELECT id, total_amount, user_id FROM orders WHERE status = 'PAID'
-- 第 2~101 条:逐个查用户(N+1 中的 N)
SELECT id, username, email FROM users WHERE id = 1
SELECT id, username, email FROM users WHERE id = 2
SELECT id, username, email FROM users WHERE id = 3
-- ... 重复 100 次
1.3 危害
- 性能急剧下降:100 个订单本应 1-2 条 SQL,变成了 101 条
- 数据库压力增大:大量短查询消耗连接池资源
- 响应时间变长:网络往返次数成倍增加
下面是 N+1 问题的执行流程示意图:
数据库 应用层 数据库 应用层 #mermaid-svg-W7qapuKhfItJGh7p{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-W7qapuKhfItJGh7p .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-W7qapuKhfItJGh7p .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-W7qapuKhfItJGh7p .error-icon{fill:#552222;}#mermaid-svg-W7qapuKhfItJGh7p .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-W7qapuKhfItJGh7p .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-W7qapuKhfItJGh7p .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-W7qapuKhfItJGh7p .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-W7qapuKhfItJGh7p .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-W7qapuKhfItJGh7p .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-W7qapuKhfItJGh7p .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-W7qapuKhfItJGh7p .marker{fill:#333333;stroke:#333333;}#mermaid-svg-W7qapuKhfItJGh7p .marker.cross{stroke:#333333;}#mermaid-svg-W7qapuKhfItJGh7p svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-W7qapuKhfItJGh7p p{margin:0;}#mermaid-svg-W7qapuKhfItJGh7p .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-W7qapuKhfItJGh7p text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-W7qapuKhfItJGh7p .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-W7qapuKhfItJGh7p .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-W7qapuKhfItJGh7p .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-W7qapuKhfItJGh7p .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-W7qapuKhfItJGh7p #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-W7qapuKhfItJGh7p .sequenceNumber{fill:white;}#mermaid-svg-W7qapuKhfItJGh7p #sequencenumber{fill:#333;}#mermaid-svg-W7qapuKhfItJGh7p #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-W7qapuKhfItJGh7p .messageText{fill:#333;stroke:none;}#mermaid-svg-W7qapuKhfItJGh7p .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-W7qapuKhfItJGh7p .labelText,#mermaid-svg-W7qapuKhfItJGh7p .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-W7qapuKhfItJGh7p .loopText,#mermaid-svg-W7qapuKhfItJGh7p .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-W7qapuKhfItJGh7p .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-W7qapuKhfItJGh7p .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-W7qapuKhfItJGh7p .noteText,#mermaid-svg-W7qapuKhfItJGh7p .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-W7qapuKhfItJGh7p .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-W7qapuKhfItJGh7p .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-W7qapuKhfItJGh7p .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-W7qapuKhfItJGh7p .actorPopupMenu{position:absolute;}#mermaid-svg-W7qapuKhfItJGh7p .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-W7qapuKhfItJGh7p .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-W7qapuKhfItJGh7p .actor-man circle,#mermaid-svg-W7qapuKhfItJGh7p line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-W7qapuKhfItJGh7p :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop 遍历每个订单 共执行 1 + 100 = 101 条 SQL SELECT * FROM orders WHERE status = 'PAID' 返回 100 条订单记录 SELECT * FROM users WHERE id = ? 返回对应用户信息
二、解决方案一:fetch join
2.1 原理
在 JPQL 中用 JOIN FETCH 一次性把关联数据查出来:
kotlin
interface OrderRepository : JpaRepository<Order, Long> {
// fetch join:一条 SQL 同时查出 Order 和 User
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :status")
fun findAllWithUser(@Param("status") status: String): List<Order>
}
生成的 SQL:
sql
-- 只有 1 条 SQL!
SELECT o.id, o.total_amount, o.user_id,
u.id, u.username, u.email
FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE o.status = 'PAID'
2.2 优缺点
| 优点 | 缺点 |
|---|---|
| 一条 SQL 搞定,性能最好 | 结果集可能膨胀(笛卡尔积) |
| 简单直观 | 多个关联一起 fetch 时 SQL 变复杂 |
| 适用于固定查询场景 | 分页查询有坑(Hibernate 内存分页警告) |
2.3 分页警告
kotlin
// ⚠️ 危险:fetch join + 分页
@Query("SELECT o FROM Order o JOIN FETCH o.user JOIN FETCH o.items")
fun findAllWithDetails(pageable: Pageable): Page<Order>
当 JOIN FETCH 搭配 Pageable 时,Hibernate 会先查全量数据再在内存中分页(而不是数据库分页),并输出警告:
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
解决方式 :一对多关联的 fetch join 不要直接用分页。改用
EntityGraph(只 fetch ToOne 关联)或拆分为两次查询。
下面是 fetch join 的执行流程示意图:
数据库 应用层 数据库 应用层 #mermaid-svg-v46clK922jGa47tn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-v46clK922jGa47tn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-v46clK922jGa47tn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-v46clK922jGa47tn .error-icon{fill:#552222;}#mermaid-svg-v46clK922jGa47tn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-v46clK922jGa47tn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-v46clK922jGa47tn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-v46clK922jGa47tn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-v46clK922jGa47tn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-v46clK922jGa47tn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-v46clK922jGa47tn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-v46clK922jGa47tn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-v46clK922jGa47tn .marker.cross{stroke:#333333;}#mermaid-svg-v46clK922jGa47tn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-v46clK922jGa47tn p{margin:0;}#mermaid-svg-v46clK922jGa47tn .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-v46clK922jGa47tn text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-v46clK922jGa47tn .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-v46clK922jGa47tn .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-v46clK922jGa47tn .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-v46clK922jGa47tn .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-v46clK922jGa47tn #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-v46clK922jGa47tn .sequenceNumber{fill:white;}#mermaid-svg-v46clK922jGa47tn #sequencenumber{fill:#333;}#mermaid-svg-v46clK922jGa47tn #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-v46clK922jGa47tn .messageText{fill:#333;stroke:none;}#mermaid-svg-v46clK922jGa47tn .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-v46clK922jGa47tn .labelText,#mermaid-svg-v46clK922jGa47tn .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-v46clK922jGa47tn .loopText,#mermaid-svg-v46clK922jGa47tn .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-v46clK922jGa47tn .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-v46clK922jGa47tn .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-v46clK922jGa47tn .noteText,#mermaid-svg-v46clK922jGa47tn .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-v46clK922jGa47tn .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-v46clK922jGa47tn .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-v46clK922jGa47tn .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-v46clK922jGa47tn .actorPopupMenu{position:absolute;}#mermaid-svg-v46clK922jGa47tn .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-v46clK922jGa47tn .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-v46clK922jGa47tn .actor-man circle,#mermaid-svg-v46clK922jGa47tn line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-v46clK922jGa47tn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 仅执行 1 条 SQL,一次性加载所有关联数据 SELECT o.*, u.* FROM orders o JOIN FETCH o.user u WHERE o.status = 'PAID' 返回 100 条订单 + 用户关联数据
三、解决方案二:@EntityGraph
3.1 原理
@EntityGraph 让你声明「这次查询顺便加载哪些关联」,Hibernate 自动生成 JOIN 或批量加载。
kotlin
@Entity
@Table(name = "orders")
@NamedEntityGraphs(
NamedEntityGraph(
name = "order.with-user",
attributeNodes = [NamedAttributeNode("user")]
),
NamedEntityGraph(
name = "order.with-details",
attributeNodes = [
NamedAttributeNode("user"),
NamedAttributeNode("items")
]
)
)
class Order(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
var user: User? = null,
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
var items: MutableList<OrderItem> = mutableListOf()
)
在 Repository 中使用:
kotlin
interface OrderRepository : JpaRepository<Order, Long> {
// 使用预定义的 EntityGraph
@EntityGraph(value = "order.with-user")
@Query("SELECT o FROM Order o WHERE o.status = :status")
fun findAllWithUser(@Param("status") status: String): List<Order>
// 也可以直接在方法上定义内联 EntityGraph
@EntityGraph(attributePaths = ["user", "items"])
fun findAllByStatus(status: String): List<Order>
}
3.2 内联 vs 预定义
kotlin
// 方式一:预定义(在实体类上声明,可复用)
@EntityGraph(value = "order.with-user")
// 方式二:内联(直接在方法上指定,简单快捷)
@EntityGraph(attributePaths = ["user"])
3.3 优缺点
| 优点 | 缺点 |
|---|---|
| 声明式,不修改 JPQL | 只能指定关联路径,不能加条件 |
| 可在方法名查询上使用 | 一对多关联分页仍有问题 |
| 同一查询可定义多个 Graph | --- |
下面是 @EntityGraph 的执行流程示意图:
数据库 应用层 数据库 应用层 #mermaid-svg-ZKyxuvaTjFDsqAqv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZKyxuvaTjFDsqAqv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZKyxuvaTjFDsqAqv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZKyxuvaTjFDsqAqv .error-icon{fill:#552222;}#mermaid-svg-ZKyxuvaTjFDsqAqv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZKyxuvaTjFDsqAqv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZKyxuvaTjFDsqAqv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZKyxuvaTjFDsqAqv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZKyxuvaTjFDsqAqv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZKyxuvaTjFDsqAqv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZKyxuvaTjFDsqAqv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZKyxuvaTjFDsqAqv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZKyxuvaTjFDsqAqv .marker.cross{stroke:#333333;}#mermaid-svg-ZKyxuvaTjFDsqAqv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZKyxuvaTjFDsqAqv p{margin:0;}#mermaid-svg-ZKyxuvaTjFDsqAqv .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZKyxuvaTjFDsqAqv text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ZKyxuvaTjFDsqAqv .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZKyxuvaTjFDsqAqv .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ZKyxuvaTjFDsqAqv .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ZKyxuvaTjFDsqAqv .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ZKyxuvaTjFDsqAqv #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ZKyxuvaTjFDsqAqv .sequenceNumber{fill:white;}#mermaid-svg-ZKyxuvaTjFDsqAqv #sequencenumber{fill:#333;}#mermaid-svg-ZKyxuvaTjFDsqAqv #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ZKyxuvaTjFDsqAqv .messageText{fill:#333;stroke:none;}#mermaid-svg-ZKyxuvaTjFDsqAqv .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZKyxuvaTjFDsqAqv .labelText,#mermaid-svg-ZKyxuvaTjFDsqAqv .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ZKyxuvaTjFDsqAqv .loopText,#mermaid-svg-ZKyxuvaTjFDsqAqv .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ZKyxuvaTjFDsqAqv .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZKyxuvaTjFDsqAqv .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ZKyxuvaTjFDsqAqv .noteText,#mermaid-svg-ZKyxuvaTjFDsqAqv .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ZKyxuvaTjFDsqAqv .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZKyxuvaTjFDsqAqv .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZKyxuvaTjFDsqAqv .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZKyxuvaTjFDsqAqv .actorPopupMenu{position:absolute;}#mermaid-svg-ZKyxuvaTjFDsqAqv .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ZKyxuvaTjFDsqAqv .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZKyxuvaTjFDsqAqv .actor-man circle,#mermaid-svg-ZKyxuvaTjFDsqAqv line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ZKyxuvaTjFDsqAqv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 声明式加载,不修改 JPQL 语句 @EntityGraph(attributePaths = "user") SELECT o FROM Order o WHERE o.status = :status 自动生成 JOIN,返回订单 + 用户数据
四、解决方案三:@BatchSize
4.1 原理
在关联属性上标注 @BatchSize,Hibernate 不会逐条查,而是批量查(IN 查询):
kotlin
@Entity
@Table(name = "orders")
class Order(
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 50) // ← 批量大小
@JoinColumn(name = "user_id")
var user: User? = null
)
效果:原来的 100 条 WHERE id = ? 变成 2 条 WHERE id IN (?, ?, ..., ?)(每批 50 个):
sql
-- 原来:100 条
SELECT * FROM users WHERE id = 1
SELECT * FROM users WHERE id = 2
-- ...
-- 加了 @BatchSize(size=50) 后:2 条
SELECT * FROM users WHERE id IN (1, 2, 3, ..., 50)
SELECT * FROM users WHERE id IN (51, 52, ..., 100)
4.2 全局配置
也可以在 application.yml 中全局设置批量大小:
yaml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 50 # 全局默认批量大小
4.3 优缺点
| 优点 | 缺点 |
|---|---|
| 配置简单,一次性解决 | 仍然是多条 SQL(虽然少了) |
| 不需要修改查询逻辑 | 比 fetch join 多几条 SQL |
| 对分页友好 | 查询时机仍延迟到访问关联时 |
下面是 @BatchSize 的执行流程示意图:
数据库 应用层 数据库 应用层 #mermaid-svg-5ghzgbxjdtAOxRzN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5ghzgbxjdtAOxRzN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5ghzgbxjdtAOxRzN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5ghzgbxjdtAOxRzN .error-icon{fill:#552222;}#mermaid-svg-5ghzgbxjdtAOxRzN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5ghzgbxjdtAOxRzN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5ghzgbxjdtAOxRzN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5ghzgbxjdtAOxRzN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5ghzgbxjdtAOxRzN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5ghzgbxjdtAOxRzN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5ghzgbxjdtAOxRzN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5ghzgbxjdtAOxRzN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5ghzgbxjdtAOxRzN .marker.cross{stroke:#333333;}#mermaid-svg-5ghzgbxjdtAOxRzN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5ghzgbxjdtAOxRzN p{margin:0;}#mermaid-svg-5ghzgbxjdtAOxRzN .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5ghzgbxjdtAOxRzN text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-5ghzgbxjdtAOxRzN .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5ghzgbxjdtAOxRzN .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-5ghzgbxjdtAOxRzN .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-5ghzgbxjdtAOxRzN .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-5ghzgbxjdtAOxRzN #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-5ghzgbxjdtAOxRzN .sequenceNumber{fill:white;}#mermaid-svg-5ghzgbxjdtAOxRzN #sequencenumber{fill:#333;}#mermaid-svg-5ghzgbxjdtAOxRzN #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-5ghzgbxjdtAOxRzN .messageText{fill:#333;stroke:none;}#mermaid-svg-5ghzgbxjdtAOxRzN .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5ghzgbxjdtAOxRzN .labelText,#mermaid-svg-5ghzgbxjdtAOxRzN .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-5ghzgbxjdtAOxRzN .loopText,#mermaid-svg-5ghzgbxjdtAOxRzN .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-5ghzgbxjdtAOxRzN .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5ghzgbxjdtAOxRzN .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-5ghzgbxjdtAOxRzN .noteText,#mermaid-svg-5ghzgbxjdtAOxRzN .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-5ghzgbxjdtAOxRzN .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5ghzgbxjdtAOxRzN .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5ghzgbxjdtAOxRzN .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5ghzgbxjdtAOxRzN .actorPopupMenu{position:absolute;}#mermaid-svg-5ghzgbxjdtAOxRzN .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-5ghzgbxjdtAOxRzN .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5ghzgbxjdtAOxRzN .actor-man circle,#mermaid-svg-5ghzgbxjdtAOxRzN line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-5ghzgbxjdtAOxRzN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 共执行 1 + 2 = 3 条 SQL(batch_size=50) SELECT * FROM orders WHERE status = 'PAID' 返回 100 条订单记录 SELECT * FROM users WHERE id IN (1,2,...,50) 返回 50 条用户记录 SELECT * FROM users WHERE id IN (51,52,...,100) 返回 50 条用户记录
五、三种方案对比与选择
| 方案 | SQL 数量 | 分页友好 | 灵活性 | 推荐场景 |
|---|---|---|---|---|
| fetch join | 1 条 | ❌(一对多有坑) | 低 | ToOne 关联的固定查询 |
| @EntityGraph | 1 条 | ❌(同上) | 中 | ToOne 关联的动态查询 |
| @BatchSize | N/batchSize 条 | ✅ | 高 | ToMany 关联 + 分页 |
| 全局配置 | N/batchSize 条 | ✅ | 高 | 项目级兜底策略 |
决策树
下面是三种方案的选择决策流程图:
#mermaid-svg-DxlzpP0bdpcmSIJg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DxlzpP0bdpcmSIJg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DxlzpP0bdpcmSIJg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DxlzpP0bdpcmSIJg .error-icon{fill:#552222;}#mermaid-svg-DxlzpP0bdpcmSIJg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DxlzpP0bdpcmSIJg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DxlzpP0bdpcmSIJg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DxlzpP0bdpcmSIJg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DxlzpP0bdpcmSIJg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DxlzpP0bdpcmSIJg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DxlzpP0bdpcmSIJg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DxlzpP0bdpcmSIJg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DxlzpP0bdpcmSIJg .marker.cross{stroke:#333333;}#mermaid-svg-DxlzpP0bdpcmSIJg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DxlzpP0bdpcmSIJg p{margin:0;}#mermaid-svg-DxlzpP0bdpcmSIJg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DxlzpP0bdpcmSIJg .cluster-label text{fill:#333;}#mermaid-svg-DxlzpP0bdpcmSIJg .cluster-label span{color:#333;}#mermaid-svg-DxlzpP0bdpcmSIJg .cluster-label span p{background-color:transparent;}#mermaid-svg-DxlzpP0bdpcmSIJg .label text,#mermaid-svg-DxlzpP0bdpcmSIJg span{fill:#333;color:#333;}#mermaid-svg-DxlzpP0bdpcmSIJg .node rect,#mermaid-svg-DxlzpP0bdpcmSIJg .node circle,#mermaid-svg-DxlzpP0bdpcmSIJg .node ellipse,#mermaid-svg-DxlzpP0bdpcmSIJg .node polygon,#mermaid-svg-DxlzpP0bdpcmSIJg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DxlzpP0bdpcmSIJg .rough-node .label text,#mermaid-svg-DxlzpP0bdpcmSIJg .node .label text,#mermaid-svg-DxlzpP0bdpcmSIJg .image-shape .label,#mermaid-svg-DxlzpP0bdpcmSIJg .icon-shape .label{text-anchor:middle;}#mermaid-svg-DxlzpP0bdpcmSIJg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DxlzpP0bdpcmSIJg .rough-node .label,#mermaid-svg-DxlzpP0bdpcmSIJg .node .label,#mermaid-svg-DxlzpP0bdpcmSIJg .image-shape .label,#mermaid-svg-DxlzpP0bdpcmSIJg .icon-shape .label{text-align:center;}#mermaid-svg-DxlzpP0bdpcmSIJg .node.clickable{cursor:pointer;}#mermaid-svg-DxlzpP0bdpcmSIJg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DxlzpP0bdpcmSIJg .arrowheadPath{fill:#333333;}#mermaid-svg-DxlzpP0bdpcmSIJg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DxlzpP0bdpcmSIJg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DxlzpP0bdpcmSIJg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DxlzpP0bdpcmSIJg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DxlzpP0bdpcmSIJg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DxlzpP0bdpcmSIJg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DxlzpP0bdpcmSIJg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DxlzpP0bdpcmSIJg .cluster text{fill:#333;}#mermaid-svg-DxlzpP0bdpcmSIJg .cluster span{color:#333;}#mermaid-svg-DxlzpP0bdpcmSIJg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DxlzpP0bdpcmSIJg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DxlzpP0bdpcmSIJg rect.text{fill:none;stroke-width:0;}#mermaid-svg-DxlzpP0bdpcmSIJg .icon-shape,#mermaid-svg-DxlzpP0bdpcmSIJg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DxlzpP0bdpcmSIJg .icon-shape p,#mermaid-svg-DxlzpP0bdpcmSIJg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DxlzpP0bdpcmSIJg .icon-shape .label rect,#mermaid-svg-DxlzpP0bdpcmSIJg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DxlzpP0bdpcmSIJg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DxlzpP0bdpcmSIJg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DxlzpP0bdpcmSIJg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是(@ManyToOne / @OneToOne)
否
是
否(@OneToMany / @ManyToMany)
是
否
遇到 N+1 问题
关联类型是 ToOne?
需要分页?
fetch join 或 @EntityGraph
一条 SQL 搞定
@EntityGraph
(只 fetch ToOne 关联)
需要分页?
@BatchSize 或
全局 default_batch_fetch_size
fetch join
注意笛卡尔积膨胀
✅ 性能最优
✅ 分页友好
⚠️ 注意内存分页
六、检测 N+1 问题
6.1 开启 SQL 日志
yaml
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG # 打印 SQL
org.hibernate.orm.jdbc.bind: TRACE # 打印参数绑定
如果看到同一个查询模式重复出现多次,就是 N+1 问题。
6.2 实战修复示例
修复前的 OrderService(N+1 问题):
kotlin
fun getOrderSummaries(status: String): List<OrderSummary> {
val orders = orderRepository.findAllByStatus(status)
return orders.map { order ->
OrderSummary(
orderId = order.id!!,
username = order.user?.username ?: "未知", // ← 触发 N+1
totalAmount = order.totalAmount
)
}
}
修复后(使用 @EntityGraph):
kotlin
// Repository 中添加 EntityGraph 查询
@EntityGraph(attributePaths = ["user"])
@Query("SELECT o FROM Order o WHERE o.status = :status")
fun findAllWithUserByStatus(@Param("status") status: String): List<Order>
// Service 调用修改后的查询
fun getOrderSummaries(status: String): List<OrderSummary> {
val orders = orderRepository.findAllWithUserByStatus(status) // 一条 SQL 搞定
return orders.map { order ->
OrderSummary(
orderId = order.id!!,
username = order.user?.username ?: "未知", // 不再触发额外查询
totalAmount = order.totalAmount
)
}
}
修复后 SQL 日志:
sql
-- 只有 1 条 SQL
SELECT o.id, o.total_amount, o.user_id,
u.id, u.username, u.email
FROM orders o
LEFT OUTER JOIN users u ON o.user_id = u.id
WHERE o.status = ?
本篇小结
| 知识点 | 核心内容 |
|---|---|
| N+1 问题 | 查列表 + 逐条查关联 = N+1 条 SQL |
| 原因 | LAZY 加载的关联属性被逐个访问 |
| fetch join | JOIN FETCH 一条 SQL 查出关联 |
| fetch join 分页坑 | 一对多 + Pageable → 内存分页 |
| @EntityGraph | 声明式关联加载,自动生成 JOIN |
| @BatchSize | 批量 IN 查询,减少 SQL 数量 |
| 全局配置 | default_batch_fetch_size: 50 |
| ToOne 关联 | 用 fetch join / EntityGraph |
| ToMany 关联 | 用 @BatchSize |
| 检测方法 | show-sql: true + 观察 SQL 日志 |
下篇预告
生产环境绝不能依赖
ddl-auto: update。下一篇学习用 Liquibase 管理数据库版本变更,实现可追溯、可回滚的数据库迁移。
如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。