MyBatis 与 SQL 层面关键技术详解
一、MyBatis 动态 SQL
动态 SQL 是 MyBatis 最核心的能力:根据入参动态拼接 SQL,而不是写死。用于"筛选条件有就拼、没有就不拼"的场景。
1.1 <if> --- 条件拼接
入参有值才拼这段 SQL。这是"可选筛选条件"的基础:
xml
<if test="paramDto.sourceSystem != null and paramDto.sourceSystem != ''">
AND o.source_system = #{paramDto.sourceSystem}
</if>
test里写 OGNL 表达式判断- 字符串要判
!= null and != ''(空字符串也不拼) - 集合要判
!= null and .size() > 0
1.2 <foreach> --- 集合展开为 IN
把 List 入参展开成 IN (?, ?, ?):
xml
<if test="paramDto.warehouseCodes != null and paramDto.warehouseCodes.size() > 0">
AND o.warehouse_code IN
<foreach collection="paramDto.warehouseCodes" item="code"
open="(" separator="," close=")">
#{code}
</foreach>
</if>
collection= 集合参数名,item= 每个元素的别名open/close/separator= 拼成(a, b, c)
1.3 <where> --- 智能处理 WHERE 和 AND
自动加 WHERE,并去掉紧跟其后的多余 AND/OR:
xml
<where>
<if test="...">AND a = #{a}</if>
<if test="...">AND b = #{b}</if>
</where>
<!-- 若第一个条件命中,<where> 会把开头的 AND 去掉,避免 WHERE AND a=... 语法错误 -->
1.4 <sql> + <include> --- SQL 片段复用(关键)
把公共的 FROM/JOIN、WHERE 条件抽成片段,多个查询复用。比如列表接口和合计接口就靠它保证筛选口径完全一致:
xml
<sql id="fromJoins">
FROM main_table o
LEFT JOIN member_base mb ON mb.member_id = o.member_id
</sql>
<sql id="whereConditions">
<where>
<if test="...">AND ...</if>
</where>
</sql>
<!-- 列表查询 -->
<select id="listPager" resultType="...">
SELECT o.*, mb.name <include refid="fromJoins"/> <include refid="whereConditions"/>
ORDER BY o.create_time DESC
</select>
<!-- 合计查询:复用同一片段,改口径不会漏改 -->
<select id="listSum" resultType="...">
SELECT SUM(o.qty), SUM(o.amount) <include refid="fromJoins"/> <include refid="whereConditions"/>
</select>
价值:改一处 where 条件,列表和合计同时生效,避免两处口径漂移。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、#{} vs ${}(安全关键)
| 写法 | 机制 | 用途 | 风险 |
|---|---|---|---|
#{param} |
预编译占位符 (PreparedStatement 的 ?),值作为参数传入 |
绝大多数场景,尤其是外部入参 | 无 SQL 注入,安全 |
${param} |
字符串直接拼接 | 动态表名/列名/排序方向等无法用占位符的场景 | 有 SQL 注入风险,禁止拼外部输入 |
xml
WHERE code = #{code} <!-- ✅ 预编译,安全 -->
ORDER BY ${sortColumn} <!-- ⚠️ 拼接,sortColumn 必须白名单校验 -->
原则:能用 #{} 就绝不用 ${} ,外部入参一律 #{}。
三、PageHelper 物理分页
3.1 工作原理
PageHelper 是分页插件(MyBatis Interceptor),拦截紧跟其后执行的第一条查询,自动:
- 生成并执行一条
SELECT COUNT(*) ...(算总数) - 在原 SQL 尾部拼
LIMIT ?, ?(取当页数据)
java
PageHelper.startPage(pageNum, pageSize); // 声明下一条查询要分页
List<Xxx> list = mapper.listXxx(param); // 这条被拦截,自动分页
PageInfo<Xxx> page = new PageInfo<>(list); // 拿到 total/pages/list
3.2 物理分页 vs 逻辑分页
- 物理分页 :SQL 层
LIMIT只取当页数据(PageHelper 用的这种),数据量大也不吃内存。 - 逻辑分页 :查全部再在内存
subList(MyBatis 的RowBounds默认行为),大数据会 OOM,禁用。
3.3 count 语句的坑
PageHelper 生成 count 时会"智能优化"(去 ORDER BY、简化 SELECT),但当 SELECT 里有标量子查询、SQL 太复杂无法安全解析时,它会退化 为 SELECT COUNT(*) FROM (原完整SQL) tmp,把子查询也带进 count 执行------导致 count 也慢。这是"SQL 直接跑快、但接口慢"的隐藏原因。解决:把大表访问移出主查询(见第六节)。
四、子查询的三种形态与性能
4.1 派生表(FROM 子查询)--- 慎用
sql
FROM (SELECT code, MAX(time) FROM big_table GROUP BY code) t
带 GROUP BY/聚合的派生表会被物化(先全表算完存临时表),大表场景是性能灾难。
4.2 相关标量子查询(SELECT 子查询)--- 带值走索引
sql
SELECT a.id,
(SELECT MAX(b.time) FROM big_table b WHERE b.code = a.code) AS lastTime
FROM main a
带着外层 a.code 的具体值去查,能走索引。但分页 count 会连它一起执行,仍有隐患。
4.3 条件子查询(WHERE 子查询)
sql
WHERE id IN (SELECT id FROM other WHERE ...)
五、JOIN 与行膨胀
多表 JOIN 时,若一个主记录关联到多条 子记录,结果集会出现重复行(行膨胀),导致:
- 列表同一条数据显示多行
- 分页
total虚高、翻页错乱
场景:一个 A单可能有多条调拨快照、一个调拨单号可能有多条出入库单。解决办法是用子查询把"多条"收敛成"一条":
sql
-- 取每个A单最新的一条快照(用 MAX(id) 兜底,比 MAX(create_time) 更稳,防同时间戳膨胀)
LEFT JOIN (
SELECT t.pledge_order_id, t.transfer_order_code
FROM snapshot t
INNER JOIN (SELECT pledge_order_id, MAX(id) AS max_id FROM snapshot GROUP BY pledge_order_id) m
ON m.pledge_order_id = t.pledge_order_id AND m.max_id = t.id
) ts ON ts.pledge_order_id = o.id
六、大表取"最后一次"的优化演进
主表小、要展示关联大表的"最后一次时间",且要分页。三种写法:
-
派生表全表聚合 (最差):对千万级大表全表
GROUP BY物化 → 十几秒 -
相关标量子查询(改善):带 code 走索引,但分页 count 仍带子查询
-
应用层二次批量查询
(最优):
- 主查询和 count 完全不碰大表(只查主表)
- 分页拿到当前页的 code 集合
- 用一条
IN批量查大表的MAX(time),走索引 - Java 层按 code 回填
java
List<Row> list = mapper.listPager(param); // 主查询不含大表
List<String> codes = list.stream().map(Row::getCode)
.filter(Objects::nonNull).distinct().collect(toList());
Map<String, Date> timeMap = toMap(mapper.listLastTimeByCodes(codes)); // 一条 IN 查
list.forEach(r -> r.setLastTime(timeMap.get(r.getCode())));
核心思想:把对大表的访问次数与结果集行数解耦。
七、索引相关
7.1 索引失效的常见原因
-
对索引列用函数 :
WHERE DATE(create_time) = '2026-09-01'用不上 create_time 索引 → 改成范围查询 -
左闭右开时间区间
(这次用的):把"某月"换算成
[月初, 下月初),避免对时间列套函数:
sqlAND inbound_time >= '2026-09-01 00:00:00' AND inbound_time < '2026-10-01 00:00:00' -
隐式类型转换、
LIKE '%xx'前导通配、OR连接非索引列等
7.2 覆盖索引
(code, time) 复合索引,SELECT MAX(time) WHERE code=? 可直接从索引取值不回表,进一步加速。
八、EXPLAIN 执行计划分析
排查慢 SQL 的核心工具,重点看:
| 列 | 关注点 |
|---|---|
type |
ALL=全表扫描(差);ref/range/eq_ref=走索引(好) |
key |
实际用了哪个索引,NULL=没走索引 |
rows |
预估扫描行数,越大越慢 |
Extra |
Using temporary=用临时表(派生表物化信号);Using filesort=额外排序;Using index=覆盖索引(好) |
select_type |
DERIVED=派生表;SUBQUERY=子查询 |
table |
<derivedN>=派生表的物化结果 |
看到 DERIVED + big_table 的 type=ALL + 大 rows + Using temporary,基本锁定派生表物化拖慢。
九、结果映射
- resultType :查询列的别名(
AS xxx)直接映射到 DTO 的同名驼峰字段(user_name AS userName)。 - 展示串拼接放 Java 层 :像
(编码)名称这种拼接,放 Service 层而非 SQL ------ 便于单测、格式调整,也避免 SQL 里CONCAT/LEFT出边界问题。SQL 只返回编码和名称原值。
十、一句话总结
MyBatis 层靠动态 SQL(<if>/<foreach>/<sql>+<include>)**灵活拼条件并复用片段保证列表与合计口径一致;用 #{} 预编译防注入;用 **PageHelper 物理分页**(注意 count 会带上 SELECT 子查询的坑)。SQL 层要警惕**派生表物化 和 JOIN 行膨胀 两大性能陷阱,用子查询收敛(MAX id)**去重、用**应用层批量二次查询 把大表访问与结果行数解耦、用左闭右开区间 保索引不失效,最终靠 EXPLAIN 验证执行计划。