MyBatis二级缓存


MyBatis源码学习系列文章目录



前言

MyBatis为了减少对数据库的查询,避免频繁的数据库交互,提供了一级缓存和二级缓存。上一章中我们详细从源码角度讲解了一级缓存的方方面面,本章我们继续从源码的角度分析二级缓存


提示:以下是本篇文章正文内容,下面案例可供参考

MyBatis二级缓存详解

在上一章中我们分析一级缓存,主要的源码都在org.apache.ibatis.executor.BaseExecutor这个类当中。
在这里插入图片描述
与这个类平级(与接口org.apache.ibatis.executor.Executor的关系而言)的另一个类就是org.apache.ibatis.executor.CachingExecutor,一看这个执行器的名字,就会觉得这是一个跟缓存有关系的类,是的,其实这个类与MyBatis的二级缓存的入口。
在这里插入图片描述
这个类中就两个属性,一个Executor类型的delegate,另一个就是TransactionalCacheManager类型的tcm。因为这个CachingExecutor的主要目的还是处理与二级缓存相关的业务,其他与数据库的增删改查甚至一级缓存它都是不管的,这些除了二级缓存之外的工作就是通过这个delegate来执行的(看起来好像代理模式,其实是装饰器模式)。
在这里插入图片描述
如上图所示,不光是增删改查,其实连获取事务对象、提交事务、回滚事务也都是这个delegate来完成的。二级缓存是谁来管理的呢?那只能是第二个属性tcm了。这是个org.apache.ibatis.cache.TransactionalCacheManager类型的对象,首先我们从名字体会一下这个类的用途,事务缓存管理者,事务缓存?想想我们前面谈到的一级缓存,那个缓存不是事务级别的吗?事务提交或者事务回滚清除缓存,这个怎么也是事务缓存呢?我们不妨看一下这个属性在方法中的使用情况。
在这里插入图片描述
这个缓存管理器在事务提交和回滚的时候执行的是自己的提交和回滚方法,在查询的时候也会调用自己的查询对象和添加对象。以下为这个管理者的方法
在这里插入图片描述
这里又出现了另一个类org.apache.ibatis.cache.decorators.TransactionalCache,我们再深入进去
在这里插入图片描述
这里会有一个惊喜,首先看下上面的注释:这里很明确的说这个就是二级缓存缓冲区了。这段文字的谷歌翻译为

2级缓存事务性缓冲区。 此类包含所有在会话期间要添加到二级缓存的缓存条目。 调用提交时将条目发送到缓存,如果会话回滚则将其丢弃。 添加了阻止缓存支持。 因此,任何返回缓存未命中的get后面都将带有put,以便可以释放与该键关联的任何锁。

要是以前没接触这一块,看起来很懵逼。首先第一段包含在会话期间添加到二级缓存的缓存条目。这就回到org.apache.ibatis.executor.CachingExecutor#query方法当中

@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds,
		ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
	1. 获取一个缓存对象 注意这里是从哪个对象中获取的 以及这个对象的生命周期	
	Cache cache = ms.getCache();
	if (cache != null) {
		2. 尝试刷新缓存 默认情况下select语句是不会刷新缓存的 除非通过flushCache标签设置为true 这样的话一级缓存和二级缓存都将失效	
		flushCacheIfRequired(ms);
		4. 如果使用缓存(默认为false)而且resultHandler为空(默认为空)则使用缓存
		if (ms.isUseCache() && resultHandler == null) {
		    4. 存储过程包含OUT参数的不支持二级缓存 必须关闭 否则抛异常
			ensureNoOutParams(ms, boundSql);
			@SuppressWarnings("unchecked")
			5. 通过二级缓存管理器查询缓存对象 然后根据key在缓存对象中查询值
			List<E> list = (List<E>) tcm.getObject(cache, key);
			if (list == null) {
			    6. 如果不存在缓存或者缓存中不包含指定key的值 就会通过delegate查询
				list = delegate.<E>query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
				7. 将数据库中查询到的值放到缓存当中
				tcm.putObject(cache, key, list); // issue #578 and #116
			}
			8 返回结果
			return list;
		}
	}
	不使用缓存的情况下 就直接调用delegate查询
	return delegate.<E>query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

