6.3 性能优化:慢查询与批量执行


文档摘要

6.3 性能优化:慢查询与批量执行 本节摘要:性能优化的第一刀不在 SQL 文本,而在执行次数。本节先讲 N 加一的定位与三种解法的取舍,再看批量插入两条路线的真实收益口径,最后用 fetchSize 游标处理几十万行的大结果集,顺带交代连接池的关键参数。 一个反直觉的结论先放这里:批量优化先看执行次数与网络往返,再谈语句本身。一条 SQL 慢 50 毫秒,循环里跑一千次就是 50 秒——把单条提速到 20 毫秒只省一半,把一千次合成一次才是数量级的差距。 第一病例:N 加一 典型现场:查 100 个订单,再逐个查订单的客户。日志里长这样: 3.1 的延迟加载(association 的 select 嵌套查询)是 N 加一的高发地:主查询 1 次,懒加载属性每次触发再查 1 次。

6.3 性能优化:慢查询与批量执行

本节摘要:性能优化的第一刀不在 SQL 文本,而在执行次数。本节先讲 N 加一的定位与三种解法的取舍,再看批量插入两条路线的真实收益口径,最后用 fetchSize 游标处理几十万行的大结果集,顺带交代连接池的关键参数。

一个反直觉的结论先放这里:批量优化先看执行次数与网络往返,再谈语句本身。一条 SQL 慢 50 毫秒,循环里跑一千次就是 50 秒——把单条提速到 20 毫秒只省一半,把一千次合成一次才是数量级的差距。

第一病例:N 加一

典型现场:查 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 二级缓存 / 集中式缓存 仅限读多改少且归属单一的数据

N 加一 vs 批量 IN:往返次数是数量级差距

第二病例:批量写入的两条路线

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 后才取到。任何批量改动都先在预发环境用真实数据量跑一遍对比,别只看理论值。

大结果集:fetchSize 与游标

几十万行的报表导出,一次 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,会话提前关闭直接报错。大结果集导出要么包在事务里,要么手动管理会话生命周期。

💡 关键直觉:连接池参数(最大连接数、空闲回收、获取超时)不是越大越好——最大连接数超过数据库的承受能力,等待反而更久。批处理任务与在线服务共池时,给批处理单独的池,防止导出任务把在线业务的连接抢光。

本节要点回顾

  • 先数次数再看语句:性能账的第一项是执行次数 × 单次耗时
  • N 加一靠日志定位:同 statement 行数 = 列表长度即坐诊;join、批量 IN、缓存三解法按场景选
  • 批量两条路线:foreach 多行 VALUES 一次成型、BATCH 执行器攒批刷出,上限与回填行为各不同
  • 大结果集用游标:fetchSize + FORWARD_ONLY 把内存从全表降到一页,会话必须保持打开
  • 连接池求稳不求大:池参数对齐数据库承受力,批任务与在线业务分池

慢的病看完,下一节看"坏了怎么查":把全册踩过的坑编成一张从报错信息出发的排错决策树。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U