第21篇-N+1问题与fetch-join-EntityGraph

【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 日志

下篇预告

第 22 篇:数据库迁移 --- Liquibase

生产环境绝不能依赖 ddl-auto: update。下一篇学习用 Liquibase 管理数据库版本变更,实现可追溯、可回滚的数据库迁移。


如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。

相关推荐
我命由我123452 小时前
Kotlin 面向对象 - Kotlin 类变量与类方法
java·服务器·后端·java-ee·kotlin·android jetpack·android runtime
plainGeekDev2 小时前
Robolectric → 分层测试:测试策略重构
android·java·kotlin
plainGeekDev2 小时前
Instrumentation → Compose Testing
android·java·kotlin
刘名喜1 天前
第17篇-PostgreSQL-Docker环境搭建
kotlin·springboot
plainGeekDev1 天前
JUnit 4 → JUnit 5 + Kotlin
android·java·kotlin
plainGeekDev1 天前
Mockito → MockK
android·java·kotlin
深色風信子1 天前
Kotlin Bytedeco OpenCV 图像图像58 无缝克隆
开发语言·opencv·kotlin·bytedeco
我命由我123451 天前
Kotlin 面向对象 - 枚举排序
android·java·开发语言·java-ee·kotlin·android studio·android-studio
李斯维1 天前
Kotlin 中的委托:by 关键字全解析
android·开发语言·kotlin