MyBatis缓存详解
文章目录
- MyBatis缓存详解
- 缓存
- BaseExecutor一级缓存(会话级缓存)
- ReuseExecutor中的Statement缓存
- 为什么整合Spring框架一级缓存会失效
- 二级缓存
- 设计二级缓存的扩展性需求
- MyBatis中二级缓存的底层架构
- 二级缓存命中条件
- 二级缓存的配置参数
- 二级缓存中的事物缓存管理器
缓存
BaseExecutor一级缓存(会话级缓存)
代码演示:
一、运行时参数相关
-
sql语句相同,参数相同。
1
2
3
4
5
6
7
8@Test
public void baseCacheTest(){
SqlSession session = factory.openSession(true);
UserDao mapper = session.getMapper(UserDao.class);
User user1 = mapper.findById(4);
User user2 = mapper.findById(4);
System.out.println(user1 == user2);
}运行结果:
true
-
不同的mappedStatementId不能命中缓存,即使sql与参数一致
1
2
3
4
5
6<select id="findById" resultType="com.lxy.entity.User" parameterType="Integer">
SELECT * FROM user WHERE id=#{id}
</select>
<select id="findById2" resultType="com.lxy.entity.User" parameterType="Integer">
SELECT * FROM user WHERE id=#{id}
</select>1
2
3
4
5
6
7
8@Test
public void baseCacheTest(){
SqlSession session = factory.openSession(true);
UserDao mapper = session.getMapper(UserDao.class);
User user1 = mapper.findById(4);
User user2 = mapper.findById2(4);
System.out.println(user1 == user2);
}运行结果:
false
-
RowBounds必须相同,否则不会命中缓存
1
2
3
4
5
6
7
8
9@Test
public void baseCacheTest(){
SqlSession session = factory.openSession(true);
UserDao mapper = session.getMapper(UserDao.class);
RowBounds rowBounds = new RowBounds(0,10);
User user1 = mapper.findById(4);
List<User> list = session.selectList("com.lxy.dao.UserDao.findById",4, rowBounds);
System.out.println(user1 == list.get(0));
}运行结果:false;
-
不同session不会命中缓存
1
2
3
4
5
6
7
8
9
10@Test
public void baseCacheTest() {
SqlSession session = factory.openSession(true);
UserDao mapper = session.getMapper(UserDao.class);
SqlSession session2 = factory.openSession(true);
UserDao mapper2 = session2.getMapper(UserDao.class);
User user1 = mapper.findById(4);
User user2 = mapper2.findById(4);
System.out.println(user1 == user2);
}运行结果:false
二、操作与配置相关
-
进行第一次操作后清除了本地缓存后不能命中
1
2
3
4
5
6
7
8
9@Test
public void baseCacheTest2() {
SqlSession session = factory.openSession(true);
UserDao mapper = session.getMapper(UserDao.class);
User user1 = mapper.findById(4);
session.clearCache();
User user2 = mapper.findById(4);
System.out.println(user1 == user2);
}运行结果:false;
-
在接口方法使用注解@Options(flushCache = Options.FlushCachePolicy.TRUE),注意,此方式为前置清空缓存,也就是说在每次执行sql之前清空一级缓存
1
2@Options(flushCache = Options.FlushCachePolicy.TRUE)
User findById(int id);1
2
3
4
5
6
7
8@Test
public void baseCacheTest2() {
SqlSession session = factory.openSession();
UserDao mapper = session.getMapper(UserDao.class);
User user1 = mapper.findById(4);
User user2 = mapper.findById(4);
System.out.println(user1 == user2);
}运行结果为:false;
-
在查询后执行修改也会清空一级缓存
1
2
3
4
5
6
7
8
9@Test
public void baseCacheTest3() {
SqlSession session = factory.openSession();
UserDao mapper = session.getMapper(UserDao.class);
User user1 = mapper.findById(4);
mapper.update("mybatis",4);
User user2 = mapper.findById(4);
System.out.println(user1 == user2);
}运行结果:false;
-
将缓存作用于降低至STATEMENT不会命中
1
2
3<settings>
<setting name="localCacheScope" value="STATEMENT"/>
</settings>1
2
3
4
5
6
7
8
9//降低缓存作用域
@Test
public void baseCacheTest4() {
SqlSession session = factory.openSession();
UserDao mapper = session.getMapper(UserDao.class);
User user1 = mapper.findById(4);
User user2 = mapper.findById(4);
System.out.println(user1 == user2);
}运行结果:false;注意:此处一级缓存并没有完全关闭,对于嵌套查询还是起作用的。此清空方式为后置清空,在每个查询及其子查询结束后清空缓存,其目的是控制缓存的作用范围,让一级缓存只在每个查询及其子查询中生效。
-
执行提交或回滚也会清空缓存
ReuseExecutor中的Statement缓存
可重用执行器中维护的是JDBC的Statement缓存,并不是一级缓存,这点要区分开来。在ReuseExecutor内部使用的是JDBC的PreparedStatement,它的特点上面已经介绍过了,就是相同的sql语句可以复用,可重用执行器的内部也是根据sql语句是否一致来判断该sql是否存在Statement缓存中的。下面来看几个例子。
一、mappedStatementsId相同,参数不相同,不同的实体类
1 2 3 4 5 6 7 8 | @Test public void reuseCacheTest(){ SqlSession session = factory.openSession(ExecutorType.REUSE,true); UserDao mapper = session.getMapper(UserDao.class); mapper.findById(4); mapper.findById(2); } |
执行结果:
==> Preparing: SELECT * FROM user WHERE id=?
> Parameters: 4(Integer)
< Total: 1
二:不同的mappedStatementsId但是sql语句相同,参数不相同
1 2 3 4 5 6 | <select id="findById" resultType="com.lxy.entity.User" parameterType="Integer"> SELECT * FROM user WHERE id=#{id} </select> <select id="findById2" resultType="com.lxy.entity.User" parameterType="Integer"> SELECT * FROM user WHERE id=#{id} </select> |
1 2 3 4 5 6 7 | @Test public void reuseCacheTest(){ SqlSession session = factory.openSession(ExecutorType.REUSE,true); UserDao mapper = session.getMapper(UserDao.class); mapper.findById(4); mapper.findById2(2); } |
==> Preparing: SELECT * FROM user WHERE id=?
> Parameters: 4(Integer)
< Total: 1
> Parameters: 4(Integer)
< Total: 1
三、不同的mappedStatementsId,sql语句不相同,参数不相同
1 2 3 | <select id="findById2" resultType="com.lxy.entity.User" parameterType="Integer"> SELECT * FROM user WHERE id=#{id} and 1=1 </select> |
1 2 3 4 5 6 7 | @Test public void reuseCacheTest(){ SqlSession session = factory.openSession(ExecutorType.REUSE,true); UserDao mapper = session.getMapper(UserDao.class); mapper.findById(4); mapper.findById2(2); } |
==> Preparing: SELECT * FROM user WHERE id=?
> Parameters: 4(Integer)
< Total: 1
==> Preparing: SELECT * FROM user WHERE id=? and 1=1
> Parameters: 2(Integer)
< Total: 1
为什么整合Spring框架一级缓存会失效
因为整合Spring之后,每次执行调用,spring都会创建一个新的会话,所以不会命中缓存。
二级缓存
二级缓存也叫应用级缓存,与一级缓存不同的是,它的作用范围是整个应用,并且支持跨线程使用。相较于一级缓存只在单个会话中生效,二级缓存有着更高的命中率。
设计二级缓存的扩展性需求
一:存储
因为内存速度快,效率高,通常会将缓存放到内存中,但是内存中的数据并不是持久化的,程序关闭、服务器断电都会导致数据丢失,我们会考虑使用硬盘持久化的方式来避免这种情况。
二:淘汰策略
只往里面一个劲的扔数据肯定是不行的,我们的内存有限,并且缓存中的某些数据一段时间可能都用不上,那么这就涉及到淘汰策略了。
第一种:FIFO先进先出策略
第二种:LRU策略,淘汰掉最近最少使用的
三:其他
例如过期清理、线程安全、命中率统计、序列化等等。
MyBatis中二级缓存的底层架构
从最顶层说起
Cache是MyBatis中的一个接口,提供了一些最基本的方法,例如设置缓存、移除缓存、获取缓存等。
底下的这一排就是二级缓存的具体实现了,在这里,采用了装饰器+责任链的设计模式,优点也已经列举出来了。接下来,我们通过代码来验证一下二级缓存的流程是否符合上图所述。
1 2 3 4 5 6 7 | @Test public void cacheTest(){ Cache cache = configuration.getCache("com.lxy.dao.UserDao"); User user = new User(); cache.putObject("daoyou",user); Object daoyou2 = cache.getObject("daoyou"); } |
注意看括号,除了ScheduledCache和BlockingCahe都一一对应上了,因为没有配置,所以没有这两个。这幅图也很好的说明了MyBatis设计二级缓存采用的设计模式。
讲到这里,我们按照调用顺序来看一下源码。
SynchronizedCache.java
1 2 3 4 5 | //使用synchronized锁来达到线程安全的目的 @Override public synchronized void putObject(Object key, Object object) { delegate.putObject(key, object); } |
LoggingCache.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | //表面上getObject是获取缓存,但其实这里并没有真正获取,而是交给了下一个 @Override public Object getObject(Object key) { //请求数加一,后面计算命中率会用到 requests++; //交给责任链的下一个去获取 final Object value = delegate.getObject(key); //获取到的值不为空,命中数加一 if (value != null) { hits++; } if (log.isDebugEnabled()) { //getHitRatio()就是计算命中率的方法,我们再看一下这个。 log.debug("Cache Hit Ratio [" + getId() + "]: " + getHitRatio()); } return value; } |
1 2 3 4 | private double getHitRatio() { //就是做了一个除法,很简单 return (double) hits / (double) requests; } |
下一步来到SerializedCache.java
1 2 3 4 5 6 7 | @Override public Object getObject(Object key) { //同样,交给下一个 Object object = delegate.getObject(key); //对获取到的数据反序列化 return object == null ? null : deserialize((byte[]) object); } |
LruCache.java下面会展开讲MyBatis的二级缓存淘汰策略
1 2 3 4 5 6 7 | @Override public Object getObject(Object key) { //从集合中获取数据 keyMap.get(key); //touch //交给下一个执行。 return delegate.getObject(key); } |
最后来到PerpetualCache.java
1 2 3 4 5 | @Override public Object getObject(Object key) { //根据key获取缓存 return cache.get(key); } |
上面提到了LruCache,在MyBatis中,对二级缓存提供了淘汰策略,这里主要讲Fifo和Lru,只填不删那是肯定要出问题的,内存空间相比硬盘少的可怜,那么MyBatis中是怎么实现这两种策略的呢?
注意:在使用二级缓存之前要确保你的实体类继承了序列化接口Serializable
第一种:FIFO
一提到FIFO肯定就想到数据结构中的队列结构先进先出,MyBatis中也给我们提供了一个类FifoCache.java
1 2 3 4 5 6 7 8 9 10 11 | private final Cache delegate; //使用队列这种数据结构 private final Deque<Object> keyList; private int size; public FifoCache(Cache delegate) { this.delegate = delegate; //队列的实现使用了链表 this.keyList = new LinkedList<Object>(); this.size = 1024; } |
在MyBatis中,把它抽象成了环形结构,每次插入缓存时都是采用的尾插法,将新来的作为尾部,当队列满了,就将队头移除掉。
1 2 3 4 5 6 | @Override public void putObject(Object key, Object value) { //添加时会调用此方法 cycleKeyList(key); delegate.putObject(key, value); } |
1 2 3 4 5 6 7 8 9 10 | private void cycleKeyList(Object key) { //每次都插到尾部 keyList.addLast(key); //如果队列的大小超过我们配置时指定的大小,就将队头移除 if (keyList.size() > size) { //移除掉队头 Object oldestKey = keyList.removeFirst(); delegate.removeObject(oldestKey); } } |
接下来我们做个例子,我会将FifoCache缓存的大小设为5,并向里面存放6个数据,来看一下效果。
1 2 3 4 5 6 7 8 9 | @Test public void FIFOTest(){ Cache cache = configuration.getCache("com.lxy.dao.UserDao"); User user = new User(); for (int i = 0; i < 6; i++) { cache.putObject("Coder"+i,user); } System.out.println(cache); } |
会发现,Coder0不见了,这也验证了FifoCache的淘汰策略,淘汰时会移除队头缓存。
另一种淘汰策略,LruCache,MyBatis默认采用的淘汰策略,相比于FifoCache它显得就比较智能了,它会淘汰缓存队列中最少使用的那一个,就像你下载了100部小电影,但是总有几部你是经常不看的,不删又占用着硬盘又下不了新的电影,那就把不经常看的删除掉。
LruCache.java
1 2 3 4 5 6 7 8 | private final Cache delegate; private Map<Object, Object> keyMap; private Object eldestKey; public LruCache(Cache delegate) { this.delegate = delegate; setSize(1024); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public void setSize(final int size) { //使用链表形式LinkedHashMap这种数据结构,在增加和删除时性能十分优秀 keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) { private static final long serialVersionUID = 4267176411845948333L; @Override protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) { boolean tooBig = size() > size; if (tooBig) { eldestKey = eldest.getKey(); } return tooBig; } }; } |
1 2 3 4 5 6 | @Override public void putObject(Object key, Object value) { //放置缓存 delegate.putObject(key, value); cycleKeyList(key); } |
如果我们没有访问缓存,只是添加的话,我们的缓存存放顺序和使用FifoCache的顺序是一样的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UjrJLnxA-1593595502882)(https://i.loli.net/2020/06/06/3xsDnwHcleMhCyW.png)]
同样的,Coder0已经被移除掉了,这个时候我希望不要删除掉Coder1缓存,那么怎么办呢,我们要调用一下getObject()方法。
1 2 3 4 5 | @Override public Object getObject(Object key) { keyMap.get(key); //touch return delegate.getObject(key); } |
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BfUQIxgT-1593595502882)(https://i.loli.net/2020/06/06/7KoLCBIP3Jk5D8j.png)]
看,此时的Coder1就被放到了链表尾部,那么这时候就有小伙伴问了,你说了这么多,也没讲它是怎么实现将访问过的缓存放到链表尾的啊,我在getObject()方法中也没看到啊,小伙伴们注意了,还记得我们刚开始将Lru的时候,它的数据结构使用的是LinkedHashMap吗,奥秘就在这里,我们在setSize里初始化LinkedHashmap时将accessOrder参数设为了true,它很关键。
在getObject方法中的keyMap.get()方法,调用的就是LinkedHashMap中的get方法,这个方法有一个特性,如果accessOrder为true,就会将该节点放到链表尾部。
1 2 3 4 5 6 7 8 9 | public V get(Object key) { Node<K,V> e; if ((e = getNode(hash(key), key)) == null) return null; if (accessOrder) //放到链表尾部 afterNodeAccess(e); return e.value; } |
这就是LruCache的如何实现删除最近最少使用的缓存的原理了,它每次删除的都是处在链表头的缓存。
在设置了缓存过期时间后,是如何清除的呢?看一下ScheduledCache
1 2 3 4 5 6 7 8 9 10 11 | private final Cache delegate; //过期时间 protected long clearInterval; //最后一次清理的时间 protected long lastClear; public ScheduledCache(Cache delegate) { this.delegate = delegate; this.clearInterval = 60 * 60 * 1000; // 1 hour this.lastClear = System.currentTimeMillis(); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | @Override public void putObject(Object key, Object object) { //存放缓存时去检查一遍队列中有没有过期的缓存 clearWhenStale(); delegate.putObject(key, object); } @Override public Object getObject(Object key) { //获取时如果缓存过期了返回null否则返回缓存 return clearWhenStale() ? null : delegate.getObject(key); } @Override public Object removeObject(Object key) { //删除时也检查一遍 clearWhenStale(); return delegate.removeObject(key); } |
1 2 3 4 5 6 7 8 | private boolean clearWhenStale() { //现在的时间减去最后一次清理的时间如果大于缓存的过期时间,就清空全部二级缓存 if (System.currentTimeMillis() - lastClear > clearInterval) { clear(); return true; } return false; } |
二级缓存命中条件
这里我们重点讲一下第一个。
看一下第一个条件,没有提交会话,无法命中缓存。
1 2 3 4 5 6 7 8 9 10 | @Test public void HitCache(){ SqlSession session=factory.openSession(); UserDao userDao = session.getMapper(UserDao.class); userDao.findById(4); SqlSession session1=factory.openSession(); UserDao userDao2 = session1.getMapper(UserDao.class); userDao2.findById(4); } |
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pQYVf2u4-1593595502884)(/Users/liuxingyu/Library/Application Support/typora-user-images/image-20200606194442735.png)]
执行了两次查询,缓存命中率为0
2.提交会话后可以命中
1 2 3 4 5 6 7 8 9 10 | @Test public void HitCache(){ SqlSession session=factory.openSession(); UserDao userDao = session.getMapper(UserDao.class); userDao.findById(4); session.commit(); SqlSession session1=factory.openSession(); UserDao userDao2 = session1.getMapper(UserDao.class); userDao2.findById(4); } |
命中率为0.5,但是出现了一个问题,为什么提交会话只进行了一次查询,而前面的没提交却进行了两次呢。这是因为命中缓存之后不需要再去数据库查询了,所以只查询了一次。
注意,只有手动提交后,二级缓存才会被命中,开启自动提交并不会命中缓存。
二级缓存的配置参数
第一个,cacheEnabled全局缓存开关,这个没什么好讲的。
第二个,useCache,代表当前的statement是否进行二级缓存,配置为false会失效。
1 2 3 4 5 6 7 8 9 10 | @Test public void HitCache(){ SqlSession session=factory.openSession(); UserDao userDao = session.getMapper(UserDao.class); userDao.findById(4); session.commit(); SqlSession session1=factory.openSession(); UserDao userDao2 = session1.getMapper(UserDao.class); userDao2.findById(4); } |
此时,第二条查询语句并不会命中缓存。
第三个:flushCache,语句执行前清空所有二级缓存,还是上面的代码,结果也是没有命中。这里有一点我搞错了,专门记下来免得自己忘了。最开始的想法,我以为缓存的命中仅仅取决于控制台里的输出日志Hit Ratio这个信息(年轻了),但实际并不是,如果命中了二级缓存后,例如查询,第二次查询命中缓存后就直接返回结果了,不会再进行查询了,所以控制台只有一条查询语句,控制台仍旧有命中率日志输出是因为select语句默认useCache为true。
在insert、update、delete语句时,flushCache默认为true,如果是false,那么本次的修改对后续的查询不可见,查询的数据还是老的数据,这里也是保证了数据的一致性。
第四个,声明缓存空间,在接口上加注解@CacheNamespace或对应接口配置文件中使用标签,注意,两者不能共存。它们两个不是同一个东西。如果在接口中只是使用注解,二级缓存的配置是不会生效的,需要在接口映射文件中配置标签,引用命名空间
第五个,引用缓存空间,也就是两个接口中的方法共用一个缓存空间,当一个方法执行清空操作时,两个接口的二级缓存会被全部清空,因为是共用嘛。
二级缓存中的事物缓存管理器
这张图就是MyBatis中会话与缓存之间的关系了,会话并不和二级缓存空间直接打交道,而是通过了一个事物缓存管理器,他俩的关系是1:1的,事务缓存管理器中又有着多个暂存区,是1:n的,相同的暂存区都对应着同一个缓存空间,是n:1的。来看代码演示。
这里我使用了一个session,进行了两个不同mapper的查询。
1 2 3 4 5 6 7 | @Test public void TxManager(){ SqlSession session=factory.openSession(); session.getMapper(UserDao.class).findById(4); session.getMapper(CustomersDao.class).findCustomersById(2); System.out.println(session); } |
可以看到,在session会话中的tcm事务缓存管理器中有两个暂存区,分别对应着User和Customer,暂存区中的value才指向二级缓存空间,所有的操作都会保存在暂存区,只有commit后才会提交到二级缓存空间。关于暂存区的理解,看下面这张图。
暂存区的作用,主要体现在修改上,大家都知道,进行修改操作后(这里指增、删、改)后会清空二级缓存,但假设没有暂存区这个概念而是直接和二级缓存打交道,我在进行修改操作后后悔了,想要回滚,但是它已经把二级缓存清空了,这就完全没必要了。暂存区的作用,此时就体现出来了,修改之后,会在TransactionalCache中有一个标记位clearOnCommit,会被置为true,表面是清理了,但实际并没有,只有当你决定要进行提交操作了,它才会真正的清理。在进行修改后再次进行查询,即便没提交会话,也会返回null。接下来我们通过代码,看一下其中的奥秘
TransactionalCache.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | 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; //默认是false this.clearOnCommit = false; this.entriesToAddOnCommit = new HashMap<Object, Object>(); this.entriesMissedInCache = new HashSet<Object>(); } //部分方法代码 @Override public void clear() { //修改后会将clearOnCommit设为true,表面清理,其实并没有 clearOnCommit = true; entriesToAddOnCommit.clear(); } @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; } } public void commit() { //提交时,只有clearOnCommit为true才会清空二级缓存,这个不难理解 if (clearOnCommit) { delegate.clear(); } flushPendingEntries(); reset(); } public void rollback() { unlockMissedEntries(); //回滚操作,重置标记位 reset(); } private void reset() { //清空后要将标记位置为false clearOnCommit = false; entriesToAddOnCommit.clear(); entriesMissedInCache.clear(); } |