乐途乐途
主页
  • 计算机基础

    • TCP/IP协议
    • Linux命令
    • HTTP协议
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C语言
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • MyBatis
    • Spring
    • Spring MVC
    • Maven 入门
    • Maven 进阶
    • Java 设计模式
  • 缓存

    • Redis
联系
阿里云
主页
  • 计算机基础

    • TCP/IP协议
    • Linux命令
    • HTTP协议
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C语言
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • MyBatis
    • Spring
    • Spring MVC
    • Maven 入门
    • Maven 进阶
    • Java 设计模式
  • 缓存

    • Redis
联系
阿里云
  • 学习路径
  • 第1章 MyBatis概述与快速上手

    • 本章定位
    • MyBatis简介
    • 环境搭建
    • 第一个MyBatis程序
    • SqlSessionFactoryBuilder与openSession重载
    • SqlSessionFactory与SqlSession
    • SqlSession核心方法
    • 不使用 XML 构建 SqlSessionFactory
    • Mapper接口与映射方式
    • Java API 目录结构
  • 第2章 全局配置文件详解

    • 本章定位
    • properties
    • settings
    • typeAliases
    • typeHandlers
    • objectFactory
    • plugins
    • environments
    • transactionManager
    • dataSource
    • databaseIdProvider
    • mappers
    • 日志配置
  • 第3章 SQL映射文件基础

    • 本章定位
    • select
    • insert
    • update
    • delete
    • 参数传递与占位符
    • 主键生成策略
    • resultType
    • resultMap
    • 自动映射详解
    • sql片段
    • SQL 语句构建器
  • 第4章 动态SQL

    • 本章定位
    • if
    • choose、when、otherwise
    • where
    • set
    • foreach
    • trim
    • bind
    • script 元素:在注解映射器中启用动态 SQL
    • _databaseId 与动态 SQL 的多数据库支持
    • 动态 SQL 中插入脚本语言
  • 第5章 结果映射与关联查询

    • 本章定位
    • resultMap详解
    • association
    • collection
    • discriminator
    • N+1查询问题
    • 延迟加载
  • 第6章 MyBatis注解开发

    • 本章定位
    • @Select
    • @Insert
    • @Update
    • @Delete
    • @Param
    • @Options
    • @SelectKey
    • @Results
    • @Result
    • @One
    • @Many
    • @SelectProvider
  • 第7章 缓存与性能优化

    • 本章定位
    • 一级缓存
    • 二级缓存
    • 缓存配置详解
    • 自定义缓存
    • Executor执行器类型
    • 分页插件

N+1查询问题

导学

本节学习目标:

  • 理解 N+1 查询问题的本质成因:嵌套 Select 方式下主查询与子查询的数量关系
  • 掌握通过 MyBatis 日志检测 N+1 问题的方法
  • 掌握三种解决方案的适用场景:嵌套结果映射(JOIN)、延迟加载(缓解)、批量加载
  • 能够量化对比不同方案的数据库往返次数和性能差异
  • 在实际项目中建立“先检测、后优化”的关联查询性能意识

定义

N+1 查询问题是指:使用嵌套 Select 方式加载关联对象时,先执行 1 次主查询获取 N 条主记录,随后对这 N 条记录中的每一条都执行 1 次子查询加载关联数据,最终产生 1 + N 次 SQL 查询。

它解决的核心痛点(准确说是“需要被解决的痛点”):

  • 开发者编写了一个看似简单的关联查询,却在生产环境引发数据库压力雪崩
  • 数据量小时问题被掩盖,数据量增长后性能呈线性劣化
  • 需要明确的检测手段和替代方案,将关联查询的数据库往返次数从 O(N) 降到 O(1)

适用位置与核心属性

N+1 问题并非由某个特定 MyBatis 元素直接引发,而是嵌套 Select 方式(association 或 collection 配置了 column + select)在批量查询场景下的固有副作用。

相关配置位置与 N+1 的关系
association 的 select 属性映射文件一对一嵌套 Select 的触发源
collection 的 select 属性映射文件一对多嵌套 Select 的触发源
lazyLoadingEnabledmybatis-config.xml开启后延迟子查询,缓解但不根治 N+1
fetchTypeassociation / collectionlazy 推迟加载,eager 立即加载(均不改变总查询次数)
defaultExecutorTypemybatis-config.xmlBATCH 模式可批量执行子查询,减少网络往返

