mybatis入门实战&源码解读(2):mybatis核心应用配置与原理源码解析

mybatis的一、二级缓存

  与大多数的持久层框架一样,MyBatis也提供了缓存策略,在应用程序和数据库都是单节点情况下,合理使用缓存能减少数据库的IO,通过缓存策略来减少数据库的查询次数,从而提高性能。但是分布式环境,如果使用不当,可能会带来一些数据一致性问题。MyBatis提供了一级缓存以及二级缓存,其中一级缓存是基于SqlSession实现的,而二级缓存是基于Mapper来实现的。
  MyBatis缓存都是基于JVM对内存来实现的,即所有的数据都是存放在Java对象中。MyBatis通过Cache接口定义缓存对象的行为。Cache接口的具体实现源码如下:


public interface Cache {

  /**
   * @return The identifier of this cache
   * 缓存id即Mapper的命名空间名称
   */
  String getId();

  /**
   * @param key
   *          Can be any object but usually it is a {@link CacheKey}
   * @param value
   *          The result of a select.
   * 将一个Java对象添加到缓存中,key就是缓存的key即CacheKey的实例,第二个参数就是需要缓存的对象,缓存以(key,value)键值对的形式存放在Cache中
   */
  void putObject(Object key, Object value);

  /**
   * @param key
   *          The key
   * @return The object stored in the cache.
   * 获取缓存key对应的缓存对象
   */
  Object getObject(Object key);

  /**
   * @param key
   *          The key
   * @return Not used
   * 将一个对象从缓存中清除
   */
  Object removeObject(Object key);

  /**
   * Clears this cache instance.
   *  清空缓存
   */
  void clear();

  /**
   * Optional. This method is not called by the core.
   *
   * @return The number of elements stored in the cache (not its capacity).
   */
  int getSize();

  /**
   * Optional. As of 3.2.6 this method is no longer called by the core.
   * <p>
   * Any locking needed by the cache must be provided internally by the cache provider.
   *
   * @return A ReadWriteLock
   */
  default ReadWriteLock getReadWriteLock() {
    return null;
  }

}

  • getId()用于获取缓存的id,通常情况下id为mapper命名空间namespace的名称,debug截图如下:
    getId()用于获取mapper的namespace名称
  • putObject()用于将一个java对象添加到缓存中,该方法有两个参数,第一个参数即需要缓存的key,即CacheKey实例,第二个参数为需要缓存的对象。
  • getObject():该方法用于获取缓存key对应的缓存对象。
  • removeObject():该方法用于将一个对象从缓存中移除。
  • clear():该方法用于清空缓存。

MyBatis缓存简介

MyBatis缓存使用装饰器模式设计,Cache接口有一个基本实现类PerpetualCache,使用HashMap实例来存放缓存对象,并且PerpetualCache类重写了Object类的equals()方法,当两个缓存对象的id相同时,就认为是两个缓存对象相同,PerpetualCache类还重写了Object类的hashCode方法,以缓存的id作为因子生成hashCode。PerpetualCache的源码如下:


package org.apache.ibatis.cache.impl;

import java.util.HashMap;
import java.util.Map;

import org.apache.ibatis.cache.Cache;
import org.apache.ibatis.cache.CacheException;

/**
 * @author Clinton Begin
 * 实现了Cache接口
 */
public class PerpetualCache implements Cache {

  private final String id;

 //使用HashMap来存放缓存对象
  private final Map<Object, Object> cache = new HashMap<>();

  public PerpetualCache(String id) {
    this.id = id;
  }

  @Override
  public String getId() {
    return id;
  }

  @Override
  public int getSize() {
    return cache.size();
  }

  @Override
  public void putObject(Object key, Object value) {
    cache.put(key, value);
  }

  @Override
  public Object getObject(Object key) {
    return cache.get(key);
  }

  @Override
  public Object removeObject(Object key) {
    return cache.remove(key);
  }

  @Override
  public void clear() {
    cache.clear();
  }

   //重写了equals方法,使用equals方法来判断,只要缓存的id相同,就认为是同一个对象
  @Override
  public boolean equals(Object o) {
    if (getId() == null) {
      throw new CacheException("Cache instances require an ID.");
    }
    if (this == o) {
      return true;
    }
    if (!(o instanceof Cache)) {
      return false;
    }

    Cache otherCache = (Cache) o;
    return getId().equals(otherCache.getId());
  }

 //重写了hashCode方法,将缓存的id作为散列因子
  @Override
  public int hashCode() {
    if (getId() == null) {
      throw new CacheException("Cache instances require an ID.");
    }
    return getId().hashCode();
  }

}

缓存装饰器

除了PerpetualCache类之外,MyBatis中为了对Perpetual类的功能进行增强,提供了一些装饰器类,在IDEA中打开的UML截图如下:
在这里插入图片描述

  • BlockingCache:阻塞版本的缓存装饰器,能够保证同一时间只有一个线程到缓存中查找指定的Key对应的数据。
  • FifoCache:先入先出缓存装饰器,FifoCache内部有一个维护具有长度限制的Key键值链表(LinkedList实例)和一个被装饰的缓存对象,Key值链表主要是维护Key的FIFO顺序,而缓存存储和获取则交给被装饰的缓存对象来完成。
  • LoggingCache:为缓存增加日志输出功能,记录缓存的请求次数和命中次数,通过日志输出缓存命中率。
  • LruCache:最近最少使用的缓存装饰器,当缓存容量满了之后,使用LRU算法淘汰最近最少使用的Key和Value。LruCache中通过重写LinkedHashMap类的removeEldestEntry()方法获取最近最少使用的Key值,将Key值保存在LruCache类的eldestKey属性中,然后在缓存中添加对象时,淘汰eldestKey对应的Value值。
  • ScheduledCache:自动刷新缓存装饰器,当操作缓存对象时,如果当前时间与上次清空缓存的时间间隔大于指定的时间间隔,则清空缓存。清空缓存的动作由getObject()、putObject()、removeObject()等方法触发。
  • SerializedCache:序列化缓存装饰器,向缓存中添加对象时,对添加的对象进行序列化处理,从缓存中取出对象时,进行反序列化处理。
  • SoftCache:软引用缓存装饰器,SoftCache内部维护了一个缓存对象的强引用队列和软引用队列,缓存以软引用的方式添加到缓存中,并将软引用添加到队列中,获取缓存对象时,如果对象已经被回收,则移除Key,如果未被回收,则将对象添加到强引用队列中,避免被回收,如果强引用队列已经满了,则移除最早入队列的对象的引用。
  • SynchronizedCache:线程安全缓存装饰器,SynchronizedCache的实现比较简单,为了保证线程安全,对操作缓存的方法使用synchronized关键字修饰。
  • TransactionalCache:事务缓存装饰器,该缓存与其他缓存的不同之处在于,TransactionalCache增加了两个方法,即commit()和rollback()。当写入缓存时,只有调用commit()方法后,缓存对象才会真正添加到TransactionalCache对象中,如果调用了rollback()方法,写入操作将被回滚。
  • WeakCache:弱引用缓存装饰器,功能和SoftCache类似,只是使用不同的引用类型。

一级缓存

    MyBatis一级缓存是SqlSession级别的缓存,默认是开启的,而且不能关闭,不能关闭是因为MyBatis的一些关键特性如一对一条件查询和一对多关联查询等联级映射都是基于MyBatis的一级缓存实现的,而且MyBatis结果集映射相关代码重度依赖CacheKey,所以MyBatis目前不支持关闭一级缓存。我们在开发过程中,经常会遇到在一次数据库会话中即一个SqlSession中,需要执行多次相同查询条件的SQL语句,MyBatis提供了一级缓存的方案来优化这部分场景,如果是相同的SQL语句,MyBatis会首先命中一级缓存,减少对数据库直接查询的次数,提高性能。
    在MyBatis全局配置文件中,提供了一个配置参数localCacheScope,用于控制一级缓存的级别,localCacheScope的取值可以是SESSION或者STATEMENT,当指定locaCacheScope的参数值为SESSION的时候,缓存对整个SESSION有效,只有执行DML语句(增、删、改操作)或者clearCache的时候,缓存才会被清除。当LocalCacheScope的值为STATEMENT的时候,缓存仅仅对当前执行的SQL语句有效,当语句执行完毕,缓存就会被清空。MyBatis的一级缓存,用户只能控制缓存级别,不能关闭缓存。

