Cache缓存原理
Mybatis提供一级缓存和二级缓存
,一级缓存即缓存在内存中的,二级缓存则是利用第三方缓存工具来缓存数据。对应类包括BaseExecutor
、CachingExecutor
、Cache接口实现类
。
1、缓存实现类 implement Cache
- Mybatis提供了非常多的缓存实现类,有最基本的
PerpetualCache
实现类、实现LRU策略的LruCache
、可保证线程安全的缓存SynchronizedCache
和具备阻塞功能的缓存BlockingCache
等。 - Cache的实现类中,除
PerpetualCache
算作是具体的缓存实现类外,其他的都算是缓存实现类的装饰类。因为每个类中都有一个Cache delegate
来实现装饰增强功能。
1.1、PerpetualCache
内部用一个Map来实现数据的缓存。Mybatis的一级缓存
则是用该实现类来缓存数据的。
private Map<Object, Object> cache = new HashMap<>();
public void putObject(Object key, Object value) {
cache.put(key, value);
}
public Object getObject(Object key) {
return cache.get(key);
}
public void clear() {
cache.clear();
}
1.2、LruCache
顾名思义,是一种具有 LRU 策略的缓存实现类,即最近最少使用的缓存会在当缓存空间满的时候,将最历史缓存中去掉。
//--☆☆-- LruCache
public class LruCache implements Cache {
//NOTE: 被装饰缓存类
private final Cache delegate;
//NOTE: 记录缓存的key,作用是:在调用getObject时触发调整LinkedHashMap节点的顺序。
private Map<Object, Object> keyMap;
//NOTE: 应该被剔除的缓存key
private Object eldestKey;
/**
* 默认缓存大小为1024
* @param delegate
*/
public LruCache(Cache delegate) {
this.delegate = delegate;
setSize(1024);
}
public void setSize(final int size) {
keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
private static final long serialVersionUID = 4267176411845948333L;
//NOTE: 该方法会在调用Map的get方法时被调用
@Override
protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
boolean tooBig = size() > size;
//NOTE: 若当前put数据后,大小大于默认值,则记录需要被删除的key,在下次put数据时会删掉这个数据
if (tooBig) {
eldestKey = eldest.getKey();
}
return tooBig;
}
};
}
@Override
public void putObject(Object key, Object value) {
delegate.putObject(key, value);
//NOTE: 删除需要删除的缓存节点
cycleKeyList(key);
}
public Object getObject(Object key) {
//NOTE: keyMap.get(key),目的是刷新 key 对应的键值对在 LinkedHashMap 的位置
keyMap.get(key);
return delegate.getObject(key);
}
//
private void cycleKeyList(Object key) {
keyMap.put(key, key);
if (eldestKey != null) {
delegate.removeObject(eldestKey);
eldestKey = null;
}
}
}
- LruCache实现的关键在于
keyMap
,其使用LinkedHashMap,并覆盖removeEldestEntry 方法。LinkedHashMap可以保证插入的键值对的顺序,当插入一个新的键值对时,LinkedHashMap内部会调整尾节点tail,head则是第一个插入的键值对,也就是最久没有被访问的节点。但默认情况下LinkedHashMap只会按照插入的顺序来维护键值对的顺序,所以为了实现最近使用
的顺序来维护键值对,则需要设置accessOrder=true
并且需覆盖removeEldestEntry
方法。 - LinkedHashMap 在插入新的键值对时会调用
removeEldestEntry
方法,以决定是否在插入新的键值对后,移除老的键值对。在代码中,当被装饰类的容量超出了 keyMap 的所规定的容量后,keyMap 会移除最长时间未被访问的键,并保存到 eldestKey 中,然后由 cycleKeyList 方法将 eldestKey 传给被装饰类的 removeObject 方法,移除相应的缓存项目。
1.3、BlockingCache
BlockingCache 基于 Java 重入锁实现了阻塞特性。同一时刻仅允许一个线程访问指定 key 的缓存项,其他线程将会被阻塞。
- 查询缓存时,会先获得对应key的锁并加锁,若命中缓存则会释放锁,否则一直锁定。
getObject
方法若返回 null,表示缓存未命中。此时 MyBatis 会进行数据库查询,并调用putObject
方法存储查询结果,对指定 key 对应的锁进行解锁。 - 当指定的key对应元素不在缓存中,BlockingCache会根据Lock进行加锁。此时其他的线程处于等待状态,直到key对应的数据被填充到缓存中,而不是让所有线程都去访问数据库。
- 但在removeObject时只仅调用了 releaseLock 方法释放锁,却没有调用被装饰类的 removeObject 方法移除指定缓存项。答案将在分析二级缓存的相关逻辑时分析。
2、缓存Key:CacheKey
为了是缓存hash更均匀,在CacheKey对象中包含了很多SQL操作的信息。且特别重要的是:一个CacheKey是由:statementId + rowBounds + 传递给JDBC的SQL + 传递给JDBC的参数值 四部分构成,并用一个特定的hash方法来构建hashcode。
2.1、构建CacheKey
一个CacheKey构成:
statementId + rowBounds + 传递给JDBC的SQL + 传递给JDBC的参数值
public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
CacheKey cacheKey = new CacheKey();
//NOTE: 记录MappedStatement id
cacheKey.update(ms.getId());
//NOTE: 记录指定查询结果集的范围
cacheKey.update(rowBounds.getOffset());
cacheKey.update(rowBounds.getLimit());
//NOTE: 查询所使用的SQL语句
cacheKey.update(boundSql.getSql());
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
//NOTE: 记录用户传入的实参值-start
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);
}
//NOTE: 记录用户传入的实参值-end
cacheKey.update(value);
}
}
return cacheKey;
}
2.2、怎样判断某两次查询是完全相同的查询?
从上述createCacheKey方法看出,一个CacheKey对象只要以下4点完全相同,则表示是同一个Sql查询。
- 传入的 statementId ;
- 查询时要求的结果集中的结果范围 (结果的范围通过rowBounds.offset和rowBounds.limit表示);
- 每次查询所产生的最终要传递给JDBC java.sql.Preparedstatement的Sql语句字符串(boundSql.getSql() 即sql语句);
- 传递给java.sql.Statement要设置的参数值,即传入的实参数值。
3、Mybatis缓存
3.1、一级缓存
- 一级缓存是Session会话级别的缓存,表示一次数据库会话的SqlSession对象之中,又被称之为本地缓存。Mybaits默认支持一级缓存,无需配置。
- MyBatis会在表示会话的SqlSession对象中建立一个简单的缓存,将每次查询到的结果结果缓存起来,当下次查询的时候,如果判断先前有个完全一样的查询,会直接从缓存中直接将结果取出,返回给用户,不需要再进行一次数据库查询了。
###3.1.1、缓存构建:BaseExecutor
**初始化入口:**创建SqlSession
过程中。SqlSession将它的工作交给了Executor
执行器这个角色来完成,负责完成对数据库的各种操作。每个Executor继承至BaseExecutor
抽象类,创建对应Executor时会调用父类(BaseExecutor)的构造函数,其中包含了一级缓存的构建,而真正的缓存信息包含在其中的localCache
(PerpetualCache)对象中。
protected BaseExecutor(Configuration configuration, Transaction transaction) {
this.transaction = transaction;
//NOTE: 延迟加载队列
this.deferredLoads = new ConcurrentLinkedQueue<>();
//NOTE: 一级缓存默认使用PerpetualCache缓存类
this.localCache = new PerpetualCache("LocalCache");
this.localOutputParameterCache = new PerpetualCache("LocalOutputParameterCache");
this.closed = false;
this.configuration = configuration;
this.wrapper = this;
}
3.1.2、一级缓存的生命周期
一级缓存是SqlSession级别的缓存
,MyBatis在开启一个数据库会话时,SqlSession就会创建一个一级缓存PerpetualCache
对象,当会话结束时,SqlSession对象以及其内部的Executor对象和PerpetualCache对象会自动释放掉。
释放PerpetualCache对象的情况
- 当SqlSession调用
close()
方法时,会释放掉一级缓存PerpetualCache对象,即该对象将不可用。 - 当SqlSession调用
clearCache()
方法时,会清空PerpetualCache对象中的数据,但该对象仍可使用。(和2的区别就是PerpetualCache对象没有被置为NULL) - SqlSession中执行了任何一个
update操作
(update()、delete()、insert()) ,都会清空PerpetualCache对象的数据,但该对象可以继续使用。
3.1.3 工程流程
在执行SQL查询语句时,调用BaseExecutor的query,
- 根据statementId、params、rowBounds、boundSql构建一个CacheKey对象,根据这个key值去缓存Cache中取出对应的key值存储的缓存结果;
- 判断从Cache(PerpetualCache)中据特定的key值取的数据数据是否为空,即是否命中;
- 若命中则返回;
- 若未命中缓存:
queryFromDatabase
从数据库中查询到结果- 将key和查询到的结果分别作为key-value对存储到Cache中;
- 返回结果。
3.2、二级缓存:CachingExecutor
- 二级缓存是
应用级别的缓存
,它的生命周期和应用的生命周期一样,其作用范围即整个应用。 - 一个SqlSession对象会将数据库操作交给Executor来完成,Mybatis的二级缓存就会在整个Executor上来搞事情,若设置
cacheEnabled=true
则会在创建Executor
对象时增加一个装饰器CachingExecutor
,后续SqlSession的操作将交给CachingExecutor
来完成。 CachingExecutor
对于查询操作,先判断该查询请求在二级缓存中是否有缓存结果,若命中,则直接返回缓存结果;若为命中缓存,则调用真正的Executor对象来完成查询操作,得到查询结果后,CachingExecutor会将真正Executor返回的查询结果放置到缓存中,然后在返回给用户。
3.2.1、二级缓存配置
二级缓存是可以有用户自定义配置的,且二级缓存不是简单地对整个应用就只有一个Cache缓存对象,而是细化到Mapper级别,即每个Mapper都可以配置一个Cache缓存。主要有两种方式配置:
- 每个Mapper分配一个Cache缓存对象(使用节点配置)
- 多个Mapper共用一个Cache缓存对象(使用节点配置)
3.2.2、如何开启二级缓存?
MyBatis对二级缓存的支持粒度很细,它会指定某一条查询语句是否使用二级缓存。
虽然在Mapper中配置了,并且为此Mapper分配了Cache对象,这并不表示我们使用Mapper中定义的查询语句查到的结果都会放置到Cache对象之中,必须指定Mapper中的某条选择语句是否支持缓存。
在<select> 节点中配置useCache="true",Mapper才会对此Select的查询支持缓存特性,否则,不会对此Select查询经过Cache缓存。
开启二级缓存的必要条件
- 全局缓存配置开关开启
cacheEnabled=true
- Mapper映射文件配置
<cache/>
或<cached-ref>
节点 - 每个sql 语句配置节点开启
useCache=true
一级缓存与二级缓存的优先级
如果开启且配置了二级缓存,那么在执行select查询语句的时候,Mybatis会先从二级缓存中获取结果,其次才是一级缓存。
二级缓存(CachingExecutor) ——> 一级缓存(BaseExecutor) ——> 数据库
- 从源代码的角度看:
//--☆☆--CachingExecutor
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) {
//NOTE: 是否配置了二级缓存
Cache cache = ms.getCache();
if (cache != null) {
flushCacheIfRequired(ms);
//NOTE: 当前执行Statement语句是否开启了缓存useCache=true
if (ms.isUseCache() && resultHandler == null) {
ensureNoOutParams(ms, boundSql);
@SuppressWarnings("unchecked")
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
//NOTE: 未命中缓存则通过被装饰executor对象delegate发起查询--BaseExecutor
list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
tcm.putObject(cache, key, list);
}
return list;
}
}
//NOTE: 没有配置二级缓存,则通过被装饰executor对象delegate发起查询--BaseExecutor
return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
//--☆☆--BaseExecutor
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
//NOTE: 缓存刷新
if (queryStack == 0 && ms.isFlushCacheRequired()) {
clearLocalCache();
}
List<E> list;
try {
queryStack++;
//NOTE: 一级缓存中是否缓存了对应sql?
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
//NOTE: 一级缓存中有数据,则返回
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
//NOTE: 一级缓存中没有则查询数据库
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
} finally {
queryStack--;
}
....//懒加载逻辑
return list;
}
3.2.3、二级缓存实现选择
- Mybatis默认提供了很多的Cache实现类,只需要给Mapper文件中的节点
<cache type=""/>
加上属性type=具体实现的Cache
即可(默认是LRUCache)。另外用户可通过实现Cache接口使用自定义的缓存。当然MyBatis还支持跟第三方内存缓存库如Memecached的集成。 - 在
CachingExecutor
调用ms.getCache
获取的Cache本质上是一系列的装饰器模式,具体结构:
- SynchronizedCache:同步Cache,实现比较简单,直接使用synchronized修饰方法。
- LoggingCache:日志功能,装饰类,用于记录缓存的命中率,如果开启了DEBUG模式,则会输出命中率日志。
- SerializedCache:序列化功能,将值序列化后存到缓存中。该功能用于缓存返回一份实例的Copy,用于保存线程安全。
- LruCache:采用了Lru算法的Cache实现,移除最近最少使用的Key/Value。
- PerpetualCache: 作为为最基础的缓存类,底层实现比较简单,直接使用了HashMap。
3.2.4、二级缓存事务管理
因为二级缓存时应用级别的,支持多Session共享。所以需要考虑事务,必然需要做到事务提交时,才将当前事务中查询时产生的缓存,同步到二级缓存中。
- TransactionalCacheManager:缓存事务管理
- TransactionalCache:支持事务的Cache
3.2.4.1 TransactionalCacheManager
缓存事务管理器,内部用一个Map来记录Cahe对象和对应事务TransactionalCache缓存对象。内部的事务提交、回滚等操作实际都是调用TransactionalCache。
3.2.4.2 TransactionalCache
事务缓存实现类,实现接口Cache。只有事务提交了缓存才生效。如果事务回滚或者不提交事务,则不对缓存产生影响。
- SQL执行完拿到结果后:
//-☆☆- CachingExecutor
if (list == null) {
list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
//将结果缓存
tcm.putObject(cache, key, list);
}
//-☆☆-TransactionalCache
//被装饰缓存对象
private final Cache delegate;
private boolean clearOnCommit;
/**
* 事务提交前,所有数据库查询的结果将缓存在该集合中
*/
private final Map<Object, Object> entriesToAddOnCommit;
/**
* 事务被提交前,当缓存未命中时,CacheKey 将会被存储在此集合中
*/
private final Set<Object> entriesMissedInCache;
//1-☆☆-TransactionalCache-事务提交前
public void putObject(Object key, Object object) {
entriesToAddOnCommit.put(key, object);
}
//2-☆☆-TransactionalCache-事务提交
public void commit() {
if (clearOnCommit) {
delegate.clear();
}
//事务提交将数据缓存到二级缓存中并重置当前缓存的数据
flushPendingEntries();
reset();
}
//3-☆☆-数据添加到二级缓存
private void flushPendingEntries() {
for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
delegate.putObject(entry.getKey(), entry.getValue());
}
//NOTE: 事务提交后,若未命中缓存则添加
for (Object entry : entriesMissedInCache) {
if (!entriesToAddOnCommit.containsKey(entry)) {
delegate.putObject(entry, null);
}
}
}
3.2.5、注意:最好不要使用Mybatis的二级缓存
-
原因:二级缓存时应用级别的,若在查询与更新操作交替进行的情境下,会出现脏数据。比如:
- 先查询记录R的查询操作A,并将结果缓存;
- 接着对记录R进行更新操作B;
- 接着再进行查询操作A,由于第一步已经将数据存入缓存,那么僵直接获取到结果,但在第二部时已经更新R记录,导致这次获取的数据不正确。
-
解决办法:可以通过插件的方式,在每次更新时,主动清除二级缓存数据。但每次执行数据库操作多需要经过插件的判断,影响性能。