Spring 全家桶 列表全部文章
Spring 全家桶

MyBatis 实战笔记:一级二级缓存、批量插入、分页与慢 SQL 排查

MyBatis 与 MyBatis-Plus 的实用笔记:#{} 与 ${} 的区别和注入风险、一级二级缓存的坑、批量插入的三种写法对比、分页插件原理、动态 SQL 与常见错误,以及生产慢 SQL 从发现到优化的完整流程。

更多

ORM 用得好是生产力,用不好就是慢 SQL 生成器。这篇按「原理 → 正确写法 → 排查」整理。

一、执行链路

Mapper 接口 动态代理 SqlSession 一级缓存在此 Executor StatementHandler JDBC 拦截器(分页插件、数据权限、SQL 审计)挂在 StatementHandler / Executor 上,靠 @Intercepts + @Signature 声明。

二、#{} 和 ${}:安全红线

写法 原理 风险
#{} 预编译 ? 占位,参数转义 安全,默认一律用它
${} 字符串直接拼接进 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 是危险信号)。

优化顺序:

  1. 加合适的联合索引,注意最左前缀。
  2. 避免索引失效:不在索引列上做函数/运算/隐式类型转换;like 不以 % 开头。
  3. 只查需要的字段,别 select *(覆盖索引能省掉回表)。
  4. 大表加 limit,深分页改游标。
  5. 实在大就分库分表或走 ES / 离线。

八、MyBatis-Plus 的几个提醒

  • selectList 不加条件就是全表,务必加 QueryWrapper 或 last("limit 100")。
  • 逻辑删除会在所有查询自动拼 deleted = 0,但不会自动给这张表加索引,记得补上。
  • @TableField(fill = FieldFill.INSERT) 做 create_time/update_time 自动填充,别手写。
  • 乐观锁 @Version 配合 update 使用,更新结果为 0 时要重试或提示。
  • in 超过 1000 个值 MySQL 会报错,代码里要分批。
— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人