Mybatis(八) - 缓存

1. 一级缓存

1.1 一级缓存的使用

一级缓存的使用相当的简单,MyBatis 本身就开启一级缓存(通常我们也不会控制它关闭),所以我们可以直接拿来用。

1.1.1 简单使用

一级缓存基于 SqlSession ,所以我们可以直接创建 SqlSessionFactory ,并从中开启一个新的 SqlSession ,默认情况下它会自动开启事务,所以一级缓存会自动使用。

下面我们编写一个简单的示例,这里面我们调用两次 DepartmentMapperfindAll 方法,由于使用一级缓存,所以第二次 findAll 方法会直接使用一级缓存的数据,而不会再次向数据库发送 SQL 语句:

public class Level1Application {
    
    public static void main(String[] args) throws Exception {
        InputStream xml = Resources.getResourceAsStream("mybatis-config.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(xml);
        SqlSession sqlSession = sqlSessionFactory.openSession();
    
        DepartmentMapper departmentMapper = sqlSession.getMapper(DepartmentMapper.class);
        System.out.println("第一次执行findAll......");
        departmentMapper.findAll();
        System.out.println("第二次执行findAll......");
        departmentMapper.findAll();
    
        sqlSession.close();
    }
}

执行 main 方法,观察控制台的打印,会发现第一次执行 findAll 方法时,它会开启一个 jdbc 的连接,并且发送 SQL 语句到数据库,但第二次再调用时,它没有再次发送 SQL :(控制台没有打印,完事后直接关闭 jdbc 连接了)

第一次执行findAll......
[main] DEBUG ansaction.jdbc.JdbcTransaction  - Opening JDBC Connection 
[main] DEBUG source.pooled.PooledDataSource  - Created connection 2130772866. 
[main] DEBUG ansaction.jdbc.JdbcTransaction  - Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@7f010382] 
[main] DEBUG apper.DepartmentMapper.findAll  - ==>  Preparing: select * from tbl_department 
[main] DEBUG apper.DepartmentMapper.findAll  - ==> Parameters:  
[main] DEBUG apper.DepartmentMapper.findAll  - <==      Total: 4 
第二次执行findAll......
[main] DEBUG ansaction.jdbc.JdbcTransaction  - Resetting autocommit to true on JDBC Connection [com.mysql.jdbc.JDBC4Connection@7f010382] 
[main] DEBUG ansaction.jdbc.JdbcTransaction  - Closing JDBC Connection [com.mysql.jdbc.JDBC4Connection@7f010382] 
[main] DEBUG source.pooled.PooledDataSource  - Returned connection 2130772866 to pool.

这就是一级缓存最基本的使用。

1.1.2 清空一级缓存

前面在第 8 章中,我们讲到 statement 的定义里面,flushCache 这个属性时提到过,它可以清空一级缓存和它所属的 namespace 下的二级缓存,当清空后,再次调用 findAll 时 MyBatis 就会重新发送 SQL 到数据库执行查询了。