在这里很奇怪,怎么突然从ms中获取缓存呢?如果这里不结合全局来看,很难弄懂,首先只有在一个mapper开启了二级缓存,这里才会有值。开启的方式是在对应的mapper.xml文件中通过cache标签开启。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="sample.mybatis.mapper.CityMapper">

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

    <select id="findByState" resultType="sample.mybatis.domain.City">
        select * from city where state = #{state}
    </select>
</mapper>

比如在上面的mapper当中通过一个<cache/>标签开启了CityMapper级别的二级缓存。
因为这个标签,在构建SqlSessionFactory这个实例对象(全局唯一)的时候,就会解析这个mapper文件。在org.apache.ibatis.builder.xml.XMLMapperBuilder#configurationElement方法当中,解析cache这个标签
在这里插入图片描述
对应的解析代码如下

private void cacheElement(XNode context) throws Exception {
    如果不存在标签 则直接返回 
	if (context != null) {
	    获取缓存类型 默认值为PERPETUAL
		String type = context.getStringAttribute("type", "PERPETUAL");
		Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
		获取eviction属性值 缓存失效算法 默认为LRU
		String eviction = context.getStringAttribute("eviction", "LRU");
		Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
		获取定时刷新的间隔值
		Long flushInterval = context.getLongAttribute("flushInterval");
		获取缓存的大小
		Integer size = context.getIntAttribute("size");
		是否需要序列化
		boolean readWrite = !context.getBooleanAttribute("readOnly", false);
		是否采用阻塞模式 解决缓存击穿问题
		boolean blocking = context.getBooleanAttribute("blocking", false);
		Properties props = context.getChildrenAsProperties();
		通过一个辅助类创建缓存对象
		builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
	}
}

以上采用了不少别名,在Configuration类中定义如下

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

创建的逻辑在org.apache.ibatis.builder.MapperBuilderAssistant#useNewCache

public Cache useNewCache(Class<? extends Cache> typeClass, Class<? extends Cache> evictionClass, Long flushInterval,
		Integer size, boolean readWrite, boolean blocking, Properties props) {
	Cache cache = new CacheBuilder(currentNamespace).implementation(valueOrDefault(typeClass, PerpetualCache.class))
			.addDecorator(valueOrDefault(evictionClass, LruCache.class)).clearInterval(flushInterval).size(size)
			.readWrite(readWrite).blocking(blocking).properties(props).build();
	这里是一个关键 当cache-ref时可用于查询		
	configuration.addCache(cache);
	当前对象中也赋值一个 注意这里currentCache会被mapper中的所有statement对象共用 
	currentCache = cache;
	return cache;
}

这里有一个很重要的当就是把这个缓存放到了configuration当中。(configuration作为DefaultSqlSessionFactory的属性也是全局唯一的)
在这里插入图片描述
可以看到其实就是按照mapper文件的命名空间为主键放到一个Map当中了。
在这里插入图片描述
在这里插入图片描述
另外在当前的org.apache.ibatis.builder.MapperBuilderAssistant对象中也会保存一份。
在这里插入图片描述
继续解析,在org.apache.ibatis.builder.MapperBuilderAssistant#addMappedStatement方法之中就会创建MappedStatement对象,什么是MappedStatement对象呢?在上面提到的org.apache.ibatis.builder.xml.XMLMapperBuilder#configurationElement方法当中最后一步就是解析这个对象,对应的代码如下

buildStatementFromContext(context.evalNodes("select|insert|update|delete"));