使用一级缓存的条件

  • 必须是相同的SQL和参数;
  • 必须是同一个SqlSession会话;
  • 必须是相同的mapper和namespace;
  • 必须是相同的Statement,即同一个mapper接口中的同一个方法;
  • 查询语句之间没有执行session.clearCache()方法;
  • 查询语句之间没有增、删、改操作,如果查询中间执行了增删改操作,就会去执行clearCache操作,也会导致不能命中缓存。

验证一级缓存的存在

(1)配置一级缓存

mybatis的默认的一级缓存级别是Session级别,在MyBatis配置文件添加如下语句就可以使用一级缓存。

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

mybatis中一级缓存共有两个选项:SESSION和STATEMENT,默认是SESSION,就是说在一次mybatis会话中符合一级缓存使用条件执行的所有语句,都会共享这个缓存。STATEMENT级别的缓存可以理解为只对当前执行的SQL语句有效,当语句执行完毕,缓存就会被清空。

(2)在MyBatis数据库中新建一张user表,并添加数据。

  • 创建user表
create table user(
	id int primary key AUTO_INCREMENT,
	name varchar(20) not null,
	age int not null,
	salary double not null,
	address varchar(100) not null
)
  • 在表中插入数据
insert into user(name,age,salary,address) values('积木',23,23.56,'深圳市华强北路'),('刘德华',36,36.65,'香港铜锣湾')
  • 查看属否插入成功
select * from user;

在这里插入图片描述

在IDEA中新建mybatis_test_02项目

  • 在pom.xml中导入坐标
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.mybatis</groupId>
    <artifactId>mybatis_test_02</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
            <version>3.5.6</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.22</version>
        </dependency>
        <dependency>
            <groupId>log4j</groupId>
            <artifactId>log4j</artifactId>
            <version>1.2.17</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>
  • 在src->main->resources目录下面创建mybatis配置文件MyBatisConfig.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>
        <!--一级缓存默认是打开的,且默认是Sqlsession级别,这里在主配置文件中选择缓存级别为SESSION-->
        <setting name="localCacheScope" value="SESSION"/>
        <!--开启二级缓存-->
        <!--因为cacheEnabled的取值默认就是true,所以这一步可以省略不进行任何配置。为true代表开启二级缓存,为false代表不开启二级缓存-->
        <setting name="cacheEnabled" value="true"/>
    </settings>
    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC"/>
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://localhost:3306/mybatis"/>
                <property name="username" value="root"/>
                <property name="password" value="Shezeq1,"/>
            </dataSource>
        </environment>
    </environments>
    <mappers>
        <package name="com.mybatis.dao"/>
    </mappers>
</configuration>
  • 在src目录下面创建com.mybatis.doamin包,并在包中新建User类,实现Serializable接口,并添加setter/getter方法以及toString方法。
package com.itheima.domain;

import java.io.Serializable;

public class User implements Serializable {
    private Integer id;
    private String name;
    private Integer age;
    private Double salary;
    private String address;

    public Integer getId() {
        return id;
    }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", age=" + age +
                ", salary=" + salary +
                ", address='" + address + '\'' +
                '}';
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

    public Double getSalary() {
        return salary;
    }

    public void setSalary(Double salary) {
        this.salary = salary;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }
}
  • 在src目录下面创建com.mybatis.dao包,在包中新建IUserDao接口,并在接口中添加findUserById方法用于查找用户信息。
package com.mybatis.dao;

import com.mybatis.domain.User;

import java.util.List;

public interface IUserDao {
    //根据id查找
    User findUserById(Integer id);
}

  • 在Resources目录下面创建com/mybatis/dao目录,并在目录中新建IUserDao.xml的mapper映射文件。
<?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.mybatis.dao.IUserDao">
    <select id="findUserById" resultType="com.mybatis.domain.User">
        select * from user where id = #{id}
    </select>
</mapper>

在test目录下面新建com.mybatis.test包,并在包中新建测试类UserTest,在测试类中新建testFindUserById方法,使用同一个SQL语句、同一个SqlSession、同一个mapper,用以证明一级缓存确实存在。

package com.itheima.test;

import com.mybatis.dao.IUserDao;
import com.mybatis.domain.User;
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 org.junit.After;
import org.junit.Before;
import org.junit.Test;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;

public class UserTest {
    private InputStream inputStream;
    private SqlSessionFactoryBuilder sqlSessionFactoryBuilder;
    private SqlSessionFactory sqlSessionFactory;
    private SqlSession sqlSession;
    private IUserDao iUserDao;
    @Before
    public void init() throws IOException {
        inputStream= Resources.getResourceAsStream("MyBatisConfig.xml");
        sqlSessionFactoryBuilder = new SqlSessionFactoryBuilder();
        sqlSessionFactory = sqlSessionFactoryBuilder.build(inputStream);
        sqlSession =sqlSessionFactory.openSession();
    }
    @After
    public void destory() throws IOException {
        sqlSession.commit();
        sqlSession.close();
        inputStream.close();
    }
    @Test
    public void testFindUserById(){
        iUserDao=sqlSession.getMapper(IUserDao.class);
        User user1 = iUserDao.findUserById(3);//通过id查找用户
        System.out.println(user1);

        //使用同一个session,同一个mapper,同一个Statement中的同一个方法查找用户
        User user2 = iUserDao.findUserById(3);//通过id查找用户
        System.out.println(user2);
        //校验两个用户是否为同一个用户
        System.out.println(user1==user2);//true
        
    }

}
  • 查找结果如下:
2021-01-13 18:43:33,124 494    [           main] DEBUG source.pooled.PooledDataSource  - Created connection 893504292.
2021-01-13 18:43:33,124 494    [           main] DEBUG ansaction.jdbc.JdbcTransaction  - Setting autocommit to false on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@3541cb24]
2021-01-13 18:43:33,128 498    [           main] DEBUG atis.dao.IUserDao.findUserById  - ==>  Preparing: select * from user where id = ?
2021-01-13 18:43:33,161 531    [           main] DEBUG atis.dao.IUserDao.findUserById  - ==> Parameters: 3(Integer)
2021-01-13 18:43:33,182 552    [           main] DEBUG atis.dao.IUserDao.findUserById  - <==      Total: 1
User{id=3, name='刘德华', age=999, salary=36.65, address='上海'}
User{id=3, name='刘德华', age=999, salary=36.65, address='上海'}
true
2021-01-13 18:43:33,184 554    [           main] DEBUG ansaction.jdbc.JdbcTransaction  - Resetting autocommit to true on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@3541cb24]
2021-01-13 18:43:33,184 554    [           main] DEBUG ansaction.jdbc.JdbcTransaction  - Closing JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@3541cb24]
2021-01-13 18:43:33,186 556    [           main] DEBUG source.pooled.PooledDataSource  - Returned connection 893504292 to pool.

从上面的日志中可以看到,只有第一次真正查询了数据库,后面都是一级缓存中拿到的对象,这个Demo证明了一级缓存确实存在。

在test目录下面新建com.mybatis.test包,在该包下面新建UserTest类,在该类中创建TestSqlSessionCache方法,用以验证一级缓存必须要在同一个session中。

package com.itheima.test;

