MyBatis的缓存机制

查询缓存

MyBatis 支持查询缓存,将查询结果临时存储在内存中,避免重复执行相同 SQL 导致的数据库往返开销,用于减轻数据压力,提高数据库性能。

MyBatis 的查询缓存分为一级缓存和二级缓存,两者的大致关系如下

img

日常开发中,80% 的数据库操作是查询,其中大量查询是重复且无状态变化的。这时候缓存就能:

  • 减少数据库 IO 次数,降低数据库压力;
  • 提升查询响应速度

而且 MyBatis 的缓存和 Hibernate 的不一样,它的代码层缓存逻辑更简单,因为 MyBatis 内置缓存,无需手动写 Map 缓存,很容易加上 Spring Cache 和 Redis 形成一个二级缓存

SqlSession

SqlSession 是 MyBatis 中最核心的接口之一,很明显,MyBatis 通过这个接口与数据库建立连接的 Session 会话

所有对数据库的操作都必须通过 SqlSession 来执行,底层封装了 JDBC 的 Connection,同时整合了缓存、事务、Mapper 映射等核心能力。

MyBatis 中,SqlSession 替你封装了这些底层操作,你只需通过 SqlSession 获取 Mapper 接口,或直接调用 API 执行 SQL,无需关心底层连接的创建和资源释放。

image-20260322180324946

尽是些这种方法

而 SqlSession 是轻量级对象,生命周期应尽可能短,一般来说,一个方法对应一个 SqlSession,绝对不能做成单例,否则会导致连接泄漏、缓存脏数据、线程安全问题。

而对于 SqlSession 原生 MyBatis 可以通过 XML 配置文件生命创建,但是 SpringBoot 整合后,SpringBoot 会自动管理 SqlSession 的生命周期,DI 会在容器启动时创建 SqlSessionFactory,每次 Mapper 方法调用时,Spring 会自动创建 和获取 SqlSession,绑定到当前线程,方法执行完成后,Spring 自动提交或回滚事务,并关闭 SqlSession。

所以说,我们使用框架开发,IoC 会让我们无需接触 SqlSession

SqlSession 是非线程安全的

  • 原因:SqlSession 内部持有 Connection、一级缓存等状态变量,多线程并发访问会导致数据错乱、连接泄漏;
  • 正确做法:每个线程独立创建 / 使用 SqlSession,用完即关(SpringBoot 已自动处理);

为什么注入 Mapper 接口就能执行 SQL?这其实涉及到 MyBatis 的动态代理

  1. 调用 sqlSession.getMapper(UserMapper.class) 时,MyBatis 会为 UserMapper 生成一个动态代理对象;
  2. 调用代理对象的方法时,会解析方法对应的 SQL ID(namespace + 方法名);
  3. 底层通过 SqlSession 执行对应的 SQL,并处理参数绑定、结果映射。

那么,Mapper 接口是 SqlSession 执行 SQL 的语法糖,这么说,也没什么问题

一级缓存

一级缓存是 SqlSession 级别的缓存。在操作数据库时需要构造 SqlSession 对象,在对象中有一个内存区域,用于存储缓存数据。不同的sqlSession之间的缓存数据区域(HashMap)是互相不影响的

一级缓存的作用域是同一个 SqlSession,在同一个 SqlSession 中两次执行相同的 Sql 语句,第一次执行完毕会将数据库中查询的数据写到内存缓存中,第二次会从缓存中获取数据将不再从数据库查询,而是从缓存拿,从而提高查询效率。

当一个 SqlSession 结束后该 SqlSession 中的一级缓存也就不存在了。Mybatis默认开启一级缓存。

一级缓存只是相对于同一个 SqlSession 而言。所以在参数和 SQL 完全一样的情况下,我们使用同一个 SqlSession 对象调用一个 Mapper 方法,往往只执行一次 SQL。因为使用 SqlSession 完成了第一次查询后,MyBatis 会将其放在缓存中,以后再查询的时候,如果没有声明需要刷新,并且缓存没有超时的情况下,SqlSession 都会取出当前缓存的数据,而不会再次发送 SQL 到数据库。

那么,一级缓存的生命周期有多长?