简单来说,其实这个对象就是代表具体的数据库操作语句了。如下图所示,包含了很多信息,绝大部分都是对应节点的属性值,比如其中的id,其实就是xml文件的java形式而已
在这里插入图片描述
从这里我们不难理解,一个mapper接口对应一个xml资源文件,一个接口又对应多个方法,每个方法对应一个mappedStatement,也就是一个mapper文件对应多个mappedStatement对象。在mapper文件中开启二级缓存相当于这些mappedStatement对象在构造的过程中MyBatis解析器会创建一个Cache对象并设置到mappedStatement对象当中,如果没有开启,那么这个属性就是空的,当然同一个mapper文件当中的所有mappedStatement都会共享这个缓存。这里还有一个点,需要特别注意,这个mapper对应的缓存对象是不是全局唯一的呢?也就是一个select标签在整个程序的生命周期中是对应几个mappedStatement对象呢?
在这里插入图片描述
在org.apache.ibatis.session.defaults.DefaultSqlSession#selectList方法中可以看到每次查询的时候会从configuration当中查询MappedStatement对象,而在Configuration当中是按照如下方式存储的

protected final Map<String, MappedStatement> mappedStatements = new StrictMap<MappedStatement>(
			"Mapped Statements collection");

在一个项目当中,Configuration是单例的,所以同一个id对应的MappedStatement也是唯一的。
在这里插入图片描述
这样我们也可以得出一个结论,就是每个MappedStatement对象对应的那个缓存对象也是全局唯一的。因为除了通过构造者模式创建MappedStatement对象的时候设置这个缓存没有其他方法可以修改这个缓存属性对象。最后我们得出的结构如下所示
在这里插入图片描述
我们上面说了这么多,这个跟我们的二级缓存有什么关系呀?我们赶紧回到query这个方法中来,在我们获得Cache对象之后,就会通过事务缓存管理器去查询,查询参数有两个,一个是Cache对象,另一个就是CacheKey对象(这个对象与一级缓存中是一样的,此处不再详述)。
在这里插入图片描述
在事务缓存管理器中对应的源码为

public Object getObject(Cache cache, CacheKey key) {
	return getTransactionalCache(cache).getObject(key);
}

private TransactionalCache getTransactionalCache(Cache cache) {
	TransactionalCache txCache = transactionalCaches.get(cache);
	if (txCache == null) {
		这里很有意思 如果不存在对应的事务缓存对象 会将当前缓存对象包装一下作为值	
		txCache = new TransactionalCache(cache);
		transactionalCaches.put(cache, txCache);
	}
	return txCache;
}

这里相当的蹊跷呀,就是如果事务缓存里的transactionalCaches中不包含Cache对象所对应的值,就会将Cache作为delegate包装为TransactionalCache缓存。为了方便理解,我们还是以图说话。
在这里插入图片描述
也就是说一番操作,最后存储在TransactionalCache对象当中的真实对象还是从MappedStatement对象中获取的那个,也就是说这里通过Cache对象去事务缓存管理器中查找到自己,然后在自己里面再根据CacheKey查找值。是不是很绕呀。所以从整体来看,这个缓存对象其实就是在MappedStatement对象中保存的那唯一一份。每次请求(无论是不是同一个事务),从MappedStatement中获取到真实缓存对象,然后根据真实缓存对象去事务缓存管理器中查询,查询不到就把自己塞进去包装一下,能查到其实也是从自己里面查的…嗯!都快绕晕了。总之,对于每个MappedStatement(其实也就是对应一个mapper接口)来说,这个缓存是全局唯一的,至于同一个mapper接口中的其他方法共享,不与其他mapper共享,而且可以跨事务共享。还记得那个TransactionalCache类的注释吗?

2级缓存事务性缓冲区。 此类包含所有在会话期间要添加到二级缓存的缓存条目。 调用提交时将条目发送到缓存,如果会话回滚则将其丢弃。 添加了阻止缓存支持。 因此,任何返回缓存未命中的get后面都将带有put,以便可以释放与该键关联的任何锁。

对于第一句,缓存条目应该没啥问题了吧,虽然是跨事务mapper间共享的,但是只有在事务中才会涉及到这些缓存的操作。接下来分析下一句事务提交与事务回滚时的操作。在事务管理管理器当中对应的提交和回滚的实现如下

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

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