一个简单的示例如下:

    public static void main(String[] args) throws Exception {
        InputStream xml = Resources.getResourceAsStream("mybatis-config.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(xml);
        SqlSession sqlSession = sqlSessionFactory.openSession();
    
        DepartmentMapper departmentMapper = sqlSession.getMapper(DepartmentMapper.class);
        System.out.println("第一次执行findAll......");
        departmentMapper.findAll();
        System.out.println("第二次执行findAll......");
        departmentMapper.findAll();
        System.out.println("清空一级缓存......");
        departmentMapper.cleanCache();
        System.out.println("清空缓存后再次执行findAll......");
        departmentMapper.findAll();
    
        sqlSession.close();
    }

以此法编写的 main 方法运行结果如下:

第一次执行findAll......
[main] DEBUG apper.DepartmentMapper.findAll  - ==>  Preparing: select * from tbl_department 
[main] DEBUG apper.DepartmentMapper.findAll  - ==> Parameters:  
[main] DEBUG apper.DepartmentMapper.findAll  - <==      Total: 4 
第二次执行findAll......
清空一级缓存......
[main] DEBUG er.DepartmentMapper.cleanCache  - ==>  Preparing: select count(id) from tbl_department 
[main] DEBUG er.DepartmentMapper.cleanCache  - ==> Parameters:  
[main] DEBUG er.DepartmentMapper.cleanCache  - <==      Total: 1 
清空缓存后再次执行findAll......
[main] DEBUG apper.DepartmentMapper.findAll  - ==>  Preparing: select * from tbl_department 
[main] DEBUG apper.DepartmentMapper.findAll  - ==> Parameters:  
[main] DEBUG apper.DepartmentMapper.findAll  - <==      Total: 4

可见,清空一级缓存后,findAll 方法又重新发送 SQL 查询数据库了。

1.1.3 一级缓存失效的情景

虽说一级缓存确实很好,不过由于一些使用不当,或者意外情况,一级缓存会失效,失效的表现肯定是重复发送同样的 SQL 了。下面我们就来看看,哪些情况会导致一级缓存的失效,以及无法使用到一级缓存的情况。

1.1.3.1 跨SqlSession的一级缓存不共享

这个很好理解,一级缓存本身就是 SqlSession 级别的缓存,这些缓存只在本 SqlSession 内有效,不同的 SqlSession 一级缓存不共享。下面我们可以来验证一下。(下面的示例来自 Level1InvalidApplication

    public static void main(String[] args) throws Exception {
        InputStream xml = Resources.getResourceAsStream("mybatis-config.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(xml);
        SqlSession sqlSession = sqlSessionFactory.openSession();
        SqlSession sqlSession2 = sqlSessionFactory.openSession();
        
        // 跨SqlSession的一级缓存不共享
        DepartmentMapper departmentMapper = sqlSession.getMapper(DepartmentMapper.class);
        DepartmentMapper departmentMapper2 = sqlSession2.getMapper(DepartmentMapper.class);
        
        departmentMapper.findAll();
        departmentMapper2.findAll();
        
        sqlSession.close();
        sqlSession2.close();
    }

看,这样开启两个 SqlSession ,在执行 findAll 查询时,观察控制台的日志打印,会发现开启了两个全新的 jdbc Connection ,并且也发送了两次相同的 SQL 。

[main] DEBUG ansaction.jdbc.JdbcTransaction  - Opening JDBC Connection 
[main] DEBUG source.pooled.PooledDataSource  - Created connection 2130772866. 
[main] DEBUG ansaction.jdbc.JdbcTransaction  - Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@7f010382] 
[main] DEBUG apper.DepartmentMapper.findAll  - ==>  Preparing: select * from tbl_department 
[main] DEBUG apper.DepartmentMapper.findAll  - ==> Parameters:  
[main] DEBUG apper.DepartmentMapper.findAll  - <==      Total: 4 
[main] DEBUG ansaction.jdbc.JdbcTransaction  - Opening JDBC Connection 
[main] DEBUG source.pooled.PooledDataSource  - Created connection 1861781750. 
[main] DEBUG ansaction.jdbc.JdbcTransaction  - Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@6ef888f6] 
[main] DEBUG apper.DepartmentMapper.findAll  - ==>  Preparing: select * from tbl_department 
[main] DEBUG apper.DepartmentMapper.findAll  - ==> Parameters:  
[main] DEBUG apper.DepartmentMapper.findAll  - <==      Total: 4

1.1.3.2 两次相同的查询间有DML操作

DML 操作,也就是增删改了,我们前面学习第 8 章时都知道,insert 、update 、delete 标签的 flushCache 默认为 true ,执行它们时,必然会导致一级缓存的清空,从而引发之前的一级缓存不能继续使用。这个效果的演示,小册就不重复演示了,与上面 1.2 节的效果是完全一样的。

1.1.3.3 手动清空了一级缓存

可能有的小伙伴注意到了,SqlSession 有一个 clearCache 方法,调用它会直接清空一级缓存,非常简单有效 ~ 下面我们也来试一下效果。

DepartmentMapper departmentMapper = sqlSession.getMapper(DepartmentMapper.class);
departmentMapper.findAll();
System.out.println("重复调用findAll方法......");
departmentMapper.findAll();
System.out.println("手动清空SqlSession的缓存......");
sqlSession.clearCache();
System.out.println("清空缓存后重新调用findAll方法......");
departmentMapper.findAll();
System.out.println("--------------------------------");

这样编写好后,第二次重复调用 findAll 方法时,控制台不会发送新的 SQL 语句,但是手动清空后,再调用,控制台就可以看到 SQL 语句的打印了:

重复调用findAll方法......
手动清空SqlSession的缓存......
清空缓存后重新调用findAll方法......
[main] DEBUG apper.DepartmentMapper.findAll  - ==>  Preparing: select * from tbl_department 
[main] DEBUG apper.DepartmentMapper.findAll  - ==> Parameters:  
[main] DEBUG apper.DepartmentMapper.findAll  - <==      Total: 4 
--------------------------------

1.1.3.4 与Spring整合时没有开启事务

默认情况下我们拿到的 SqlSession 都是开启了事务的,即便是在用 SqlSessionFactory 获取 SqlSession 时,传入的参数为 true ( sqlSessionFactory.openSession(true) ,意味着不开启事务),连续查询两次 findAll 方法时一级缓存也会生效。不过可能有的小伙伴遇到过一个特殊的情况:用 SpringFramework / SpringBoot 整合 MyBatis 时,当 Service 的方法没有标注 **@Transactional** 注解,或者没有被事务增强器的通知切入时,两次查询同一条数据时,会发送两次 SQL 到数据库,这样看上去像是一级缓存失效了!这种情况出现的原因,小册在这里作一个补充性的讲解。

SpringFramework / SpringBoot 整合 MyBatis 后,Service 方法中没有开启事务时,每次调用 Mapper 查询数据时,底层都会创建一个全新的 **SqlSession** 去查数据库,而一级缓存本身就是基于 SqlSession 的,每次都开启全新的,那不就相当于上面的 1.3.1 节提到的,跨 SqlSession 的一级缓存不共享了嘛。

有关这部分原理,小伙伴们可以参照我之前写过的一篇文章理解:MyBatis的一级缓存竟然还会引来麻烦?

1.1.4 使用一级缓存要注意的

一级缓存固然好用,但小心一个比较危险的东西:一级缓存是存放到 SqlSession 中,如果我们在查询到数据后,直接在数据对象上作修改,修改之后又重新查询相同的数据,虽然此时一级缓存可以生效,但因为存放的数据其实是对象的引用,导致第二次从一级缓存中查询到的数据,就是我们刚刚改过的数据,这样可能会发生一些错误

可能这样只用文字说不好理解,我们配一段代码演示一下:

public class Level1ReferenceApplication {
    
    public static void main(String[] args) throws Exception {
        InputStream xml = Resources.getResourceAsStream("mybatis-config.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(xml);
        SqlSession sqlSession = sqlSessionFactory.openSession();
        
        DepartmentMapper departmentMapper = sqlSession.getMapper(DepartmentMapper.class);
        Department department = departmentMapper.findById("18ec781fbefd727923b0d35740b177ab");
        System.out.println("department: " + department);
        department.setName("哈哈哈哈");
        System.out.println("department: " + department);
        
        Department department2 = departmentMapper.findById("18ec781fbefd727923b0d35740b177ab");
        System.out.println("department2: " + department2);
        System.out.println(department == department2);
    }
}

看这段代码,上面先查询一次 id18ec781fbefd727923b0d35740b177ab 的部门,查询出来以后打印一下,随后将其 name 改为 "哈哈哈哈" ,修改后再次查询 id18ec781fbefd727923b0d35740b177ab 的部门,由于此时一级缓存生效,会把缓存中的数据拿出来,最后我们对比一下两个 Department 对象的引用是否一致(即判断两个对象是否为同一个)。

运行 main 方法,控制台的打印结果如下:

department: Department{id='18ec781fbefd727923b0d35740b177ab', name='开发部', tel='123'}
department: Department{id='18ec781fbefd727923b0d35740b177ab', name='哈哈哈哈', tel='123'}
department2: Department{id='18ec781fbefd727923b0d35740b177ab', name='哈哈哈哈', tel='123'}
true

危险的现象出现了:由于我们修改了第一次查询的结果,而这个结果本身就是一级缓存中存放的数据库查询结果,导致我们修改了其中的 name 属性后,第二次再查询时,取出来的数据是我们刚刚修改了的!这样就有可能引发一些不必要的麻烦和错误了。

如何避免这种情况呢?本质的目的是为了将之前的一级缓存失效掉。要么,用全新的 SqlSession ,要么,查询前清一下一级缓存。不过上面提到的那篇文章中还提到了另外一种方案:全局配置中,设置 **local-cache-scope** 属性为 **statement** ,不过这种设置的方法是针对全局了,不是很合适,所以小册在此不展开,有关 localCacheScope 配置的描述,小伙伴们可以参照 MyBatis 的文档描述(即下图)。

img

1.2 一级缓存的设计原理

掌握了一级缓存的使用以及要注意的,下面我们深入源码中,探究一下一级缓存的设计,以及在查询中如何发挥其作用的。

1.2.1 缓存模型的设计

首先我们先来了解一下 MyBatis 的缓存模型,其实缓存的本质是一个类似于 **Map** 的东西,有 key 有 value 。MyBatis 中专门设计了一个 **Cache** 接口来模仿 Map ,定义缓存最基本的增删改查方法。

1.2.1.1 Cache接口与实现类

public interface Cache {
    // 每个缓存都有id
    String getId();
    // 放缓存
    void putObject(Object key, Object value);
    // 取缓存
    Object getObject(Object key);
    // 删缓存
    Object removeObject(Object key);
    // 清缓存
    void clear();
    // 查大小
    int getSize();
}

有接口那就一定有实现,借助 IDEA ,可以发现 Cache 接口的实现类还是很多的:

img

注意观察包名!绝大多数 Cache 实现类的包名,最后有个 decorators ,这很明显是装饰者的意思呀!只有一个 PerpetualCache ,包名的最后是 impl ,那得了,这分明就是一个实现类 + 好多个装饰者的设计了。可是为什么 MyBatis 要将缓存模型设计为一堆装饰者呢?

1.2.1.2 Cache实现类的装饰者设计意义

究其根源,我们要先提一点 MyBatis 二级缓存的东西了,MyBatis 中的二级缓存本身是占用应用空间的,换句话说,MyBatis 中的二级缓存实际使用的是 JVM 的内存,那默认情况来讲,占用的内存太多不利于应用本身的正常运行,所以 MyBatis 会针对缓存的各种特性和过期策略,设计了一些能够修饰原本缓存件的装饰者,以此达到动态拼装缓存实现的目的。

这个设计到后面的设计模式章节还会再展开讲,毕竟现在我们还没有讲到二级缓存,可能小伙伴们没概念,下一章我们把二级缓存过完之后,在回过头来,可能理解起来会更容易一点。

1.2.1.3 PerpetualCache的设计

既然大部分都是装饰者,那我们先看看这个本身 Cache 接口的最初实现 PerpetualCache ,它是一个没有任何修饰的、最单纯的缓存实现:

public class PerpetualCache implements Cache {

    private final String id;

    private final Map<Object, Object> cache = new HashMap<>();

噗。。。这设计也忒不走心了吧,直接套一个 HashMap 就完事了?哎,还真就套一层就完事了!因为缓存本身就是 Map 类型的设计,直接拿现成的岂不美哉?

至于其它的嘛,现在一级缓存阶段暂时涉及不到,小册先不展开了,小伙伴们也不要在这个阶段去翻看,避免本末倒置。

1.2.2 一级缓存的设计位置

既然有了 PerpetualCache ,那它一定是组合到某个位置,从而形成一级缓存的吧!小册先不讲,小伙伴们来猜一下,这个 PerpetualCache 能放在哪里呢?

emmmmm,大概率是 SqlSession 的实现类中吧!我们翻开 SqlSession 接口的默认实现 DefaultSqlSession

public class DefaultSqlSession implements SqlSession {

    private final Configuration configuration;
    private final Executor executor;

    private final boolean autoCommit;
    private boolean dirty;
    private List<Cursor<?>> cursorList;
}

很抱歉 ~ 这里并没有缓存的设计呢 ~ (手动狗头)

哎,但是小伙伴们不要急,观察一下这几个成员,你觉得 PerpetualCache 最有可能放在这里面的谁里头呢?

连想都不用想,肯定是 ExecutorConfiguration 本身是全局的配置,不适合放 SqlSession 实例相关的东西,下面 3 个很明显都放不下,那只剩下 Executor 了。OK ,下面我们进去看看 Executor 吧:

public interface Executor { ... }

呃。。。又是接口。。。这让我咋找呢?诶,别急,我们继续往下看实现类就是啦!借助 IDEA ,可以发现 Executor 有如下几个实现类:

img

这看类名,第一直觉肯定是 CachingExecutor 吧!这类名上都明摆着写着缓存了!我们赶紧点进去看看:

public class CachingExecutor implements Executor {

    private final Executor delegate;
    private final TransactionalCacheManager tcm = new TransactionalCacheManager();
}

emmmmmm。。。搁这逗我呢?这里面咋是套了一层代理呢?到底真正干活的是谁?

好吧,其实最底层的还是要看 BaseExecutor ,这个类名的设计比较类似于 AbstractXXX ,它本身也是一个抽象类,是它里面组合了 PerpetualCache

public abstract class BaseExecutor implements Executor {

    private static final Log log = LogFactory.getLog(BaseExecutor.class);

    protected Transaction transaction;
    protected Executor wrapper;

    protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads;
    // 【!!!看这里!!!】
    protected PerpetualCache localCache;
    protected PerpetualCache localOutputParameterCache;
    protected Configuration configuration;
}

可能小伙伴读到这里会有一些许的不爽,“明明一开始直接告诉我不就得了?为什么非要跟耍猴似的折腾我呢?”

小伙伴们不要生气不要着急,阿熊我本身一开始翻这部分源码的时候,就是这个心路历程,很多小伙伴想知道我平时读源码的思路,觉得我在小册里没有讲解很多,所以阿熊也在想着如何能把我的一些思路通过比较容易接受的方式,跟小伙伴们分享一下。思来想去,这样似乎是不错的,于是乎这里就设计了这样的一个你们可能感觉有些 “耍猴” 的环节,希望小伙伴们能理解啦。

OK ,了解了一级缓存的设计位置,下面我们再来看看,如果一个 select 查询被执行时,一级缓存是如何工作的。

1.2.3 一级缓存的生效原理

我们以上面 1.1 的简单示例来测试,在此之前我们找到 BaseExecutor 这个类的 query 方法( MyBatis 3.5.5 版本在第 141 行),在这个方法体的第 152 行打一个断点,然后我们以 Debug 的方式运行 Level1Application ,当程序停在断点时,我们观察一下一级缓存是如何生效和工作的。

1.2.3.1 query方法概览

我们先看看 query 方法的核心逻辑吧,先大概有个思路:

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, 
                         ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    // ......
    // 如果statement指定了需要刷新缓存,则清空一级缓存
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
        clearLocalCache();
    }
    List<E> list;
    try {
        queryStack++;
        // 查询之前先检查一级缓存中是否存在数据
        list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
        if (list != null) {
            // 有,则直接取缓存
            handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
        } else {
            // 没有,则查询数据库
            list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
        }
    } finally {
        queryStack--;
    }
    if (queryStack == 0) {
        // ......
        // 全局localCacheScope设置为statement,则清空一级缓存
        if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
            // issue #482
            clearLocalCache();
        }
    }
    return list;
}