MyBatis 在开启一个数据库会话时,会创建一个新的 SqlSession 对象,SqlSession 对象中会有一个新的 Executor 对象。Executor 对象中持有一个新的 PerpetualCache 对象;当会话结束时,SqlSession 对象及其内部的 Executor 对象还有 PerpetualCache 对象也一并释放掉。

image-20260322181059819

如果 SqlSession 调用了 close() 方法关闭了这个数据库 Session,MyBatis 会释放掉一级缓存 PerpetualCache 对象,一级缓存将不可用。但是如果 SqlSession 调用了clearCache(),会清空 PerpetualCache 对象中的数据,但是该对象仍可使用。

而 SqlSession 中执行了任何一个update操作update()、delete()、insert())等,都会清空 PerpetualCache 对象的数据,但是该对象可以继续使用。

怎么判断某两次查询是完全相同的查询?

MyBatis认为,对于两次查询,如果以下条件都完全一样,那么就认为它们是完全相同的两次查询。

  • 传入的 StatementId
  • 这次查询所产生的最终要传递给 JDBC 的 Sql 语句字符串
  • 传递给 java.sql.Statement 要设置的参数值
  • 查询时要求的结果集中的结果范围

而一级缓存返回的是 同一个对象的引用,因此在同一个 SqlSession 内,你操作的都是同一个 Java 对象。所以说,一级缓存无法在多个应用服务器之间共享,也就是分布式环境下是用不了的,因为它绑定在单个请求的 SqlSession 上。

img

二级缓存

MyBatis 的二级缓存是 Mapper 级别的缓存,它也可以提高对数据库查询的效率,以提高应用的性能。

MyBatis的缓存机制整体设计以及二级缓存的工作模式如下

img

至于为何是 Mapper 级别的缓存,是因为,多个 SqlSession 去操作同一个 Mapper 的 sql 语句的时候,多个 SqlSession 去操作数据库得到数据会存在二级缓存区域,多个 SqlSession 在 Mapper 级别下可以共用二级缓存,所以说二级缓存是跨 SqlSession 的。

二级缓存是多个 SqlSession 共享的,其作用域是 Mapper 的同一个 namespace,不同的 SqlSession 两次执行相同 namespace 下的 sql 语句且向 sql 中传递参数也相同的,即最终执行相同的 sql 语句下,第一次执行完毕会将数据库中查询的数据写到二级缓存中,第二次会从缓存中获取数据,不再从数据库查询,从而提高查询效率。

1
2
3
4
5
6
7
8
9
10
11
SqlSession1 执行 UserMapper.selectUserById(1) → 
① 查 SqlSession1 一级缓存 → 无 →
② 查 UserMapper 二级缓存 → 无 →
③ 执行 SQL 查数据库 → 返回结果 →
④ 结果存入 SqlSession1 一级缓存 →
⑤ SqlSession1 提交/关闭 → 一级缓存数据刷入 UserMapper 二级缓存 →

SqlSession2 执行 UserMapper.selectUserById(1) →
① 查 SqlSession2 一级缓存 → 无 →
② 查 UserMapper 二级缓存 → 有 →
③ 直接返回缓存结果(不查数据库)

只有 SqlSession 提交 或 关闭后,一级缓存数据才会刷入二级缓存

Mybatis 默认没有开启二级缓存,需要在 setting 全局参数中配置开启二级缓存。二级缓存的开启需要进行配置,实现二级缓存的时候,MyBatis 要求返回的 POJO 必须是可序列化的。 也就是要求实现 Serializable 接口

配置方法很简单,只需要在映射XML文件配置就可以开启缓存了<cache/>

1
2
3
4
5
<!-- 原生MyBatis配置文件 mybatis-config.xml -->
<settings>
<!-- 开启二级缓存全局开关(默认false) -->
<setting name="cacheEnabled" value="true"/>
</settings>
1
2
3
4
# SpringBoot 配置 application.yml
mybatis:
configuration:
cache-enabled: true # 全局开启二级缓存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 单个 Mapper 开启二级缓存 -->
<!-- UserMapper.xml(namespace 对应 UserMapper 接口) -->
<?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">

