ORM 用得好是生产力,用不好就是慢 SQL 生成器。这篇按「原理 → 正确写法 → 排查」整理。
一、执行链路
二、#{} 和 ${}:安全红线
| 写法 | 原理 | 风险 |
|---|---|---|
#{} |
预编译 ? 占位,参数转义 |
安全,默认一律用它 |
${} |
字符串直接拼接进 SQL | SQL 注入,且可能让索引失效 |
${} 只在动态表名、动态排序字段这类无法参数化的地方用,而且必须白名单校验,绝不能直接把前端参数拼进去:
// 错误:直接把前端传入的排序字段拼进 SQL
@Select("select * from t_order order by ${orderBy}")
// 正确:白名单
if (!Set.of("create_time", "amount").contains(orderBy)) throw new BizException("非法排序字段");
三、一级缓存与二级缓存
| 一级缓存 | 二级缓存 | |
|---|---|---|
| 范围 | SqlSession 级别(同一次会话) | namespace 级别(跨会话,整个 Mapper) |
| 默认 | 开启(无法关闭) | 关闭 |
| 失效 | 执行 insert/update/delete 或 clearCache() |
同 namespace 发生写操作 |
一级缓存的坑:同一个事务里连续两次相同的查询,第二次直接走缓存,拿到的是内存里的旧对象。如果第一次查询后有其他地方改了库,你会读到脏数据。这也是「明明改了库,查出来还是旧值」的高频原因。
二级缓存的生产建议:不要用。 理由:
- 分布式环境下多实例各存一份,天然不一致;
- 缓存粒度是 namespace,任何一张表的写操作都会清掉整个 namespace 的缓存,命中率极低;
- 脏数据排查困难。
要做缓存就在服务层用 Redis,粒度可控、能主动失效。
四、批量操作:三种写法对比
| 写法 | 10 万条耗时(参考) | 说明 |
|---|---|---|
循环单条 insert |
分钟级 | 每条一次网络往返 + 事务,最慢 |
foreach 拼 insert into ... values (),(),() |
秒级 | 简单,但 SQL 超长可能报错(MySQL max_allowed_packet) |
| ExecutorType.BATCH | 秒级,内存更省 | 推荐 |
// 推荐:BATCH 模式 + 分批提交
try (SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
OrderMapper mapper = session.getMapper(OrderMapper.class);
for (int i = 0; i < list.size(); i++) {
mapper.insert(list.get(i));
if (i % 1000 == 0) session.flushStatements(); // ★ 分批刷,防止内存堆积
}
session.flushStatements();
session.commit();
}
MySQL 侧还要在 JDBC URL 上加 rewriteBatchedStatements=true,否则 MySQL 驱动会把批量拆成单条发,白忙一场:
jdbc:mysql://host:3306/db?rewriteBatchedStatements=true&useServerPrepStmts=true
五、分页插件
MyBatis-Plus 的 PaginationInnerInterceptor 原理是拦截 SQL,自动拼 LIMIT,并单独执行一条 COUNT。
@Bean
public MybatisPlusInterceptor mpInterceptor() {
MybatisPlusInterceptor i = new MybatisPlusInterceptor();
i.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return i;
}
// 使用
Page<Order> page = orderMapper.selectPage(new Page<>(1, 20), wrapper);
深分页问题:LIMIT 1000000, 20 会先扫 100 万行再丢弃,非常慢。优化:
-- 用上一页最大 id 做游标(推荐)
select * from t_order where id > #{lastId} order by id limit 20;
-- 或延迟关联:先用覆盖索引拿 id,再回表
select o.* from t_order o
join (select id from t_order where status=1 order by create_time limit 1000000,20) t
on o.id = t.id;
大 count 优化:COUNT(*) 在千万级表上很慢。可以关掉 MP 的 count(Page.setSearchCount(false)),或改用近似值/缓存计数。
六、动态 SQL 与常见错误
<select id="list" resultType="Order">
select * from t_order
<where>
<if test="orgId != null"> and org_id = #{orgId}</if>
<if test="status != null"> and status = #{status}</if>
<if test="keywords != null and keywords != ''">
and order_no like concat('%', #{keywords}, '%')
</if>
</where>
order by create_time desc
</select>
常见错误:
| 错误 | 原因 | 解法 |
|---|---|---|
Parameter 'xxx' not found |
多参数没加 @Param |
方法参数加 @Param("xxx") |
Mapped Statements collection does not contain value |
mapper.xml 的 namespace 或 id 对不上 | 检查 namespace 是否等于接口全限定名 |
Result Maps collection does not contain value |
resultMap 引用名写错 |
检查 |
| 字段查出来是 null | 列名与属性名不匹配 | 开 map-underscore-to-camel-case: true,或写 resultMap |
Too many results |
selectOne 返回多行 |
加 limit 1 |
七、慢 SQL 从发现到优化
发现:
-- MySQL 慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 超过 1s 记录
SHOW VARIABLES LIKE 'slow_query_log_file';
-- 实时看正在跑的慢语句
SELECT * FROM information_schema.PROCESSLIST
WHERE COMMAND != 'Sleep' AND TIME > 2 ORDER BY TIME DESC;
分析(永远用 EXPLAIN):
EXPLAIN SELECT * FROM t_order WHERE org_id = 100 AND status = 1;
重点看四列:type(ALL 全表扫描要优化,至少到 range/ref)、key(实际用到的索引)、rows(扫描行数)、Extra(Using filesort / Using temporary 是危险信号)。
优化顺序:
- 加合适的联合索引,注意最左前缀。
- 避免索引失效:不在索引列上做函数/运算/隐式类型转换;
like不以%开头。 - 只查需要的字段,别
select *(覆盖索引能省掉回表)。 - 大表加
limit,深分页改游标。 - 实在大就分库分表或走 ES / 离线。
八、MyBatis-Plus 的几个提醒
selectList不加条件就是全表,务必加QueryWrapper或last("limit 100")。- 逻辑删除会在所有查询自动拼
deleted = 0,但不会自动给这张表加索引,记得补上。 @TableField(fill = FieldFill.INSERT)做 create_time/update_time 自动填充,别手写。- 乐观锁
@Version配合update使用,更新结果为 0 时要重试或提示。 in超过 1000 个值 MySQL 会报错,代码里要分批。
一起聊聊