核心原理

N+1 问题演示时序图 vs JOIN 方案对比

问题量化分析:

方案主查询次数子查询次数总 SQL 次数数据库往返次数适用数据量
嵌套 Select(立即加载)1N1 + N1 + N极小数据量(N < 10)
嵌套 Select(延迟加载)1按需触发1 + M(M ≤ N)1 + M关联对象使用频率低
嵌套结果映射(JOIN)1011大数据量、批量加载

关键洞察:

  • 延迟加载是缓解,不是根治:它减少了实际执行的子查询次数(M ≤ N),但如果最终访问了所有关联对象,总查询次数仍然是 1 + N。
  • JOIN 是根治:通过单次查询返回全部所需数据,数据库往返恒定为 1 次。代价是结果集可能因一对多 JOIN 而膨胀(主表列重复),但网络传输和数据库解析通常仍优于多次往返。
  • 批量加载:MyBatis 的 BATCH 执行器可将多条子查询打包发送,减少网络往返,但数据库仍执行 N 条 SQL,只是通信开销降低。

完整示例

场景说明

乐途公司学生管理系统中,需要查询所有班级及其学生列表。我们将对比嵌套 Select 方式与 JOIN 方式的数据库负载差异,以 2 个班级、3 名学生为例,推演到 5 个班级的一般情况。

操作前的数据库表结构及初始数据

-- 班级表
CREATE TABLE class (
    id INT PRIMARY KEY AUTO_INCREMENT,
    class_name VARCHAR(20),
    department VARCHAR(20)
);

-- 学生表
CREATE TABLE student (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(20),
    age INT,
    major VARCHAR(20),
    score DECIMAL(5,2),
    class_id INT,
    mentor_id INT
);

初始数据:

idclass_namedepartment
1软件工程一班计算机学院
2信息安全一班计算机学院
idnameagemajorscoreclass_idmentor_id
1大翔22软件工程89.5011
2白歌21软件工程92.0012
3小崔23信息安全85.0021

完整的映射文件片段

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.flywing.mapper.ClassMapper">

    <resultMap id="StudentBaseMap" type="com.flywing.entity.Student">
        <id property="id" column="s_id"/>
        <result property="name" column="s_name"/>
        <result property="age" column="s_age"/>
        <result property="major" column="s_major"/>
        <result property="score" column="s_score"/>
    </resultMap>

    <!-- ==================== N+1 方案:嵌套Select ==================== -->
    <resultMap id="ClassWithStudentsN1" type="com.flywing.entity.Class">
        <id property="id" column="id"/>
        <result property="className" column="class_name"/>
        <result property="department" column="department"/>
        <collection property="students" ofType="com.flywing.entity.Student"
                  column="id"
                  select="selectStudentsByClassId"/>
    </resultMap>

    <select id="selectAllClassesN1" resultMap="ClassWithStudentsN1">
        SELECT id, class_name, department FROM class
    </select>

    <select id="selectStudentsByClassId" resultMap="StudentBaseMap">
        SELECT id AS s_id, name AS s_name, age AS s_age,
               major AS s_major, score AS s_score
        FROM student
        WHERE class_id = #{classId}
    </select>

    <!-- ==================== 根治方案:嵌套结果映射JOIN ==================== -->
    <resultMap id="ClassWithStudentsJoin" type="com.flywing.entity.Class">
        <id property="id" column="c_id"/>
        <result property="className" column="class_name"/>
        <result property="department" column="department"/>
        <collection property="students" ofType="com.flywing.entity.Student"
                    resultMap="StudentBaseMap"
                    columnPrefix="s_"/>
    </resultMap>

    <select id="selectAllClassesJoin" resultMap="ClassWithStudentsJoin">
        SELECT
            c.id AS c_id,
            c.class_name,
            c.department,
            s.id AS s_id,
            s.name AS s_name,
            s.age AS s_age,
            s.major AS s_major,
            s.score AS s_score
        FROM class c
        LEFT JOIN student s ON c.id = s.class_id
        ORDER BY c.id
    </select>