可以发现,一级缓存起作用的位置,是在向数据库发起查询之前,先拦截检查一下,如果一级缓存中有数据,则直接从缓存中取数据并返回,否则才查询数据库。

理清楚思路后,我们来 Debug 运行。

1.2.3.2 第一次进入BaseExecutor

list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
  handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
  list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}

第一次执行 departmentMapper.findAll() 方法,此时可以发现 localCache 中是空的,一级缓存干干净净:

img

OK ,没有数据,那就必须走数据库查询了,进入 queryFromDatabase 方法:

private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, 
                                      ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    List<E> list;
    // 缓存占位,代表此时还没有查询到数据
    localCache.putObject(key, EXECUTION_PLACEHOLDER);
    try {
        // 执行数据库查询
        list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
    } finally {
        localCache.removeObject(key);
    }
    // 查询结果放入缓存
    localCache.putObject(key, list);
    if (ms.getStatementType() == StatementType.CALLABLE) {
        localOutputParameterCache.putObject(key, parameter);
    }
    return list;
}

可以发现,queryFromDatabase 方法中主要干的事情,就是查询数据,并放入缓存了。由于查询到了结果,放入了缓存,所以返回到外层的 query 方法后,localCache 中就有数据了:

img

1.2.3.3 第二次进入BaseExecutor

