万隆的笔记 万隆的笔记
博文索引
笔试面试
  • 在线学站

    • 菜鸟教程 (opens new window)
    • 入门教程 (opens new window)
    • Coursera (opens new window)
  • 在线文档

    • w3school (opens new window)
    • Bootstrap (opens new window)
    • Vue (opens new window)
    • 阿里开发者藏经阁 (opens new window)
  • 在线工具

    • tool 工具集 (opens new window)
    • bejson 工具集 (opens new window)
    • 文档转换 (opens new window)
  • 更多在线资源
  • Changlog
  • Aboutme
GitHub (opens new window)
博文索引
笔试面试
  • 在线学站

    • 菜鸟教程 (opens new window)
    • 入门教程 (opens new window)
    • Coursera (opens new window)
  • 在线文档

    • w3school (opens new window)
    • Bootstrap (opens new window)
    • Vue (opens new window)
    • 阿里开发者藏经阁 (opens new window)
  • 在线工具

    • tool 工具集 (opens new window)
    • bejson 工具集 (opens new window)
    • 文档转换 (opens new window)
  • 更多在线资源
  • Changlog
  • Aboutme
GitHub (opens new window)
  • MyBatis

    • MyBatis 简介
    • MyBatis简单应用
    • MyBatis常⽤配置
    • MyBatis 单表 CRUD 操作
    • MyBatis动态 SQL
    • MyBatis复杂映射
    • MyBatis注解开发
    • MyBatis缓存
      • 一级缓存
      • 二级缓存
      • 二级缓存整合redis
    • MyBatis插件
    • ⾃定义持久层框架
    • MyBatis架构原理
    • MyBatis源码剖析
    • MyBatis设计模式
  • Spring-MyBatis

  • MyBatis-Plus

  • MyBatis
  • MyBatis
2022-11-07
目录

MyBatis缓存

# MyBatis缓存

# 一级缓存

  1. 在一个sqlSession中,对User表根据id进行两次查询,查看他们发出sql语句的情况。
public class MyBatisCacheTest {

    private UserMapper userMapper;
    private SqlSession sqlSession;

    @Before
    public void before() throws IOException {
        // 加载核⼼配置⽂件
        InputStream resourceAsStream = Resources.getResourceAsStream("SqlMapConfig.xml");
        // 获得sqlSession⼯⼚对象
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(resourceAsStream);
        // 获得sqlSession对象
        sqlSession = sqlSessionFactory.openSession();
        userMapper = sqlSession.getMapper(UserMapper.class);
    }

    @After
    public void after() {
        // 释放资源
        sqlSession.close();
    }

    @Test
    public void test1() {
        // 第一次查询
        User user1 = userMapper.findById(1);
        System.out.println(user1);
        // 第二次查询
        User user2 = userMapper.findById(1);
        System.out.println(user2);
    }
}

log输出结果(关于日志输出的配置,参考官方文档 (opens new window))。:

DEBUG [main] - Logging initialized using 'class org.apache.ibatis.logging.log4j.Log4jImpl' adapter.
DEBUG [main] - Logging initialized using 'class org.apache.ibatis.logging.log4j.Log4jImpl' adapter.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - Opening JDBC Connection
DEBUG [main] - Created connection 2077742806.
DEBUG [main] - Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - ==>  Preparing: select * from user where id = ? 
DEBUG [main] - ==> Parameters: 1(Integer)
DEBUG [main] - <==      Total: 1
User{id=1, username='jack', password='123456', orderList='null', roleList='null'}
User{id=1, username='jack', password='123456', orderList='null', roleList='null'}
DEBUG [main] - Resetting autocommit to true on JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - Closing JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - Returned connection 2077742806 to pool.
  1. 同样是对user表进行两次查询,只不过两次查询之间进行了一次update操作。