</mapper>

实际执行结果

N+1 方案执行结果(selectAllClassesN1):

查询结果:

class.idclass_namedepartmentstudents
1软件工程一班计算机学院[大翔, 白歌]
2信息安全一班计算机学院[小崔]

控制台 SQL 输出:

==>  Preparing: SELECT id, class_name, department FROM class
==>  Parameters:
<==  Columns: id, class_name, department
<==  Row: 1, 软件工程一班, 计算机学院
====>  Preparing: SELECT id AS s_id, name AS s_name, age AS s_age, major AS s_major, score AS s_score FROM student WHERE class_id = ?
====>  Parameters: 1(Integer)
<====  Columns: s_id, s_name, s_age, s_major, s_score
<====  Row: 1, 大翔, 22, 软件工程, 89.50
<====  Row: 2, 白歌, 21, 软件工程, 92.00
<==  Row: 2, 信息安全一班, 计算机学院
====>  Preparing: SELECT id AS s_id, name AS s_name, age AS s_age, major AS s_major, score AS s_score FROM student WHERE class_id = ?
====>  Parameters: 2(Integer)
<====  Columns: s_id, s_name, s_age, s_major, s_score
<====  Row: 3, 小崔, 23, 信息安全, 85.00
<==  Total: 2

统计:主查询 1 次 + 子查询 2 次 = 3 条 SQL。若班级数为 5,则产生 6 条 SQL。

JOIN 方案执行结果(selectAllClassesJoin):

查询结果与 N+1 方案完全一致。

控制台 SQL 输出:

==>  Preparing: SELECT c.id AS c_id, c.class_name, c.department, s.id AS s_id, s.name AS s_name, s.age AS s_age, s.major AS s_major, s.score AS s_score FROM class c LEFT JOIN student s ON c.id = s.class_id ORDER BY c.id
==>  Parameters:
<==  Columns: c_id, class_name, department, s_id, s_name, s_age, s_major, s_score
<==  Row: 1, 软件工程一班, 计算机学院, 1, 大翔, 22, 软件工程, 89.50
<==  Row: 1, 软件工程一班, 计算机学院, 2, 白歌, 21, 软件工程, 92.00
<==  Row: 2, 信息安全一班, 计算机学院, 3, 小崔, 23, 信息安全, 85.00
<==  Total: 3

统计:仅 1 条 SQL,无论班级数量多少,数据库往返恒定为 1 次。

性能对比数据

假设网络往返延迟为 10ms,数据库执行单条简单查询耗时 5ms:

班级数学生总数N+1 方案总耗时JOIN 方案总耗时性能差距
231×(10+5) + 2×(10+5) = 45ms1×(10+5) = 15ms3 倍
5151×15 + 5×15 = 90ms1×15 = 15ms6 倍
501501×15 + 50×15 = 765ms1×15 = 15ms51 倍
50015001×15 + 500×15 = 7515ms1×15 = 15ms501 倍

结论:随着主记录数量增长,N+1 方案的总耗时线性增长,而 JOIN 方案保持恒定。大数据量下差距可达数百倍。

分析

  • 成因:N+1 问题的根源是嵌套 Select 的“逐行触发”机制。MyBatis 对主查询结果集的每一行,按 column 值发起独立子查询,无法自动合并为批量 IN 查询。
  • 检测:开启 MyBatis 日志后,若发现 1 条主查询后跟随着大量结构相似的子查询(尤其 Parameters 值递增),即可判定存在 N+1 问题。
  • 根治:将嵌套 Select 改为嵌套结果映射(JOIN 查询),利用 SQL 的集合操作能力一次性取回数据。
  • 缓解:若无法改为 JOIN(如关联查询过于复杂、跨库查询等),可开启延迟加载,确保未使用的关联对象不触发查询。但访问全部关联对象时,问题依旧。

易错场景 / 常见误区