第二次执行 departmentMapper.findAll() 方法,因为此时缓存中已经有数据了,所以上面的判断会走 if 的分支而不是 else :

img

这个方法就没啥意思了,主要是对存储过程有输出资源的一些处理,我们不关心,重要的是,list 这个变量有值了,query 方法的最底下就是返回 list 这个变量,所以第二次查询走一级缓存这个特性就得以体现了。

1.2.3.4 清空一级缓存

执行了两次 findAll 方法后,接下来要清空一级缓存了,还记得 query 方法一开始的源码吗:

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, 
                         ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    // ......
    // 如果statement指定了需要刷新缓存,则清空一级缓存
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
        clearLocalCache();
    }
    List<E> list;
    // ......
}

如果 statement 指定了需要刷新缓存,则一级缓存会被清空。而 cleanCache 对应的 mapper.xml 中就是指定了 flushCache=true

<select id="cleanCache" resultType="int" flushCache="true">
    select count(id) from tbl_department
</select>

那这里 Debug 的时候,借助 IDEA 可以发现此处返回 true :

img

clearLocalCache 方法被执行,一级缓存也就清空了。

OK ,以上就是一级缓存的生效和工作原理了,整体来看比较简单易懂,小伙伴们可以跟着源码 Debug 走一下,体会一下一级缓存的设计。

2. 二级缓存

2.1 二级缓存的使用

2.1.1 简单使用

最简单的使用方式,只需要在 mapper.xml 上打一个 <cache /> 标签,就算开启二级缓存了:

<?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.linkedbear.mybatis.mapper.DepartmentMapper">
    <cache />
    <!-- ...... -->
</mapper>

对应的 Mapper 接口,则需要用 @CacheNamespace 注解开启:

@CacheNamespace
public interface DepartmentMapper {
    // ......
}

另外,不要忘记给实体类实现 Serializable 接口,否则二级缓存也是不能用的。

然后我们就可以编写测试代码了,这个测试代码完全可以基于之前的一级缓存测试代码扩展。我们知道,使用二级缓存时,必须关闭 SqlSession 时,一级缓存的数据才会写入二级缓存,所以此处我们需要在查询动作完成后,关闭 sqlSession ,并重新开启一个新的 SqlSession

public class Level2Application {
    
    public static void main(String[] args) throws Exception {
        InputStream xml = Resources.getResourceAsStream("mybatis-config.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(xml);
        SqlSession sqlSession = sqlSessionFactory.openSession();
    
        DepartmentMapper departmentMapper = sqlSession.getMapper(DepartmentMapper.class);
        System.out.println("第一次执行findAll......");
        departmentMapper.findAll();
        System.out.println("第二次执行findAll......");
        departmentMapper.findAll();
        
        sqlSession.close();
    
        // 开启一个新的SqlSession,测试二级缓存
        SqlSession sqlSession2 = sqlSessionFactory.openSession();
        DepartmentMapper departmentMapper2 = sqlSession2.getMapper(DepartmentMapper.class);
        System.out.println("sqlSession2执行findAll......");
        departmentMapper2.findAll();
        
        sqlSession2.close();
    }
}

编写好测试代码后,运行 main 方法,观察控制台的 SQL 日志打印:

第一次执行findAll......
[main] DEBUG .mapper.DepartmentMapper  - Cache Hit Ratio [com.linkedbear.mybatis.mapper.DepartmentMapper]: 0.0 
[main] DEBUG ion.jdbc.JdbcTransaction  - Opening JDBC Connection 
[main] DEBUG .pooled.PooledDataSource  - Created connection 2061347276. 
[main] DEBUG ion.jdbc.JdbcTransaction  - Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection] 
[main] DEBUG DepartmentMapper.findAll  - ==>  Preparing: select * from tbl_department 
[main] DEBUG DepartmentMapper.findAll  - ==> Parameters:  
[main] DEBUG DepartmentMapper.findAll  - <==      Total: 4 
第二次执行findAll......
[main] DEBUG .mapper.DepartmentMapper  - Cache Hit Ratio [com.linkedbear.mybatis.mapper.DepartmentMapper]: 0.0 
[main] DEBUG ion.jdbc.JdbcTransaction  - Resetting autocommit to true on JDBC Connection [com.mysql.jdbc.JDBC4Connection] 
[main] DEBUG ion.jdbc.JdbcTransaction  - Closing JDBC Connection [com.mysql.jdbc.JDBC4Connection@7adda9cc] 
[main] DEBUG .pooled.PooledDataSource  - Returned connection 2061347276 to pool. 
sqlSession2执行findAll......
[main] DEBUG .mapper.DepartmentMapper  - Cache Hit Ratio [com.linkedbear.mybatis.mapper.DepartmentMapper]: 0.3333333333333333