@Test
    public void test2() {
        // 第一次查询
        User user1 = userMapper.findById(1);
        System.out.println(user1);
        user1.setUsername("jack2");
        userMapper.update(user1);
        // 提交事务
        sqlSession.commit(); 
        // 第二次查询
        User user2 = userMapper.findById(1);
        System.out.println(user2);
    }

控制台输出:

DEBUG [main] - Logging initialized using 'class org.apache.ibatis.logging.log4j.Log4jImpl' adapter.
DEBUG [main] - Logging initialized using 'class org.apache.ibatis.logging.log4j.Log4jImpl' adapter.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - PooledDataSource forcefully closed/removed all connections.
DEBUG [main] - Opening JDBC Connection
DEBUG [main] - Created connection 2077742806.
DEBUG [main] - Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - ==>  Preparing: select * from user where id = ? 
DEBUG [main] - ==> Parameters: 1(Integer)
DEBUG [main] - <==      Total: 1
User{id=1, username='jack2', password='123456', orderList='null', roleList='null'}
DEBUG [main] - ==>  Preparing: update user set username = ?, password = ? where id = ? 
DEBUG [main] - ==> Parameters: jack2(String), 123456(String), 1(Integer)
DEBUG [main] - <==    Updates: 1
DEBUG [main] - Committing JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - ==>  Preparing: select * from user where id = ? 
DEBUG [main] - ==> Parameters: 1(Integer)
DEBUG [main] - <==      Total: 1
User{id=1, username='jack2', password='123456', orderList='null', roleList='null'}
DEBUG [main] - Resetting autocommit to true on JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - Closing JDBC Connection [com.mysql.jdbc.JDBC4Connection@7bd7d6d6]
DEBUG [main] - Returned connection 2077742806 to pool.

总结:

  1. 第一次发起查询用户id为1的用户信息,先去找缓存中是否有id为1的用户信息,如果没有,从数据库查询用户信息,得到用户信息后,将用户信息存储到一级缓存中。
  2. 如果中间sqlSession去执行commit操作(执行插入、更新、删除),则会清空SqlSession中的 一级缓存,这样做的目的为了让缓存中存储的是最新的信息,避免脏读。
  3. 第二次发起查询用户id为1的用户信息,先去找缓存中是否有id为1的用户信息,缓存中有,直接从缓存中获取用户信息。

cache_l1

# 原理探究与源码分析

一级缓存到底是什么?一级缓存什么时候被创建、一级缓存的工作流程是怎样的?相信你现在应该会有 这几个疑问,那么我们本节就来研究一下一级缓存的本质。

大家可以这样想,上面我们一直提到一级缓存,那么提到一级缓存就绕不开SqlSession,所以索性我们就直接从SqlSession探究,看看有没有创建缓存或者与缓存有关的属性或者方法:

cache_src_1

调研了一圈,发现上述所有方法中,好像只有 clearCache()和缓存沾点关系,那么就直接从这个方法入手吧。在分析源码时,我们要了解此类以及它的子类、父类的关系,这样才会对这个类有更深的认识。分析了一圈,你可能会得到如下类图和流程:

cache_class_1

流程走到 Perpetualcache中的 clear方法之后,会调用其 cache.clear()方法,这个cache其实是 private Map<Object, Object> cache = new HashMap<Object, Object>(),也就是一个Map实例对象,所以说 cache.clear()其实就是 map.clear(),即缓存其实就是本地存放的一个map对象。

每一个SqISession都会存放一个map对象的引用,那么这个cache是何时创建的呢?你觉得最有可能创建缓存的地方是哪里呢?

我觉得是Executor,为什么?因为Executor是执行器,用来执行SQL请求,而且清除缓存的方法也在Executor中执行,所以很可能缓存的创建也很 有可能在Executor中。在查看Executor类的方法我们可以发现有一个createCacheKey方法,这个方法很像是创建缓存的方法,跟进去看看,你发现 createCacheKey方法是由 BaseExecutor执行的,代码如下:

CacheKey cacheKey = new CacheKey();
// id就是Sql语句的所在位置包名+类名+SQL名称, MappedStatement的id
cacheKey.update(ms.getId()); 
// offset 就是 0
cacheKey.update(rowBounds.getOffset());
// limit 就是 Integer.MAXVALUE
cacheKey.update(rowBounds.getLimit());
// 具体sql语句
cacheKey.update(boundSql.getSql());
...
  // sql参数
  cacheKey.update(value);
...
 if (configuration.getEnvironment() != null) {
    // issue #176 , 定义在 mybatis-config.xml 中的 environments -> environment id
    cacheKey.update(configuration.getEnvironment().getId());
  }

创建缓存key会经过一系列的update方法,udate方法由一个CacheKey这个对象来执行的,这个update方法最终把六个值存进updateList(ArrayList),对照上面的代码和下面的图示,你应该能理解这六个值都是什么了:

cache_src_2

回归正题,那么创建完缓存之后该用在何处呢?经过我们对一级缓存的探究之后,我们发现一级缓存更多是用于查询操作,毕竟一级缓存也叫做查询缓存,为什么叫查询缓存我们一会儿说。我们先来看一下这个缓存到底用在哪了,我们跟踪到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);
}

@SuppressWarnings("unchecked")
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
  // ......
    // 优先从缓存取
    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--;
  }
  // ......
}

private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
  List<E> list;
  localCache.putObject(key, EXECUTION_PLACEHOLDER);
  try {
    list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
  } finally {
    localCache.removeObject(key);
  }
  // 缓存
  localCache.putObject(key, list);
  if (ms.getStatementType() == StatementType.CALLABLE) {
    localOutputParameterCache.putObject(key, parameter);
  }
  return list;
}

如果缓存中查不到的话,就从数据库查,在 queryFromDatabase中,会对 localcache进行写入。localcache 对象的put方法最终交给Map进行存放。

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

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

# 二级缓存

二级缓存的原理和一级缓存原理一样:第一次查询,会将数据放入缓存中,然后第二次查询则会直接去缓存中取。但是一级缓存是基于sqlSession的,而二级缓存是基于mapper文件的namespace的,也就是说多个sqlSession可以共享一个mapper中的二级缓存区域,并且如果两个mapper的namespace相同,即使是两个mapper,那么这两个mapper中执行sql查询到的数据也将存在相同的二级缓存区域中。

cache_src_3

# 开启二级缓存

和一级缓存默认开启不一样,二级缓存需要我们手动开启,首先在全局配置文件 sqlMapConfig.xml文件中加入如下代码:

<configuration>
    <settings>
        <!--开启二级缓存-->
        <setting name="cacheEnabled" value="true"/>
    </settings>
  	<!--.....-->
</configuration>

其次在对应的Mapper文件开启缓存,例如UserMapper.xml文件中:

<!--开启二级缓存--> 
<cache></cache>

或者在对应的Mapper类加上 @CacheNamespace(blocking = true)注解,如UserMapper:

@CacheNamespace(blocking = true)
public interface UserMapper { ... }

**注意,配置文件和接口注释是不能够配合使用的 (二选一)。**我们主要以配置文件的方式为例,同时会讲对应的注解写法。

我们可以看到mapper.xml文件中就这么一个空标签,其实这里可以配置的(后面会说到)。PerpetualCache这个类是 mybatis默认实现缓存功能的类。我们不写 type就使用 mybatis默认的缓存,也可以去实现Cache接口来自定义缓存:

cache_src_4

也就是说如果我们不指定二级缓存的实现,那么二级缓存底层还是HashMap结构。注意的是,开启了二级缓存后,还需要将要缓存的pojo实现Serializable接口,为了将缓存数据取出执行反序列化操作,因为二级缓存数据存储介质多种多样,不一定只存在内存中,有可能存在硬盘中,如果我们要再取这个缓存的话,就需要反序列化了。所以mybatis中的pojo都去实现Serializable接口。例如,User类:

public class User implements Serializable {
    private int id;
    private String username;
    private String password;
  //...
}

# 测试用例

  1. 测试二级缓存和sqlSession无关

    @Test
    public void testTwoCache() {
      // 根据sqlSessionFactory 产生 session
      SqlSession sqlSession1 = sqlSessionFactory.openSession();
      SqlSession sqlSession2 = sqlSessionFactory.openSession();
      UserMapper userMapper1 = sqlSession1.getMapper(UserMapper.class);
      UserMapper userMapper2 = sqlSession2.getMapper(UserMapper.class);
      // 第一次查询,发出sql语句,并将查询的结果放入缓存中
      User u1 = userMapper1.findById(1);
      System.out.println(u1);
      // 第一次查询完后关闭 sqlSession
      sqlSession1.close();
      // 第二次查询,即使sqlSession1已经关闭了,这次查询依然不发出sql语句
      User u2 = userMapper2.findById(1);
      System.out.println(u2);
      sqlSession2.close();
    }
    
    

    从执行结果可知,上面两个不同的sqlSession,第一个关闭了,第二次查询依然不发出sql查询语句。

    cache_src_4_1

  2. 测试执行commit()操作,二级缓存数据清空

    @Test
    public void testTwoCache2() {
      // 根据sqlSessionFactory 产生 session
      SqlSession sqlSession1 = sqlSessionFactory.openSession();
      SqlSession sqlSession2 = sqlSessionFactory.openSession();
      SqlSession sqlSession3 = sqlSessionFactory.openSession();
      UserMapper userMapper1 = sqlSession1.getMapper(UserMapper.class);
      UserMapper userMapper2 = sqlSession2.getMapper(UserMapper.class);
      UserMapper userMapper3 = sqlSession3.getMapper(UserMapper.class);
      // 第一次查询,发出sql语句,并将查询的结果放入缓存中
      User u1 = userMapper1.findById(1);
      System.out.println(u1);
      // 第一次查询完后关闭 sqlSession
      sqlSession1.close();
      // 执行更新操作,commit()
      u1.setUsername( "jack3" );
      userMapper3.update(u1);
      sqlSession3.commit();
      // 第二次查询,由于上次更新操作,缓存数据已经清空(防止数据脏读),这里必须再次发出sql语句
      User u2 = userMapper2.findById(1);
      System.out.println(u2);
      sqlSession2.close();
    }
    

    第二次查询,由于上次更新操作,缓存数据已经清空(防止数据脏读),这里必须再次发出sql语句。

    cache_src_4_2

# <cache/>配置

mapper文件中配置cache的标签,有以下配置选项(@CacheNamespace同理):

  • evication:缓存回收策略

    • LRU,最近最少使用的,删除最长时间不用的(默认);
    • FIFO,先进先出,按对象进入缓存的顺序来移除他们;
    • SOFT,软引用,移除基于垃圾回收器状态和软引用规则的对象;
    • WEAK,弱引用,更积极的移除基于垃圾回收器状态和弱引用规则的对象。
  • flushInterval:刷新间隔时间,单位为毫秒,不配置就是当sql被执行时才刷新

  • size:引用数目,一个正整数,代表缓存最多可以存储多少个对象,设置过大会导致内存溢出

  • readonly:只读,可以快速的读取缓存,但没办法修改缓存,默认为false。

<cache blocking="true"/>
@CacheNamespace(blocking = true)

# statement配置

除了mapper的cache标签可配置外,还可以指定映射文件中具体的SQL语句(statement)进行配置(@Options同理):

  • userCache:是否启用二级缓存,默认情况是true。针对每次查询都需要最新的数据sql,建议设置成false,禁用二级缓存,直接从数 据库中获取。
  • flushCache:是否刷新二级缓存,默认情况是true。一般下执行完commit操作都需要刷新缓存,默认刷新缓存,这样可以避免数据库脏读。建议默认即可。
