精华内容
下载资源
问答
  • mybatis缓存机制
    2022-03-12 17:13:11

    1、一级缓存和二级缓存

    MyBatis 内置了一个强大的事务性查询缓存机制,它可以非常方便地配置和定制。 我们查询时,如果缓存中存在数据,那么我们就可以直接从缓存中获取,而不是再去向数据库进行请求。

    • 一级缓存默认开启,缓存范围仅为单个SqlSession会话内,不能串缓存
    • 二级缓存需要手动开启,范围属于Mapper Namespace

    二级缓存的运行规则:

    • 二级缓存默认所有查询操作均使用缓存  useCache = "true"
    • 写操作commit提交时对该namespace缓存会强制清空
    • 配置useCache = "false" 可以不用缓存
    • 配置flushCache = "true"代表强制清空缓存

    2、测试一级缓存

        @Test
        public void testLv1Cache(){
            SqlSession sqlsession = null;
            try{
                sqlsession = MybatisUtils.openSession();
                Goods g1 = sqlsession.selectOne("goods.selectById",1603);
                System.out.println("g1.hashCode()="+g1.hashCode());
                Goods g2 = sqlsession.selectOne("goods.selectById",1603);
                System.out.println("g2.hashCode()="+g2.hashCode());//sqlSession存在一级缓存
                System.out.println("g1+g2="+(g1==g2));
                sqlsession.commit();
            }catch (Exception e){
                e.printStackTrace();
                if(sqlsession!=null){sqlsession.rollback(); }
            }
    //一级缓存当中,如果sqlSession的会话commit()之后,一级缓存将会被清空
    //一级缓存的生存周期只存在于sqlSession当中
            try{
                sqlsession = MybatisUtils.openSession();
                Goods g3 = sqlsession.selectOne("goods.selectById",1603);
                Goods g4 = sqlsession.selectOne("goods.selectById",1603);
                System.out.println("g3.hashCode()="+g3.hashCode());
                System.out.println("g4.hashCode()="+g4.hashCode());//sqlSession的一级缓存只存在该对话,不能串对话
                System.out.println("g3+g4="+(g3==g4));
                sqlsession.commit();
            }catch (Exception e){
                e.printStackTrace();
                if(sqlsession!=null){sqlsession.rollback(); }
            }
        }

    输出结果:
     

    g1.hashCode()=893192050
    g2.hashCode()=893192050
    g1+g2=true
    
    g3.hashCode()=333683827
    g4.hashCode()=333683827
    g3+g4=true

    g1和g2属于同一个对象:使用了一级缓存,同理g3和g4

    而且可以看到g1、g2和g3、g4的哈希值并不相同,说明不同SqlSession对象的一级缓存不同共用

    3、在mapper.xml设置二级缓存

    <cache eviction="LRU" flushInterval="600000" size="512" readOnly="true"/>
    
    eviction:缓存清除策略--LRU(默认) LFU FIFO SOFT WEAK
    flushInterval:缓存清除间隔ms ,每个一段时间自动清除缓存
    size:二级缓存存储上限,用于保存对象或集合(1个集合算一个对象)的数量上限
    readOnly: true:代表返回只读缓存,每次从缓存取出的时缓存的对象本身呢个,效率高
    		false:取出的缓存为对象的副本,效率低
    
    <select id="selectAll" resultType="com.test.mybatis.entity.Goods" useCache:"true">
            select * from t_goods order by goods_id desc limit 10
    </select>
    useCache:是否放进二级缓存,List对象建议设置成false
    
    <insert id="insert" parameterType="com.test.mybatis.entity.Goods" flushCache:"true">
    </insert>
    flushCache:执行语句时执行清空缓存

    4、测试二级缓存

        @Test
        public void testLv2Cache(){
            SqlSession sqlsession1 = null;
            SqlSession sqlsession2 = null;
            try{
                sqlsession1 = MybatisUtils.openSession();
                Goods g1 = sqlsession1.selectOne("goods.selectById",1603);
                System.out.println(g1.hashCode());
                sqlsession1.commit();
            }catch (Exception e){
                e.printStackTrace();
                if(sqlsession1!=null){sqlsession1.rollback(); }
            }
            try{
                sqlsession2 = MybatisUtils.openSession();
                Goods g2 = sqlsession2.selectOne("goods.selectById",1603);
                System.out.println(g2.hashCode());//g1==g2,二级缓存存放的数据生效
                sqlsession2.commit();
            }catch (Exception e){
                e.printStackTrace();
                if(sqlsession2!=null){sqlsession2.rollback(); }
            }
            //缓存命中率0.5,二级缓存不会随着sqlSession的关闭而关闭!
        }

    输出结果:

    g1.hashCode():1691185247
    g2.hashCode():1691185247

    由此可见不同的SqlSession对象通过使用二级缓存共用相同的对象

    更多相关内容
  • MyBatis缓存机制

    2021-10-10 22:34:32
    缓存Cache一、简介1、缓存机制介绍2. 一级缓存和二级缓存二、一级缓存三、二级缓存3.1 mybatis自带的二级缓存3.1.1 代码测试二级缓存3.1.2 查询结果存入二级缓存的时机3.1.3 二级缓存相关配置四、整合EHCache4.1 ...

    一、简介

    1、缓存机制介绍

    在这里插入图片描述

    当客户端发起一次查询请求时,首先通过java程序进行网络传输访问mysql数据库及对应的数据的服务器硬盘,当第二次的请求也是查询相同的数据时再通过这个流程显然有点“浪费”上次请求访问到的资源,所以我们将第一次查询到的数据存到缓存区域,当发生下一次相同请求时直接在缓存区域拿就行了。

    2. 一级缓存和二级缓存

    ①使用顺序

    在这里插入图片描述
    查询的顺序是:

    • 先查询二级缓存,因为二级缓存中可能会有其他程序已经查出来的数据,可以拿来直接使用。
    • 如果二级缓存没有命中,再查询一级缓存
    • 如果一级缓存也没有命中,则查询数据库
    • SqlSession关闭之前,一级缓存中的数据会写入二级缓存

    ②效用范围

    • 一级缓存:SqlSession级别
    • 二级缓存:SqlSessionFactory级别

    在这里插入图片描述
    它们之间范围的大小参考下面图:
    在这里插入图片描述

    二、一级缓存

    当使用相同查询条件查询数据时,一共只打印了一条SQL语句,两个变量指向同一个对象。

    一级缓存失效的情况:

    • 不是同一个SqlSession
    • 同一个SqlSession但是查询条件发生了变化
    • 同一个SqlSession两次查询期间执行了任何一次增删改操作
    • 同一个SqlSession两次查询期间手动清空了缓存
    • 同一个SqlSession两次查询期间提交了事务

    三、二级缓存

    3.1 mybatis自带的二级缓存

    3.1.1 代码测试二级缓存

    ① 开启二级缓存功能

    在想要使用二级缓存的Mapper配置文件中加入cache标签

    <mapper namespace="com.zengchuiyu.mybatis.dao.EmployeeMapper">
        <!-- 启动二级缓存功能 -->
        <cache/>
    

    ②让实体类支持序列化

    public class Employee implements Serializable {
    

    ③junit测试

    这个功能的测试操作需要将SqlSessionFactory对象设置为成员变量

    public class CacheTest {
    
        private SqlSessionFactory factory;
    
        @Before
        public void init() throws IOException {
    
            factory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsStream("mybatis-config.xml"));
    
        }
    
        //测试二级缓存,(mybatis自带的)
        @Test
        public void test1(){
    
            SqlSession session = factory.openSession();
    
            EmployeeMapper mapper = session.getMapper(EmployeeMapper.class);
    
            Employee employee = mapper.selectEmpById(2);
            System.out.println("employee = " + employee);
    
            //在执行第二次查询前,关闭当前SqlSession
            session.close();
    
            //开启新的SqlSession
            session = factory.openSession();
    
            mapper = session.getMapper(EmployeeMapper.class);
            employee = mapper.selectEmpById(2);
            System.out.println("employee = " + employee);
    
            session.close();
        }
    }
    

    打印效果:

    22:48:18.669 [main] DEBUG com.zengchuiyu.mybatis.dao.EmployeeMapper - Cache Hit Ratio [com.zengchuiyu.mybatis.dao.EmployeeMapper]: 0.5

    ④缓存命中率

    日志中打印的Cache Hit Ratio叫做缓存命中率

    Cache Hit Ratio [com.atguigu.mybatis.EmployeeMapper]: 0.0(0/1)
    Cache Hit Ratio [com.atguigu.mybatis.EmployeeMapper]: 0.5(1/2)
    Cache Hit Ratio [com.atguigu.mybatis.EmployeeMapper]: 0.6666666666666666(2/3)
    Cache Hit Ratio [com.atguigu.mybatis.EmployeeMapper]: 0.75(3/4)
    Cache Hit Ratio [com.atguigu.mybatis.EmployeeMapper]: 0.8(4/5)
    

    缓存命中率=命中缓存的次数/查询的总次数

    3.1.2 查询结果存入二级缓存的时机

    结论:SqlSession关闭的时候,一级缓存中的内容会被存入二级缓存

    3.1.3 二级缓存相关配置

    • eviction属性:缓存回收策略

    LRU(Least Recently Used) – 最近最少使用的:移除最长时间不被使用的对象。

    FIFO(First in First out) – 先进先出:按对象进入缓存的顺序来移除它们。

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

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

    默认的是 LRU。

    • flushInterval属性:刷新间隔,单位毫秒

    默认情况是不设置,也就是没有刷新间隔,缓存仅仅调用语句时刷新

    • size属性:引用数目,正整数

    代表缓存最多可以存储多少个对象,太大容易导致内存溢出

    • readOnly属性:只读,true/false

    true:只读缓存;会给所有调用者返回缓存对象的相同实例。因此这些对象不能被修改。这提供了很重要的性能优势。

    false:读写缓存;会返回缓存对象的拷贝(通过序列化)。这会慢一些,但是安全,因此默认是 false。

    四、整合EHCache

    4.1 EHCache简介

    官网地址:https://www.ehcache.org/

    在这里插入图片描述

    Ehcache is an open source, standards-based cache that boosts performance, offloads your database, and simplifies scalability. It’s the most widely-used Java-based cache because it’s robust, proven, full-featured, and integrates with other popular libraries and frameworks. Ehcache scales from in-process caching, all the way to mixed in-process/out-of-process deployments with terabyte-sized caches.

    Ehcache是一个开源的,基于标准的缓存,可以提高性能,卸载数据库,简化可伸缩性。它是最广泛使用的基于java的缓存,因为它健壮、可靠、功能齐全,并与其他流行的库和框架集成。Ehcache从进程内缓存扩展到具有tb大小缓存的进程内/进程外混合部署。

    4.2 整合操作

    ①Mybatis环境

    在Mybatis环境下整合EHCache,前提当然是要先准备好Mybatis的环境

    ②添加依赖

    • 依赖信息:
    <!-- Mybatis EHCache整合包 -->
    <dependency>
        <groupId>org.mybatis.caches</groupId>
        <artifactId>mybatis-ehcache</artifactId>
        <version>1.2.1</version>
    </dependency>
    <!-- slf4j日志门面的一个具体实现 -->
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.2.3</version>
    </dependency>
    
    • 依赖传递情况:

    在这里插入图片描述

    • 各主要jar包作用
    jar包名称作用
    mybatis-ehcacheMybatis和EHCache的整合包
    ehcacheEHCache核心包
    slf4j-apiSLF4J日志门面包
    logback-classic支持SLF4J门面接口的一个具体实现

    ③整合EHCache

    [1]创建EHCache配置文件

    ehcache.xml
    在这里插入图片描述
    [2]文件内容

    <?xml version="1.0" encoding="utf-8" ?>
    <ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:noNamespaceSchemaLocation="../config/ehcache.xsd">
        <!-- 磁盘保存路径 -->
        <diskStore path="D:\zengchuiyu\ehcache"/>
        
        <defaultCache
                maxElementsInMemory="1000"
                maxElementsOnDisk="10000000"
                eternal="false"
                overflowToDisk="true"
                timeToIdleSeconds="120"
                timeToLiveSeconds="120"
                diskExpiryThreadIntervalSeconds="120"
                memoryStoreEvictionPolicy="LRU">
        </defaultCache>
    </ehcache>
    

    引入第三方框架或工具时,配置文件的文件名可以自定义吗?
    可以自定义:文件名是由我告诉其他环境
    不能自定义:文件名是框架内置的、约定好的,就不能自定义,以避免框架无法加载这个文件

    ④加入logback日志
    存在SLF4J时,作为简易日志的log4j将失效,此时我们需要借助SLF4J的具体实现logback来打印日志。

    [1]各种Java日志框架简介
    门面:

    名称说明
    JCL(Jakarta Commons Logging)陈旧
    SLF4J(Simple Logging Facade for Java)★适合
    jboss-logging特殊专业领域使用

    实现:

    名称说明
    log4j★最初版
    JUL(java.util.logging)JDK自带
    log4j2Apache收购log4j后全面重构,内部实现和log4j完全不同
    logback★优雅、强大

    注:标记★的技术是同一作者。

    [2]logback配置文件
    在这里插入图片描述

    <?xml version="1.0" encoding="UTF-8"?>
    <configuration debug="true">
        <!-- 指定日志输出的位置 -->
        <appender name="STDOUT"
            class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <!-- 日志输出的格式 -->
                <!-- 按照顺序分别是:时间、日志级别、线程名称、打印日志的类、日志主体内容、换行 -->
                <pattern>[%d{HH:mm:ss.SSS}] [%-5level] [%thread] [%logger] [%msg]%n</pattern>
            </encoder>
        </appender>
        
        <!-- 设置全局日志级别。日志级别按顺序分别是:DEBUG、INFO、WARN、ERROR -->
        <!-- 指定任何一个日志级别都只打印当前级别和后面级别的日志。 -->
        <root level="DEBUG">
            <!-- 指定打印日志的appender,这里通过“STDOUT”引用了前面配置的appender -->
            <appender-ref ref="STDOUT" />
        </root>
        
        <!-- 根据特殊需求指定局部日志级别 -->
        <logger name="com.atguigu.crowd.mapper" level="DEBUG"/>
        
    </configuration>
    

    ⑤ EHCache配置文件说明

    当借助CacheManager.add(“缓存名称”)创建Cache时,EhCache便会采用指定的的管理策略。

    defaultCache标签各属性说明:
    在这里插入图片描述

    五、缓存基本原理

    5.1 Cache接口

    ① 顶级接口

    org.apache.ibatis.cache.Cache接口:所有缓存都必须实现的顶级接口
    在这里插入图片描述
    ② Cache接口中的方法

    在这里插入图片描述
    在这里插入图片描述
    ③ 缓存的本质

    根据Cache接口中方法的声明我们能够看到,缓存的本质是一个Map

    5.2 PerpetualCache

    在这里插入图片描述

    org.apache.ibatis.cache.impl.PerpetualCache是Mybatis的默认缓存,也是Cache接口的默认实现。Mybatis一级缓存和自带的二级缓存都是通过PerpetualCache来操作缓存数据的。但是这就奇怪了,同样是PerpetualCache这个类,怎么能区分出来两种不同级别的缓存呢?

    其实很简单,调用者不同。

    一级缓存:由BaseExecutor调用PerpetualCache
    二级缓存:由CachingExecutor调用PerpetualCache,而CachingExecutor可以看做是对BaseExecutor的装饰

    展开全文
  • mybatis缓存机制

    2022-06-09 10:23:04
      学习了mybatis缓存机制,现对其进行总结。


    前言

      学习了mybatis缓存机制,现对其进行总结。


    一、mybatis中一级缓存和二级缓存及其区别?

      Mybatis 一级缓存的作用域是同一个 SqlSession,在同一个 sqlSession 中两次执行相同的 sql 语句,第一次执行完毕会将数据库中查询的数据写到缓存(内存),第二次会从缓存中获取,从而提高查询效率。当一个 sqlSession 结束后该 sqlSession 中的 一级缓存也就不存在了。Mybatis 默认开启一级缓存。
       Mybatis 二级缓存是多个 SqlSession 共享的,其作用域是 mapper 的同一个 namespace,不同 的 sqlSession 两次执行相同 namespace 下的 sql 语句且向 sql 中传递参数也相同即最终执行 相同的 sql 语句,第一次执行完毕会将数据库中查询的数据写到缓存(内存),第二次会从 缓存中获取数据将不再从数据库查询,从而提高查询效率。Mybatis 默认没有开启二级缓存 需要在 setting 全局参数中配置开启二级缓存。

    区别:
    1.一级缓存的作用域是一个sqlsession内;二级缓存作用域是针对mapper进行缓存;
    2.一级缓存是默认开启的;二级缓存需要手动配置

    二、一级缓存的特点

    在这里插入图片描述

      一级缓存区域是根据 SqlSession 为单位划分的。 每次查询会先从缓存区域找,如果找不到从数据库查询,查询到数据将数据写入缓存。 Mybatis 内部存储缓存使用一个 HashMap,key 为 hashCode+sqlId+Sql 语句。value 为 从查询出来映射生成的 java 对象 sqlSession 执行 insert、update、delete 等操作 commit 提交后会清空缓存区域

    使用一级缓存

        @Test
        public  void Test(){
            SqlSession sqlSession = MybatisUtils.openSession();
            CustomerMapper customerMapper = sqlSession.getMapper(CustomerMapper.class);
            Customer customer = customerMapper.getCustomerWithId(2);
            System.out.println("Customer1----"+customer);
            Customer customer2 = customerMapper.getCustomerWithId(2);
            System.out.println("Customer1----"+customer2);
            System.out.println("是否相等:"+(customer2==customer));
            sqlSession.close();
        }
    

    结果图:
    在这里插入图片描述

      至于上图显示的命中率为0,是因为我们配置开启了二级缓存,显示的是二级缓存的命中率。
      查询的顺序: 二级缓存 => 一级缓存 => 数据库

    关闭二级缓存查询结果显示:
    在这里插入图片描述

    二级缓存的特点

    执行流程:
    在这里插入图片描述
    开启二级缓存
    mybatis主配置文件中加入以下代码:

      <setting name="cacheEnabled" value="true"/>
    

    然后在对应的mapper.xml里面加入配置

        <cache eviction="FIFO" flushInterval="6000" readOnly="false" size="50" >
    
        </cache>
    

    在这里插入图片描述
    下图对cache的属性进行介绍
    在这里插入图片描述

      由图可知 二级缓存是有设置定时情况缓存的时间的 也有设置对应的回收策略 这也回答了上面的疑问(二级缓存什么时候失效)

    二级缓存代码测试
    在这里插入图片描述
    使用动态代理的方式

       @Test
        public  void Test2(){
            SqlSession sqlSession = MybatisUtils.openSession();
            CustomerMapper customerMapper = sqlSession.getMapper(CustomerMapper.class);
            Customer customer = customerMapper.getCustomerWithId(2);
            System.out.println("customer=="+customer);
            sqlSession.close();
    
            SqlSession sqlSession2= MybatisUtils.openSession();
            CustomerMapper customerMapper2 = sqlSession2.getMapper(CustomerMapper.class);
            Customer customer2 = customerMapper2.getCustomerWithId(2);
            System.out.println("customer2=="+customer2);
            System.out.println("是否相等:"+(customer2==customer));
            sqlSession2.close();
        }
    

    运行结果
    在这里插入图片描述

    不使用动态代理的方式

     @Test
        public  void Test3(){
            SqlSession sqlSession = MybatisUtils.openSession();
            Customer customer = sqlSession.selectOne("getCustomerWithId", 2);
            System.out.println("customer=="+customer);
            sqlSession.close();
    
            SqlSession sqlSession2= MybatisUtils.openSession();
            Customer customer2 = sqlSession2.selectOne("getCustomerWithId", 2);
            
            System.out.println("customer2=="+customer2);
            System.out.println("是否相等:"+(customer2==customer));
            sqlSession2.close();
        }
    

    运行结果
    在这里插入图片描述

    结果与上图一模一样
    可知缓存与用何种方式执行SQL无关

    但我们修改配置
    在这里插入图片描述
    再次运行上面运行过的代码;比如我们这里运行test2
    运行结果如图:抛出该JavaBean不是可序列化的异常
    在这里插入图片描述
    于是我们在该类上实现Serializable接口
    在这里插入图片描述
    再次运行
    在这里插入图片描述

    运行成功,并且只执行了一次SQL语句,第二次查询缓存命中。但是查询出来的两个对象却不相等了。

    我们再次回到cache的属性上面:
    readOnly:是否只读 。
    true:只读,mybatis认为所有从缓存中获取数据的操作都是只读操作,不会修改数据。 mybatis为了加快获取速度,直接会将数据在缓存中的引用交给用户,不安全,但速度快。
    false:非只读,mybatis觉得获取的数据可能会被修改 mybatis会利用序列化&反序列化的技术克隆一份新的数据给你,安全,但速度慢。

      因为上面说到mybatis会用到序列化&反序列化技术克隆一份新的数据,所以对应的pojo需要实现序列化。 并且既然是复制一份新的数据给你,所以两次查询的对象并不相等了 这也可以算是和一级缓存的差别之一了。

    再次回顾cache相关属性

    cache的相关属性介绍:
    eviction: 缓存的回收策略
    LRU(默认):最近最少使用,移除最长时间不被使用的对象
    FIFO:先进先出,安对象进入缓存的顺序来移除它们
    SOFT:软引用,移除基于垃圾回收器的状态和软引用规则的对象
    WEAK: 弱引用,更积极的移除基于垃圾收集器状态和弱引用规则的对象
    flushInterval:缓存刷新间隔 缓存多长时间清空一次,默认不清空,设置一个毫秒值
    readOnly:是否只读 。
    true:只读,mybatis认为所有从缓存中获取数据的操作都是只读操作,不会修改数据。 mybatis为了加快获取速度,直接会将数据在缓存中的引用交给用户,不安全,但速度快。
    false:非只读,mybatis觉得获取的数据可能会被修改 mybatis会利用序列化&反序列化的技术克隆一份新的数据给你,安全,但速度慢。
    size:缓存最多存放多少个引用。
    type:指定自定义缓存的全类名,实现Mybatis提供的Cache接口即可。

    总结

    1.一级缓存的作用域是一个sqlsession内;二级缓存作用域是针对mapper进行缓存;
    2.一级缓存是默认开启的;二级缓存需要手动配置(参见第6点)
    3.一级缓存sqlSession 执行 insert、update、delete 等操作 commit 提交后会清空缓存区域。sqlSession.close()后一级缓存也没有了。但是销毁sqlSession后会将里面的缓存存到二级缓存中;
    4.二级缓存cache中readonly属性如果为false 那么相应的pojo类必须实现Serializable接口 并且其缓存查询到的对象都是通过序列化或者反序列化克隆的,所以对象之间两两不相等
    5.二级缓存的生命周期和应用同步,它是用来解决一级缓存不能跨会话共享数据的问题,范围是namespace级别的,可以被多个会话共享(只要是同一个接口的相同方法,都可以进行共享)。
    6.只要没有显式地设置cacheEnabled为false,都会使用CachingExector装饰基本的执行器(SIMPLE、REUSE、BATCH)。 二级缓存总是默认开启的但是每个Mapper的二级开关是默认关闭的。
    7.二级缓存进行增删改操作也会刷新二级缓存,导致二级缓存失效

    代码测试如下:

       @Test
        public  void Test3(){
            SqlSession sqlSession = MybatisUtils.openSession();
            Customer customer = sqlSession.selectOne("getCustomerWithId", 2);
            System.out.println("customer=="+customer);
            sqlSession.close();
    
            SqlSession sqlSession2= MybatisUtils.openSession();
            Customer customer2 = sqlSession2.selectOne("getCustomerWithId", 2);
            System.out.println("customer2=="+customer2);
            sqlSession2.close();
            SqlSession sqlSession3= MybatisUtils.openSession();
            Customer customer3 = sqlSession3.selectOne("getCustomerWithId", 2);
            System.out.println("customer3=="+customer3);
            System.out.println("2、1是否相等:"+(customer2==customer));
            System.out.println("3、1是否相等:"+(customer3==customer));
            System.out.println("3、2是否相等:"+(customer3==customer2));
            sqlSession3.close();
        }
    
    

    运行结果:
    在这里插入图片描述

    什么情况下有必要去开启二级缓存

    1、因为所有的增删改都会刷新二级缓存,导致二级缓存失效,所以适合在查询为主的应用中使用,比如历史交易、历史订单的查询。否则缓存就失去了意义。
    2、如果多个namespace中有针对于同一个表的操作,比如Blog表,如果在一个namespace中刷新了缓存,另一个namespace中没有刷新,就会出现读到脏数据的情况。所以,推荐在一个Mapper里面只操作单表的情况使用。

    展开全文
  • MyBatis缓存机制详解

    千次阅读 2021-12-28 11:11:17
    MyBatis缓存机制详解1. MyBatis缓存1.1 MyBatis缓存概述1.2 MyBatis一二级缓存区别2. MyBatis一级缓存2.1 MyBatis一级缓存概述2.2 MyBatis一级缓存配置2.3 MyBatis一级缓存原理分析2.4 MyBatis一级缓存总结3. ...

    1. MyBatis缓存

    1.1 MyBatis缓存概述

    MyBatis作为目前最常用的ORM数据库访问持久层框架,其本身支持动态SQL存储映射等高级特性也非常优秀,通过Mapper文件采用动态代理模式使SQL与业务代码相解耦,日常开发中使用也非常广泛,本文主要讨论mybatis缓存功能,mybatis缓存本身设计初衷是为了解决同一会话相同查询的效率问题,单机环境下也确实起到了提高查询效率的作用,但是随着业务场景变化以及分布式微服务的出现,其弊端也渐渐显现出来,不同会话间操作数据,关联查询数据采用mybatis缓存时会存在出现脏数据的风险。

    1.2 MyBatis一二级缓存区别

    1.Mybatis一级缓存是SQLSession级别的,一级缓存的作用域是SQlSession;Mabits一级缓存默认是开启的。 在同一个SqlSession中,执行相同的SQL查询时;第一次会去查询数据库,并写在缓存中,第二次会直接从缓存中取。 在同一次会话中执行两次相同查询中间执行了更新操作的时候,缓存会被清空,第二次相同查询仍然会去查询数据库。

    2.Mybatis二级缓存是Mapper级别的,二级缓存的作用域是全局的,多个SQlSession共享的,二级缓存的作用域更大;Mybatis二级缓存默认是没有开启的。 第一次调用mapper下的SQL去查询用户的信息,查询到的信息会存放在该mapper对应的二级缓存区域。 第二次调用namespace下的mapper映射文件中,相同的sql去查询用户信息,会去对应的二级缓存内取结果。

    2. MyBatis一级缓存

    2.1 MyBatis一级缓存概述

    默认情况下,只启用了本地的会话缓存,也就是一级缓存,它仅仅对一个会话中的数据进行缓存。 mybatis一级缓存指的是在应用运行过程中,一次数据库会话中,执行多次相同的查询,会优先查询缓存中的数据,减少数据库查询次数,提高查询效率。
    每个SqlSession中持有了Executor,每个Executor中有一个LocalCache。当用户发起查询时,MyBatis根据当前执行的语句生成MappedStatement,在Local Cache进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入Local Cache,最后返回结果给用户。

    2.2 MyBatis一级缓存配置

    mybatis一级缓存默认是开启的,可根据需要选择级别是session或这statement。开发者只需在MyBatis的配置文件中,添加如下语句,就可以使用一级缓存。共有两个选项,session或者statement,默认是session级别,即在一个MyBatis会话中执行的所有语句,都会共享这一个缓存。一种是statement级别,可以理解为缓存只对当前执行的这一个Statement有效。

    <setting name="localCacheScope" value="SESSION"/>
    

    2.3 MyBatis一级缓存原理分析

    1.在初始化SqlSesion时,会使用Configuration类创建一个全新的Executor,作为DefaultSqlSession构造函数的参数。

        // newExecutor 尤其可以注意这里,如果二级缓存开关开启的话,是使用CahingExecutor装饰BaseExecutor的子类
        if (cacheEnabled) {
          executor = new CachingExecutor(executor);                      
        }
    

    2.SqlSession创建完毕后,根据Statment的不同类型,会进入SqlSession的不同方法中,如果是Select语句的话,最后会执行到SqlSession的selectList,代码如下所示:

    @Override
    public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
          MappedStatement ms = configuration.getMappedStatement(statement);
          return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
    }
    

    3.SqlSession把具体的查询职责委托给了Executor。如果只开启了一级缓存的话,首先会进入BaseExecutor的query方法。代码如下所示:

    @Override
    public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
        BoundSql boundSql = ms.getBoundSql(parameter);
        CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
        return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
    }
    

    4.在上述代码中,会先根据传入的参数生成CacheKey,进入该方法查看CacheKey是如何生成的,代码如下所示:

    CacheKey cacheKey = new CacheKey();
    cacheKey.update(ms.getId());
    cacheKey.update(rowBounds.getOffset());
    cacheKey.update(rowBounds.getLimit());
    cacheKey.update(boundSql.getSql());
    //后面是update了sql中带的参数
    cacheKey.update(value);
    

    在上述的代码中,将MappedStatement的Id、SQL的offset、SQL的limit、SQL本身以及SQL中的参数传入了CacheKey这个类,最终构成CacheKey。以下是这个类的内部结构:

    public CacheKey() {
        this.hashcode = DEFAULT_HASHCODE;
        this.multiplier = DEFAULT_MULTIPLYER;
        this.count = 0;
        this.updateList = new ArrayList<Object>();
    }
    

    首先是成员变量和构造函数,有一个初始的hachcode和乘数,同时维护了一个内部的updatelist。在CacheKey的update方法中,会进行一个hashcode和checksum的计算,同时把传入的参数添加进updatelist中。如下代码所示:

    public void update(Object object) {
        int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object); 
        count++;
        checksum += baseHashCode;
        baseHashCode *= count;
        hashcode = multiplier * hashcode + baseHashCode;
        
        updateList.add(object);
    }
    

    除去hashcode、checksum和count的比较外,只要updatelist中的元素一一对应相等,那么就可以认为是CacheKey相等。只要两条SQL的下列五个值相同,即可以认为是相同的SQL。
    Statement Id + Offset + Limmit + Sql + Params

    5.BaseExecutor的query方法继续往下走,代码如下所示:

    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);
    }
    

    如果查不到的话,就从数据库查,在queryFromDatabase中,会对localcache进行写入。

    在query方法执行的最后,会判断一级缓存级别是否是STATEMENT级别,如果是的话,就清空缓存,这也就是STATEMENT级别的一级缓存无法共享localCache的原因。代码如下所示:

    if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
            clearLocalCache();
    }
    

    在源码分析的最后,我们确认一下,如果是insert/delete/update方法,缓存就会刷新的原因。
    SqlSession的insert方法和delete方法,都会统一走update的流程,代码如下所示:

    @Override
    public int insert(String statement, Object parameter) {
        return update(statement, parameter);
      }
       @Override
      public int delete(String statement) {
        return update(statement, null);
    }
    

    update方法也是委托给了Executor执行。BaseExecutor的执行方法如下所示:

    @Override
    public int update(MappedStatement ms, Object parameter) throws SQLException {
        ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
        if (closed) {
          throw new ExecutorException("Executor was closed.");
        }
        clearLocalCache();
        return doUpdate(ms, parameter);
    }
    

    每次执行update前都会清空localCache。

    2.4 MyBatis一级缓存总结

    1.MyBatis一级缓存的生命周期和SqlSession一致。
    2.MyBatis一级缓存内部设计简单,只是一个没有容量限定的HashMap,在缓存的功能性上有所欠缺。
    3.MyBatis的一级缓存最大范围是SqlSession内部,有多个SqlSession或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为Statement。

    3. MyBatis二级缓存

    3.1 MyBatis二级缓存概述

    在上文中提到的一级缓存中,其最大的共享范围就是一个SqlSession内部,如果多个SqlSession之间需要共享缓存,则需要使用到二级缓存。开启二级缓存后,会使用CachingExecutor装饰Executor,进入一级缓存的查询流程前,先在CachingExecutor进行二级缓存的查询,具体的工作流程如下所示。
    在这里插入图片描述

    3.2 MyBatis二级缓存配置

    要正确的使用二级缓存,需完成如下配置的。
    1.在MyBatis的配置文件中开启二级缓存。

    <setting name="cacheEnabled" value="true"/>
    

    2.在MyBatis的映射XML中配置cache或者 cache-ref 。
    cache标签用于声明这个namespace使用二级缓存,并且可以自定义配置。

    <cache/>   
    
    type:cache使用的类型,默认是PerpetualCache,这在一级缓存中提到过。
    eviction: 定义回收的策略,常见的有FIFO,LRU。
    flushInterval: 配置一定时间自动刷新缓存,单位是毫秒。
    size: 最多缓存对象的个数。
    readOnly: 是否只读,若配置可读写,则需要对应的实体类能够序列化。
    blocking: 若缓存中找不到对应的key,是否会一直blocking,直到有对应的数据进入缓存。
    

    3.cache-ref代表引用别的命名空间的Cache配置,两个命名空间的操作使用的是同一个Cache。

    <cache-ref namespace="mapper.StudentMapper"/>
    

    3.3 MyBatis二级缓存原理分析

    源码分析从CachingExecutor的query方法展开,源代码走读过程中涉及到的知识点较多,不能一一详细讲解,读者朋友可以自行查询相关资料来学习。
    CachingExecutor的query方法,首先会从MappedStatement中获得在配置初始化时赋予的Cache。

    Cache cache = ms.getCache();
    

    本质上是装饰器模式的使用,具体的装饰链是:
    SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache。

    以下是具体这些Cache实现类的介绍,他们的组合为Cache赋予了不同的能力。

    SynchronizedCache:同步Cache,实现比较简单,直接使用synchronized修饰方法。
    LoggingCache:日志功能,装饰类,用于记录缓存的命中率,如果开启了DEBUG模式,则会输出命中率日志。
    SerializedCache:序列化功能,将值序列化后存到缓存中。该功能用于缓存返回一份实例的Copy,用于保存线程安全。
    LruCache:采用了Lru算法的Cache实现,移除最近最少使用的Key/Value。
    PerpetualCache: 作为为最基础的缓存类,底层实现比较简单,直接使用了HashMap。
    

    3.4 MyBatis二级缓存总结

    1.MyBatis的二级缓存相对于一级缓存来说,实现了SqlSession之间缓存数据的共享,同时粒度更加的细,能够到namespace级别,通过Cache接口实现类不同的组合,对Cache的可控性也更强。
    2.MyBatis在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用二级缓存的条件比较苛刻。
    3.在分布式环境下,由于默认的MyBatis Cache实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将MyBatis的Cache接口实现,有一定的开发成本,直接使用Redis、Memcached等分布式缓存可能成本更低,安全性也更高。

    4. MyBatis缓存测试

    测试案例地址:https://gitee.com/rjzhu/opencode/tree/master/mybatis-cache-demo

    /**
     * MyBatis缓存测试类
     */
    public class StudentMapperTest {
    
        private SqlSessionFactory factory;
    
        /**
         * 初始化SqlSessionFactory
         */
        @Before
        public void setUp() throws Exception {
            factory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("mybatis-config.xml"));
        }
    
        /**
         * 查看缓存配置是否生效
         * <setting name="localCacheScope" value="SESSION"/>
         * <setting name="cacheEnabled" value="true"/>
         */
        @Test
        public void showDefaultCacheConfiguration() {
            System.out.println("本地缓存范围: " + factory.getConfiguration().getLocalCacheScope());
            System.out.println("二级缓存是否被启用: " + factory.getConfiguration().isCacheEnabled());
        }
    
        /**
         * MyBatis缓存测试一
         * 测试:同一个会话,相同查询连续查询三次
         * 结果:第一次查询数据库,二三次查询从缓存读取
         * 结论:同一个会话,多次相同查询,只有第一次查询数据库,其他都是缓存中获取,提高了查询效率
         */
        @Test
        public void testLocalCache() {
            SqlSession sqlSession = factory.openSession(true); // 自动提交事务
            StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
    
            //第一次查询数据库,二三次查询直接从缓存读取
            System.out.println("第一次查询:" + studentMapper.getStudentById(1));
            System.out.println("第二次查询:" + studentMapper.getStudentById(1));
            System.out.println("第三次查询:" + studentMapper.getStudentById(1));
    
            sqlSession.close();
        }
    
        /**
         * MyBatis缓存测试二
         * 测试:同一个会话,先查询,再新增,再次重复第一次查询
         * 结果:第一次与第二次查询都查询数据库,修改操作后执行的相同查询,查询了数据库,一级缓存失效。
         * 结论:同一个会话执行更新操作后缓存失效,源码中会清空缓存
         */
        @Test
        public void testLocalCacheClear() {
            SqlSession sqlSession = factory.openSession(true); // 自动提交事务
            StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
    
            //第一次与第二次查询都查询数据库,修改操作后执行的相同查询,查询了数据库,一级缓存失效。
            System.out.println("第一次查询:" + studentMapper.getStudentById(1));
            System.out.println("增加了" + studentMapper.addStudent(StudentEntity.builder().name("明明").age(20).build()) + "个学生");
            System.out.println("第二次查询:" + studentMapper.getStudentById(1));
    
            sqlSession.close();
        }
    
        /**
         * MyBatis缓存测试三
         * 测试:同时开启两个会话,会话一连续两次查询,会话二更新操作,会话一再次相同查询,会话二相同查询
         * 结果:会话一第一次查询数据库,第二次查询缓存,会话二更新完成,会话一再次相同查询仍然查询缓存(读取脏数据),会话二查询数据库获取最新数据。
         * 结论:缓存作用范围是一个会话当中,当其中有会话更新数据,其他会话会读取到脏数据
         */
        @Test
        public void testLocalCacheScope() {
            //开启两个SqlSession,在sqlSession1中查询数据,使一级缓存生效,在sqlSession2中更新数据库
            //验证一级缓存只在数据库会话内部共享。
            SqlSession sqlSession1 = factory.openSession(true); // 自动提交事务
            SqlSession sqlSession2 = factory.openSession(true); // 自动提交事务
    
            StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
            StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
    
            System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
            System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
            System.out.println("studentMapper2更新了" + studentMapper2.updateStudentName("小岑", 1) + "个学生的数据");
            System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
        }
    
        /**
         * MyBatis缓存测试四
         * 测试:同时开启两个会话,两个会话执行相同的查询
         * 结果:两次都是查询数据库
         * 结论:缓存作用范围是一个会话当中,不同会话,即使是相同查询,只使用各自的缓存
         */
        @Test
        public void testCacheWithoutCommitOrClose() {
            SqlSession sqlSession1 = factory.openSession(true); // 自动提交事务
            SqlSession sqlSession2 = factory.openSession(true); // 自动提交事务
    
            StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
            StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
    
            //两次都是从数据库读取,说明需要提交事务,第二次查询才能走缓存
            System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
    
        }
    
        /**
         * MyBatis缓存测试四
         * 测试:同时开启两个会话,两个会话执行相同的查询
         * 结果:两次都是查询数据库
         * 结论:缓存作用范围是一个会话当中,不同会话,即使是相同查询,只使用各自的缓存
         */
        @Test
        public void testCacheWithCommitOrClose() {
            SqlSession sqlSession1 = factory.openSession(true); // 自动提交事务
            SqlSession sqlSession2 = factory.openSession(true); // 自动提交事务
    
            StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
            StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
    
            //第一次提交以后,第二次走缓存
            System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
            sqlSession1.close();
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
    
        }
    
        /**
         * MyBatis缓存测试五
         * 测试:同时开启三个会话,通过接口方式,会话一查询后提交事务,会话二执行相同查询,缓存查询,会话三更新提交事务,会话二查询缓存
         * 结果:只有第一次查询数据库,其余都是查询缓存
         * 结论:只有提交事务以后,后续相同查询才会查询缓存,否则查询数据库
         */
        @Test
        public void testCacheWithUpdate() {
            SqlSession sqlSession1 = factory.openSession(true); // 自动提交事务
            SqlSession sqlSession2 = factory.openSession(true); // 自动提交事务
            SqlSession sqlSession3 = factory.openSession(true); // 自动提交事务
    
            StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
            StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
            StudentMapper studentMapper3 = sqlSession3.getMapper(StudentMapper.class);
    
            System.out.println("studentMapper1读取数据: " + studentMapper.getStudentById(1));
            sqlSession1.close();
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
    
            studentMapper3.updateStudentName("方方", 1);
            sqlSession3.commit();
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
        }
    
        /**
         * MyBatis缓存测试六
         * 测试:测试关联查询,出现脏数据问题,
         * 结论:不同会话之间关联查询的时候,其中会话更新单独更新关联的其中一个表,另一个会话感知不到,在不同的mapper文件中,缓存查询会出现脏数据情况
         */
        @Test
        public void testCacheWithDiffererntNamespace() {
            // 设置自动提交事务
            SqlSession sqlSession1 = factory.openSession(true);
            SqlSession sqlSession2 = factory.openSession(true);
            SqlSession sqlSession3 = factory.openSession(true);
    
            StudentMapper studentMapper1 = sqlSession1.getMapper(StudentMapper.class);
            StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
            ClassMapper classMapper3 = sqlSession3.getMapper(ClassMapper.class);
    
            System.out.println("studentMapper1读取数据: " + studentMapper1.getStudentByIdWithClassInfo(1));
            sqlSession1.close();
    
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentByIdWithClassInfo(1));
    
            //更新数据
            classMapper3.updateClassName("特色一班", 1);
            sqlSession3.commit();
    
            //读取到脏数据,studentMapper2读取数据: StudentEntity(id=1, name=方方, age=16, className=一班)
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentByIdWithClassInfo(1));
        }
    
    }
    

    5. 参考文档

    MyBatis中文网:https://mybatis.net.cn/index.html
    MyBatis英文网:https://mybatis.org/mybatis-3/index.html
    MyBatis执行流程源码分析:https://blog.csdn.net/m0_37583655/article/details/122115750
    聊聊MyBatis缓存机制
    mybatis一级缓存和二级缓存的区别是什么

    展开全文
  • mybatis缓存机制详解

    千次阅读 热门讨论 2021-12-16 10:12:13
    mybatis提供了缓存机制减轻数据库压力,提高数据库性能。mybatis的缓存分为一级和二级两种: 一级缓存:SqlSession级别的缓存,缓存的数据只在SqlSession内有效 二级缓存:mapper级别的缓存,同一个namespace公用这...
  • 13.MyBatis缓存机制

    2022-02-25 08:32:32
    13.MyBatis缓存机制 1. 为什么使用缓存? 当用户频繁查询某些固定的数据时,第一次将这些数据从数据库中查询出来,保存在缓存中。当用户再次查询这些数据时,不用再通过数据库查询,而是去缓存里面查询。减少网络连接和...
  • Mybatis有哪些缓存机制? 如图Mybaits里面设计了二级缓存,来提升数据的一个检索效率,也就是避免每一次数据的检索都去查询数据库,一级缓存是SqlSession级别的一个缓存,也叫本地缓存,因为每一个用户在执行查询的...
  • mybatis 包含了一个非常强大的查询缓存特性,它可以非常方便地配置和定制,共有两级缓存机制。 当程序通过mybatis发起查询命令,mybatis则会根据程序发送的命令首先去缓存中寻找,如果命中缓存,则直接将缓存中的数据...
  • 聊聊MyBatis缓存机制

    千次阅读 多人点赞 2018-04-18 13:24:16
    点击上方“程序员小灰”,选择“置顶公众号”有趣有内涵的文章第一时间送达!本文转载自公众号 美团技术点评前言MyBatis是常见的Java数据库访问层框架。在日常工作中,开...
  • mybatis缓存默认分为两级 一级缓存sqlsession(默认开启) 和二级缓存(需要手动开启) 一级缓存也是本地缓存,在与数据库的一次会话当中,从数据库中查询到的数据会被放入缓存中(以对象形式放入,二级缓存是单纯...
  • mybatis 一级缓存是 SqlSession 级别的缓存,默认支持一级缓存,不需要在配置文件去配置。 mybaits 的二级缓存是 mapper 范围级别,除了在 SqlMapConfig.xml 设置二级缓存的总开关<settingname=‘cacheEnabled’ ...
  • Mybatis缓存机制案例

    2018-08-03 15:20:20
    通过log4j打印来查看mybatis缓存实现的机制, 原博客http://blog.csdn.net/zouxucong/article/details/68947052
  • 这里写自定义目录标题MyBatis的工作原理MyBatis的工作原理MyBatis的主要成员Mybatis加载机制Mybatis缓存机制一级缓存说明:具体应用二级缓存说明:禁用二级缓存刷新二级缓存应用场景局限性 MyBatis的工作原理 ...
  • 本文主要讲解MyBatis非常棒的缓存机制的设计原理,给读者们介绍一下MyBatis缓存机制的轮廓,然后会分别针对缓存机制中的方方面面展开讨论。 MyBatis将数据缓存设计成两级结构,分为一级缓存、二级缓存: ...
  • java互联网架构-Mybatis缓存机制.pdf
  • MyBatis缓存机制深度解剖[收集].pdf
  • 相同查询条件下,第二次查询使用了第一次的查询结果,而且两次查询是在不同的方法中进行,第一次的对象肯定会被GC回收,所以应该有某种缓存机制存在,那么只可能是 Mybatis 实现了某种缓存机制。 经过查询 Mybatis ...
  • MyBatis 包含了一个非常强大的 查询缓存特性,它可以非常方便地配置和定制。MyBatis 3 中的缓存实现的很多改进都已经实现了,使得它更加强大而且易于配置。mybatis 默认情况下只会开启一级缓存,也就是局部的 session...
  • 本文主要讲解MyBatis非常棒的缓存机制的设计原理,给读者们介绍一下MyBatis缓存机制的轮廓,然后会分别针对缓存机制中的方方面面展开讨论。

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 46,996
精华内容 18,798
关键字:

mybatis缓存机制