一个列表查询接口,20条数据要3秒。
查了半天,发现是MyBatis的N+1问题。
改了一行配置,从3秒优化到50毫秒。
问题现象
接口:查询订单列表,每个订单要显示用户名
实体类:
@Data public class Order { private Long id; private Long userId; private String orderNo; private BigDecimal amount; private User user; // 关联用户 }Mapper:
<resultMap id="orderResultMap" type="Order"> <id column="id" property="id"/> <result column="user_id" property="userId"/> <result column="order_no" property="orderNo"/> <result column="amount" property="amount"/> <association property="user" column="user_id" select="selectUserById"/> </resultMap> <select id="selectOrderList" resultMap="orderResultMap"> SELECT * FROM orders WHERE status = 1 LIMIT 20 </select> <select id="selectUserById" resultType="User"> SELECT * FROM users WHERE id = #{userId} </select>问题:查询20条订单,居然执行了21条SQL!
什么是N+1问题
第1条SQL:查询订单列表(返回20条) 第2条SQL:查询第1个订单的用户 第3条SQL:查询第2个订单的用户 ... 第21条SQL:查询第20个订单的用户1次查询订单 + N次查询用户 = N+1次查询
每条SQL都有网络开销,20条订单就要21次数据库交互,当然慢。
如何发现N+1问题
方法一:开启SQL日志
mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl看到一堆重复的SELECT * FROM users WHERE id = ?就是了。
方法二:用druid监控
spring: datasource: druid: stat-view-servlet: enabled: true访问/druid看SQL执行次数。
方法三:Arthas监控
# 监控SQL执行 watch com.mysql.cj.jdbc.StatementImpl execute "{params,returnObj}" -x 2解决方案
方案一:改用JOIN查询(推荐)
一条SQL搞定:
<resultMap id="orderResultMap" type="Order"> <id column="id" property="id"/> <result column="user_id" property="userId"/> <result column="order_no" property="orderNo"/> <result column="amount" property="amount"/> <association property="user" javaType="User"> <id column="user_id" property="id"/> <result column="user_name" property="name"/> </association> </resultMap> <select id="selectOrderList" resultMap="orderResultMap"> SELECT o.id, o.user_id, o.order_no, o.amount, u.name as user_name FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE o.status = 1 LIMIT 20 </select>效果:1条SQL,50ms搞定。
方案二:开启懒加载 + 批量查询
如果不想改SQL,可以开启懒加载和批量查询:
mybatis: configuration: lazy-loading-enabled: true aggressive-lazy-loading: false default-executor-type: batch但这个方案不如JOIN彻底。
方案三:手动批量查询
public List<Order> getOrderList() { // 1. 查询订单 List<Order> orders = orderMapper.selectOrderList(); // 2. 收集userId Set<Long> userIds = orders.stream() .map(Order::getUserId) .collect(Collectors.toSet()); // 3. 批量查询用户 List<User> users = userMapper.selectByIds(userIds); Map<Long, User> userMap = users.stream() .collect(Collectors.toMap(User::getId, u -> u)); // 4. 组装数据 orders.forEach(order -> { order.setUser(userMap.get(order.getUserId())); }); return orders; }SQL:
<select id="selectByIds" resultType="User"> SELECT * FROM users WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>效果:2条SQL,比N+1好很多。
不同方案对比
| 方案 | SQL数量 | 复杂度 | 适用场景 |
|---|---|---|---|
| JOIN查询 | 1 | 低 | 简单关联 |
| 批量查询 | 2 | 中 | 复杂关联 |
| 懒加载 | N+1 | 低 | 很少访问关联数据 |
collection也会有N+1
<!-- 查询用户及其订单列表 --> <resultMap id="userResultMap" type="User"> <id column="id" property="id"/> <collection property="orders" column="id" select="selectOrdersByUserId"/> </resultMap>同样的问题:查10个用户,会执行11条SQL。
解决:
<resultMap id="userResultMap" type="User"> <id column="id" property="id"/> <result column="name" property="name"/> <collection property="orders" ofType="Order"> <id column="order_id" property="id"/> <result column="order_no" property="orderNo"/> </collection> </resultMap> <select id="selectUserWithOrders" resultMap="userResultMap"> SELECT u.id, u.name, o.id as order_id, o.order_no FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.status = 1 </select>MyBatis-Plus方案
如果用MyBatis-Plus,可以用@TableField注解:
@Data @TableName("orders") public class Order { private Long id; private Long userId; @TableField(exist = false) // 非数据库字段 private User user; }然后在Service层手动组装:
public List<Order> getOrderList() { List<Order> orders = orderMapper.selectList(wrapper); // 批量查询用户 Set<Long> userIds = orders.stream() .map(Order::getUserId) .collect(Collectors.toSet()); Map<Long, User> userMap = userService.listByIds(userIds) .stream() .collect(Collectors.toMap(User::getId, u -> u)); orders.forEach(o -> o.setUser(userMap.get(o.getUserId()))); return orders; }性能对比
测试数据:100条订单
| 方案 | SQL数量 | 耗时 |
|---|---|---|
| N+1(原始) | 101 | 3200ms |
| JOIN查询 | 1 | 45ms |
| 批量查询 | 2 | 60ms |
提升:70倍!
远程排查经验
有次生产环境接口响应变慢,我在外面用星空组网连到公司内网,打开druid监控一看,一个接口执行了500多条SQL。
典型的N+1问题,改成JOIN查询立马解决。
远程能直接看监控、看日志,排查效率高很多。
总结
| 场景 | 推荐方案 |
|---|---|
| 简单一对一关联 | JOIN查询 |
| 复杂多表关联 | 批量查询 |
| 一对多关联 | JOIN或批量 |
| 很少用关联数据 | 懒加载 |
避免N+1的原则:
- 不要在resultMap里用
select属性 - 关联查询优先用JOIN
- 必须分开查就用批量查询
- 开启SQL日志及时发现问题
一句话:看到association/collection里有select属性,基本就是N+1。