6.3 性能优化:慢查询与批量执行 本节摘要:性能优化的第一刀不在 SQL 文本,而在执行次数。本节先讲 N 加一的定位与三种解法的取舍,再看批量插入两条路线的真实收益口径,最后用 fetchSize 游标处理几十万行的大结果集,顺带交代连接池的关键参数。 一个反直觉的结论先放这里:批量优化先看执行次数与网络往返,再谈语句本身。一条 SQL 慢 50 毫秒,循环里跑一千次就是 50 秒——把单条提速到 20 毫秒只省一半,把一千次合成一次才是数量级的差距。 第一病例:N 加一 典型现场:查 100 个订单,再逐个查订单的客户。日志里长这样: 3.1 的延迟加载(association 的 select 嵌套查询)是 N 加一的高发地:主查询 1 次,懒加载属性每次触发再查 1 次。
本节摘要:性能优化的第一刀不在 SQL 文本,而在执行次数。本节先讲 N 加一的定位与三种解法的取舍,再看批量插入两条路线的真实收益口径,最后用 fetchSize 游标处理几十万行的大结果集,顺带交代连接池的关键参数。
一个反直觉的结论先放这里:批量优化先看执行次数与网络往返,再谈语句本身。一条 SQL 慢 50 毫秒,循环里跑一千次就是 50 秒——把单条提速到 20 毫秒只省一半,把一千次合成一次才是数量级的差距。
典型现场:查 100 个订单,再逐个查订单的客户。日志里长这样:
DEBUG c.e.m.OrderMapper.selectPage - <== Total: 100 DEBUG c.e.m.UserMapper.selectById - <== Total: 1 ← 然后这行重复 100 遍 DEBUG c.e.m.UserMapper.selectById - <== Total: 1 ...
3.1 的延迟加载(association 的 select 嵌套查询)是 N 加一的高发地:主查询 1 次,懒加载属性每次触发再查 1 次。定位就靠把 Mapper 日志开到 DEBUG 数行数——同一 statement 的日志行数等于列表长度,即坐诊 N 加一。
消灭有三条路,各有取舍:
| 解法 | 做法 | 适用与代价 |
|---|---|---|
| join 一次取回 | 3.2 的 association/collection 联表映射 | 一对一最好用;一对多 join 会撑爆去重成本 |
| 批量 IN 二次查询 | 先查主表,收集 id,一次 IN 查子表,内存组装 | 通用性最好;多一次往返但次数从 N 降到 2 |
| 缓存挡重复 | 5.1 二级缓存 / 集中式缓存 | 仅限读多改少且归属单一的数据 |

foreach 拼 VALUES 多行插入,和 ExecutorType.BATCH 批量执行器,适用场景不同:
<!-- 路线一:foreach 多行 VALUES(4.3 的 foreach 展开) --> <insert id="batchInsert"> INSERT INTO orders(sn, amount, status) VALUES <foreach collection="list" item="o" separator=","> (#{o.sn}, #{o.amount}, #{o.status}) </foreach> </insert>
// 路线二:批量执行器——SQL 只发一次文本,参数分批附上 try (SqlSession session = factory.openSession(ExecutorType.BATCH)) { OrderMapper mapper = session.getMapper(OrderMapper.class); for (Order o : list) { mapper.insert(o); // 语句先攒在客户端 } session.commit(); // commit 时整批刷出 }
实测口径两条:路线一条数上限受 max_allowed_packet 约束(几万行就要分批);路线二对 UPDATE/DELETE 的收益远高于逐条发(省掉每条的编译与往返),但拿自增主键回填要 commit 后才取到。任何批量改动都先在预发环境用真实数据量跑一遍对比,别只看理论值。
几十万行的报表导出,一次 selectList 会把全部行装进内存——这是 OOM 的经典来源。开游标流式读取:
<select id="streamAll" resultType="OrderRow" fetchSize="1000" resultSetType="FORWARD_ONLY"> SELECT sn, amount, status FROM orders </select>
try (Cursor<OrderRow> cursor = session.getMapper(OrderReportMapper.class).streamAll()) { for (OrderRow row : cursor) { // 每次只取 1000 行到内存 writer.write(row); } }
fetchSize 是每次往返取多少行的"水位",FORWARD_ONLY 声明只向前不回头——内存占用从"全表"降到"一页"。
⚠️ 常见坑:游标流式读依赖连接保持打开——用 Template/注解方式在事务外拿 Cursor,会话提前关闭直接报错。大结果集导出要么包在事务里,要么手动管理会话生命周期。
💡 关键直觉:连接池参数(最大连接数、空闲回收、获取超时)不是越大越好——最大连接数超过数据库的承受能力,等待反而更久。批处理任务与在线服务共池时,给批处理单独的池,防止导出任务把在线业务的连接抢光。
慢的病看完,下一节看"坏了怎么查":把全册踩过的坑编成一张从报错信息出发的排错决策树。