一级缓存
导学
本节学习目标:
- 理解一级缓存的作用域与生命周期
- 掌握一级缓存的命中规则与缓存Key构成
- 学会通过日志验证缓存命中与失效行为
- 规避多SqlSession场景下的一级缓存误用
定义
一级缓存(Local Cache)是MyBatis在SqlSession级别维护的本地内存缓存,默认自动开启,无需任何额外配置。它解决的核心性能痛点是:在同一个数据库会话中,重复执行完全相同的查询时避免多次访问数据库,从而降低网络开销与数据库压力。
例如,在一次业务处理中,先查询学生信息做校验,随后同一流程中再次查询同一条记录,一级缓存可直接返回内存中的结果,无需再次执行SQL。
核心原理
作用域与生命周期
一级缓存的作用域严格绑定在SqlSession实例上:
- 创建:
SqlSession被SqlSessionFactory.openSession()创建时,一级缓存随之初始化 - 生效:仅在当前
SqlSession内部生效,对外不可见 - 清空:执行
sqlSession.clearCache()、执行insert/update/delete语句后自动清空,或SqlSession关闭时销毁
缓存Key的构成
MyBatis通过CacheKey对象判断两次查询是否相同。Key由以下要素组合生成:
| 组成要素 | 说明 |
|---|---|
| MappedStatement ID | Mapper命名空间 + 语句ID,如com.fly.mapper.StudentMapper.selectById |
| RowBounds偏移量 | offset值 |
| RowBounds限制数 | limit值 |
| SQL语句 | 最终生成的带?占位符的SQL |
| 参数值 | 所有传入参数的实际值 |
只要上述任一要素不同,即视为不同查询,不会命中缓存。
查询时序
从时序图可见,第二次查询完全绕过了数据库,直接从内存获取结果。
完整示例
场景说明
乐途公司人事系统需要查询员工信息。在同一个业务流程中,先查询员工做权限校验,再查询同一条记录展示详情。利用一级缓存,第二次查询不应再发送SQL。
操作前的数据库表结构及初始数据
CREATE TABLE student (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20),
age INT,
major VARCHAR(20),
score DECIMAL(5,2)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO student (name, age, major, score) VALUES
('大翔', 22, '计算机科学', 95.5),
('白歌', 21, '软件工程', 88.0),
('小崔', 20, '计算机科学', 92.0),
('黄俪', 21, '信息安全', 90.5),
('李眉', 22, '软件工程', 87.0);
当前数据状态:
| id | name | age | major | score |
|---|---|---|---|---|
| 1 | 大翔 | 22 | 计算机科学 | 95.50 |
| 2 | 白歌 | 21 | 软件工程 | 88.00 |
| 3 | 小崔 | 20 | 计算机科学 | 92.00 |
| 4 | 黄俪 | 21 | 信息安全 | 90.50 |
| 5 | 李眉 | 22 | 软件工程 | 87.00 |
完整代码与配置
mybatis-config.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<settings>
<!-- 开启日志,观察SQL执行情况 -->
<setting name="logImpl" value="SLF4J"/>
<!-- 一级缓存默认开启,此处显式声明 -->
<setting name="localCacheScope" value="SESSION"/>
</settings>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/fly_db?useSSL=false&serverTimezone=UTC"/>
<property name="username" value="root"/>
<property name="password" value="123456"/>
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="com/fly/mapper/StudentMapper.xml"/>
</mappers>
</configuration>
StudentMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.fly.mapper.StudentMapper">
<select id="selectById" resultType="com.fly.entity.Student">
SELECT id, name, age, major, score
FROM student
WHERE id = #{id}
</select>
<update id="updateScoreById">
UPDATE student
SET score = #{score}
WHERE id = #{id}
</update>
</mapper>
Student.java
package com.fly.entity;
public class Student {
private Integer id;
private String name;
private Integer age;
private String major;
private Double score;
// Getter与Setter省略
}
FirstCacheDemo.java
package com.fly.demo;
import com.fly.entity.Student;
import com.fly.mapper.StudentMapper;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import java.io.InputStream;
public class FirstCacheDemo {
public static void main(String[] args) throws Exception {
String resource = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
// 同一个SqlSession
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
StudentMapper mapper = sqlSession.getMapper(StudentMapper.class);
System.out.println("=== 第一次查询 id=1 ===");
Student s1 = mapper.selectById(1);
System.out.println("结果:" + s1.getName() + ",分数:" + s1.getScore());
System.out.println("\n=== 第二次查询 id=1 ===");
Student s2 = mapper.selectById(1);
System.out.println("结果:" + s2.getName() + ",分数:" + s2.getScore());
System.out.println("\n=== 两次结果是否为同一对象 ===");
System.out.println(s1 == s2); // 应为true
System.out.println("\n=== 执行更新操作 ===");
mapper.updateScoreById(1, 99.0);
System.out.println("\n=== 第三次查询 id=1(缓存已清空)===");
Student s3 = mapper.selectById(1);
System.out.println("结果:" + s3.getName() + ",分数:" + s3.getScore());
System.out.println("\n=== s1与s3是否为同一对象 ===");
System.out.println(s1 == s3); // 应为false
}
}
}
实际执行结果
控制台SQL日志输出(使用SLF4J + Logback):
=== 第一次查询 id=1 ===
[com.fly.mapper.StudentMapper.selectById] - ==> Preparing: SELECT id, name, age, major, score FROM student WHERE id = ?
[com.fly.mapper.StudentMapper.selectById] - ==> Parameters: 1(Integer)
[com.fly.mapper.StudentMapper.selectById] - <== Total: 1
结果:大翔,分数:95.5
=== 第二次查询 id=1 ===
结果:大翔,分数:95.5
=== 两次结果是否为同一对象 ===
true
=== 执行更新操作 ===
[com.fly.mapper.StudentMapper.updateScoreById] - ==> Preparing: UPDATE student SET score = ? WHERE id = ?
[com.fly.mapper.StudentMapper.updateScoreById] - ==> Parameters: 99.0(Double), 1(Integer)
[com.fly.mapper.StudentMapper.updateScoreById] - <== Updates: 1
=== 第三次查询 id=1(缓存已清空)===
[com.fly.mapper.StudentMapper.selectById] - ==> Preparing: SELECT id, name, age, major, score FROM student WHERE id = ?
[com.fly.mapper.StudentMapper.selectById] - ==> Parameters: 1(Integer)
[com.fly.mapper.StudentMapper.selectById] - <== Total: 1
结果:大翔,分数:99.0
=== s1与s3是否为同一对象 ===
false
分析
- 第一次查询:发送SQL到数据库,返回大翔的信息,并将结果存入一级缓存
- 第二次查询:日志中无SQL输出,直接从一级缓存获取,且
s1 == s2为true,说明返回的是同一个对象引用 - 执行update后:MyBatis自动清空当前SqlSession的一级缓存,保证数据一致性
- 第三次查询:重新发送SQL,获取更新后的分数99.0,且
s1 == s3为false,说明是新的对象实例
易错场景与常见误区
| 误区 | 正解 |
|---|---|
| 认为一级缓存在多个SqlSession之间共享 | 一级缓存仅在当前SqlSession内有效,跨SqlSession必然查库 |
| 手动修改缓存返回的对象属性不会影响后续读取 | 一级缓存返回同一对象引用,修改后再次读取会得到修改后的值(脏读风险) |
| 查询后只要不关闭SqlSession,缓存永远有效 | 执行任何insert/update/delete都会自动清空一级缓存 |
| 关闭SqlSession后重新打开,缓存还在 | SqlSession关闭即销毁一级缓存,新SqlSession没有任何历史缓存 |
认为localCacheScope=STATEMENT可以保留一级缓存 | STATEMENT表示每条语句执行后都清空缓存,一级缓存几乎失效 |
反例:跨SqlSession不共享一级缓存
// 反例演示:两个不同SqlSession查询同一数据
SqlSession session1 = sqlSessionFactory.openSession();
SqlSession session2 = sqlSessionFactory.openSession();
try {
StudentMapper mapper1 = session1.getMapper(StudentMapper.class);
StudentMapper mapper2 = session2.getMapper(StudentMapper.class);
// session1 第一次查询
mapper1.selectById(1); // 发送SQL
// session2 查询同一数据
mapper2.selectById(1); // 仍然发送SQL!一级缓存不共享
} finally {
session1.close();
session2.close();
}
面试考点
Q1:MyBatis一级缓存的作用域是什么?默认是否开启?
一级缓存作用域是SqlSession级别,默认自动开启,无需配置。生命周期与SqlSession绑定,随其创建而创建,随其关闭或执行增删改操作而清空。
Q2:一级缓存的Key由哪些因素决定?
由MappedStatement ID、RowBounds的offset和limit、SQL语句(含占位符)、以及所有参数值共同组成。任一要素不同,Key就不同,不会命中缓存。
Q3:为什么执行insert/update/delete后会清空一级缓存?
因为增删改操作可能改变数据库状态,MyBatis为了保证缓存数据与数据库一致,会自动清空当前SqlSession的一级缓存,防止脏读。
Q4:一级缓存返回的对象是副本还是同一引用?有什么风险?
默认返回同一对象引用(
readOnly语义由一级缓存内部实现决定)。如果业务代码修改了返回对象的属性,再次查询会从缓存拿到已被修改的对象,造成逻辑错误。建议不要直接修改Mapper返回的实体对象。
小结
一级缓存是MyBatis最基础的性能优化机制,它在SqlSession级别自动维护查询结果,无需任何配置即可生效。其核心特点是:同Session、同查询、命中内存。但开发者必须清醒认识其作用域边界——跨SqlSession不共享、增删改后自动清空、返回同一对象引用。合理利用一级缓存,可以在单会话的多层业务逻辑中显著减少重复查询。
下一章引子
一级缓存解决了单会话内的重复查询问题,但如果多个SqlSession需要共享缓存数据,一级缓存就无能为力了。MyBatis提供了二级缓存,将缓存作用域提升到Mapper命名空间级别,实现跨SqlSession的全局缓存共享。下一节将深入讲解二级缓存的开启方式、查询顺序以及与一级缓存的协作关系。