MyBatis 延迟加载的原理是什么?如何解决 N+1 查询?
简化版
MyBatis 可为使用嵌套 select 的 association/collection 创建代理,首次访问关联属性时再执行额外 SQL,这就是延迟加载。查询 N 个父对象后逐个访问关联,可能形成 1 次父查询加 N 次子查询;可改用 JOIN 嵌套结果,或先批量查询父对象再用一次 IN 查询装配子数据。
详细版
全局 lazyLoadingEnabled 控制延迟加载基础能力,映射上的 fetchType="lazy|eager" 可覆盖具体关联策略。延迟对象记录待加载信息,属性触发时通过 ResultLoader 执行嵌套 statement 并回填结果。
一级缓存可能减少同一 SqlSession 内相同子查询,但无法消除一般 N+1。JOIN 可一次取回但会放大行数并影响一对多分页;两阶段批量查询通常在数据量与分页正确性之间更稳妥。
完整版教学
一、延迟加载如何配置
<association property="department"
column="department_id"
select="findDepartmentById"
fetchType="lazy"/>
父查询先返回 User 代理,department 暂不查询;第一次读取 user.getDepartment() 时,代理触发 findDepartmentById。若从不访问该属性,就节省了一次查询。
| 配置/映射 | 作用 | 需要注意 |
|---|---|---|
lazyLoadingEnabled | 开启全局延迟加载能力 | 不等于所有关联都会合适地延迟 |
fetchType="lazy" | 单个 association/collection 延迟 | 常见于嵌套 select |
fetchType="eager" | 单个关联立即加载 | 可覆盖全局策略 |
| 嵌套 select | 访问属性时再查 | 容易形成 N+1 |
| 嵌套 resultMap | JOIN 一次组装 | 可能重复行、分页困难 |
记忆钩子:延迟加载省的是“没访问的关联查询”,不是自动优化列表;一旦列表里每个对象都访问关联,它就从省查询变成 N+1。
二、N+1 是如何产生的
SELECT * FROM users LIMIT 100; -- 1 次
SELECT * FROM dept WHERE id = ?; -- 最多 100 次
列表序列化器、日志 toString() 或映射工具可能无意访问所有属性,使原本“按需”的加载变成批量隐式查询。开发环境数据少时不明显,生产列表一大就会放大数据库往返。
带数字看更直观:分页查 100 个用户,如果每个用户访问一次部门,最坏是 101 次 SQL。假设每次数据库往返平均 5ms,仅网络和调度等待就可能接近 101 × 5ms = 505ms,还没算数据库执行和结果映射。若部门 id 只有 8 个,一级缓存可能减少部分重复查询,但这不是稳定方案,因为关联分布一变,SQL 次数又会上来。
三、三种解决方案
第一种是 JOIN + nested resultMap,一次查询组装对象图,适合关联数据量可控的多对一或一对一。第二种是两阶段查询:先查父对象,再收集外键用 IN 批量查关联并在内存组装,适合列表分页。第三种是明确 DTO,只查询接口真正需要的字段,避免加载整个对象图。
没有一种方案对所有场景最优,应比较 SQL 次数、返回行数、网络体积、数据库执行计划和分页语义。
| 方案 | SQL 次数 | 适合场景 | 代价 |
|---|---|---|---|
| 延迟加载 | 访问多少查多少 | 关联属性很少被访问 | 列表访问时 N+1,不易预测 |
| JOIN 嵌套结果 | 通常 1 次 | 一对一、多对一、数据量可控 | 重复行、列冲突、一对多分页困难 |
| 两阶段 IN 查询 | 通常 2 次 | 列表分页后一批装配子数据 | 需要代码组装,IN 过大要分片 |
| DTO 精确查询 | 1 次或少量 | 接口只需要展示字段 | 不复用完整领域对象 |
两阶段查询的流程可以这样理解:
1. SELECT * FROM users ORDER BY id LIMIT 100;
2. 收集 department_id,去重得到 [1, 2, 5, 8]
3. SELECT * FROM dept WHERE id IN (?, ?, ?, ?);
4. 在内存按 department_id 回填到 100 个 UserDTO
这里父对象有 100 个,但部门只有 4 个,SQL 从最坏 101 次降到 2 次。即使部门有 80 个,也仍是 2 次查询,只是第二条 IN 参数更多,需要注意数据库参数上限和执行计划。
四、JOIN 的重复行与分页陷阱
一对多 JOIN 会为每个子对象重复父列,结果集可能远大于父对象数量。直接 LIMIT 10 限制的是 JOIN 后的行,不一定得到 10 个完整父对象,甚至会截断最后一个父对象的子集合。
常见方案是先分页查询父 ID,再按 ID 查询完整父子结果,或执行第二条 IN 查询加载子集合。
五、延迟加载的生命周期风险
延迟属性可能在事务之外、序列化阶段才被访问,此时数据库上下文、连接状态或加载所需环境可能不符合预期,异常也会远离原查询位置。跨层返回带延迟代理的领域对象会让 SQL 在难以预测的地方发生。
接口层更适合在 Service 事务边界内明确完成所需数据装配,再返回稳定 DTO。
例如 Service 方法已经返回,Controller 开始把对象转成 JSON。序列化器遍历 getter 时访问了 getOrders(),这时才触发延迟 SQL;日志里看到 SQL 出现在 Controller 序列化阶段,而不是 Service 查询阶段,排查会非常绕。更糟的是,如果事务已结束、会话已关闭,还可能直接加载失败。
六、如何发现 N+1
开启受控 SQL 日志、数据源代理统计或链路指标,观察一次业务请求的 SQL 数量和重复模板。测试应准备足够多父数据并断言查询次数,不能只验证返回内容正确。
比较可靠的测试方式是准备 20 个父对象,并让每个父对象都有不同子记录,然后断言一次接口调用 SQL 模板数量不随父对象数量线性增长。只用 1 条父数据测试看不出 N+1,因为 1 + 1 和正常两阶段查询都是 2 次 SQL,很容易误判。
七、常见误区与追问
- 误区:开启延迟加载一定提升性能。 只有关联属性不被访问时才省查询;列表批量访问关联时可能产生 N+1。
- 误区:一级缓存可以彻底解决 N+1。 一级缓存只能减少同一 SqlSession 内相同参数的重复子查询,父对象关联键分散时仍会大量查询。
- 追问:JOIN 一次查完为什么也有问题? 一对多 JOIN 会重复父行,数据库分页限制的是结果行,不一定是完整父对象数量。
- 追问:两阶段查询适合什么场景? 先分页查父对象或父 ID,再用 IN 批量查询子数据,适合列表分页和一对多集合装配。
- 误区:N+1 只会由业务代码显式 getter 触发。 JSON 序列化、日志、Bean 拷贝、调试器也可能访问延迟属性并触发 SQL。
- 追问:接口层为什么建议返回 DTO? DTO 在 Service 事务边界内明确装配所需字段,避免延迟代理在序列化阶段突然查库。
八、加强记忆
延迟加载是代理在访问属性时执行嵌套 select,省不省查询取决于是否访问;列表逐个访问会产生 N+1。JOIN 能减少次数但会放大行和破坏分页,两阶段“父分页 + 子 IN 查询”通常更容易控制。