<!-- namespace 是二级缓存的作用域标识,相同namespace共享缓存 -->
<mapper namespace="com.example.mapper.UserMapper">
<!-- 开启当前Mapper的二级缓存(核心配置) -->
<cache/>

<!-- 查询语句默认使用二级缓存(useCache="true" 可省略) -->
<select id="selectUserById" resultType="com.example.entity.User">
SELECT id, name, age FROM user WHERE id = #{id}
</select>
</mapper>

如果我们配置了二级缓存就意味着:

  • 所有 SELECT 语句自动缓存,是<cache/> 配置后,当前 Mapper 下所有 select 默认 useCache="true";若某条 select 不想缓存,可配置:<select useCache="false">
  • 增删改刷新缓存:MyBatis 中增删改默认 flushCache="true",执行后会清空当前 Mapper 的二级缓存,避免脏数据
  • 缓存会使用默认的 LRU 算法来收回:缓存容量默认 1024 个对象引用
  • 缓存会存储列表集合或对象的1024个引用:可自定义:<cache size="2048">
  • 缓存会被视为是read/write(可读/可写)的缓存:缓存返回的是对象的拷贝,而非引用,调用者修改对象不会影响缓存中的数据;若配置 readOnly="true",返回只读副本(性能更高,但不能修改)

但二级缓存并非万能

  • 不要在多表关联查询中使用二级缓存

    1
    2
    3
    4
    5
    6
    <!-- OrderMapper.xml 中关联查询用户和订单 -->
    <select id="selectOrderWithUser" resultMap="OrderUserMap">
    SELECT o.id, o.order_no, u.name
    FROM order o LEFT JOIN user u ON o.user_id = u.id
    WHERE o.id = #{id}
    </select>

    此时缓存的是 订单 + 用户 的结果,但如果 UserMapper 执行了 update 操作,而 OrderMapper 的二级缓存不会感知,会返回脏数据,因为二级缓存按 namespace 隔离。

  • SpringBoot 中 SqlSession 由 Spring 管理,默认情况是 每个 Mapper 方法调用对应一个 SqlSession,因此:

    • 同个 Service 方法内多次调用同一 Mapper 查询,先走一级缓存,方法结束后才刷入二级缓存;
    • 跨 Service 方法调用,才会真正命中二级缓存。

MyBatis 的缓存工作原理

一级缓存工作原理

下图是根据 id 查询用户的一级缓存图解

img

MyBatis 一级缓存,二级缓存都是本地缓存,是其最基础、默认开启且无需额外配置的缓存机制

它的基础属性我们上面已经介绍了

  • 作用域:SqlSession 级别
  • 存储介质:内存,基于 HashMap 实现,键值对结构
  • 默认状态:强制开启,无法通过配置关闭
  • 命中条件:只有当 Key 完全一致时,一级缓存才会命中
  • 核心价值:避免同一 SqlSession 内重复执行相同 SQL,减少数据库 IO 开销

MyBatis 一级缓存的实现逻辑集中在 org.apache.ibatis.session.defaults.DefaultSqlSessionorg.apache.ibatis.cache.impl.PerpetualCache 两个核心类中

每个 SqlSession 内部持有一个 Executor,这个Executor并非线程池的那个接口,这个的意思是执行器,而 Executor 中又包含一个 PerpetualCache 实例,它是一级缓存的具体实现

image-20260322193057569

MyBatis 的 Executor(执行器)有两个核心实现:SimpleExecutor(简单执行器)、ReuseExecutor(复用执行器)

image-20260322193247331
image-20260322193354160

PerpetualCache可以看到是基于 HashMap 实现的键值对结构

image-20260322192942784

DefaultSqlSessionselectList 源码看缓存触发逻辑

image-20260322193546388
image-20260322193648167

DefaultSqlSession 源码看缓存清空的触发点

增删改操作触发清空

image-20260322193741423

然后,Executor 中的一级缓存会被PerpetualCache.clear()清空,避免增删改后缓存与数据库数据不一致。

image-20260322193935509

二级缓存的工作原理

二级缓存的工作图如下

img

二级缓存虽然是本地缓存,但是缓存的 本地 是指单个应用实例(JVM 进程),若部署多实例也就是进行分布式,二级缓存无法跨进程共享;