考虑到事务缓存管理器是与CachingExecutor同一级别,其实就是SqlSession级别,也就是事务级别的。所以transactionalCaches这个属性中的值也是对应一个事务的。不同事务之间不会相互影响的。这里会遍历这个对象,然后分别执行事务缓存对象的提交方法。什么时候一个事务内部会有多个TransactionalCache对象?那就是同一个事务内部调用了不同的mapper接口。那么事务提交和事务回滚究竟做了什么呢?首先我们看一下这个类里面的属性

private final Cache delegate;
private boolean clearOnCommit;
private final Map<Object, Object> entriesToAddOnCommit;
private final Set<Object> entriesMissedInCache;

public TransactionalCache(Cache delegate) {
	this.delegate = delegate;
	this.clearOnCommit = false;
	this.entriesToAddOnCommit = new HashMap<Object, Object>();
	this.entriesMissedInCache = new HashSet<Object>();
}

其中的delegate应该不陌生了,就是前面我们一直提到的那个MappedStatement中的那个唯一一份的缓存对象。而entriesToAddOnCommit和entriesMissedInCache是干嘛的?在前面我们有一个点没有讲,就是如果没有从二级缓存中获取到值,最后会继续如下的逻辑获取到目标值,然后还会添加到二级缓存中,此时根据Cache查找到二级缓存对象,但是这里并不是直接就修改里面的delegate,而是先存储在本对象中,也就是entriesToAddOnCommit里面。从这个属性的名称来看,就是待提交的缓存条目。因为事务还没有结束,所以先暂存,而不是直接刷到delegate当中。其实到这里,就应该理解This class holds all cache entries that are to be added to the 2nd level cache during a Session这句话了,其实TransactionalCache并不是指的那个二级缓存,真正的二级缓存其实就是MappedStatement对象中的Cache属性,TransactionalCache其实就是二级缓存的事务缓存区(The 2nd level cache transactional buffer),在事务没有结束时,所有待缓存的条目都保存在entriesToAddOnCommitentriesMissedInCache当中的。等到事务结束,再根据事务结果决定是否需要将这些条目提交到二级缓存还是直接丢弃。(Entries are sent to the cache when commit is called or discarded if the Session is rolled back).

此处先正名一下(划重点):
org.apache.ibatis.cache.decorators.TransactionalCache:二级缓存事务缓冲区
org.apache.ibatis.cache.TransactionalCacheManager:二级缓存事务缓冲区管理者
org.apache.ibatis.mapping.MappedStatement#cache:二级缓存
org.apache.ibatis.cache.decorators.TransactionalCache#delegate:二级缓存事务缓冲区二级缓存代理

org.apache.ibatis.cache.TransactionalCacheManager#putObject

public void putObject(Cache cache, CacheKey key, Object value) {
	// 获取到二级缓存缓冲区对象 并添加条目
	getTransactionalCache(cache).putObject(key, value);
}

org.apache.ibatis.cache.decorators.TransactionalCache#putObject

@Override
public void putObject(Object key, Object object) {
	// 首先将条目添加到缓冲区
	entriesToAddOnCommit.put(key, object);
}

在正常情况下,首先将查询的值存储在待提交区,等到事务提交完成之后才提交到二级缓存当中,这样能避免另一个事务查询到脏数据。毕竟没有提交事务不能保证与数据库数据的一致。
在这里插入图片描述
事务提交对应代码为

public void commit() {
    这个值在默认情况下都是false 只有在执行了select之外的操作比如update需要清空缓存的时候设置为true
	if (clearOnCommit) {
		delegate.clear();
	}
	将本地的条目刷到二级缓存当中
	flushPendingEntries();
	重置操作 清空entriesToAddOnCommit和entriesMissedInCache,并将clearOnCommit设置为false
	reset();
}

private void flushPendingEntries() {
	// 将待添加Map中的缓存数据刷到二级缓存中
	for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
		delegate.putObject(entry.getKey(), entry.getValue());
	}
	// 对应存在Missed缓存中Key 如果不在待添加条目中 则认为对应的值为null
	for (Object entry : entriesMissedInCache) {
		if (!entriesToAddOnCommit.containsKey(entry)) {
			delegate.putObject(entry, null);
		}
	}
}