import com.mybatis.dao.IUserDao;
import com.mybatis.domain.User;
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 org.junit.After;
import org.junit.Before;
import org.junit.Test;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;

public class UserTest {
    private InputStream inputStream;
    private SqlSessionFactoryBuilder sqlSessionFactoryBuilder;
    private SqlSessionFactory sqlSessionFactory;
    private SqlSession sqlSession;
    private IUserDao iUserDao;
    private User user;
    @Before
    public void init() throws IOException {
        inputStream= Resources.getResourceAsStream("MyBatisConfig.xml");
        sqlSessionFactoryBuilder = new SqlSessionFactoryBuilder();
        sqlSessionFactory = sqlSessionFactoryBuilder.build(inputStream);
        sqlSession =sqlSessionFactory.openSession();

    }
    @After
    public void destory() throws IOException {
        sqlSession.commit();
        sqlSession.close();
        inputStream.close();
    }
    @Test
    public void testSqlSessionCache(){
        iUserDao=sqlSession.getMapper(IUserDao.class);
        User user1 = iUserDao.findUserById(3);
        System.out.println(user1);

        //创建一个新的SqlSession
        sqlSession = sqlSessionFactory.openSession();
        //使用新的sqlsession创建新的IUserDao对象
        iUserDao=sqlSession.getMapper(IUserDao.class);
        User user2 = iUserDao.findUserById(3);
        System.out.println(user2);
        System.out.println(user1==user2);//false
    }
}

  • log4j的查找结果如下:
2021-01-13 18:56:36,022 534    [           main] DEBUG source.pooled.PooledDataSource  - Created connection 893504292.
2021-01-13 18:56:36,022 534    [           main] DEBUG ansaction.jdbc.JdbcTransaction  - Setting autocommit to false on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@3541cb24]
2021-01-13 18:56:36,025 537    [           main] DEBUG atis.dao.IUserDao.findUserById  - ==>  Preparing: select * from user where id = ?
2021-01-13 18:56:36,057 569    [           main] DEBUG atis.dao.IUserDao.findUserById  - ==> Parameters: 3(Integer)
2021-01-13 18:56:36,076 588    [           main] DEBUG atis.dao.IUserDao.findUserById  - <==      Total: 1
User{id=3, name='刘德华', age=999, salary=36.65, address='上海'}
2021-01-13 18:56:36,078 590    [           main] DEBUG ansaction.jdbc.JdbcTransaction  - Opening JDBC Connection
2021-01-13 18:56:36,095 607    [           main] DEBUG source.pooled.PooledDataSource  - Created connection 802243390.
2021-01-13 18:56:36,095 607    [           main] DEBUG ansaction.jdbc.JdbcTransaction  - Setting autocommit to false on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@2fd1433e]
2021-01-13 18:56:36,096 608    [           main] DEBUG atis.dao.IUserDao.findUserById  - ==>  Preparing: select * from user where id = ?
2021-01-13 18:56:36,096 608    [           main] DEBUG atis.dao.IUserDao.findUserById  - ==> Parameters: 3(Integer)
2021-01-13 18:56:36,098 610    [           main] DEBUG atis.dao.IUserDao.findUserById  - <==      Total: 1
User{id=3, name='刘德华', age=999, salary=36.65, address='上海'}
false
2021-01-13 18:56:36,098 610    [           main] DEBUG ansaction.jdbc.JdbcTransaction  - Resetting autocommit to true on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@2fd1433e]
2021-01-13 18:56:36,099 611    [           main] DEBUG ansaction.jdbc.JdbcTransaction  - Closing JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@2fd1433e]
2021-01-13 18:56:36,099 611    [           main] DEBUG source.pooled.PooledDataSource  - Returned connection 802243390 to pool.

可以看到,在不同的SqlSession中执行相同的查询(相同的Mapper和namespace,相同的Statement即相同接口中的方法,相同的SQL和参数),一级缓存失效,第二次查找操作查询了数据库

在test目录下面新建com.mybatis.test包,在该包下面新建UserTest类,在该类中创建TestSqlSessionCache方法,用以验证一级缓存只在数据库会话内部共享。

package com.itheima.test;

import com.mybatis.dao.IUserDao;
import com.mybatis.domain.User;
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 org.junit.After;
import org.junit.Before;
import org.junit.Test;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;

public class UserTest {
    private InputStream inputStream;
    private SqlSessionFactoryBuilder sqlSessionFactoryBuilder;
    private SqlSessionFactory sqlSessionFactory;
    private SqlSession sqlSession;
    private IUserDao iUserDao;
    private User user;
    @Before
    public void init() throws IOException {
        inputStream= Resources.getResourceAsStream("MyBatisConfig.xml");
        sqlSessionFactoryBuilder = new SqlSessionFactoryBuilder();
        sqlSessionFactory = sqlSessionFactoryBuilder.build(inputStream);
        sqlSession =sqlSessionFactory.openSession();

    }
    @After
    public void destory() throws IOException {
        sqlSession.commit();
        sqlSession.close();
        inputStream.close();
    }
    @Test
    public void testdeleteUserd(){
        SqlSession sqlSession1 = sqlSessionFactory.openSession();
        SqlSession sqlSession2 = sqlSessionFactory.openSession();
        IUserDao iUserDao1 = sqlSession1.getMapper(IUserDao.class);
        IUserDao iUserDao2 = sqlSession2.getMapper(IUserDao.class);
        User user1= iUserDao1.findUserById(2);
        User user2= iUserDao2.findUserById(2);
        System.out.println(user1);
        System.out.println(user2);
        System.out.println(user1==user2);//false
        System.out.println("iUserDao1执行更新操作"+iUserDao1.updateUser(new User(2,"齐达内",23,2345.5432,"北京")));
        System.out.println("iUserDao2执行查找操作"+iUserDao2.findUserById(2));
        System.out.println("iUserDao1执行查找操作"+iUserDao1.findUserById(2));

    }

}

执行结果如下:

2021-01-13 19:21:19,774 489    [           main] DEBUG source.pooled.PooledDataSource  - Created connection 561480862.
2021-01-13 19:21:19,774 489    [           main] DEBUG ansaction.jdbc.JdbcTransaction  - Setting autocommit to false on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@2177849e]
2021-01-13 19:21:19,777 492    [           main] DEBUG atis.dao.IUserDao.findUserById  - ==>  Preparing: select * from user where id = ?
2021-01-13 19:21:19,811 526    [           main] DEBUG atis.dao.IUserDao.findUserById  - ==> Parameters: 2(Integer)
2021-01-13 19:21:19,835 550    [           main] DEBUG atis.dao.IUserDao.findUserById  - <==      Total: 1
2021-01-13 19:21:19,838 553    [           main] DEBUG ansaction.jdbc.JdbcTransaction  - Opening JDBC Connection
2021-01-13 19:21:19,854 569    [           main] DEBUG source.pooled.PooledDataSource  - Created connection 802243390.
2021-01-13 19:21:19,854 569    [           main] DEBUG ansaction.jdbc.JdbcTransaction  - Setting autocommit to false on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@2fd1433e]
2021-01-13 19:21:19,855 570    [           main] DEBUG atis.dao.IUserDao.findUserById  - ==>  Preparing: select * from user where id = ?
2021-01-13 19:21:19,855 570    [           main] DEBUG atis.dao.IUserDao.findUserById  - ==> Parameters: 2(Integer)
2021-01-13 19:21:19,856 571    [           main] DEBUG atis.dao.IUserDao.findUserById  - <==      Total: 1
User{id=2, name='刘德华', age=999, salary=36.65, address='上海'}
User{id=2, name='刘德华', age=999, salary=36.65, address='上海'}
false
2021-01-13 19:21:19,856 571    [           main] DEBUG ybatis.dao.IUserDao.updateUser  - ==>  Preparing: update user set name=?,age=?,salary=?,address=? where id=?
2021-01-13 19:21:19,857 572    [           main] DEBUG ybatis.dao.IUserDao.updateUser  - ==> Parameters: 齐达内(String), 23(Integer), 2345.5432(Double), 北京(String), 2(Integer)
2021-01-13 19:21:19,859 574    [           main] DEBUG ybatis.dao.IUserDao.updateUser  - <==    Updates: 1
iUserDao1执行更新操作1
iUserDao2执行查找操作User{id=2, name='刘德华', age=999, salary=36.65, address='上海'}
2021-01-13 19:21:19,860 575    [           main] DEBUG atis.dao.IUserDao.findUserById  - ==>  Preparing: select * from user where id = ?
2021-01-13 19:21:19,860 575    [           main] DEBUG atis.dao.IUserDao.findUserById  - ==> Parameters: 2(Integer)
2021-01-13 19:21:19,861 576    [           main] DEBUG atis.dao.IUserDao.findUserById  - <==      Total: 1
iUserDao1执行查找操作User{id=2, name='齐达内', age=23, salary=2345.5432, address='北京'}