sqlSession 第一次执行 findAll 方法时,由于一级缓存和二级缓存中都没有数据,所以需要查询数据库,查询到数据后第二次执行 findAll 方法时,一级缓存中已经存在数据,所以无需再查询数据库。关闭 sqlSession 时,可以发现控制台中有 Closing JDBC Connection 的字眼,此时一级缓存中的数据已经保存至二级缓存。另外开启 sqlSession2 时,再执行 findAll 方法,控制台甚至连 Connection 都懒得打开了,因为 MyBatis 发现二级缓存中有现成的数据了,于是直接取出,返回。

从这段过程中,我们可以总结出几个细节:

  • SqlSession 关闭时,一级缓存的数据进入二级缓存。

  • 二级缓存中有数据时,直接取出,不会预先开启 Connection按需加载的思想)

1.1.2 二级缓存的配置

默认的二级缓存开启,其实背后都有一些 MyBatis 帮我们设定好的默认值,我们可以通过修改这些配置,达到自定义本地二级缓存的目的。

修改的载体必然是这个 <cache> 标签(对应 Mapper 接口的则是 @CacheNamespace 注解的属性),它有不少属性,下面我们先看看它都可以配置什么。

属性描述备注

eviction

缓存的回收策略

默认 LRU

type

二级缓存的实现

默认 org.apache.ibatis.cache.impl.PerpetualCache ,即本地内存的二级缓存

size

缓存引用数量

默认值 1024

flushInterval

缓存刷新间隔(定时清除时间间隔)

默认无,即没有刷新间隔

readOnly

缓存是否只读

默认 false ,需要二级缓存对应的实体模型类需要实现 Serializable 接口

blocking

阻塞获取缓存数据

若缓存中找不到对应的 key ,是否会一直 blocking ,直到有对应的数据进入缓存。默认 false

属性不算多,但我们接触的不是很多,下面小册就一些比较重要的属性展开讲解一下。

1.1.2.1 eviction

缓存的回收策略,它可以配置当缓存容量即将溢出时如何回收空间。MyBatis 的官方文档中提到的可用的清除策略有:

  • LRU – 最近最少使用:移除最长时间不被使用的对象。

  • FIFO – 先进先出:按对象进入缓存的顺序来移除它们。

  • SOFT – 软引用:基于垃圾回收器状态和软引用规则移除对象。

  • WEAK – 弱引用:更积极地基于垃圾收集器状态和弱引用规则移除对象。

默认情况下我们不需要多余配置,默认的 LRU 策略已经足够用。

1.1.2.2 type

缓存的实现载体,上一章我们在了解缓存模型中就知道,MyBatis 的缓存根接口是 Cache ,那有接口就要有实现类,默认的实现类与一级缓存一样,都是 **PerpetualCache** ,如果我们需要使用外置第三方缓存件,那这个 type 属性就需要指定了(比方说整合 ehcache 的 org.mybatis.caches.ehcache.EhcacheCache )。如何整合 EhCache ,我们下面马上就会讲到。

1.1.2.3 readOnly

缓存是否只读,这个设置比较有趣。还记得上一章讲过的一级缓存吗,我们当时测试了一个场景,如果第一次查出数据后,直接修改该数据,之后第二次查询时,从一级缓存中查出来的数据是被修改过的,并非数据库的真实数据,原因是 MyBatis 利用一级缓存是直接将数据的引用交出去,至于我们怎么利用,MyBatis 不管

二级缓存就不一样了,我们从二级缓存中查出来的数据那可是跨 SqlSession 的,谁知道你改不改数据(还不敢保证改的对不对),万一你改了那别人从二级缓存中拿的数据就是被你改过的,这样万一出点问题,那可就出大事了。MyBatis 自然帮我们考虑到了这一点,于是它给二级缓存设计了一个只读属性。这个只读属性如果设置为 false ,则通过二级缓存查询的数据会执行一次基于 jdk 序列化的对象深拷贝,这样就可以保证拿到的数据不会对原二级缓存产生影响(但一次对象的深拷贝会导致性能降低);而 readOnly 设置为 true ,则只读的缓存会像一级缓存那样,直接返回二级缓存本身,虽然可能不安全,但好在处理速度快

由此也就解释了,为什么默认情况下,开启 MyBatis 的二级缓存,需要实体模型类实现 Serializable 接口。

1.1.3 整合EhCache

OK ,下面我们来回顾一下 MyBatis 如何整合外置第三方缓存,比较流行的缓存是 EhCache ,最近几年也有 MyBatis 整合 Redis 做二级缓存的了,整合的逻辑都是一样的。下面我们以整合 EhCache 为例回顾。

MyBatis 的官方文档中也有对 EhCache 整合的说明:mybatis.org/ehcache-cac…

1.1.3.1 导入依赖

既然整合 EhCache ,那 jar 包必然少不了了,MyBatis 本身已经提供了整合的 jar 包,所以直接拿过来用就可以:

    <dependency>
        <groupId>org.mybatis.caches</groupId>
        <artifactId>mybatis-ehcache</artifactId>
        <version>1.2.1</version>
    </dependency>

1.1.3.2 配置EhCache

接下来,我们需要配置一下 EhCache 了,EhCache 的配置,需要在 src/main/resources 中放一个 ehcache.xml 的配置文件:

<?xml version="1.0" encoding="UTF-8" ?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd">
    <!--
        磁盘存储:将缓存中暂时不使用的对象,转移到硬盘,类似于Windows系统的虚拟内存
        path:指定在硬盘上存储对象的路径
     -->
    <diskStore path="C:\ehcache"/>

    <!--
        defaultCache:默认的缓存配置信息,如果不加特殊说明,则所有对象按照此配置项处理
        maxElementsInMemory:设置内存缓存的上限,最多存储多少个记录对象
        maxElementsOnDisk:设置硬盘缓存的上限,内存放不下时会向硬盘中缓存(0表示无上限)
        eternal:代表对象是否永不过期
        timeToIdleSeconds:最大的空闲时间(秒)(对象在多长时间没有被访问就会失效)
        timeToLiveSeconds:最大的存活时间(秒)(对象从创建到失效所需要的时间)
        overflowToDisk:是否允许对象被写入到磁盘
        memoryStoreEvictionPolicy:缓存清空策略
            * FIFO:先进先出
            * LFU:最少使用的清空
            * LRU:最近最少使用(即未被使用的时间最长)
     -->
    <defaultCache
            maxElementsInMemory="100"
            maxElementsOnDisk="10000"
            eternal="false"
            timeToIdleSeconds="120"
            timeToLiveSeconds="120"
            overflowToDisk="true"
            diskExpiryThreadIntervalSeconds="120"
            memoryStoreEvictionPolicy="LRU"
    />
</ehcache>

这个配置文件不需要我们全部写,直接抄过来就 OK 。相关的配置在上面也有注释,小伙伴们可以根据自己的需要合理配置。

还有一点,不要忘记,哪里需要用二级缓存,哪里就配置上 EhCache 的缓存实现:

<mapper namespace="com.linkedbear.mybatis.mapper.DepartmentMapper">
    <cache type="org.mybatis.caches.ehcache.EhcacheCache"/>
    <!-- ...... -->
</mapper>

1.1.3.3 测试效果

不需要任何多余的编写测试代码,我们直接重新运行一遍 Level2Application 即可。运行结束后,我们观察一下 C 盘有没有多一个 ehcache 文件夹,以及里面有没有缓存文件生成:

img

可以发现,数据已经成功的借助 EhCache 缓存到磁盘上了,说明 EhCache 整合成功。

到这里,对于 MyBatis 的二级缓存也就讲解完毕了,接下来,又到了大家最“头疼”的原理分析环节。

2.2 二级缓存的设计原理

首先我们先回顾一下二级缓存的模型设计,以及里面涉及到的装饰者模式。

2.2.1 Cache实现类的装饰者模式

上一章我们提到了 Cache 接口的那些实现类:

img

这里面只有 PerpetualCache 是真正有缓存能力的实现类,其余的都是装饰者。装饰者最大的特征,是在原有的功能上扩展新的特性,多种装饰者的组合,可以保证任意增加新的功能行为而不用修改原有的基本代码。

读到这里,小伙伴们肯定会产生一个新的疑问:**MyBatis 怎么根据我们的二级缓存的配置,构造对应的缓存实现呢?**哎这个问题到位了,下面我们要填之前的坑了。

2.2.2 二级缓存的初始化位置

我们之前提到了 MyBatis 解析 mapper.xml 和 Mapper 接口时,会处理 <cache> 标签和 @CacheNamespace 注解,当时我们把这部分跳过了,本章我们就回过头来看看那部分源码的实现。

2.2.2.1 mapper.xml中的cache解析

还记得解析 mapper.xml 的位置起源吧,是解析 MyBatis 的全局配置文件,里面会解析 <mapper> 标签,从而触发 mapper.xml 的解析,而解析 mapper.xml 的逻辑中有如下两行源码:

cacheRefElement(context.evalNode("cache-ref"));
cacheElement(context.evalNode("cache"));

上面是处理 <cache-ref> 的,我们暂且不关心,下面的 cacheElement 方法是处理 <cache> 标签。

2.2.2.2 解析cache标签

进入 cacheElement 方法:(关键注释已标注在源码中)

private void cacheElement(XNode context) {
    if (context != null) {
        // 默认的类型是PERPETUAL,也即PerpetualCache
        String type = context.getStringAttribute("type", "PERPETUAL");
        Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
        // 默认的过期策略 LRU
        String eviction = context.getStringAttribute("eviction", "LRU");
        Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
        // 获取其他属性
        Long flushInterval = context.getLongAttribute("flushInterval");
        Integer size = context.getIntAttribute("size");
        boolean readWrite = !context.getBooleanAttribute("readOnly", false);
        boolean blocking = context.getBooleanAttribute("blocking", false);
        Properties props = context.getChildrenAsProperties();
        // 2.2.3 创建Cache对象
        builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
    }
}

从源码中可以很明显的发现,缓存实现类、过期策略都可以声明别名的,说明它们的底层其实都是对应的某些实现类,而这些实现类,早在第 7 章中我们就看过了,可能部分小伙伴忘记了它的位置,小册提醒一下,是在 BaseBuilder 的构造方法中:

public abstract class BaseBuilder {
    protected final Configuration configuration;
    protected final TypeAliasRegistry typeAliasRegistry;
    protected final TypeHandlerRegistry typeHandlerRegistry;

    public BaseBuilder(Configuration configuration) {
        this.configuration = configuration;
        // 注意这个typeAliasRegistry
        this.typeAliasRegistry = this.configuration.getTypeAliasRegistry();
        this.typeHandlerRegistry = this.configuration.getTypeHandlerRegistry();
    }

注意这个 typeAliasRegistry ,这里面注册了 MyBatis 默认的一些内置别名,其中就有如下这样一段:

    typeAliasRegistry.registerAlias("PERPETUAL", PerpetualCache.class);
    typeAliasRegistry.registerAlias("FIFO", FifoCache.class);
    typeAliasRegistry.registerAlias("LRU", LruCache.class);
    typeAliasRegistry.registerAlias("SOFT", SoftCache.class);
    typeAliasRegistry.registerAlias("WEAK", WeakCache.class);

可以发现,这些别名对应的都是 Cache 接口的实现类!由此也能在一定程度上透漏着装饰者的味道了吧。

OK ,我们的重点不在这里,更为重要的是关心最下面的 builderAssistant.useNewCache 方法。

2.2.2.3 创建Cache对象

又用到 MapperBuilderAssistant 了,这家伙真有点 “无所不能” 的意思啊,我们看看它如何创建出 Cache 对象的吧:

public Cache useNewCache(Class<? extends Cache> typeClass, 
        Class<? extends Cache> evictionClass,
        Long flushInterval, Integer size, boolean readWrite,
        boolean blocking, Properties props) {
    Cache cache = new CacheBuilder(currentNamespace)
                          .implementation(valueOrDefault(typeClass, PerpetualCache.class))
                          .addDecorator(valueOrDefault(evictionClass, LruCache.class))
                          .clearInterval(flushInterval)
                          .size(size)
                          .readWrite(readWrite)
                          .blocking(blocking)
                          .properties(props)
                          .build();
    configuration.addCache(cache);
    currentCache = cache;
    return cache;
}

注意看!它使用了一个 CacheBuilder 创建的 Cache 对象!这很明显是建造器的设计。仔细观察一下这段链式调用,可以发现 <cache> 标签中的属性,在这里全部都用到了,先不点进去看,光在外头,应该各位会产生一种强烈的感觉:每一行调用都有可能外挂一个装饰者!到底是不是这样呢?我们分解来看。

CacheBuilder的成员

我们先看看 CacheBuilder 本身的设计:

public class CacheBuilder {
    private final String id;
    private Class<? extends Cache> implementation;
    private final List<Class<? extends Cache>> decorators;
    private Integer size;
    private Long clearInterval;
    private boolean readWrite;
    private Properties properties;
    private boolean blocking;
}

可以发现,这里面也是包含了 <cache> 标签的所有必备要素,这里面两个小细节:

  • implementation 属性对应的是 Cache 接口的落地实现,decorators 代表要外挂的装饰者们;

  • properties 属性意味着 <cache> 标签也有 <property> 子标签,可以传入配置。

了解了基本设计,下面我们就挑上面重要的方法来看。

implementation

注意看 implementation(valueOrDefault(typeClass, PerpetualCache.class)) 这行代码,它的入参是一个 Class 对象,而且带默认值 PerpetualCache ,这很明显是为了确定 Cache 接口的落地实现,在没有任何整合的前提下,MyBatis 肯定会用 PerpetualCache 作为落地实现。

addDecorator

看方法名,就差扒拉着耳朵告诉你它要加装饰者了,这个方法的实现,就是向 decorators 这个集合中添加装饰者实现:

private final List<Class<? extends Cache>> decorators;

public CacheBuilder addDecorator(Class<? extends Cache> decorator) {
    if (decorator != null) {
        this.decorators.add(decorator);
    }
    return this;
}

不过默认情况下,它只会添加一个 LruCache 的实现,难不成它意味着默认创建出来的缓存只有一层装饰者吗?带着这个疑问,我们继续往下看。

readWrite

剩下几个方法中,小册选了一个比较简单且有代表性的方法来看:

public CacheBuilder readWrite(boolean readWrite) {
    this.readWrite = readWrite;
    return this;
}

看上去它只是把 “缓存是否只读” 记录到 CacheBuilder 中而已,没啥别的意思?但实际上不是这么简单(有伏笔),我们继续往下看。

build

最下面的动作是构建二级缓存了:(关键注释已标注在源码中)

public Cache build() {
    // 兜底处理
    setDefaultImplementations();
    // 创建默认的PerpetualCache对象
    Cache cache = newBaseCacheInstance(implementation, id);
    setCacheProperties(cache);
    // issue #352, do not apply decorators to custom caches
    // 如果是PerpetualCache类,则用装饰者逐层包装
    if (PerpetualCache.class.equals(cache.getClass())) {
        for (Class<? extends Cache> decorator : decorators) {
            cache = newCacheDecoratorInstance(decorator, cache);
            setCacheProperties(cache);
        }
        // 2.2.3.5 包装完毕后,处理MyBatis的标准装饰者
        cache = setStandardDecorators(cache);
    } else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {
        cache = new LoggingCache(cache);
    }
    return cache;
}

纵读整段源码,其实都不算难理解了,装饰者模式在此体现得淋漓尽致。

源码的中间偏下位置,在所有传入的装饰者都包装完成后,还有一个 setStandardDecorators 方法,它就是前面提到的伏笔了。

setStandardDecorators

进入 setStandardDecorators 方法中,我们在此又发现了好多装饰者:(注释已标全)

private Cache setStandardDecorators(Cache cache) {
    try {
        MetaObject metaCache = SystemMetaObject.forObject(cache);
        // 缓存大小
        if (size != null && metaCache.hasSetter("size")) {
            metaCache.setValue("size", size);
        }
        // 定时清空二级缓存
        if (clearInterval != null) {
            cache = new ScheduledCache(cache);
            ((ScheduledCache) cache).setClearInterval(clearInterval);
        }
        // 读写缓存
        if (readWrite) {
            cache = new SerializedCache(cache);
        }
        // 外挂日志记录、同步缓存
        cache = new LoggingCache(cache);
        cache = new SynchronizedCache(cache);
        // 阻塞读取缓存
        if (blocking) {
            cache = new BlockingCache(cache);
        }
        return cache;
    } catch (Exception e) {
        throw new CacheException("Error building standard cache decorators.  Cause: " + e, e);
    }
}

确实是伏笔的回应吧,上面记录的这些属性,在下面都有对应的装饰者 Cache ,所以最终这些配置都是以装饰者的身份,包装到最底层的 PerpetualCache 上了。

经过这一系列逻辑处理后,Cache 对象也就成功的创建了,二级缓存也就初始化完成了。

2.2.3 二级缓存的生效原理

接下来是二级缓存的生效机制探究了,我们还是以 1.1 节的最简单的二级缓存使用作为测试代码,调试观察二级缓存的生效过程。

2.2.3.1 准备断点

跟上一章一样,我们先准备断点。这次我们把断点打在 org.apache.ibatis.executor.CachingExecutor 的第 96 行,即 query 方法的第 2 行:

private final TransactionalCacheManager tcm = new TransactionalCacheManager();

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, 
                         ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    Cache cache = ms.getCache();
    if (cache != null) {
        flushCacheIfRequired(ms);
        if (ms.isUseCache() && resultHandler == null) {
            ensureNoOutParams(ms, boundSql);
            List<E> list = (List<E>) tcm.getObject(cache, key);
            if (list == null) {
                list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
                tcm.putObject(cache, key, list); // issue #578 and #116
            }
            return list;
        }
    }
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

我们可以顺便看一下这个 query 方法的大体逻辑。一上来它会先去尝试获取二级缓存,如果没有二级缓存,则直接执行查询(跳到 BaseExecutor 中了);如果有二级缓存,则会尝试从 TransactionalCacheManager 中拿着二级缓存和缓存的 key 取查询数据,如果获取到了,则直接返回,没有获取到,则查询到结果后放入二级缓存中。

2.2.3.2 第一次进入断点

之后我们就可以以 Debug 的方式运行 Level2Application 了,当程序停在断点时,我们观察二级缓存是如何生效和工作的。

断点落下,此时可以发现 cache 是有值的,而且确实是按照上面构建的方式一步一步套出来的:

img

然后,它要使用 tcm.getObject(cache, key) 方法,从二级缓存中取数据,显然刚开始运行,二级缓存是空的,所以必然返回的 list 为 null :

img

既然没有数据,那就查数据库咯,查询完成后使用 tcm.putObject(cache, key, list); 放入二级缓存。

不过请注意,这个 putObject 方法是不会直接放入二级缓存的,我们可以通过 Debug 的断点处查看数据:

img

为什么缓存为空呢?我们要看一下 putObject 方法的实现:

// TransactionalCacheManager
public void putObject(Cache cache, CacheKey key, Object value) {
    getTransactionalCache(cache).putObject(key, value);
}

// TransactionCache
public void putObject(Object key, Object object) {
    entriesToAddOnCommit.put(key, object);
}

注意最下面它 put 的那个集合变量名:entriesToAddOnCommitOnCommit ???合着要等到事务提交咯?那当然啦,我们在学习 MyBatis 二级缓存的时候就知道,MyBatis 的二级缓存需要在 **SqlSession** 关闭时,一级缓存中的数据才能写入二级缓存,这里当然不能直接存进去。

这样经过一轮查询后,SqlSession 的一级缓存中就已经有数据了,第二次再进入断点时依然是上面的流程,不再重复。

2.2.3.3 SqlSession关闭

SqlSession 关闭时,一级缓存的数据要写入二级缓存,此时会触发 Executor 的关闭,我们找到 CachingExecutorclose 方法:(可能会有小伙伴不理解为什么又找到 Executor 而不是 SqlSession ,我们后面放到生命周期部分再详细讲解)

public void close(boolean forceRollback) {
    try {
        // issues #499, #524 and #573
        if (forceRollback) {
            tcm.rollback();
        } else {
            tcm.commit();
        }
    } finally {
        delegate.close(forceRollback);
    }
}

注意看,它又调用了 TransactionalCacheManagercommit 方法。看到这里可能有部分小伙伴的脑子里问号越来越多了:搞缓存就搞缓存嘛,为啥非要扯上事务呢?

TransactionalCacheManager的设计缘由

仔细思考一下,二级缓存是跨 SqlSession 的,也就是跨 Connection 的,那既然是跨连接,就必须要考虑到事务了,否则会出现一些意外情况,比方说小册来举个例子:

img

如果二级缓存的存放不需要考虑事务的话,那就有可能出现上面的问题:sqlSession1 先更新数据,后查询全部,此时查询出来的数据是修改后的脏数据,就这样直接放入二级缓存了,但随后 sqlSession1 执行了 rollback ,撤消了修改的数据,但数据库里的数据可以撤销修改,但二级缓存没办法撤销呀,这样就造成了隐患。sqlSession1 关闭后,重新开启一个新的 SqlSession ,并直接查询数据,此时二级缓存中有被修改过的错误数据,但 sqlSession2 并不知情,导致就这么把错误数据取出来了,从而引发错误。

由此我们就应该清楚,二级缓存应该是基于事务提交的,只有事务提交后,数据库的数据确定没有问题,这个时候 **SqlSession** 中的一级缓存数据也是准确的,这样才能把一级缓存的数据写入到二级缓存中,这也就是 TransactionalCacheManager 设计的意义。

commit的动作

搞明白 TransactionalCacheManager 的良苦用心,那我们就看看 commit 的动作中都干了什么吧:

public void commit() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
        txCache.commit();
    }
}

