Mybatis - 精巧的持久层框架-缓存机制的深刻理解
Mybatis缓存机制
Mybatis的缓存机制是其性能优化的核心,也是面试中的高频考点。理解它不仅能写出更高性能的代码,还能明白框架设计中对性能与数据一致性权衡的智慧。
此教程从概念到实战,从基础到企业应用,确保不仅能看懂,更能跟着动手实践,彻底掌握它。
Mybatis缓存机制深度解析与实战
引子:为什么需要缓存?
想象一下,你每次去图书馆借同一本《Java编程思想》,都得重新在前台办理一遍完整的借书手续。这显然效率低下。如果前台有个小架子,放着最近常被借阅的书,你来了直接拿走,效率是不是就高多了?
在数据库交互中,缓存(Cache) 就是这个“小架子”。它是一块内存区域,用于存储那些已经被查询过的数据。当下次再需要同样的数据时,程序可以直接从缓存中获取,而不必再次访问慢速的数据库,从而大幅提升应用性能。
Mybatis内置了两种缓存:一级缓存和二级缓存。
第一部分:一级缓存 (SqlSession级别)
1. 概念解析
-
别名:本地缓存 (Local Cache)。
-
作用域 (Scope):它的生命周期与
SqlSession
完全绑定。也就是说,每个SqlSession
对象都有自己独立的一级缓存。当SqlSession
被创建时,它的一级缓存就诞生了;当SqlSession
被关闭时,它的一级缓存也随之销毁。 -
工作状态:默认开启,无法关闭。这是Mybatis的内置特性。
-
工作原理(核心):
- 在一个
SqlSession
中,当你第一次执行某个查询时,Mybatis会从数据库获取数据,并将这份数据存入当前SqlSession
的一级缓存中。 - 在该
SqlSession
未关闭且未执行任何增删改操作的情况下,你再次执行完全相同的查询(SQL语句、参数都一样),Mybatis会直接从一级缓存中返回数据,而不会再次访问数据库。
- 在一个
-
缓存失效的场景:
SqlSession
被关闭 (session.close()
)。- 在当前
SqlSession
中执行了任何增、删、改(DML)操作 (insert
,update
,delete
)。因为这可能导致缓存中的数据与数据库不一致(“脏数据”),所以Mybatis会清空缓存以保证数据准确性。 - 手动调用
session.clearCache()
方法。
2. 动手实践:验证一级缓存
项目结构准备:我们将使用一个标准的Maven项目结构。
mybatis-cache-demo/
├── pom.xml
└── src/
├── main/
│ ├── java/
│ │ └── com/
│ │ └── example/
│ │ ├── entity/
│ │ │ └── User.java // 用户实体类
│ │ ├── mapper/
│ │ │ └── UserMapper.java // Mapper接口
│ │ └── test/
│ │ └── L1CacheTest.java // 我们的一级缓存测试类
│ └── resources/
│ ├── mappers/
│ │ └── UserMapper.xml // SQL映射文件
│ └── mybatis-config.xml // Mybatis全局配置
└── test/
└── ... (我们这里为了方便,测试类也放在main下)
准备代码
-
pom.xml
(依赖)<dependencies> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.9</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.24</version> <scope>provided</scope> </dependency> </dependencies>
-
mybatis-config.xml
(全局配置)<!-- src/main/resources/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> <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/your_db?useSSL=false&serverTimezone=UTC"/> <property name="username" value="root"/> <property name="password" value="your_password"/> </dataSource> </environment> </environments> <mappers> <mapper resource="mappers/UserMapper.xml"/> </mappers> </configuration>
-
User.java
(实体类)// src/main/java/com/example/entity/User.java package com.example.entity; import lombok.Data; import lombok.ToString; @Data // 使用Lombok简化代码 public class User { private Integer id; private String username; private String password; // 我们特意添加一个构造函数,方便观察对象是否被重新创建 public User() { System.out.println("User对象被创建了!(A new User object was created!)"); } }
-
UserMapper.java
和UserMapper.xml
// src/main/java/com/example/mapper/UserMapper.java package com.example.mapper; import com.example.entity.User; public interface UserMapper { User findById(Integer id); int updateUsername(User user); }
<!-- src/main/resources/mappers/UserMapper.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.example.mapper.UserMapper"> <select id="findById" resultType="com.example.entity.User"> SELECT * FROM user WHERE id = #{id} </select> <update id="updateUsername"> UPDATE user SET username = #{username} WHERE id = #{id} </update> </mapper>
-
L1CacheTest.java
(核心测试代码)// src/main/java/com/example/test/L1CacheTest.java package com.example.test; import com.example.entity.User; import com.example.mapper.UserMapper; 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.IOException; import java.io.InputStream; public class L1CacheTest { public static void main(String[] args) throws IOException { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); // 使用同一个SqlSession try (SqlSession session = sqlSessionFactory.openSession(true)) { UserMapper mapper = session.getMapper(UserMapper.class); System.out.println("--- 场景1:验证一级缓存的存在 ---"); System.out.println("第一次查询ID为1的用户..."); User user1 = mapper.findById(1); System.out.println(user1); System.out.println("\n第二次查询ID为1的用户 (在同一个session中)..."); User user2 = mapper.findById(1); System.out.println(user2); System.out.println("user1 == user2 ? " + (user1 == user2)); // 验证是否是同一个对象 System.out.println("\n--- 场景2:验证DML操作会清空一级缓存 ---"); System.out.println("执行更新操作..."); user1.setUsername("admin_updated"); mapper.updateUsername(user1); System.out.println("\n更新后,再次查询ID为1的用户..."); User user3 = mapper.findById(1); System.out.println(user3); System.out.println("user1 == user3 ? " + (user1 == user3)); } } }
预期输出与分析:
--- 场景1:验证一级缓存的存在 ---
第一次查询ID为1的用户...
User对象被创建了!(A new User object was created!) <-- 第一次查询,创建了对象
User(id=1, username=admin, password=...)
第二次查询ID为1的用户 (在同一个session中)...
User(id=1, username=admin, password=...) <-- 第二次查询,没有打印“User对象被创建了”
user1 == user2 ? true <-- 证明了第二次是从缓存中拿的同一个对象!
--- 场景2:验证DML操作会清空一级缓存 ---
执行更新操作...
更新后,再次查询ID为1的用户...
User对象被创建了!(A new User object was created!) <-- DML后,缓存失效,重新查询数据库,创建了新对象
User(id=1, username=admin_updated, password=...)
user1 == user3 ? false <-- 证明了缓存被清空,拿到了新的对象
3. 企业级思考
一级缓存非常有用,它能有效减少单个业务逻辑单元(例如一个Service方法内部)的数据库查询次数。但在典型的Web应用中,每个用户请求通常会创建一个新的SqlSession
,执行完后就关闭。这意味着一级缓存无法跨请求共享数据。为了解决这个问题,二级缓存应运而生。
第二部分:二级缓存 (SqlSessionFactory级别)
1. 概念解析
- 别名:全局缓存 (Global Cache)。
- 作用域 (Scope):它的生命周期与
SqlSessionFactory
绑定,或者说它是在Mapper的命名空间Namespace级别共享的。这意味着,所有SqlSession
都可以共享同一个Mapper的二级缓存。 - 工作状态:默认关闭,需要手动开启。
- 工作原理(核心):
- 当一个
SqlSession
执行完查询并提交/关闭 (commit
/close
)后,它的一级缓存中的数据会被转移到对应Mapper的二级缓存中。 - 另一个新的
SqlSession
来执行相同的查询时,它会先去二级缓存中查找数据。 - 如果找到了,就直接返回数据;如果没找到,再走“查询数据库 -> 放入自己的一级缓存”的老路。
- 当一个
- 开启二级缓存的三个步骤(缺一不可):
- 在
mybatis-config.xml
中开启全局缓存开关。 - 在需要缓存的
Mapper.xml
文件中添加<cache/>
标签。 - 需要被缓存的实体类(POJO)必须实现
java.io.Serializable
接口。因为二级缓存可能将对象存储在硬盘或通过网络传输,这需要序列化。
- 在
2. 动手实践:开启并验证二级缓存
修改代码 (在之前的基础上)
-
修改
mybatis-config.xml
:<configuration> <!-- 开启全局缓存开关 --> <settings> <setting name="cacheEnabled" value="true"/> </settings> <!-- 其他配置... --> </configuration>
-
修改
UserMapper.xml
:<mapper namespace="com.example.mapper.UserMapper"> <!-- 开启当前Mapper的二级缓存 --> <cache></cache> <!-- 其他SQL... --> </mapper>
-
修改
User.java
:// src/main/java/com/example/entity/User.java import java.io.Serializable; // 引入接口 @Data public class User implements Serializable { // 实现Serializable接口 // ... 内容不变 }
-
创建
L2CacheTest.java
(新的测试类):// src/main/java/com/example/test/L2CacheTest.java package com.example.test; import com.example.entity.User; import com.example.mapper.UserMapper; 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.IOException; import java.io.InputStream; public class L2CacheTest { public static void main(String[] args) throws IOException { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); System.out.println("--- 验证二级缓存 ---"); User user1 = null; // 第一个 session try (SqlSession session1 = sqlSessionFactory.openSession(true)) { UserMapper mapper1 = session1.getMapper(UserMapper.class); System.out.println("Session 1: 第一次查询..."); user1 = mapper1.findById(1); System.out.println(user1); } // session1关闭时,数据会从它的一级缓存刷新到二级缓存 System.out.println("\nSession 1 已关闭。\n"); User user2 = null; // 第二个 session try (SqlSession session2 = sqlSessionFactory.openSession(true)) { UserMapper mapper2 = session2.getMapper(UserMapper.class); System.out.println("Session 2: 再次查询相同数据..."); user2 = mapper2.findById(1); System.out.println(user2); } System.out.println("\nuser1.equals(user2) ? " + user1.equals(user2)); System.out.println("user1 == user2 ? " + (user1 == user2)); } }
预期输出与分析:
--- 验证二级缓存 ---
Session 1: 第一次查询...
User对象被创建了!(A new User object was created!) <-- 第一个session查询,创建对象
User(id=1, username=admin_updated, password=...)
Session 1 已关闭。
Session 2: 再次查询相同数据...
User(id=1, username=admin_updated, password=...) <-- 第二个session查询,没有打印“User对象被创建了”
<-- 这证明了数据来自缓存,而不是数据库!
user1.equals(user2) ? true <-- 内容相同
user1 == user2 ? false <-- 但对象不同!因为二级缓存返回的是序列化后再反序列化的副本,不是原对象。
这个false
的结果是理解二级缓存的关键,它与一级缓存的true
形成鲜明对比。
3. 企业级应用与思考
-
适用场景:二级缓存非常适合读多写少、数据不常变化的场景。
- 绝佳例子:系统配置表、国家/地区/省份代码表、商品分类信息、用户角色权限。这些数据被频繁读取,但很少修改。为它们开启二级缓存能极大地提升性能。
- 不适用例子:商品库存、用户余额、订单状态。这些数据变化频繁,如果使用缓存,很容易出现数据不一致的问题。
-
缓存击穿与第三方缓存:Mybatis自带的二级缓存功能相对基础。在大型分布式系统中,为了解决缓存击穿、雪崩等问题,以及实现更精细的缓存控制(如设置过期时间),企业通常会整合专业的第三方缓存框架,如 Redis 或 Ehcache。
- 企业实践:在
Mapper.xml
的<cache>
标签中,可以通过type
属性指定使用Redis作为二级缓存的实现。这样做的好处是,缓存由独立的Redis服务管理,可以被多个应用实例共享,并且应用重启后缓存依然存在。
- 企业实践:在
总结与对比
特性 | 一级缓存 (L1) | 二级缓存 (L2) |
---|---|---|
作用域 | SqlSession |
SqlSessionFactory (或Mapper Namespace) |
生命周期 | 与SqlSession 共存亡 |
与应用共存亡 |
默认状态 | 默认开启,无法关闭 | 默认关闭,需手动开启 |
共享性 | 不共享,SqlSession 之间隔离 |
所有SqlSession 共享 |
数据一致性 | 强,DML操作自动清空 | 弱,依赖于配置和DML刷新 |
对象引用 | 返回同一个对象 (== 为true ) |
返回对象的副本 (反序列化,== 为false ) |
核心用途 | 优化单个业务流程内的重复查询 | 优化跨业务、跨请求的全局热点数据查询 |
核心记忆点:缓存是性能和数据一致性之间的一种权衡。 一级缓存牺牲了小部分内存,换取了单个会话内的性能提升,且能保证强一致性。二级缓存牺牲了更强的实时一致性,换取了全局范围的巨大性能提升。理解这个核心思想,你就真正掌握了Mybatis的缓存机制。