sqlSession1更新了id为2的user的信息,从User{id=2, name=‘刘德华’, age=999, salary=36.65, address=‘上海’}改成了User{id=2, name=‘齐达内’, age=23, salary=2345.5432, address=‘北京’},但是之后sqlSession2查询id为2的user信息的时候,user的信息还是User{id=2, name=‘刘德华’, age=999, salary=36.65, address=‘上海’},出现了脏读,这也说明了一级缓存只能在数据库会话内部共享,在并发情况下可能会有数据一致性问题。

一级缓存具体执行过程

在这里插入图片描述
每个SqlSession持有一个Executor,每一个Executor中都有一个localCache。当用户发起查询的时候,mybatis会根据配置文件和mapper映射文件生成MappedStatement对象,然后在localcache中进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,则查询数据库,然后将查询到的结果写回localCache中,最后将结果返回给用户,具体的实现类的类关系图如下所示。

一级缓存源码解读

前面说过,MyBatis一级缓存是SqlSession级别的缓存,SqlSession是面向用户的API,但是真正执行SQL操作的是Executor组件,Executor采用的是模板方法设计模式,BaseExecutor类用处理一些通用的逻辑,其中一级缓存相关的逻辑就是在BaseExecutor类中完成的。接下来首先通过Debug的方式来介绍一级缓存的源码,然后介绍BaseExecutor以及PerpetualExecutor实现细节。

一级缓存获取源码解读

  不管是一级缓存还是二级缓存,都实现了Cache接口,通过clearCache作为入口,我们能追踪到一级缓存的实现类PerpetualCache,该类中有getObject获取缓存、clear清除缓存、removeObjecty移除缓存。PerpetualCache的源码具体如下图所示:

/**
 *    Copyright 2009-2019 the original author or authors.
 *
 *    Licensed under the Apache License, Version 2.0 (the "License");
 *    you may not use this file except in compliance with the License.
 *    You may obtain a copy of the License at
 *
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 *    Unless required by applicable law or agreed to in writing, software
 *    distributed under the License is distributed on an "AS IS" BASIS,
 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *    See the License for the specific language governing permissions and
 *    limitations under the License.
 */
package org.apache.ibatis.cache.impl;

import java.util.HashMap;
import java.util.Map;

import org.apache.ibatis.cache.Cache;
import org.apache.ibatis.cache.CacheException;

/**
 * @author Clinton Begin
 */
public class PerpetualCache implements Cache {

  private final String id;

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

  public PerpetualCache(String id) {
    this.id = id;
  }

  @Override
  public String getId() {
    return id;
  }

  @Override
  public int getSize() {
    return cache.size();
  }

  @Override
  public void putObject(Object key, Object value) {
    cache.put(key, value);
  }

  @Override
  public Object getObject(Object key) {
    return cache.get(key);
  }

  @Override
  public Object removeObject(Object key) {
    return cache.remove(key);
  }

  @Override
  public void clear() {
    cache.clear();
  }

  @Override
  public boolean equals(Object o) {
    if (getId() == null) {
      throw new CacheException("Cache instances require an ID.");
    }
    if (this == o) {
      return true;
    }
    if (!(o instanceof Cache)) {
      return false;
    }

    Cache otherCache = (Cache) o;
    return getId().equals(otherCache.getId());
  }

  @Override
  public int hashCode() {
    if (getId() == null) {
      throw new CacheException("Cache instances require an ID.");
    }
    return getId().hashCode();
  }

}

无论我们是通过getMapper方法从代理对象中拿到的Mapper,还是使用MyBatis预定义的SelectOne或者是SelectList方法,最终都需要执行到DefaultSqlSession类的SelectList方法,并且最后是从PerpetualCache类中的getObject获取缓存对象,所以我们首先将断点打到PerpetualCache类的getObject方法上,然后点击debug,可以看到获取缓存过程有如下的调用。
在这里插入图片描述
从图中可以看到,首先会调用UserTest的testFindUserById方法,跳过其他的一些执行过程,我们直接看DefaultSqlSession的SelectList方法,实现源代码如下:

public class DefaultSqlSession implements SqlSession {

	@Override
	  public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
	    try {
	      MappedStatement ms = configuration.getMappedStatement(statement);
	      return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
	    } catch (Exception e) {
	      throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
	    } finally {
	      ErrorContext.instance().reset();
	    }
	  }
}	  

DefaultSqlSession的selectList方法会调用CachingExecutor类的query方法,此部分代码的调用是使用了装饰器模式,实现源码如下:

public class CachingExecutor implements Executor {
	@Override
	  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);
	        @SuppressWarnings("unchecked")
	        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;
	      }
	    }
	    //这里调用代理执行器的query方法,装饰器模式。
	    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
	  }
}	  

从上述代码中可以看到,CachingExecutor类的query方法会调用delegate的query方法,即BaseExecutor的query方法。一级缓存使用PerpetualCache实例实现,在BaseExecutor类中维护了两个PerpetualCache属性,其中localCache属性用于缓存MyBatis查询结果,localOutputParameterCache属性用于缓存存储过程调用结果。具体的实现源码如下:

public abstract class BaseExecutor implements Executor {
 	protected PerpetualCache localCache;
 	protected PerpetualCache localOutputParameterCache;


	 @SuppressWarnings("unchecked")
	  @Override
	  public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
	    ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
	    if (closed) {
	      throw new ExecutorException("Executor was closed.");
	    }
	    if (queryStack == 0 && ms.isFlushCacheRequired()) {
	      clearLocalCache();
	    }
	    List<E> list;
	    try {
	      queryStack++;
	      //如果resultHandler为空,就会调用localCache的getObject方法去localCache里面去获取缓存。
	      //首先根据缓存key从localCache属性中查找是否具有缓存对象
	      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) {
	      for (DeferredLoad deferredLoad : deferredLoads) {
	        deferredLoad.load();
	      }
	      // issue #601
	      deferredLoads.clear();
	      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
	        // issue #482
	        clearLocalCache();
	      }
	    }
	    return list;
	  }
}	  

在BaseExecutor类的query方法中,首先根据缓存key从localCache属性中查找是否具有缓存对象,如果查不到,则调用queryFromDataBase()方法从数据库中获取数据,然后将数据写入localCache对象中。如果localCache中缓存了本次查询的结果,则直接从缓存中获取,然后调用PerpetualCache去缓存中查找对对象。具体获取缓存的源码如下:

public class PerpetualCache implements Cache {
  private final Map<Object, Object> cache = new HashMap<>();//只是一个简单的hashMap
	@Override
	  public Object getObject(Object key) {
	    return cache.get(key);
	  }
}	  

从代码中可以看到,cache缓存只是一个简单的HashMap。缓存获取的调用过程如下:

>org.apache.ibatis.session.defaults.DefaultSqlSession#selectList()
	>org.apache.ibatis.executor.CachingExecutor#query()
		>org.apache.ibatis.executor.BaseExecutor#query()
			>org.apache.ibatis.cache.impl.PerpetualCache#getObject()

MyBatis通过CacheKey对象来描述缓存的key值。在进行查询时,首先创建Cache对象(CacheKey对象决定了缓存的Key与哪些因素有关系)。如果两次查询操作CacheKey对象相同,就认为这两次查询执行的是相同的SQL语句。CacheKey对象通过BaseExecutor类的createCacheKey()方法创建,代码如下:

@Override
  public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    CacheKey cacheKey = new CacheKey();
    cacheKey.update(ms.getId());//Mapper Id
    cacheKey.update(rowBounds.getOffset());//偏移量
    cacheKey.update(rowBounds.getLimit());//条数
    cacheKey.update(boundSql.getSql());//SQL语句
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
    // mimic DefaultParameterHandler logic
    //所有参数值
    for (ParameterMapping parameterMapping : parameterMappings) {
      if (parameterMapping.getMode() != ParameterMode.OUT) {
        Object value;
        String propertyName = parameterMapping.getProperty();
        if (boundSql.hasAdditionalParameter(propertyName)) {
          value = boundSql.getAdditionalParameter(propertyName);
        } else if (parameterObject == null) {
          value = null;
        } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
          value = parameterObject;
        } else {
          MetaObject metaObject = configuration.newMetaObject(parameterObject);
          value = metaObject.getValue(propertyName);
        }
        cacheKey.update(value);
      }
    }
    if (configuration.getEnvironment() != null) {
      // issue #176
      cacheKey.update(configuration.getEnvironment().getId());
    }
    return cacheKey;
  }

从上面的代码可以看到,缓存的Key与下面的这些因素有关:

  • Mapper的id,即Mapper命名空间与<select|update|insert|delete>标签的id组成的全局限定类名。
  • 查询结果的偏移量以及查询条数。
  • 具体的SQL语句以及SQL语句中需要传递的所有参数。
  • MyBatis主配置文件中,通过标签配置的环境信息对应的id属性值。

mybatis一级缓存存储源码解读

在PerpetualCache类中有一个putObject方法,这个方法是存放缓存的方法,
那么首先取消所有断点,然后在putObject方法上打断点,点击debug来进行反推调试。断点截图如下所示:在这里插入图片描述
首先看DefaultSqlSession类的SelectList方法,源码如下:

public class DefaultSqlSession implements SqlSession {

	 @Override
	  public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
	    try {
	      MappedStatement ms = configuration.getMappedStatement(statement);
	      return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
	    } catch (Exception e) {
	      throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
	    } finally {
	      ErrorContext.instance().reset();
	    }
	  }
}

DefaultSqlSession的selectList方法会调用CachingExecutor类的query方法,源码如下:

public class CachingExecutor implements Executor {

	 @Override
	  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);
	        @SuppressWarnings("unchecked")
	        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);
	  }
}	  

该方法会去调用BaseExecutor类的query方法。

 @SuppressWarnings("unchecked")
  @Override
  public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    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) {
      for (DeferredLoad deferredLoad : deferredLoads) {
        deferredLoad.load();
      }
      // issue #601
      deferredLoads.clear();
      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        // issue #482
        clearLocalCache();
      }
    }
    return list;
  }

这里可以看到,只有当从本地缓存中没有查找,才会去数据库中查找,即当list为空的时候,才会调用BaseExecutor类的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;
  }

该方法会调用localCache.putObject(key, list)方法,而localCache就是PerpetualCache类,即调用PerpetualCache类中的putObject方法来存放缓存,具体实现如下:

public class PerpetualCache implements Cache {
		@Override
  public Object getObject(Object key) {
    return cache.get(key);
  }

}

缓存保存源码调用过程如下:

>org.apache.ibatis.session.default.DefaultSqlSession.#selectList()
	>org.apache.ibatis.executor.CachingExecutor#query()
		>org.apache.ibatis.executor.BaseExecutor#query()
			>org.apache.ibatis.executor.BaseExecutor#queryFromDatabase()
				>org.apache.ibatis.cache.imp.PerpetualCache#putObject()

mybatis一级缓存清除源码解读

在PerpetualCache类中有一个clear方法,这个方法是清除缓存的方法,
那么首先取消所有断点,然后在clear方法上打断点,点击debug来进行反推调试。断点截图如下所示:
在这里插入图片描述
从图中可以看到,首先是调用DefaultSqlsession类中的clearCache()方法,具体实现源码如下:

public class DefaultSqlSession implements SqlSession {
	 @Override
	  public void clearCache() {
	    executor.clearLocalCache();
	  }
  }

该方法中调用了CachingExecutor的clearLocalCache()方法,具体源码实现如下:

public class CachingExecutor implements Executor {

	@Override
	  public void clearLocalCache() {
	    delegate.clearLocalCache();
	  }
}	  

CachingExecutor类会调用BaseExecutor类中的clearLocalCache()方法,具体实现代码如下:

public abstract class BaseExecutor implements Executor {

	@Override
	  public void clearLocalCache() {
	    if (!closed) {
	      localCache.clear();
	      localOutputParameterCache.clear();
	    }
	}
}	

BaseExecutor类会调用PerpetualCache类的clear()方法,具体实现如下:

public class PerpetualCache implements Cache {

	@Override
	  public void clear() {
	    cache.clear();
	  }
}	  

缓存清除源码调用过程如下所示:

>org.apache.ibatis.session.defaults.DefaultSqlSession#clearCache()
	>org.apache.ibatis.executor.CachingExecutor#clearLocalCache()
		>org.apache.ibatis.executo.BaseExecutor#clearLocalCache()
			>org.apache.ibatis.cache.impl.PerpetualCache#clear()

有两个会话,其中一个会话在查询的时候,另一个会话并发的修改查询到的数据,一级缓存是否会生效,如果生效是否会导致数据不正确?

  • 先来看下面这个例子:
package com.itheima.test;

import com.mybatis.dao.IUserDao;
import com.mybatis.domain.User;
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 org.junit.After;
import org.junit.Before;
import org.junit.Test;

import java.io.IOException;
import java.io.InputStream;

public class CacheTest {
    private InputStream inputStream;
    private SqlSessionFactoryBuilder sqlSessionFactoryBuilder;
    private SqlSessionFactory sqlSessionFactory;
    private SqlSession sqlSession;
    private IUserDao iUserDao;
    private User user;
    @Before
    public void init() throws IOException {
        inputStream= Resources.getResourceAsStream("MyBatisConfig.xml");
        sqlSessionFactoryBuilder = new SqlSessionFactoryBuilder();
        sqlSessionFactory = sqlSessionFactoryBuilder.build(inputStream);
        sqlSession =sqlSessionFactory.openSession();

    }
    @After
    public void destory() throws IOException {
//        sqlSession.commit();
//        sqlSession.close();
//        inputStream.close();
    }
  
    @Test
    public void testCache(){
        //新建两个SqlSession
        SqlSession sqlSession1 = sqlSessionFactory.openSession();
        SqlSession sqlSession2 = sqlSessionFactory.openSession();
        //分别获取IUserDao
        IUserDao iUserDao1 = sqlSession1.getMapper(IUserDao.class);
        IUserDao iUserDao2 = sqlSession2.getMapper(IUserDao.class);
        //
        User user1 = iUserDao1.findUserById(3);
        User user2 = iUserDao1.findUserById(3);
        System.out.println("user1 = "+user1);
        System.out.println("user2 = "+user2);
        //其中的一个SqlSession将User改了
        int i = iUserDao1.updateUser(new User(3, "梁静茹", 23, 23.32, "湖北省黄冈市英山县"));
        System.out.println(i);
        User user3 = iUserDao1.findUserById(3);
        User user4 = iUserDao2.findUserById(3);
        System.out.println("user1 = "+user3);//user1 = User{id=3, name='梁静茹', age=23, salary=23.32, address='湖北省黄冈市英山县'}
        System.out.println("user2 = "+user4);//出现了脏读,明明sqlSession1已经将数据库中的user已经改动了,但是改动之后sqlSession2还是读到的原来的数据,即读到了缓存,
        // user2 = User{id=3, name='刘德华', age=999, salary=36.65, address='上海'}
    }
}