而且二级缓存可通过扩展 MyBatis 缓存接口,接入 Redis 去做分布式缓存,此时二级缓存不再是本地缓存,但是这是扩展能力

一般情况下我们禁用 MyBatis 二级缓存

核心业务缓存用 Spring Cache + Redis,去对值得缓存的数据做业务层结果缓存

而 EhCache 是一个纯Java的进程内缓存框架,是一种广泛使用的开源 Java 分布式缓存,具有快速、精干等特点,是 Hibernate 中默认的 CacheProvider。可以让 MyBatis 去整合 EhCache 来实现类似本地缓存的分布式缓存

MyBatis 的事务管理

MyBatis 进行的事务管理机制

MyBatis 的事务管理是保证数据库操作原子性、一致性的核心机制,它既封装了 JDBC 事务的底层逻辑,又提供了与 Spring 整合的灵活方式。

MyBatis 事务完全遵循数据库事务的 ACID 原则,这是理解事务管理的基础:

  • 原子性(Atomicity):一个事务内的所有操作要么全执行,要么全回滚;
  • 一致性(Consistency):事务执行前后,数据库数据保持合法状态(如转账后总金额不变);
  • 隔离性(Isolation):多个事务并发执行时,互不干扰(MyBatis 复用数据库的隔离级别);
  • 持久性(Durability):事务提交后,数据永久保存到数据库,不会因宕机丢失。

而 MyBatis 定义了 TransactionTransactionFactory 两个核心接口,封装不同的事务实现:

  • Transaction接口封装事务的提交、回滚、关闭等操作,比较关键的实现类有JdbcTransactionManagedTransaction

    image-20260322194741029
  • TransactionFactory,生成 Transaction 实例的工厂类,同样,比较关键的实现类有JdbcTransactionFactoryManagedTransactionFactory

    image-20260322194842242

只不过,这两者的区别还是比较明显的,至少在使用场景上

