订单列表只返回二十行,接口却拖了很久。翻 SQL 日志,第一条查订单,后面跟着一串按买家 ID 查名称的语句。每条看起来都不慢,加在一起,请求就慢下来了。
这种“先查一批,再给每行补一次关联”的形状,就是常说的 N+1 查询。MyBatis 的嵌套查询映射,以及 Service 里循环调用 Mapper,都可能出现它。相同买家是否命中缓存、分页是否额外查总数,会影响实际次数,先以当前请求的日志为准。 MyBatis 对 N+1 的说明
先数一遍同一次请求发了多少 SQL
在测试环境用测试数据,只打开相关 Mapper 包的 DEBUG 日志。下面的包名换成你项目的实际 namespace 前缀,保留原来的其他日志设置。
logging:
level:
com.example.order.mapper: DEBUG
MyBatis 可以通过 Mapper 的命名空间或具体语句名控制日志。DEBUG 会显示执行的 SQL 和参数,观察窗口与数据范围按项目约定控制,定位后恢复原级别。 MyBatis 日志配置
固定筛选条件和每页条数,发一次请求,沿请求日志看三件事。
- 第一条列表 SQL 回了多少行。
- 后面是不是反复出现同一形状的语句,只换了买家 ID。
- 把每页条数从二十改成十以后,关联查询的条数是否也跟着减少。
并发请求混在同一份日志时,带上项目已有的请求标识一起看。也要看看补查询发生在哪一层,别只看到 Service 返回就停止取样。配置了懒加载的关联,在后续访问属性时仍可能发查询,比如渲染页面或组装响应。 MyBatis 的关联嵌套查询与懒加载
每笔订单只对应一个买家,就把名称一起带回来
下面按 MyBatis 3.5.19、MySQL 8.4、JDK 17 或 21 演示。假设测试库已经有 app_order(id, buyer_id) 和 app_user(id, display_name)。两个表的 id 都是 BIGINT 主键,每笔订单最多匹配一个买家。示例用手写 LIMIT,按订单 ID 倒序取第一页。
返回值只需要订单 ID、买家 ID 和显示名称,可以用一个独立的列表 DTO。
OrderListRow.java
package com.example.order.dto;
public record OrderListRow(
Long orderId,
Long buyerId,
String buyerName
) {}
Mapper 方法继续返回一页列表。limit 由现有的分页参数校验保证是正整数,并设好项目允许的上限。
OrderListMapper.java
package com.example.order.mapper;
import com.example.order.dto.OrderListRow;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface OrderListMapper {
List<OrderListRow> selectFirstPage(@Param("limit") int limit);
}
对应 XML 把买家名称查进来,再明确映射到 record 的构造参数。XML 的加载位置按项目已有的 mapper-locations 配置安排,namespace 对应上面的接口。 MyBatis 的参数和构造器结果映射
OrderListMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.order.mapper.OrderListMapper">
<resultMap id="orderListRow"
type="com.example.order.dto.OrderListRow">
<constructor>
<idArg column="order_id"
javaType="java.lang.Long"
name="orderId"/>
<arg column="buyer_id"
javaType="java.lang.Long"
name="buyerId"/>
<arg column="buyer_name"
javaType="java.lang.String"
name="buyerName"/>
</constructor>
</resultMap>
<select id="selectFirstPage" resultMap="orderListRow">
SELECT o.id AS order_id,
o.buyer_id AS buyer_id,
u.display_name AS buyer_name
FROM app_order AS o
LEFT JOIN app_user AS u ON u.id = o.buyer_id
ORDER BY o.id DESC
LIMIT #{limit}
</select>
</mapper>
这里每笔订单最多连接到一条买家记录,行数不会因这个关联展开。LEFT JOIN 也会保留找不到买家的订单,此时 buyerName 为 null,页面怎么显示由业务约定决定。 MySQL 8.4 的 LEFT JOIN 规则
现有 Service 改用这个查询以后,把循环里的 selectBuyerById 也移除,直接使用返回的 buyerName。如果工程已有分页插件,先核对它生成的最终 SQL,再把这个关联合进原来的分页方式。
关联的是多行明细时,先保住分页
订单和订单明细又是另一种关系。一张订单可能有多行明细,连接后再 LIMIT,截取的可能就是展开后的行。结果看起来有二十行,实际订单数却变少了。
这种列表可以先查出这一页的订单,拿页内订单 ID 批量查明细,再按订单 ID 组装。页内 ID 为空时直接返回空列表,批量查询的大小也要跟着分页上限控制。拼 IN 条件时使用参数绑定。 MyBatis 的 foreach 用法 · MyBatis foreach 查询报错怎么排查
用同一页数据复测
再发一次同样的请求,检查原来的逐条补买家查询是否消失,同时核对订单 ID、顺序、每页条数和缺少买家时的显示结果。带有总数的接口,也把原来的总数口径一起对上。
SQL 数量收敛以后,继续看每条 SQL 的耗时。关联字段、索引和实际数据分布还会影响查询计划。 MySQL 查询突然变慢怎么查
如果只是同一批 ID 第二次变快,还要确认是否命中了 MyBatis 的本地缓存。它可以减少同一会话中的重复查询,不能据此认定逐行补查询的代码已经改好了。 MyBatis 本地缓存说明
MyBatis 一页数据,怎么查出了几十条 SQL
https://java.li/archives/mybatis-n-plus-one-query
评论