说明了一级缓存只能在数据库会话内部共享。

delete、update、insert操作对缓存的影响

在PerpetualCache类中有一个clear方法,CUD操作对缓存的影响就是会清除缓存,
那么首先取消所有断点,然后在clear方法上打断点,点击debug来进行反推调试。断点截图如下所示:
在这里插入图片描述
从图中看,CUD操作会首先调用DefaultSqlSession的update方法,该方法的源码如下:

public class DefaultSqlSession implements SqlSession {

	@Override
	  public int update(String statement, Object parameter) {
	    try {
	      dirty = true;
	      MappedStatement ms = configuration.getMappedStatement(statement);
	      return executor.update(ms, wrapCollection(parameter));
	    } catch (Exception e) {
	      throw ExceptionFactory.wrapException("Error updating database.  Cause: " + e, e);
	    } finally {
	      ErrorContext.instance().reset();
	    }
	  }
}	  

update方法会调用CachingExecutor的update方法,实现源码如下:

public class CachingExecutor implements Executor {

	@Override
	  public int update(MappedStatement ms, Object parameterObject) throws SQLException {
	    flushCacheIfRequired(ms);//清理二级缓存,与一级缓存无关
	    return delegate.update(ms, parameterObject);
	  }
}	  

update方法会调用BaseExecutor的update方法,update方法在调用doUpdate()方法完成更新操作之前,首先会调用clearLocalCache()方法,具体实现如下:

public abstract class BaseExecutor implements Executor {

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

该方法会先调用本类的clearLocalCache方法清除缓存,然后调用doUpdate方法真的去执行更新了,源代码实现如下:

public abstract class BaseExecutor implements Executor {

	@Override
	  public void clearLocalCache() {
	    if (!closed) {
	      localCache.clear();
	      localOutputParameterCache.clear();
	    }
	  }
}	  

很显然这个方法调用PerpetualCache的clear方法,方法实现源码如下:

public class PerpetualCache implements Cache {

	@Override
	  public void clear() {
	    cache.clear();
	  }
}	  

CUD过程中缓存清除源码调用过程如下所示:

>org.apache.ibatis.session.defaults.DefaultSqlSession#update()
	>org.apache.ibatis.executor.CachingExecutor#update()
		>org.apache.ibatis.executo.BaseExecutor#update()
				>org.apache.ibatis.executo.BaseExecutor#clearLocalCache()
					>org.apache.ibatis.cache.impl.PerpetualCache#clear()

注意:在分布式环境中,务必将MyBatis的localCacheScope属性设置为STATEMENT,因为如果localCacheScope属性设置为STATEMENT,则每次查询操作完成后,都会调用clearCache()方法清空缓存,这样就能避免其他节点执行SQL更新语句之后,本节点缓存得不到刷新而导致数据一致性问题

一级缓存时序图

在这里插入图片描述

二级缓存

MyBatis二级缓存的使用

    二级缓存的xml配置使用比较简单,只需要以下几个步骤:

  • 在MyBatis主配置文件MyBatisCofnig.xml中指定cacheEnabled属性值为true,打开二级缓存。
<?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>
        <setting name="cacheEnabled" value="true"/>
    </settings>
    <typeAliases>
        <package name="com.mybatis.domain"/>
    </typeAliases>
    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC"/>
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://localhost:3306/mybatis"/>
                <property name="username" value="root"/>
                <property name="password" value="xxxx"/>
            </dataSource>
        </environment>
    </environments>
    <mappers>
        <package name="com.mybatis.dao"/>
    </mappers>
</configuration>
  • 在MyBatis的mapper映射文件中配置缓存策略、缓存的刷新频率、缓存容量等属性,如:
<?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.mybatis.dao.IUserDao">
    <cache
        eviction="FIFO"  //缓存策略,先进先出
        flushInterval="60000"  //缓存刷新时间
        size="512"   //缓存的容量
        readOnly="true"></cache>
</mapper>
  • 在配置Mapper时,通过useCache属性指定Mapper执行时是否使用缓存,另外通过flushCache属性指定Mapper执行后是否刷新缓存。
<?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.mybatis.dao.IUserDao">
    <cache
        eviction="FIFO"
        flushInterval="60000"
        size="512"
        readOnly="true"></cache>
    <select id="findUserById" resultType="user" useCache="true" flushCache="false">
        select * from user where id = #{id}
    </select>
</mapper>

通过上面的几个简单步骤,二级缓存就会可以生效了。执行查询操作时,查询结果会缓存到二级缓存中,执行更新操作后,二级缓存会被清空。

二级缓存的实现

    MyBatis中的缓存策略采用的是装饰器设计模式,Cache接口有一个基本的实现类PerpetualCache,该类通过HashMap类的实例来存放缓存对象。MyBatis缓存存储是基于JVM来实现的,即所有的缓存数据都是存放在Java对象中的。MyBatis通过Cache缓存接口定义缓存行为。Cache接口的实现代码如下:

package org.apache.ibatis.cache;

import java.util.concurrent.locks.ReadWriteLock;

public interface Cache {

  /**
   * @return The identifier of this cache,返回缓存的标识符
   * 该方法用于获取缓存的id,通常情况下缓存的id为Mapper命名空间的名称
   */
  String getId();

  /**
   * @param key
   *          Can be any object but usually it is a {@link CacheKey}
   * @param value
   *          The result of a select.
   * 将一个缓存对象添加到缓存中,该方法有两个参数,第一个参数就是缓存的Key,即CacheKey的实例;第二个参数为需要缓存的对象
   */
  void putObject(Object key, Object value);

  /**
   * @param key
   *          The key
   * @return The object stored in the cache.
   * 该方法用于获取缓存Key对应的缓存对象
   */
  Object getObject(Object key);

//该方法用于将一个对象从缓存中移除
  Object removeObject(Object key);

  /**
   * Clears this cache instance.
   * 该方法用于清空缓存
   */
  void clear();

  /**
   * Optional. This method is not called by the core.
   *
   * @return The number of elements stored in the cache (not its capacity).
   * 返回存储在缓存中的元素个数
   */
  int getSize();

  /**
   * Optional. As of 3.2.6 this method is no longer called by the core.
   * <p>
   * Any locking needed by the cache must be provided internally by the cache provider.
   *
   * @return A ReadWriteLock
   * 返回一个ReadWriteLock的读写锁
   */
  default ReadWriteLock getReadWriteLock() {
    return null;
  }
}
  • getId()方法用于获取缓存对象的id,通常情况下缓存空间的id为Mapper命名空间(namespace)的名称
  • putObject(Object key, Object value):该方法用于将一个Java对象添加到缓存中,该方法有连个参数,第一个参数为需要缓存的key,即CacheKey缓存实例;第二个参数为需要缓存的对象;
  • getObject():该方法用于获取缓存key对应的缓存对象;
  • removeObject(Object key):该方法用于将一个Java对象从缓存中清除;
  • clear():该方法用于清空缓存
  • getReadWriteLock():该方法用于返回一个ReadWriteLock对象。