<select id="findById" resultType="org.example.hello.mybatis.entity.User" flushCache="false">
  select * from user where id = #{id}
</select>
@Select("select * from user where id = #{id}")
@Options(useCache = false)
User findById(int id);

# 二级缓存整合redis

上面我们介绍了MyBatis自带的二级缓存,但是这个缓存是单服务器本地工作,无法实现分布式缓存。 分布式缓存简单来说就是找一个服务器(或集群),专⻔用来存储缓存数据的,不同的服务器的缓存数据都往它那存和取,取缓存数据也从它那里取。目前,市面上的缓存框架有Redis、Memcached、Ehcache等等。这里我们介绍MyBatis与Redis的整合。

刚刚提到过,MyBatis提供了一个 Cache接口,如果要实现自己的缓存逻辑,实现 Cache接口开发即可。MybBatis提供了一个针对 Cache接口的Redis实现类,该类存在 mybatis-redis包中:

  1. POM文件引入jar包

    <dependency>
        <groupId>org.mybatis.caches</groupId>
        <artifactId>mybatis-redis</artifactId>
        <version>1.0.0-beta2</version>
    </dependency>
    
  2. 映射文件 <cache>指定 "type" 为具体实现类(注解 @CacheNamespace指定"implementation"):

    <cache type="org.mybatis.caches.redis.RedisCache" />
    
    @CacheNamespace(blocking = true, implementation = org.mybatis.caches.redis.RedisCache.class)
    
  3. 增加 redis.properties配置文件:

    host=localhost
    port=6379
    connectionTimeout=5000
    password=
    database=0
    
  4. 测试用例:

    @Test
    public void testTwoCache4Redis() {
      SqlSession sqlSession1 = sqlSessionFactory.openSession();
      SqlSession sqlSession2 = sqlSessionFactory.openSession();
      SqlSession sqlSession3 = sqlSessionFactory.openSession();
      UserMapper mapper1 = sqlSession1.getMapper(UserMapper.class);
      UserMapper mapper2 = sqlSession2.getMapper(UserMapper.class);
      UserMapper mapper3 = sqlSession3.getMapper(UserMapper.class);
    
      User user1 = mapper1.findById(1);
      System.out.println(user1);
      sqlSession1.close();
      // 清空缓存
      User user = new User();
      user.setId(1);
      user.setUsername("jack4");
      mapper3.update(user);
      sqlSession3.commit();
    
      User user2 = mapper2.findById(1);
      System.out.println(user2);
      sqlSession2.close();
    }
    

    测试结果:

    cache_l2.png

# 源码分析

RedisCache和普遍实现MyBatis的缓存方案大同小异,无非是实现 Cache接口,并使用 jedis操作Redis缓存,不过该项目在设计细节上有一些区别:

RedisCache在MyBatis启动的时候,由MyBatis的CacheBuilder创建,创建的方式很简单,就是调用RedisCache的带有String参数的构造方法,即 RedisCache(String id),而在RedisCache的构造方法中, 调用了 RedisConfigurationBuilder来创建 RedisConfig对象,并由 RedisConfig来创建 JedisPool。

public final class RedisCache implements Cache {
  // ...
  
  public RedisCache(final String id) {
    if (id == null) {
      throw new IllegalArgumentException("Cache instances require an ID");
    }
    this.id = id;
    RedisConfig redisConfig = RedisConfigurationBuilder.getInstance().parseConfiguration();
	pool = new JedisPool(redisConfig, redisConfig.getHost(), redisConfig.getPort(),
			redisConfig.getConnectionTimeout(), redisConfig.getSoTimeout(), redisConfig.getPassword(),
			redisConfig.getDatabase(), redisConfig.getClientName());
  }
  
  // ...
  
}

RedisConfig类继承了 JedisPoolConfig,并提供了 host、port等属性的包装,简单看一下 RedisConfig的属性:

public class RedisConfig extends JedisPoolConfig {

	private String host = Protocol.DEFAULT_HOST;
	private int port = Protocol.DEFAULT_PORT;
	private int connectionTimeout = Protocol.DEFAULT_TIMEOUT;
	private int soTimeout = Protocol.DEFAULT_TIMEOUT;
	private String password;
	private int database = Protocol.DEFAULT_DATABASE;
	private String clientName;
	// ...
}

RedisConfig对象是由 RedisConfigurationBuilder创建的,简单看下这个类的主要方法:

private static final String SYSTEM_PROPERTY_REDIS_PROPERTIES_FILENAME = "redis.properties.filename";
private static final String REDIS_RESOURCE = "redis.properties";
private RedisConfigurationBuilder() {
  redisPropertiesFilename = System.getProperty(SYSTEM_PROPERTY_REDIS_PROPERTIES_FILENAME, REDIS_RESOURCE);
}

public RedisConfig parseConfiguration(ClassLoader classLoader) {
  Properties config = new Properties();

  InputStream input = classLoader.getResourceAsStream(redisPropertiesFilename);
  if (input != null) {
    try {
      config.load(input);
    } catch (IOException e) {
      throw new RuntimeException(
        "An error occurred while reading classpath property '"
        + redisPropertiesFilename
        + "', see nested exceptions", e);
    } finally {
      try {
        input.close();
      } catch (IOException e) {
        // close quietly
      }
    }
  }

  RedisConfig jedisConfig = new RedisConfig();
  setConfigProperties(config, jedisConfig);
  return jedisConfig;
}

核心的方法就是 parseConfiguration方法,该方法从 classpath中读取一个 redis.properties文件,并将该配置文件中的内容设置到RedisConfig对象中,并返回。接下来,就是 RedisCache使用 RedisConfig类创建完成 JedisPool的创建,最后在 RedisCache中实现了一个简单的模板方法,用来操作Redis:

private Object execute(RedisCallback callback) {
  Jedis jedis = pool.getResource();
  try {
    return callback.doWithRedis(jedis);
  } finally {
    jedis.close();
  }
}

模板接口为 RedisCallback,这个接口中就只需要实现了一个 doWithRedis方法而已:

public interface RedisCallback {

	Object doWithRedis(Jedis jedis);
}

接下来看看 Cache中最重要的两个方法:putObject和 getObject,通过这两个方法来查看 mybatis-redis储存数据的格式:

@Override
public void putObject(final Object key, final Object value) {
  execute(new RedisCallback() {
    @Override
    public Object doWithRedis(Jedis jedis) {
      jedis.hset(id.toString().getBytes(), key.toString().getBytes(), SerializeUtil.serialize(value));
      return null;
    }
  });
}

@Override
public Object getObject(final Object key) {
  return execute(new RedisCallback() {
    @Override
    public Object doWithRedis(Jedis jedis) {
      return SerializeUtil.unserialize(jedis.hget(id.toString().getBytes(), key.toString().getBytes()));
    }
  });
}

可以很清楚的看到,mybatis-redis在存储数据的时候,使用的是hash结构:

  • 把 cache的id作为这个hash的key (cache的id就是在MyBatis中mapper的namespace,如 org.example.hello.mybatis.mapper.UserMapper)。
  • 把mapper中的查询sql语句、查询参数等组装的key对象数据作为 hash 的field。
  • 把此次查询的结果直接使用SerializeUtil序列化,作为 hash 的value。(SerializeUtil和其他的序列化类差不多,负责对象的序列化和反序列化);

cache_l2_2.png

上次更新: 5/30/2023, 10:53:02 PM
MyBatis插件

MyBatis插件→

最近更新
01
2025
01-15
02
Elasticsearch面试题
07-17
03
Elasticsearch进阶
07-16
更多文章>
Theme by Vdoing
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式