如果是事务回滚的话,对应源码和情况如下所示

public void rollback() {
    删除二级缓存中未查到条目对应的缓存
	unlockMissedEntries();
	reset();
}

private void unlockMissedEntries() {
	for (Object entry : entriesMissedInCache) {
		try {
			删除二级缓存中未查询到的条目
			delegate.removeObject(entry);
		} catch (Exception e) {
			log.warn("Unexpected exception while notifiying a rollback to the cache adapter."
					+ "Consider upgrading your cache adapter to the latest version.  Cause: " + e);
		}
	}
}

在这里插入图片描述
在以上两个场景当中,都会做一件事情,就是如果在查询过程中没有查询到的值会记录到一个MissedInCache的集合里面,如果事务提交时,在待提交区没有对应条目,会保存一个null值或者事务回滚时直接删除最终缓存中的值。

假如存在以下这样的一个案例

insert X to table
commit
select * from table
delete X from table
select * from table -> this should not retrieve any data

首先一个事务添加了数据到数据库当中并提交事务,然后一个新的事务去查询数据库,一开始在二级缓存中不存在值,所以会在entriesMissedInCache中保存值,接下来会从数据库中查询到值,然后保存到entriesToAddOnCommit待提交区,然后再删除这个值,删除会导致缓存清空策略。
org.apache.ibatis.executor.CachingExecutor#update

@Override
public int update(MappedStatement ms, Object parameterObject) throws SQLException {
	flushCacheIfRequired(ms);   // 更新之前清空二级缓存
	return delegate.update(ms, parameterObject);
}

