MyBatis知识梳理
作者:没有四次元口袋的蓝胖
日期:2026-07-23
标签:Java, MyBatis, 缓存, 分页, 面试题
一、MyBatis 缓存
1.1 一级缓存(本地缓存)
作用域:同一个SqlSession内有效。
SqlSessionsession=sqlSessionFactory.openSession();UserMappermapper=session.getMapper(UserMapper.class);// 第一次查询 → 走数据库Useru1=mapper.selectById(1);// 第二次查询同一个 SQL → 走一级缓存,不再查库Useru2=mapper.selectById(1);// u1 == u2(同一个对象引用)一级缓存失效的四种情况:
| 情况 | 原因 |
|---|---|
| SqlSession 关闭 | 缓存随 session 销毁 |
| SqlSession 执行了增删改操作并 commit | 缓存被清空,防止脏读 |
手动清除缓存session.clearCache() | 主动清空 |
| 使用不同的 SqlSession | 各自独立的缓存 |
MyBatis默认开启一级缓存,无法关闭。
1.2 二级缓存(全局缓存)
作用域:同一个namespace(同一个 Mapper)的所有 SqlSession 共享。
SqlSession A(查询)→ 数据写入二级缓存 SqlSession B(查询)→ 从二级缓存读取开启步骤:
<!-- 1. 全局配置开启二级缓存 --><settings><settingname="cacheEnabled"value="true"/></settings><!-- 2. Mapper XML 中声明使用缓存 --><mappernamespace="com.example.UserMapper"><cache/><!-- 加上这行 --></mapper>// 3. 实体类必须实现 SerializablepublicclassUserimplementsSerializable{...}注意事项:
- 二级缓存是跨 SqlSession的,多个 session 共享
- 同样在增删改并 commit 后会清空缓存
- 实际开发中很少使用,因为多表操作时容易出现脏数据
- 分布式环境下,多个实例各自的二级缓存不同步
1.3 一级 vs 二级缓存对比
| 维度 | 一级缓存 | 二级缓存 |
|---|---|---|
| 作用域 | 单个 SqlSession | 同一个 namespace |
| 默认状态 | 默认开启,不可关闭 | 默认关闭,需手动开启 |
| 缓存类型 | 本地缓存 | 跨 session 共享 |
| 实际使用 | 自动生效,无感知 | 很少用(分布式+多表场景有坑) |
面试追问:为什么实际开发很少用二级缓存?
答:多表查询时缓存清理范围是 namespace 级别,容易脏读;分布式多实例各自缓存不同步。一般用 Redis 做统一缓存更可靠。
二、#{} 与 ${} 的区别(⭐ 高频)
2.1 核心区别
| 维度 | #{} | ${} |
|---|---|---|
| 本质 | 预编译参数(占位符?) | 字符串拼接(直接拼到 SQL 中) |
| SQL 示例 | WHERE name = ? | WHERE name = '张三' |
| 是否防 SQL 注入 | ✅ 是 | ❌ 否 |
| 使用场景 | 绝大多数情况 | 动态表名、列名、排序等 |
2.2 #{} 工作原理(预编译)
<selectid="selectByName">SELECT * FROM user WHERE name = #{name}</select>MyBatis 生成的 SQL:
SELECT*FROMuserWHEREname=?-- 参数:'张三'- 使用
PreparedStatement,参数用?占位 - 参数值由 JDBC 驱动处理,不会被当作 SQL 语法
- 防止 SQL 注入
2.3 ${} 工作原理(字符串替换)
<selectid="selectByColumn">SELECT * FROM user ORDER BY ${columnName}</select>MyBatis 生成的 SQL:
SELECT*FROMuserORDERBYname-- columnName 的值 "name" 直接拼进去了- 使用
Statement,参数值直接拼接到 SQL 字符串中 - 有 SQL 注入风险
2.4 什么时候必须用 ${}?
<!-- 动态表名 -->SELECT * FROM ${tableName} WHERE id = #{id}<!-- 动态列名 -->SELECT ${columnName} FROM user<!-- 动态排序 -->SELECT * FROM user ORDER BY ${orderColumn} ${orderDir}表名、列名、排序方向这些不能用 ? 占位,只能用${}。
安全原则:能用
#{}就用#{},只有表名、列名等不能用占位符的场景才用${},且必须在代码层做参数校验,防止注入。
三、MyBatis 分页
3.1 为什么需要分页?
数据量大时不能一次全部查出:
- 内存可能撑不住(OOM)
- 数据库压力大
- 用户体验差
3.2 分页方式对比
| 方式 | 原理 | 优缺点 |
|---|---|---|
| SQL 手写 LIMIT | LIMIT offset, size | 简单但每页都要写 |
| RowBounds | MyBatis 内置,内存分页 | ❌ 先查全部再截取,数据量大时 OOM |
| PageHelper 插件 | 拦截 SQL,自动拼接 LIMIT | ✅ 最常用,对业务代码无侵入 |
3.3 PageHelper 使用(最推荐)
引入依赖:
<dependency><groupId>com.github.pagehelper</groupId><artifactId>pagehelper-spring-boot-starter</artifactId><version>1.4.7</version></dependency>使用:
@GetMapping("/users")publicPageInfo<User>listUsers(@RequestParam(defaultValue="1")intpageNum,@RequestParam(defaultValue="10")intpageSize){// ① 设置分页参数(紧跟着的查询会生效)PageHelper.startPage(pageNum,pageSize);// ② 正常写查询,不用改 SQLList<User>users=userMapper.selectAll();// ③ 用 PageInfo 包装结果returnnewPageInfo<>(users);}PageInfo 返回的关键信息:
{"pageNum":1,"pageSize":10,"total":100,"pages":10,"list":[...],"isFirstPage":true,"isLastPage":false}3.4 PageHelper 原理
// PageHelper 内部做了这些事:// 1. startPage() 把分页参数存入 ThreadLocalpublicstaticvoidstartPage(intpageNum,intpageSize){ThreadLocal<Page<?>>localPage=...;localPage.set(newPage<>(pageNum,pageSize));}// 2. 拦截器拦截 SQL 执行// 3. 从 ThreadLocal 取出分页参数// 4. 自动改写 SQL:SELECT * FROM user → SELECT * FROM user LIMIT ?, ?// 5. 同时自动执行 SELECT COUNT(*) 获取总数// 6. 用 PageInfo 封装结果3.5 LIMIT 分页的性能问题
-- 第 1 页(快)SELECT*FROMuserLIMIT0,10-- 第 10000 页(慢!)SELECT*FROMuserLIMIT99990,10-- MySQL 会扫描 100000 行,丢弃前 99990 行,只返回 10 行深度分页优化:
-- 方案一:延迟关联(先查 ID,再回表)SELECTu.*FROMuseruINNERJOIN(SELECTidFROMuserORDERBYidLIMIT99990,10)tONu.id=t.id-- 方案二:游标分页(记住上次最后一条的 ID)SELECT*FROMuserWHEREid>#{lastId} ORDER BY id LIMIT 10-- 适合"加载更多"场景,不适合"跳页"四、思维导图速览
MyBatis 核心知识 ├── 缓存 │ ├── 一级缓存 │ │ ├── SqlSession 级别(本地缓存) │ │ ├── 默认开启,不可关闭 │ │ └── 增删改 / 换 session / 手动清除 → 失效 │ │ │ └── 二级缓存 │ ├── namespace 级别(跨 session 共享) │ ├── 需手动开启(cacheEnabled + <cache/>) │ ├── 实体类要实现 Serializable │ └── 实际很少用(分布式用 Redis) │ ├── #{} vs ${} │ ├── #{} → PreparedStatement 占位符 ? → 防注入 → 默认用这个 │ ├── ${} → Statement 字符串拼接 → 有注入风险 │ └── ${} 使用场景:动态表名、列名、排序 │ └── 分页 ├── RowBounds → 内存分页(不推荐) ├── PageHelper → 拦截器自动改 SQL(最常用) │ ├── startPage() 存入 ThreadLocal │ ├── 拦截器改写 SQL 拼 LIMIT │ └── PageInfo 封装结果 └── 深度分页优化 ├── 延迟关联(先查 ID 再回表) └── 游标分页(WHERE id > lastId)五、写在最后
面试高频回答
MyBatis 缓存:
“MyBatis 有一级缓存和二级缓存。一级缓存是 SqlSession 级别的本地缓存,默认开启,执行增删改或切换 session 会失效。二级缓存是 namespace 级别的全局缓存,需要手动开启,但实际开发很少用,因为分布式多实例缓存不同步,多表查询容易脏读,一般用 Redis 替代。”
#{} 与 ${}:
“#{} 是预编译占位符,用 PreparedStatement,参数不会被当作 SQL 语法,能防止 SQL 注入。${} 是字符串拼接,参数直接拼进 SQL,有注入风险。只有动态表名、列名等不能用占位符的场景才用 ${},且必须在代码层做参数校验。”
分页:
“实际开发用 PageHelper 插件,它通过拦截器自动改写 SQL 拼接 LIMIT,对业务代码无侵入。深度分页时 LIMIT offset 性能差,可以用延迟关联(先查 ID 再回表)或游标分页(WHERE id > lastId)优化。”
学习建议
- 缓存:重点理解一级缓存的失效场景,二级缓存知道为什么不用就行
- #{} vs ${}:必须能说出原理和安全区别
- 分页:PageHelper 会用就行,深度分页优化是加分点