误区正解
认为开启了延迟加载就解决了 N+1 问题延迟加载只是推迟了子查询时机。如果最终访问了所有关联对象,总查询次数仍然是 1 + N
认为 fetchType="eager" 会导致 N+1,fetchType="lazy" 不会两种 fetchType 的总查询次数潜力相同,只是执行时机不同。EAGER 立即触发全部,LAZY 按需触发部分
JOIN 查询结果集行数多于对象数,以为是 BugJOIN 结果集行数 = 主对象数 × 平均关联对象数,这是正常的。MyBatis 通过 <id> 去重后返回的对象数是正确的
为了“避免 N+1”而将所有关联查询改为 JOIN,导致大宽表 JOIN 性能差当关联表字段极多、或关联层级很深时,JOIN 结果集过于膨胀,反而增加网络传输和内存解析压力。此时应权衡使用分步查询或延迟加载
使用 BATCH 执行器认为解决了 N+1BATCH 执行器将多条 SQL 打包发送,减少了网络往返次数,但数据库仍然执行 N 条 SQL,只是通信开销降低

面试考点

Q1:什么是 MyBatis 的 N+1 查询问题?它是如何产生的?

A:N+1 问题发生在使用嵌套 Select 方式(association 或 collection 配置了 column + select)加载关联对象时。框架先执行 1 次主查询获取 N 条记录,然后对每条记录执行 1 次子查询,总共产生 1 + N 次 SQL。其产生原因是 MyBatis 无法自动将逐行子查询优化为批量查询,每次都需要独立的数据库往返。

Q2:如何检测项目中是否存在 N+1 问题?

A:最可靠的方法是开启 MyBatis SQL 日志(log4j.logger.java.sql=DEBUG 或 slf4j 对应配置),观察业务查询的日志输出。如果看到 1 条主查询后跟随着大量结构相同、仅参数值不同的子查询,即可判定存在 N+1。生产环境中也可通过数据库慢查询日志或 APM 工具(如 SkyWalking、Pinpoint)监控 SQL 执行次数异常。

Q3:解决 N+1 问题的完整方案有哪些?各自的优缺点是什么?

A:三种方案:(1)嵌套结果映射(JOIN):改为单次 JOIN 查询,根治 N+1,数据库往返恒定为 1。优点是最彻底,缺点是结果集可能膨胀,复杂多层关联时 JOIN 语句难以维护。(2)延迟加载:开启 lazyLoadingEnabled,避免未使用的关联对象触发查询。优点是配置简单,缺点是仅缓解,访问全部关联时问题依旧;且代理对象依赖 SqlSession 生命周期。(3)批量加载 / 自定义 IN 查询:在业务层先查主表,提取所有外键 ID,再执行一次 WHERE id IN (...) 批量查询关联表,最后手动组装对象。优点是不改 XML 即可批量加载,缺点是破坏了 MyBatis 的自动映射,增加了业务层复杂度。

Q4:假设一个班级平均有 50 名学生,查询 100 个班级。嵌套 Select 和 JOIN 方式分别会产生多少条 SQL 和多少行结果集?

A:嵌套 Select:101 条 SQL(1 次主查询 + 100 次子查询)。JOIN 方式:1 条 SQL,结果集约 100 × 50 = 5000 行(MyBatis 在内存中按 class.id 去重为 100 个 Class 对象,每个含 50 个 Student)。虽然 JOIN 结果集行数大,但单次网络往返和数据库解析通常仍优于 101 次独立查询。

小结

N+1 查询问题是 MyBatis 嵌套 Select 关联映射在批量场景下的固有性能陷阱。它的危害具有隐蔽性:小数据量时表现正常,数据量增长后性能线性劣化。根治方案是将嵌套 Select 改为嵌套结果映射(JOIN 查询),将数据库往返从 O(N) 降至 O(1)。延迟加载是有效的缓解手段,但不能替代架构层面的查询优化。建立“日志检测 → 量化分析 → 方案选择”的优化流程,是持久层性能调优的基本功。

下一章引子

结果映射与关联查询的深层机制已完整呈现。从 resultMap 的基础映射,到 association 和 collection 的关联组装,再到 discriminator 的多态分支,以及延迟加载和 N+1 问题的性能权衡——这些知识构成了 MyBatis 高级映射的核心版图。下一章将迈入 动态 SQL 的世界,学习如何在 XML 中编写条件分支、循环遍历和动态片段复用,让映射文件具备应对复杂查询场景的表达能力。

上一页
discriminator
下一页
延迟加载