private void flushCacheIfRequired(MappedStatement ms) {
	Cache cache = ms.getCache();
	缓存不为空 是否需要刷新缓存(默认情况下非select方法为trueif (cache != null && ms.isFlushCacheRequired()) {
		tcm.clear(cache);
	}
}

最后会调到方法org.apache.ibatis.cache.decorators.TransactionalCache#clear

@Override
public void clear() {
	1. 这里会将这个值设置为true
	clearOnCommit = true;
	2. 清空待提交区
	entriesToAddOnCommit.clear();
}

然后此时再查询时,因为clearOnCommit标识位为true,即使从二级缓存中读取到了值也会返回空,因为当前事务之前执行了删除操作(备注:即使查询与删除的不是同一个值,也会返回空)(防止脏读问题)

@Override
public Object getObject(Object key) {
    // issue #116
    Object object = delegate.getObject(key);
    // 从缓存中查询不到 认为是Missed
    if (object == null) {
        entriesMissedInCache.add(key);
    }
    // issue #146
    因为这个clearOnCommit被上面设置为true了,所以返回null
    if (clearOnCommit) {
        return null;
    } else {
        return object;
    }
}

此时通过二级缓存查询的值为null,然后再查询数据库仍然是null(ACID),再将返回的null保存到待提交区,然后提交事务

public void commit() {
    if (clearOnCommit) {
        根据标识位 清空真实缓存
        delegate.clear();
    }
    提交待提交数据
    flushPendingEntries();
    这里会重新设置clearOnCommit为false
    reset();
}

在这里再次提交事务时,首先把二级缓存清空掉,然后将删除key对应的值null放到缓存当中。这样无论是本事务还是其他事务,都会读取到正确的值了。这里有一个其他的问题需要注意:就是无论是不是删除相同的key,都会把对应的二级缓存清空,而不是根据主键精确删除,这就很让人头疼了…
以上特意提到一些案例用于理解二级缓存中属性,实际数据库操作情况可能会更复杂,且不说MyBatis的二级缓存属于本地缓存,在分布式情况下无法使用,就算单体项目也会面临如果一张表在多个mapper中使用的话会导致脏数据的问题,由于二级缓存是与每个单独的mapper关联的,比如mapperA中的缓存包含tableA+tableB的数据,在mapperB中仅仅包含tableA的数据,调用mapperB的修改方法更新tableA的值,此时会更新mapperB中的缓存,但是却没法更新mapperA中的值,也就是说mapperA中关于tableA的值其实就与数据库不一致了。当然了可以通过cache-ref标签引用同一个二级缓存解决问题

<cache-ref namespace="sample.mybatis.mapper.HotelMapper"/>

参考这个issue:https://github.com/mybatis/mybatis-3/issues/1756
但无疑这只会把问题变得越来越复杂了。因此一般这种缓存都放到业务层,通过其他方式来解决,而不是在数据层。

总结

二级缓存也叫应用缓存,存在于SqlSessionFactory的生命周期中,可以理解为跨SqlSession,缓存是以namespace为单位的,不同namespace下的操作互不影响。在MyBatis的核心配置文件中cacheEnabled参数是二级缓存的全局开关,默认值为true。如果把这个参数设置为false,那么二级缓存是被关闭的

<!-- 这个配置使全局的映射器启用或禁用缓存 -->
<setting name="cacheEnabled" value="true" />

只有这个配置开启还不行,对于每个mapper,还需要单独打开开关

<cache/>

或者引用另一个mapper的缓存

<cache-ref namespace="sample.mybatis.mapper.HotelMapper"/>

对于这个标签,还可以设置不同的属性给缓存提供不同的功能比如

<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>

这里面采用的就是装饰器的模式,后面我们还会详细探讨。以上配置的效果如下

  1. mapper文件中的所有select语句将被缓存
  2. mapper文件中的所有insert、update和delete语句都会刷新缓存
  3. 缓存会使用LRU(Least Recently Used)算法来进行回收(对应org.apache.ibatis.cache.decorators.LruCache)
  4. 固定时间进行刷新缓存(对应org.apache.ibatis.cache.decorators.ScheduledCache#ScheduledCache)
  5. 缓存会存储列表集合或者对象的512个引用
  6. 缓存将会被序列化(org.apache.ibatis.cache.decorators.SerializedCache)

关于org.apache.ibatis.cache.decorators.TransactionalCache中还未说明的注释内容:Blocking cache support has been added. Therefore any get() that returns a cache miss will be followed by a put() so any lock associated with the key can be released.这个需要org.apache.ibatis.cache.decorators.BlockingCache,这个可用于解决缓存击穿的问题。后续会提及。

还有一点不得不提,就是在上一章中的标签属性flushCache,这个属性不但影响一级缓存,而且影响二级缓存。

<select id="findByState" resultType="sample.mybatis.domain.City" flushCache="true">
    select *
    from city
    where state = #{state}
</select>

这里的flushCache标签不但会使查询语句的一级缓存被清空,而且二级缓存也会被清空。结合一级缓存和二级缓存的查询顺序如下图所示:
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
MyBatis二级缓存是一种在多个SqlSession之间共享缓存数据的机制。它是在SqlSessionFactory级别上进行缓存的,可以跨多个SqlSession共享缓存数据。二级缓存的本质是将查询结果存储在内存中,以提高查询效率。 要启用MyBatis二级缓存,需要在MyBatis的配置文件中设置<setting name="cacheEnabled" value="true" />。默认情况下,二级缓存是关闭的,需要手动开启。 值得注意的是,使用MyBatis二级缓存时,返回的POJO必须是可序列化的,因为缓存需要将数据序列化到内存中。 需要注意的是,二级缓存是基于namespace级别的,不同的namespace拥有独立的二级缓存。当在一个namespace中进行数据修改时,会自动刷新该namespace下的所有查询缓存。而当在一个namespace中进行数据修改时,其他namespace下的查询缓存不会被刷新。因此,在使用二级缓存时,需要注意数据的一致性和缓存的刷新机制。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *3* [【MyBatisMyBatis 二级缓存全详解](https://blog.csdn.net/qq_21383435/article/details/124768956)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *2* [Mybatis二级缓存](https://blog.csdn.net/weixin_52851967/article/details/125190163)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

lang20150928

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值