呦,这是把它里面的所有缓存都取出来,挨个 commit 呀,刚才上面我们也看到了,TransactionalCache 里面有那个 entriesToAddOnCommit 集合,那是不是 commit 了之后,相应的这些集合的数据也就都写入到二级缓存呢?答案是肯定的,进入到 TransactionalCache 中,源码的逻辑非常简单:

public void commit() {
    if (clearOnCommit) {
        delegate.clear();
    }
    // 刷新等待写入的缓存
    flushPendingEntries();
    reset();
}

private void flushPendingEntries() {
    for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
        // 写入最底层的缓存中
        delegate.putObject(entry.getKey(), entry.getValue());
    }
    for (Object entry : entriesMissedInCache) {
        if (!entriesToAddOnCommit.containsKey(entry)) {
            delegate.putObject(entry, null);
        }
    }
}

可以发现,它还是一层装饰者,最里层的 delegate 肯定还是 PerpetualCachecommit 的动作就是将 entriesToAddOnCommit 中的数据写入最内层的二级缓存中。

相应的,如果是回滚的话,只需要把这些集合全部清空即可:

public void rollback() {
    unlockMissedEntries();
    reset();
}

private void reset() {
    clearOnCommit = false;
    // 直接清空所有要写入的缓存
    entriesToAddOnCommit.clear();
    entriesMissedInCache.clear();
}

经过这样一番操作之后,一级缓存的数据就写入到二级缓存中了。

2.2.3.4 第三次进入断点

第三次进入断点,此时 sqlSession 已经关闭,sqlSession2 开启后进入。此时只凭观察 Cache 对象的属性,就已经知道二级缓存真实的存在了:

img

注意看 HashMap 中的 value ,是一个字节数组,这也就说明了二级缓存在写入时已经执行了一次基于 jdk 的序列化动作每次从二级缓存取数据时,会再执行一次反序列化,将字节数组转为缓存数据对象

至此,我们也就对整个二级缓存的生效原理也研究明白了。

最后更新于