    MyBatis中的缓存采用装饰器模式设计,Cache接口有一个基本的实现类PerpetualCache,该类的实现比较简单,通过一个HashMap存放实例缓存对象,PerpetualCache类重写了Object类的equals()方法,当两个缓存对象的id相同时,即认为缓存对象相同;PerpetualCache类还重写了Object类的hashCode()方法,仅以缓存对象的id作为因子生成hashCode,PerpetualCache类的实现源码如下:

package org.apache.ibatis.cache.impl;

import java.util.HashMap;
import java.util.Map;

import org.apache.ibatis.cache.Cache;
import org.apache.ibatis.cache.CacheException;

/**
 * @author Clinton Begin
 */
public class PerpetualCache implements Cache {

  private final String id;

  //使用HashMap存放缓存对象
  private final Map<Object, Object> cache = new HashMap<>();

  public PerpetualCache(String id) {
    this.id = id;
  }

  //获取缓存的id,即名称空间的名字
  @Override
  public String getId() {
    return id;
  }
  //获取缓存中元素的个数
  @Override
  public int getSize() {
    return cache.size();
  }
  //将一个Java对象放入缓存中,该方法中有两个参数,第一个参数为缓存的Key,即CacheKey实例;第二个对象为需要缓存的对象。
  @Override
  public void putObject(Object key, Object value) {
    cache.put(key, value);
  }
 //获取缓存key对应的Java对象
  @Override
  public Object getObject(Object key) {
    return cache.get(key);
  }
  //将一个对象从缓存中移除
  @Override
  public Object removeObject(Object key) {
    return cache.remove(key);
  }
  //该方法用于清空缓存
  @Override
  public void clear() {
    cache.clear();
  }
  //重写了Object类的equals()方法,当缓存对象的id相同的时候,即认为是缓存的对象相同。
  @Override
  public boolean equals(Object o) {
    if (getId() == null) {
      throw new CacheException("Cache instances require an ID.");
    }
    if (this == o) {
      return true;
    }
    if (!(o instanceof Cache)) {
      return false;
    }

    Cache otherCache = (Cache) o;
    return getId().equals(otherCache.getId());
  }

  //重写了Object类的hashCode()方法,以缓存对象的id作为因子生成hashCode。
  @Override
  public int hashCode() {
    if (getId() == null) {
      throw new CacheException("Cache instances require an ID.");
    }
    return getId().hashCode();
  }
}

    业务系统中存在很多的静态数据,如字典表、菜单表、权限表等,这些数据的特定是不会轻易修改但是又是查询的热点数据。一级缓存针对的是同一个SqlSession会话中相同的SQL,并不适合热点数据的缓存场景,如下图:一、二级缓存之间的区别在这里插入图片描述

获取二级缓存源码解析

因为二级缓存主要是在mapper映射文件中配置参数,主要配置参数如下:

<?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.mybatis.dao.IUserDao">
    <!--开启二级缓存支持,flushInterval="60000"表示缓存数据的有效期是60s,size表示512个引用即512次查询的结果。blocking是否阻塞,默认是false:是用来防止缓存击穿的-->
    <cache eviction="FIFO" flushInterval="60000" size="512" readOnly="false" blocking="true"></cache>
    <select id="findUserById" resultType="com.mybatis.domain.User" useCache="true" >
        select * from user where id = #{id}
    </select>
</mapper>
  • eviction=“FIFO”,即配置缓存策略为先进先出;
  • flushInterval=60000:指定刷新频率为60秒;
  • size=512:指定缓存容量为512,即缓存中最多存放512个缓存对象的引用;
  • readOnly=“true”:表示提供只读功能,会给所有调用者返回相同对象的实例;readOnly=“false”:表示可读可写的缓存,会通过序列化返回对象的拷贝,速度上会慢一些,但是安全性比只读的要好很多。

具体调试过程:

    二级缓存使用的是装饰器模式,首先取消所有的断点,然后在PerpetualCache类的getObject()方法上打断点,通过debug的方式来看一下获取二级缓存的源码调用:

  • 二级缓存调用过程(装饰器模式在开源的应用,职责分离原则,所有的Cache都实现了Cache接口)
>org.apache.ibatis.session.defaults.DefaultSqlSession#selectList()
	>org.apache.ibatis.executor.CachingExecutor#query() 89L
		>org.apache.ibatis.executor.CachingExecutor#query() 101L
			>org.apache.ibatis.cache.TransactionalCacheManager#getObject()
				>org.apache.ibatis.cache.decorators.TransactionalCache#getObject()
					>org.apache.ibatis.cache.decorator.BlockingCache#getObject()
						>org.apache.ibatis.cache.decorators.SynchronizedCache#getObject()
							>org.apache.ibatis.cache.decorators.LoggingCache#getObject()
								>org.apache.ibatis.cache.decorators.SerializedCache#getObject()
									>org.apache.ibatis.cache.decorators.ScheduledCache#getObject()
										>org.apache.ibatis.cache.decorators.FifoCache#getObject()
											>org.apache.ibatis.cache.impl.PerpetualCache#getObject()
  • 二级缓存实现装饰器模式图
    在这里插入图片描述
  • debug断点截图
    在这里插入图片描述
  • 首先会调用DefaultSqlSession类的selectList()方法,具体的实现如下:
public class DefaultSqlSession implements SqlSession {
	@Override
  public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
    try {
      MappedStatement ms = configuration.getMappedStatement(statement);
      return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
    } finally {
      ErrorContext.instance().reset();
    }
  }
}
  • 这里DefaultSqlSession会调用CachingExecutor类的query方法。CachingExecutor类的query方法首先会调用createCacheKey()方法创建缓存Key对象,具体的实现源码如下:
public class CachingExecutor implements Executor {
	@Override
	  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
	    BoundSql boundSql = ms.getBoundSql(parameterObject);
	    CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
	    return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
	  }
}	  
  • 然后调用MappedStatement对象的getCache()方法获取MappedStatement对象中维护的二级缓存对象,接下来看MappedStatement对象创建二级缓存实例的过程。XMLMapperBuilder在解析Mapper配置的时候会调用cacheElement()方法解析标签,cacheElement()方法的实现代码如下:
 private void cacheElement(XNode context) {
    if (context != null) {
      String type = context.getStringAttribute("type", "PERPETUAL");
      Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
      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();
      builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
    }
  }

如上面的代码所示,在获取标签的所有属性信息后,调用MapperBuilderAssistant对象的userNewCache()方法创建二级缓存实例,然后通过MapperBuilderAssistant的currentCache属性保存二级缓存对象的引用。在调用MapperBuilderAssistant对象的addMappedStatement()方法创建MappedStatement对象时会将当前命名空间对应的二级缓存对象的引用添加到MappedStatement对象中。

  • 然后CachingExecutor类的query方法尝试从二级缓存对象中获取结果,如果获取不到,则调用模板Executor对象的query()方法从数据库中获取数据,在将数据添加到二级缓存中。具体的过程如下所示:
public class CachingExecutor implements Executor {

@Override
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
      //获取MappedStatement对象中维护的二级缓存对象
    Cache cache = ms.getCache();
    //首先判断是否需要刷新二级缓存
    //如果二级缓存不为空,
    if (cache != null) {
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, boundSql);
        @SuppressWarnings("unchecked")
        //从MappedStatement对象中对应的二级缓存中获取数据
        List<E> list = (List<E>) tcm.getObject(cache, key);
        if (list == null) {
            //如果缓存数据不存在,则从数据库中查询数据
          list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          //将数据放到MappedStatement对应的二级缓存中
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        return list;
      }
    }
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }
}  
  • 当执行更新语句的时候,同一命名空间下面的二级缓存将会被清空。下面是CachingExecutor的update()方法的实现:
public class CachingExecutor implements Executor {

   @Override
  public int update(MappedStatement ms, Object parameterObject) throws SQLException {
    flushCacheIfRequired(ms);
    return delegate.update(ms, parameterObject);
  }
}  
  
  • 上述代码的CachingExcutor类的update()方法会调用flushCacheIfRequired()方法确定是否需要刷新缓存,该方法的实现如下:
public class CachingExecutor implements Executor {

    private void flushCacheIfRequired(MappedStatement ms) {
        Cache cache = ms.getCache();
        if (cache != null && ms.isFlushCacheRequired()) {
          tcm.clear(cache);
        }
      }
}      

在flushCacheIfRequired()方法中会判断<select|update|delete|insert>标签的flushCache属性,如果属性默认伪true,则清空缓存。标签的flushCache属性默认伪false,而<update|delete|insert>标签的flushCache属性默认为true。然后后面就是调用其他的各种装饰类,这里就不再一一赘述了,如果想再详细深入探讨,读者可以自行下载源码自行解析,后面本人也会在B站做几期MyBatis源码详解的视频。

  • 接着说CachingExecutor里面的query方法,query方法会调用TransactionalCacheManager的getObject方法获取二级缓存,具体实现如下:
public class TransactionalCacheManager {
	public Object getObject(Cache cache, CacheKey key) {
	    return getTransactionalCache(cache).getObject(key);
	  }
}	  
  • TransactionalCacheManager的getObject()方法会调用getTransactionalCache方法来获取缓存,具体实现如下:
public class TransactionalCacheManager {
	private TransactionalCache getTransactionalCache(Cache cache) {
	    return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new);
	  }
}	  
  • 该方法会调用TransactionalCache的getObject方法来获取缓存,事物缓存装饰器,该缓存与其他缓存的不同之处在于,TransactionalCache增加了两个方法,即commit()和rollback()。当写入缓存的时候,只有调用commit()方法之后,缓存对象才会真正的添加到TransactionalCache对象中,如果调用了rollback()方法,写入操作会被回滚。具体实现如下:
public class TransactionalCache implements Cache {
    @Override
  public Object getObject(Object key) {
    // issue #116
    Object object = delegate.getObject(key);
    if (object == null) {
      entriesMissedInCache.add(key);
    }
    // issue #146
    if (clearOnCommit) {
      return null;
    } else {
      return object;
    }
  }
}
  • 接下来继续装饰器模式的调用,TransactionalCache的getObject方法会调用BlockingCache的getObject方法,BlockingCache是一个阻塞版本的缓存装饰器,能够保证同一时间只有一个线程能到缓存中查找指定key的对应数据。
public class BlockingCache implements Cache {
	@Override
	  public Object getObject(Object key) {
	    acquireLock(key);
	    Object value = delegate.getObject(key);
	    if (value != null) {
	      releaseLock(key);
	    }
	    return value;
	  }
}	  
  • BlockingCache的getObject方法会调用SynchronizedCache类的getObject方法,SynchronizedCache是一个线程安全的缓存装饰器,SynchronizedCache的实现比较简单,为了保证线程安全,对操作缓存的方法使用synchronized关键字进行修饰。
public class SynchronizedCache implements Cache {
     @Override
  public synchronized Object getObject(Object key) {
    return delegate.getObject(key);
  }
}
  • SynchronizedCache类的getObject方法会调用LoggingCache的getObject方法,为缓存增加日志输出功能,记录缓存的请求次数和命中次数,通过日志输出缓存的命中率。
public class LoggingCache implements Cache {

	 @Override
	  public Object getObject(Object key) {
	    requests++;
	    final Object value = delegate.getObject(key);
	    if (value != null) {
	      hits++;
	    }
	    if (log.isDebugEnabled()) {
	      log.debug("Cache Hit Ratio [" + getId() + "]: " + getHitRatio());
	    }
	    return value;
	  }
}	  	  
  • LoggingCache的getObject方法会调用SerializedCache的getObject方法,SerializedCache是序列化缓存装饰器,向缓存中添加对象时,对添加的对象进行序列化处理,从缓存中取出对象时,进行反序列化处理。具体的源码实现如下:
public class SerializedCache implements Cache {

	 @Override
	  public Object getObject(Object key) {
	    Object object = delegate.getObject(key);
	    return object == null ? null : deserialize((byte[]) object);
	  }
}	  
  • SerializedCache的getObject方法会调用ScheduleCache的getObject方法,它是自动刷新缓存装饰器,当操作缓存对象的时候,如果当前时间与上次清空缓存的时间间隔大于指定的时间间隔,则清空缓存。清空缓存的动作由getObject()、putObject()、removeObject()等方法来触发。具体实现如下:
public class ScheduledCache implements Cache {
    @Override
  public Object getObject(Object key) {
    return clearWhenStale() ? null : delegate.getObject(key);
  }
}
  • ScheduleCache的getObject方法会调用FifoCache类的getObject方法,FifoCache是先入先出缓存装饰器,FifoCache内部有一个维护具有长度限制的Key键值链表(LinkedList实例)和一个被装饰的缓存对象,Key值链表主要是维护Key的FIFO顺序,而缓存存储和获取则交给被装饰的缓存对象来完成。具体的实现如下:
public class FifoCache implements Cache {
	@Override
  public Object getObject(Object key) {
    return delegate.getObject(key);
  }
}
  • 最后FifoCache类的getObject方法会调用PerpetualCache的getObject方法,PerpetualCache的getObject方法具体实现如下:
public class PerpetualCache implements Cache {

	 @Override
	  public Object getObject(Object key) {
	    return cache.get(key);
	  }
}	  

清除二级缓存

  • xml中配置的update不能清空缓存数据。
  • 只有修改会话提交之后,才会执行清空操作。
  • 任何一种增、删、改操作都会清空整个namespace中的缓存。

清除二级缓存的调用过程以及关键源码

>org.apache.ibatis.session.defaults.DefaultSqlSession#selectList()
    >org.apache.ibatis.executo.CachingExecutor#query() 81L
        >org.apache.ibatis.executo.CachingExecutor#query() 93
            >org.apache.ibatis.executo.CachingExecutor#flushCacheIfRequired()//清除缓存
  • 其他类之前已经说过了,这里就说一下CachingExecutor类的query()方法,具体的实现如下:
@Override
  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);
        @SuppressWarnings("unchecked")
        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);
  }
  • 首先会调用MappedStatement的getCache()方法获取二级缓存,然后判断缓存是否为空,然后调用flushCacheIfRequired方法来清空缓存,具体实现如下:
public class CachingExecutor implements Executor {
	 private void flushCacheIfRequired(MappedStatement ms) {
	    Cache cache = ms.getCache();
	    if (cache != null && ms.isFlushCacheRequired()) {
	      tcm.clear(cache);
	    }
	  }
}	  

如上面的代码所示,在CachingExecutor的query()方法中,首先调用createCacheKey()方法创建缓存Key对象,然后调用MappedStatement对象的getCache()方法获取MappedStatement对象中维护的二级缓存对象。然后尝试从二级缓存对象中获取结果,如果获取不到,则调用目标Executor对象的query()方法从数据库获取数据,再将数据添加到二级缓存中。当执行更新语句后,同一命名空间下的二级缓存将会被清空。下面是CachingExecutor的update()方法的实现:

public class CachingExecutor implements Executor {

	@Override
	  public int update(MappedStatement ms, Object parameterObject) throws SQLException {
	    flushCacheIfRequired(ms);
	    return delegate.update(ms, parameterObject);
	  }
}	  

如上面的代码所示,CachingExecutor的update()方法中会调用flushCacheIfRequired()方法确定是否需要刷新缓存,该方法代码如下:

public class CachingExecutor implements Executor {
	 private void flushCacheIfRequired(MappedStatement ms) {
	    Cache cache = ms.getCache();
	    if (cache != null && ms.isFlushCacheRequired()) {
	      tcm.clear(cache);
	    }
	  }
}	  

在该方法中会判断<select|update|delete|insert?标签的flushCache属性,如果属性值为true,则清空缓存。标签的flushCache属性默认为false,而<update|delete|insert>标签的flushCache属性值默认为true。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值