实现类 事务控制方式 适用场景
JdbcTransaction 基于 JDBC 的 Connection 控制(setAutoCommit/commit/rollback 原生 MyBatis 开发、Spring 整合(默认)
ManagedTransaction 不主动控制事务,交由容器(如 Spring)管理(仅做连接关闭) 完全依赖 Spring 事务管理

使用 JDBC 原生事务管理JdbcTransaction其实是使用 JDBC 原生的事务管理方式,通过java.sql.Connection对象来控制事务。在这种方式下,MyBatis 会从数据源获取一个Connection对象,然后由开发者手动调用Connectioncommit()方法来提交事务,调用rollback()方法来回滚事务。具体源码逻辑如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class JdbcTransaction implements Transaction {
// 提交事务
public void commit() throws SQLException {
if (connection != null && !connection.getAutoCommit()) {
if (log.isDebugEnabled()) {
log.debug("Committing JDBC Connection [" + connection + "]");
}
connection.commit();
}
}

// 回滚事务
public void rollback() throws SQLException {
if (connection != null && !connection.getAutoCommit()) {
if (log.isDebugEnabled()) {
log.debug("Rolling back JDBC Connection [" + connection + "]");
}
connection.rollback();
}
}

// 关闭事务
public void close() throws SQLException {
if (connection != null) {
resetAutoCommit();
if (log.isDebugEnabled()) {
log.debug("Closing JDBC Connection [" + connection + "]");
}
connection.close();
}
}

使用 Spring 框架的事务管理ManagedTransaction 含义为托管事务,即其内部不会对事物进行管理,而是将事务控制托管给其它框架。例如,在Mybatis整合Spring框架的项目中,通常会借助 Spring 的事务管理机制来管理事务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ManagedTransaction implements Transaction {

// 提交事务
public void commit() throws SQLException {

}

// 回滚事务
public void rollback() throws SQLException {

}

// 关闭事务
public void close() throws SQLException {
if (connection != null) {
resetAutoCommit();
if (log.isDebugEnabled()) {
log.debug("Closing JDBC Connection [" + connection + "]");
}
connection.close();
}
}

// ... 省略其他方法
}

可以看到,在ManagedTransaction中它既不会提交也不会回滚一个连接,而是将事务的整个生命周期管理工作交由容器处理,像SpringJEE 应用服务器的上下文环境就可以充当这样的容器。

而当使用 springBoot 项目中当引入mybatis依赖后其实我们无需手动配置ManagedTransaction。这是因为在springboot-start中会根据项目中配置的数据源自动创建合适的事务管理器并注册到 Spring 容器中,进而直接使用 @Transactional 注解来实现事务控制。

事实上,无论是 SqlSession,还是 Executor,它们的事务方法,最终都指向了 Transaction 的事务方法,即都是由 Transaction来完成事务提交、回滚的。

image.png

MyBatis里,虽然所有的sql执行都委托于Executor但这不意味着Executor中的执行insert ()、update () 等方法时,其会显示控制事务。换言之,在Executor中执行数据库操纵方法时,其并不会出现所谓的commit,rollback等操作。具体的事务内容需要你自己实现

关闭自动提交,但未执行Commit会发生什么

我们将SqlSession中的autoCommit 属性设定为false,即关闭SqlSession的自动提交

我们在代码中并未手动 commit而仅执行close关闭当前会话,那如果不执行提交操作,仅调用会话的关闭方法,事务内部究竟会发生什么?

MyBatis在架构设计时已充分考虑到此类情况。当执行close方法时,MyBatis 会进行一系列逻辑判断,并根据判断结果决定是否执行rollback操作。

在 MyBatis 进行事务管理

我们可以通过如下三种方式来管理事务:

  • 编程式管理事务:在代码中显式开启、提交或回滚事务。
  • 声明式管理事务:通过 AOP 代理实现事务管理,可以让代码更简洁,更容易维护。
  • 注解式管理事务:通过注解方式管理事务,是声明式管理事务的一种扩展方式。

以上三种方式,最常用的是声明式管理事务,在 MyBatis 中,声明式事务管理需要借助 Spring 框架来实现,而且 MyBatis 无专属注解式事务。对于编程式事务,几乎不使用,除非你要自定义逻辑,但是通常也会自己封装成 AOP 的形式来避免代码冗余。

注意,Spring Data JPA 是基于 Hibernate 实现的,Spring 声明式事务是 Spring 框架本身提供的事务管理能力,也就是那个 @Transactional 注解、TransactionManager 等内容,它是一个 通用的事务管理工具,可以适配各种持久层框架。

MyBatis 结合 Spring 实现声明式事务的核心逻辑是:

  1. Spring 提供事务管理的核心能力,DataSourceTransactionManager
  2. MyBatis 提供 SqlSessionFactoryMapper 等组件,与 Spring 的事务管理器整合;
  3. 你只需要在业务方法上加 @Transactional,Spring 就会接管 MyBatis 的数据库连接,实现事务的提交 / 回滚。

例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. MyBatis 的 Mapper 接口,这里图意省事没用 XML 配置文件,用的注解
@Mapper
public interface UserMapper {
@Insert("INSERT INTO user(name) VALUES(#{name})")
int insertUser(User user);
}

// 2. 业务层用 Spring 声明式事务
@Service
public class UserService {
@Autowired
private UserMapper userMapper;

// Spring 的声明式事务
@Transactional
public void addUser(String name) {
userMapper.insertUser(new User(name));
// 若抛异常,Spring 会回滚 MyBatis 的操作
if (name.isEmpty()) {
throw new RuntimeException("用户名为空");
}
}
}

手动控制事务进行编程式事务的方式大致如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Service
public class UserService {
@Autowired
private SqlSessionFactory sqlSessionFactory;

// 手动编程式事务(MyBatis 原生方式)
public void addUserManual(String name) {
// 1. 获取 SqlSession(关闭自动提交)
SqlSession sqlSession = sqlSessionFactory.openSession(false);
try {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
// 2. 执行 SQL
mapper.insertUser(new User(name));
if (name.isEmpty()) {
throw new RuntimeException("用户名为空");
}
// 3. 手动提交
sqlSession.commit();
} catch (Exception e) {
// 4. 异常回滚
sqlSession.rollback();
throw e;
} finally {
// 5. 关闭 SqlSession
sqlSession.close();
}
}
}