Cache 接口学习

368 阅读31分钟

缓存是优化数据库性能的常用手段之一,我们在实践中经常使用的是 Memcached、Redis 等外部缓存组件,很多持久化框架提供了集成这些外部缓存的功能,同时自身也提供了内存级别的缓存,MyBatis 作为持久化框架中的佼佼者,自然也提供了这些功能。

MyBatis 的缓存分为一级缓存、二级缓存两个级别,并且都实现了 Cache 接口,所以这一讲我们就重点来介绍 Cache 接口及其核心实现类,这也是一级缓存和二级缓存依赖的基础实现。

不过在讲解这些内容之前,我先来介绍下装饰器模式,因为 Cache 模块除了提供基础的缓存功能外,还提供了多种扩展功能,而这些功能都是通过装饰器的形式提供的。

装饰器模式

我们在做一个产品的时候,需求会以多期的方式执行,随着产品的不断迭代,新需求也会不断出现,我们开始设计一个类的时候,可能并没有考虑到新需求的场景,此时就需要为某些组件添加新的功能来满足这些需求。

如果要符合开放-封闭的原则,我们最好不要直接修改已有的具体实现类,因为会破坏其已有的稳定性,在自测、集成测试以及线上回测的时候,除了要验证新需求外,还要回归测试波及的历史功能,这是让开发人员和测试人员都非常痛苦的地方,也是违反开放-封闭原则带来的最严重的问题之一。

除了修改原有实现之外,还有一种修改方案,那就是继承,也就是需要创建一个新的子类,然后在子类中覆盖父类的相关方法,并添加实现新需求的扩展。

但继承在某些场景下是不可行的,例如,要覆盖的方法被 final 关键字修饰了,那么在 Java 的语法中就无法被覆盖。使用继承方案的另一个缺点就是整个继承树的膨胀,例如,当新需求存在多种排列组合或是复杂的判断时,那就需要写非常多的子类实现。

正是由于这些缺点的存在,所以应该尽量多地使用组合方式进行扩展,尽量少使用继承方式进行扩展,除非迫不得已。

装饰器模式就是一种通过组合方式实现扩展的设计模式,它可以完美地解决上述功能增强的问题。装饰器的核心思想是为已有实现类创建多个包装类,由这些新增的包装类完成新需求的扩展。

装饰器模式使用的是组合方式,相较于继承这种静态的扩展方式,装饰器模式可以在运行时根据系统状态,动态决定为一个实现类添加哪些扩展功能。

装饰器模式的核心类图,如下所示:

从图中可以看到,装饰器模式中的核心类主要有下面四个。

  • Component 接口:已有的业务接口,是整个功能的核心抽象,定义了 Decorator 和 ComponentImpl 这些实现类的核心行为。JDK 中的 IO 流体系就使用了装饰器模式,其中的 InputStream 接口就扮演了 Component 接口的角色。
  • ComponentImpl 实现类:实现了上面介绍的 Component 接口,其中实现了 Component 接口最基础、最核心的功能,也就是被装饰的、原始的基础类。在 JDK IO 流体系之中的 FileInputStream 就扮演了 ComponentImpl 的角色,它实现了读取文件的基本能力,例如,读取单个 byte、读取 byte[] 数组。
  • Decorator 抽象类:所有装饰器的父类,实现了 Component 接口,其核心不是提供新的扩展能力,而是封装一个 Component 类型的字段,也就是被装饰的目标对象。需要注意的是,这个被装饰的对象可以是 ComponentImpl 对象,也可以是 Decorator 实现类的对象,之所以这么设计,就是为了实现下图的装饰器嵌套。这里的 DecoratorImpl1 装饰了 DecoratorImpl2,DecoratorImpl2 装饰了 ComponentImpl,经过了这一系列装饰之后得到的 Component 对象,除了具有 ComponentImpl 的基础能力之外,还拥有了 DecoratorImpl1 和 DecoratorImpl2 的扩展能力。JDK IO 流体系中的 FilterInputStream 就扮演了 Decorator 的角色。

  • DecoratorImpl1、DecoratorImpl2:Decorator 的具体实现类,它们的核心就是在被装饰对象的基础之上添加新的扩展功能。在 JDK IO 流体系中的 BufferedInputStream 就扮演了 DecoratorImpl 的角色,它在原有的 InputStream 基础上,添加了一个 byte[] 缓冲区,提供了更加高效的读文件操作。

Cache 接口及核心实现

Cache 接口是 MyBatis 缓存中最顶层的抽象接口,位于 org.apache.ibatis.cache 包中,定义了 MyBatis 缓存最核心、最基础的行为。

Cache 接口中的核心方法主要是 putObject()、getObject() 和 removeObject() 三个方法,分别用来写入、查询和删除缓存数据。

其中的 PerpetualCache 扮演了装饰器模式中 ComponentImpl 这个角色,实现了 Cache 接口缓存数据的基本能力。

public interface Cache {

  String getId();


  void putObject(Object key, Object value);

  Object getObject(Object key);

  Object removeObject(Object key);

  void clear();

  int getSize();

  default ReadWriteLock getReadWriteLock() {
    return null;
  }

}

PerpetualCache 中有两个核心字段:

  • 一个是 id 字段(String 类型),记录了缓存对象的唯一标识;
  • 另一个是 cache 字段(HashMap 类型),真正实现 Cache 存储的数据结构,对 Cache 接口的实现也会直接委托给这个 HashMap 对象的相关方法,例如,PerpetualCache 中 putObject() 方法就是调用 cache 的 put() 方法写入缓存数据的。
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 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();
  }

}

Cache 接口装饰器

除了 PerpetualCache 之外的其他所有 Cache 接口实现类,都是装饰器实现,也就是 DecoratorImpl 的角色。

下面我们就逐个分析这些 Cache 接口的装饰器都提供了哪些功能上的扩展。

1.BlockingCache

顾名思义,BlockingCache 是在原有 Cache 实现之上添加了阻塞线程的特性。

对于一个 Key 来说,同一时刻,BlockingCache 只会让一个业务线程到数据库中去查找,查找到结果之后,会添加到 BlockingCache 中缓存。

作为一个装饰器,BlockingCache 自然会包含一个 Cache 类型的字段,也就是 delegate 字段。除此之外,BlockingCache 还包含了一个 locks 集合(ConcurrentHashMap<Object, CountDownLatch> 类型)和一个 timeout 字段(long 类型),其中 locks 为每个 Key 分配了一个 CountDownLatch 用来控制并发访问,timeout 指定了一个线程在 BlockingCache 上阻塞的最长时间。

public class BlockingCache implements Cache {

  private long timeout;
  private final Cache delegate;
  private final ConcurrentHashMap<Object, CountDownLatch> locks;

  public BlockingCache(Cache delegate) {
    this.delegate = delegate;
    this.locks = new ConcurrentHashMap<>();
  }

  //...

  @Override
  public void putObject(Object key, Object value) {
    try {
      delegate.putObject(key, value);
    } finally {
      releaseLock(key);
    }
  }


}

下面我们来看 BlockingCache 的 getObject() 方法实现,其中需要先调用 acquireLock() 方法获取锁,才能查询 delegate 缓存,命中缓存之后会立刻调用 releaseLock() 方法释放锁,如果未命中缓存则不会释放锁。

在 acquireLock() 方法中,通过 locks 这个 ConcurrentHashMap 集合以及其中各个 Key 关联的 CountDownLatch 对象,实现了锁的效果,具体实现如下:

private void acquireLock(Object key) {

    // 初始化一个全新的CountDownLatch对象
    CountDownLatch newLatch = new CountDownLatch(1);
    while (true) {
        // 尝试将key与newLatch这个CountDownLatch对象关联起来
        // 如果没有其他线程并发,则返回的latch为null
        CountDownLatch latch = locks.putIfAbsent(key, newLatch);

        if (latch == null) {
            // 如果当前key未关联CountDownLatch,
            // 则无其他线程并发,当前线程获取锁成功
            break;
        }
        // 当前key已关联CountDownLatch对象,则表示有其他线程并发操作当前key,
        // 当前线程需要阻塞在并发线程留下的CountDownLatch对象(latch)之上,
        // 直至并发线程调用latch.countDown()唤醒该线程
        if (timeout > 0) { // 根据timeout的值,决定阻塞超时时间
            boolean acquired = latch.await(timeout, TimeUnit.MILLISECONDS);
            if (!acquired) { // 超时未获取到锁,则抛出异常
                throw new CacheException("...");
            }
        } else { // 死等
            latch.await();
        }
    }
}

在 releaseLock() 方法中,会从 locks 集合中删除 Key 关联的 CountDownLatch 对象,并唤醒阻塞在这个 CountDownLatch 对象上的业务线程。

看到这里,你可能会问:假设业务线程 1、2 并发访问某个 Key,线程 1 查询 delegate 缓存失败,不释放锁,timeout <=0 的时候,线程 2 就会阻塞吗?是的,但是线程 2 不会永久阻塞,因为我们需要保证线程 1 接下来会查询数据库,并调用 putObject() 方法或 removeObject() 方法,其中会通过 releaseLock() 方法释放锁。

最终,我们得到 BlockingCache 的核心原理如下图所示:

2.FifoCache

MyBatis 中的缓存本质上就是 JVM 堆中的一块内存,我们需要严格控制 Cache 的大小,防止 Cache 占用内存过大而影响程序的性能。操作系统有很多缓存淘汰规则,MyBatis 也提供了类似的规则来清理缓存。

这就引出了 FifoCache 装饰器,它是 FIFO(先入先出)策略的装饰器。在系统运行过程中,我们会不断向 Cache 中增加缓存条目,当 Cache 中的缓存条目达到上限的时候,则会将 Cache 中最早写入的缓存条目清理掉,这也就是先入先出的基本原理。

public class FifoCache implements Cache {

  private final Cache delegate;
  private final Deque<Object> keyList;
  private int size;

  //....

  private void cycleKeyList(Object key) {
    keyList.addLast(key);
    if (keyList.size() > size) {
      Object oldestKey = keyList.removeFirst();
      delegate.removeObject(oldestKey);
    }
  }

}

FifoCache 作为一个 Cache 装饰器,自然也会包含一个指向 Cache 的字段(也就是 delegate 字段),同时它还维护了两个与 FIFO 相关的字段:

  • 一个是 keyList 队列(LinkedList),主要利用 LinkedList 集合有序性,记录缓存条目写入 Cache 的先后顺序;
  • 另一个是当前 Cache 的大小上限(size 字段),当 Cache 大小超过该值时,就会从 keyList 集合中查找最早的缓存条目并进行清理。

FifoCache 的 getObject() 方法和 removeObject() 方法实现非常简单,都是直接委托给底层 delegate 这个被装饰的 Cache 对象的同名方法。FifoCache 的关键实现在 putObject() 方法中,在将数据写入被装饰的 Cache 对象之前,FifoCache 会通过 cycleKeyList() 方法执行 FIFO 策略清理缓存,然后才会调用 delegate.putObject() 方法完成数据写入。

3.LruCache

除了 FIFO 策略之外,MyBatis 还支持 LRU(Least Recently Used,近期最少使用算法)策略来清理缓存。LruCache 就是使用 LRU 策略清理缓存的装饰器实现,如果 LruCache 发现缓存需要清理,它会清除最近最少使用的缓存条目。

public class LruCache implements Cache {

  private final Cache delegate;
  private Map<Object, Object> keyMap;
  private Object eldestKey;

  public LruCache(Cache delegate) {
    this.delegate = delegate;
    setSize(1024);
  }
  //...
}

LruCache 中除了有一个 delegate 字段指向被装饰 Cache 对象之外,还维护了一个 LinkedHashMap 集合(keyMap 字段),用来记录各个缓存条目最近的使用情况,以及一个 eldestKey 字段(Object 类型),用来指向最近最少使用的 Key。

LinkedHashMap 继承了 HashMap,底层使用数组来存储 KV 数据,数组中存储的是 LinkedHashMap.Entry 类型的元素。在 LinkedHashMap.Entry 中除了存储 KV 数据之外,还维护了 before、after 两个字段分别指向当前 Entry 前后的两个 Entry 节点。在 LinkedHashMap 中维护了 head、tail 两个指针,分别指向了第一个和最后一个 Entry 节点。LinkedHashMap 的原理如下图所示:

在上图(1)中,通过 Entry 中的 before 和 after 指针形成了一个链表,当我们调用 get() 方法访问 Key 4 时,LinkedHashMap 除了返回 Value 4 之外,还会默默修改 Entry 链表,将 Key 4 项移动到链表的尾部,得到上图(2)中的结构。

LruCache 中的 keyMap 覆盖了 LinkedHashMap 默认的 removeEldestEntry() 方法实现,当 LruCache 中缓存条目达到上限的时候,返回 true,即删除 Entry 链表中 head 指向的 Entry。LruCache 就是依赖 LinkedHashMap 上述的这些特点来确定最久未使用的缓存条目并完成删除的。

下面是 LruCache 初始化过程中,keyMap 对 LinkedHashMap.removeEldestEntry() 方法的覆盖:

keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
	// 调用LinkedHashMap.put()方法时,会调用removeEldestEntry()方法
	// 决定是否删除head指向的Entry数据
	protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {

      boolean tooBig = size() > size;  
      if (tooBig) { // 已到达缓存上限,更新eldestKey字段,并返回true,LinkedHashMap会删除该Key
          eldestKey = eldest.getKey();  
      }
      return tooBig;
  
  }
}

了解了 LruCache 核心原理之后,我们再来看 getObject()、putObject() 等 Cache 接口方法的实现。

首先是 getObject() 方法,除了委托给底层被装饰的 Cache 对象获取缓存数据之外,还会执行 keyMap.get() 方法更新 Key 在这个 LinkedHashMap 集合中的顺序。

在 putObject() 方法中,除了将 KV 数据写入底层被装饰的 Cache 对象中,还会调用 cycleKeyList() 方法将 KV 数据写入 keyMap 集合中,此时可能会触发 eldestKey 数据的清理,具体实现如下:

private void cycleKeyList(Object key) {
  keyMap.put(key, key); // 将KV数据写入keyMap集合
  if (eldestKey != null) {
      // 如果eldestKey不为空,则将从底层Cache中删除
      delegate.removeObject(eldestKey);
      eldestKey = null;
  }
}

4. SoftCache

看到 SoftCache 这个名字,有一定 Java 经验的同学可能会立刻联想到 Java 中的软引用(Soft Reference),所以这里我们就先来简单回顾一下什么是强引用和软引用,以及这些引用的相关机制。

强引用是 JVM 中最普遍的引用,我们常用的赋值操作就是强引用,例如,Person p = new Person(); 这条语句会将新创建的 Person 对象赋值为 p 这个变量,p 这个变量指向这个 Person 对象的引用,就是强引用。这个 Person 对象被引用的时候,即使是 JVM 内存空间不足触发 GC,甚至是内存溢出(OutOfMemoryError),也不会回收这个 Person 对象。

软引用比强引用稍微弱一些。当 JVM 内存不足时,GC 才会回收那些只被软引用指向的对象,从而避免 OutOfMemoryError。当 GC 将只被软引用指向的对象全部回收之后,内存依然不足时,JVM 才会抛出 OutOfMemoryError。根据软引用的这一特性,我们会发现软引用特别适合做缓存,因为缓存中的数据可以从数据库中恢复,所以即使因为 JVM 内存不足而被回收掉,也可以通过数据库恢复缓存中的对象。

在使用软引用的时候,需要注意一点:当拿到一个软引用的时候,我们需要先判断其 get() 方法返回值是否为 null。如果为 null,则表示这个软引用指向的对象在之前的某个时刻,已经被 GC 掉了;如果不为 null,则表示这个软引用指向的对象还存活着。

在有的场景中,我们可能需要在一个对象的可达性(是否已经被回收)发生变化时,得到相应的通知,引用队列(Reference Queue) 就是用来实现这个需求的。在创建 SoftReference 对象的时候,我们可以为其关联一个引用队列,当这个 SoftReference 指向的对象被回收的时候,JVM 就会将这个 SoftReference 作为通知,添加到与其关联的引用队列,之后我们就可以从引用队列中,获取这些通知信息,也就是 SoftReference 对象。

下面我们正式开始介绍 SoftCache。SoftCache 中的 value 是 SoftEntry 类型的对象,这里的 SoftEntry 是 SoftCache 的内部类,继承了 SoftReference,其中指向 key 的引用是强引用,指向 value 的引用是软引用,具体实现如下:

  private final Deque<Object> hardLinksToAvoidGarbageCollection;
  private final ReferenceQueue<Object> queueOfGarbageCollectedEntries;
  private final Cache delegate;
  private int numberOfHardLinks;

  //....

  private static class SoftEntry extends SoftReference<Object> {
  
      private final Object key;
  
      SoftEntry(Object key, Object value, ReferenceQueue<Object> garbageCollectionQueue) {
  
          // 指向value的是软引用,并且关联了引用队列
  
          super(value, garbageCollectionQueue);
  
          // 指向key的是强引用
  
          this.key = key;
  
      }
  
  }

了解了 SoftCache 存储的对象类型之后,下面我们再来看它的核心字段。

  • delegate(Cache 类型):SoftCache 装饰的底层 Cache 对象。
  • queueOfGarbageCollectedEntries(ReferenceQueue 类型):该引用队列会与每个 SoftEntry 对象关联,用于记录已经被回收的缓存条目,即 SoftEntry 对象,SoftEntry 又通过 key 这个强引用指向缓存的 Key 值,这样我们就可以知道哪个 Key 被回收了。
  • hardLinksToAvoidGarbageCollection(LinkedList类型):在 SoftCache 中,最近经常使用的一部分缓存条目(也就是热点数据)会被添加到这个集合中,正如其名称的含义,该集合会使用强引用指向其中的每个缓存 Value,防止它被 GC 回收。
  • numberOfHardLinks(int 类型):指定了强连接的个数,默认值是 256,也就是最近访问的 256 个 Value 无法直接被 GC 回收。
  • 了解了核心字段的含义之后,我们再来看 SoftCache 对 Cache 接口中核心方法的实现。

    首先是 putObject() 方法,它除了将 KV 数据放入底层被装饰的 Cache 对象中保存之外,还会调用 removeGarbageCollectedItems() 方法,根据 queueOfGarbageCollectedEntries 集合,清理已被 GC 回收的缓存条目,具体实现如下:

      @Override
      public void putObject(Object key, Object value) {
        removeGarbageCollectedItems();
        delegate.putObject(key, new SoftEntry(key, value, queueOfGarbageCollectedEntries));
      }
    
    
    
    
      private void removeGarbageCollectedItems() {
      
          SoftEntry sv;
      
          // 遍历queueOfGarbageCollectedEntries集合,其中记录了被GC回收的Key
      
          while ((sv = (SoftEntry) queueOfGarbageCollectedEntries.poll()) != null) {
      
              delegate.removeObject(sv.key); // 清理被回收的Key
      
          }
      
      }
    

    接下来看 getObject() 方法,在查询缓存的同时,如果发现 Value 已被 GC 回收,则同步进行清理;如果查询到缓存的 Value 值,则会同步调整 hardLinksToAvoidGarbageCollection 集合的顺序,具体实现如下:

    
    
    
    public Object getObject(Object key) {
    
        Object result = null;
    
        // 从底层被装饰的缓存中查找数据
    
        SoftReference<Object> softReference = (SoftReference<Object>) delegate.getObject(key);
    
        if (softReference != null) {
    
            result = softReference.get();
    
            if (result == null) {
    
                // Value为null,则已被GC回收,直接从缓存删除该Key
    
                delegate.removeObject(key);
    
            } else { // 未被GC回收
    
                // 将Value添加到hardLinksToAvoidGarbageCollection集合中,防止被GC回收
    
                synchronized (hardLinksToAvoidGarbageCollection) {
    
                    hardLinksToAvoidGarbageCollection.addFirst(result);
    
                    // 检查hardLinksToAvoidGarbageCollection长度,超过上限,则清理最早添加的Value
    
                    if (hardLinksToAvoidGarbageCollection.size() > numberOfHardLinks) {
    
                        hardLinksToAvoidGarbageCollection.removeLast();
    
                    }
    
                }
    
            }
    
        }
    
        return result;
    
    }
    

    最后来看 removeObject() 和 clear() 这两个清理方法,它们除了清理被装饰的 Cache 对象之外,还会清理 hardLinksToAvoidGarbageCollection 集合,具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。

      @Override
      public Object removeObject(Object key) {
        removeGarbageCollectedItems();
        return delegate.removeObject(key);
      }
    
      @Override
      public void clear() {
        synchronized (hardLinksToAvoidGarbageCollection) {
          hardLinksToAvoidGarbageCollection.clear();
        }
        removeGarbageCollectedItems();
        delegate.clear();
      }
    
      private void removeGarbageCollectedItems() {
        SoftEntry sv;
        while ((sv = (SoftEntry) queueOfGarbageCollectedEntries.poll()) != null) {
          delegate.removeObject(sv.key);
        }
      }
    

    5.WeakCache

    WeakCache 涉及 Java 的弱引用概念,所以这里我就先带你回顾一下弱引用(WeakReference)的一些特性。

    弱引用比软引用的引用强度还要弱。弱引用可以引用一个对象,但无法阻止这个对象被 GC 回收,也就是说,在 JVM 进行垃圾回收的时候,若发现某个对象只有一个弱引用指向它,那么这个对象会被 GC 立刻回收。

    从这个特性我们可以得到一个结论:只被弱引用指向的对象只在两次 GC 之间存活。而只被软引用指向的对象是在 JVM 内存紧张的时候才被回收,它是可以经历多次 GC 的,这就是两者最大的区别。在 WeakReference 指向的对象被回收时,也会将 WeakReference 对象添加到关联的队列中。

    JDK 提供了一个基于弱引用实现的 HashMap 集合—— WeakHashMap,其中的 Entry 继承了 WeakReference,Entry 中使用弱引用指向 Key,使用强引用指向 Value。当没有强引用指向 Key 的时候,Key 可以被 GC 回收。当再次操作 WeakHashMap 的时候,就会遍历关联的引用队列,从 WeakHashMap 中清理掉相应的 Entry。

    下面我们回到 WeakCache,它的实现与 SoftCache 十分类似,两者的唯一区别在于:WeakCache 中存储的是 WeakEntry 对象,它继承了 WeakReference,通过 WeakReference 指向 Value 对象。具体的实现与 SoftCache 基本相同,这里就不再展示,你若感兴趣的话可以参考源码进行学习。

    至于剩下的 Cache 装饰器,理解起来就比较简单了,这里我就不赘述了,如有需要你同样可以参考源码来理解和学习。

    public class WeakCache implements Cache {
      private final Deque<Object> hardLinksToAvoidGarbageCollection;
      private final ReferenceQueue<Object> queueOfGarbageCollectedEntries;
      private final Cache delegate;
      private int numberOfHardLinks;
    
      public WeakCache(Cache delegate) {
        this.delegate = delegate;
        this.numberOfHardLinks = 256;
        this.hardLinksToAvoidGarbageCollection = new LinkedList<>();
        this.queueOfGarbageCollectedEntries = new ReferenceQueue<>();
      }
      //....
      @Override
      public void putObject(Object key, Object value) {
        removeGarbageCollectedItems();
        delegate.putObject(key, new WeakEntry(key, value, queueOfGarbageCollectedEntries));
      }
    
      @Override
      public Object getObject(Object key) {
        Object result = null;
        @SuppressWarnings("unchecked") // assumed delegate cache is totally managed by this cache
        WeakReference<Object> weakReference = (WeakReference<Object>) delegate.getObject(key);
        if (weakReference != null) {
          result = weakReference.get();
          if (result == null) {
            delegate.removeObject(key);
          } else {
            hardLinksToAvoidGarbageCollection.addFirst(result);
            if (hardLinksToAvoidGarbageCollection.size() > numberOfHardLinks) {
              hardLinksToAvoidGarbageCollection.removeLast();
            }
          }
        }
        return result;
      }
    	//....
    }
    

    一级缓存

    一级缓存介绍

    在应用运行过程中,我们有可能在一次数据库会话中,执行多次查询条件完全相同的SQL,MyBatis提供了一级缓存的方案优化这部分场景,如果是相同的SQL语句,会优先命中一级缓存,避免直接对数据库进行查询,提高性能。具体执行过程如下图所示。

    每个SqlSession中持有了Executor,每个Executor中有一个LocalCache。当用户发起查询时,MyBatis根据当前执行的语句生成MappedStatement,在Local Cache进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入Local Cache,最后返回结果给用户。具体实现类的类关系图如下图所示。

    public abstract class BaseExecutor implements Executor {
    
        //...
        //本地缓存
        protected PerpetualCache localCache;  
        //...
    }
    

    一级缓存配置

    我们来看看如何使用MyBatis一级缓存。开发者只需在MyBatis的配置文件中,添加如下语句,就可以使用一级缓存。共有两个选项,SESSION或者STATEMENT,默认是SESSION级别,即在一个MyBatis会话中执行的所有语句,都会共享这一个缓存。一种是STATEMENT级别,可以理解为缓存只对当前执行的这一个Statement有效。

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

    一级缓存实验

    接下来通过实验,了解MyBatis一级缓存的效果,每个单元测试后都请恢复被修改的数据。

    首先是创建示例表student,创建对应的POJO类和增改的方法,具体可以在entity包和mapper包中查看。

    CREATE TABLE `student` (
      `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
      `name` varchar(200) COLLATE utf8_bin DEFAULT NULL,
      `age` tinyint(3) unsigned DEFAULT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
    

    在以下实验中,id为1的学生名称是凯伦。

    实验1

    开启一级缓存,范围为会话级别,调用三次getStudentById,代码如下所示:

    public void getStudentById() throws Exception {
            SqlSession sqlSession = factory.openSession(true); // 自动提交事务
            StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
            System.out.println(studentMapper.getStudentById(1));
            System.out.println(studentMapper.getStudentById(1));
            System.out.println(studentMapper.getStudentById(1));
        }
    

    执行结果:

    我们可以看到,只有第一次真正查询了数据库,后续的查询使用了一级缓存。

    实验2

    增加了对数据库的修改操作,验证在一次数据库会话中,如果对数据库发生了修改操作,一级缓存是否会失效。

    @Test
    public void addStudent() throws Exception {
            SqlSession sqlSession = factory.openSession(true); // 自动提交事务
            StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
            System.out.println(studentMapper.getStudentById(1));
            System.out.println("增加了" + studentMapper.addStudent(buildStudent()) + "个学生");
            System.out.println(studentMapper.getStudentById(1));
            sqlSession.close();
    }
    

    执行结果:

    我们可以看到,在修改操作后执行的相同查询,查询了数据库,一级缓存失效。

    实验3

    开启两个SqlSession,在sqlSession1中查询数据,使一级缓存生效,在sqlSession2中更新数据库,验证一级缓存只在数据库会话内部共享。

    @Test
    public void testLocalCacheScope() throws Exception {
            SqlSession sqlSession1 = factory.openSession(true); 
            SqlSession sqlSession2 = factory.openSession(true); 
    
            StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
            StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
    
            System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
            System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
            System.out.println("studentMapper2更新了" + studentMapper2.updateStudentName("小岑",1) + "个学生的数据");
            System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
    }
    

    sqlSession2更新了id为1的学生的姓名,从凯伦改为了小岑,但session1之后的查询中,id为1的学生的名字还是凯伦,出现了脏数据,也证明了之前的设想,一级缓存只在数据库会话内部共享。

    一级缓存工作流程&源码分析

    那么,一级缓存的工作流程是怎样的呢?我们从源码层面来学习一下。

    工作流程

    一级缓存执行的时序图,如下图所示。

    源码分析

    接下来将对MyBatis查询相关的核心类和一级缓存的源码进行走读。这对后面学习二级缓存也有帮助。

    SqlSession: 对外提供了用户和数据库之间交互需要的所有方法,隐藏了底层的细节。默认实现类是DefaultSqlSession。

    Executor: SqlSession向用户提供操作数据库的方法,但和数据库操作有关的职责都会委托给Executor。

    如下图所示,Executor有若干个实现类,为Executor赋予了不同的能力,大家可以根据类名,自行学习每个类的基本作用。

    在一级缓存的源码分析中,主要学习BaseExecutor的内部实现。

    BaseExecutor: BaseExecutor是一个实现了Executor接口的抽象类,定义若干抽象方法,在执行的时候,把具体的操作委托给子类进行执行。

    protected abstract int doUpdate(MappedStatement ms, Object parameter) throws SQLException;
    protected abstract List<BatchResult> doFlushStatements(boolean isRollback) throws SQLException;
    protected abstract <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException;
    protected abstract <E> Cursor<E> doQueryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds, BoundSql boundSql) throws SQLException;
    

    在一级缓存的介绍中提到对Local Cache的查询和写入是在Executor内部完成的。在阅读BaseExecutor的代码后发现Local Cache是BaseExecutor内部的一个成员变量,如下代码所示。

    public abstract class BaseExecutor implements Executor {
        protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads;
        protected PerpetualCache localCache;
        //...
    }
    

    在阅读相关核心类代码后,从源代码层面对一级缓存工作中涉及到的相关代码。为执行和数据库的交互,首先需要初始化SqlSession,通过DefaultSqlSessionFactory开启SqlSession:

      private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
        Transaction tx = null;
        try {
          // 获取mybatis-config.xml配置文件中配置的Environment对象
          final Environment environment = configuration.getEnvironment();
    
            
          // 获取TransactionFactory对象
          final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
          // 创建 Transaction 对象
          tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
          // 创建执行器
          // 根据配置创建Executor对象
          final Executor executor = configuration.newExecutor(tx, execType);
          // 创建DefaultSqlSession
          // 产生一个DefaultSqlSession对象
          return new DefaultSqlSession(configuration, executor, autoCommit);
        
        
        } catch (Exception e) {
          // 如果打开事务出错,则关闭它
          closeTransaction(tx); // may have fetched a connection so lets call close()
          throw ExceptionFactory.wrapException("Error opening session.  Cause: " + e, e);
        } finally {
          // 最后清空错误上下文
          ErrorContext.instance().reset();
        }
      }
    

    在初始化SqlSesion时,会使用Configuration类创建一个全新的Executor,作为DefaultSqlSession构造函数的参数,创建Executor代码如下所示:

    //org.apache.ibatis.session.Configuration#newExecutor()
    public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
        executorType = executorType == null ? defaultExecutorType : executorType;
        executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
        Executor executor;
        
        if (ExecutorType.BATCH == executorType) {
          executor = new BatchExecutor(this, transaction);
        } else if (ExecutorType.REUSE == executorType) {
          executor = new ReuseExecutor(this, transaction);
        } else {
          executor = new SimpleExecutor(this, transaction);
        }
        
        // 尤其可以注意这里,如果二级缓存开关开启的话,是使用CahingExecutor装饰BaseExecutor的子类
        if (cacheEnabled) {
          executor = new CachingExecutor(executor);                      
        }
        executor = (Executor) interceptorChain.pluginAll(executor);
        return executor;
    }
    

    SqlSession创建完毕后,根据Statment的不同类型,会进入SqlSession的不同方法中,如果是Select语句的话,最后会执行到SqlSession的selectList,代码如下所示:

    @Override
    public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
          MappedStatement ms = configuration.getMappedStatement(statement);
          return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
    }
    

    SqlSession把具体的查询职责委托给了Executor。如果只开启了一级缓存的话,首先会进入BaseExecutor的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);
    }
    

    在上述代码中,会先根据传入的参数生成CacheKey,进入该方法查看CacheKey是如何生成的,代码如下所示:

    //org.apache.ibatis.executor.BaseExecutor#createCacheKey
    public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
        CacheKey cacheKey = new CacheKey();
        cacheKey.update(ms.getId());
        cacheKey.update(rowBounds.getOffset());
        cacheKey.update(rowBounds.getLimit());
        cacheKey.update(boundSql.getSql());
        
        //...
        //后面是update了sql中带的参数
        cacheKey.update(value);
        //...	
    }
    

    在上述的代码中,将MappedStatement的Id、SQL的offset、SQL的limit、SQL本身以及SQL中的参数传入了CacheKey这个类,最终构成CacheKey。以下是这个类的内部结构:

    public class CacheKey implements Cloneable, Serializable {
    
          public static final CacheKey NULL_CACHE_KEY = new CacheKey() {
        
            @Override
            public void update(Object object) {
              throw new CacheException("Not allowed to update a null cache key instance.");
            }
        
            @Override
            public void updateAll(Object[] objects) {
              throw new CacheException("Not allowed to update a null cache key instance.");
            }
          };
        
        private static final int DEFAULT_MULTIPLYER = 37;
        private static final int DEFAULT_HASHCODE = 17;
        
        private int multiplier;
        private int hashcode;
        private long checksum;
        private int count;
        private List<Object> updateList;
        
        public CacheKey() {
            this.hashcode = DEFAULT_HASHCODE;
            this.multiplier = DEFAULT_MULTIPLYER;
            this.count = 0;
            this.updateList = new ArrayList<Object>();
        }
        
        //...
    
    }
    

    首先是成员变量和构造函数,有一个初始的hachcode和乘数,同时维护了一个内部的updatelist。在CacheKey的update方法中,会进行一个hashcode和checksum的计算,同时把传入的参数添加进updatelist中。如下代码所示:

    public void update(Object object) {
        
        int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object); 
        count++;
        checksum += baseHashCode;
        baseHashCode *= count;
        hashcode = multiplier * hashcode + baseHashCode;
        
        updateList.add(object);
    }
    

    同时重写了CacheKey的equals方法,代码如下所示:

    @Override
    public boolean equals(Object object) {
        .............
        for (int i = 0; i < updateList.size(); i++) {
          Object thisObject = updateList.get(i);
          Object thatObject = cacheKey.updateList.get(i);
          if (!ArrayUtil.equals(thisObject, thatObject)) {
            return false;
          }
        }
        return true;
    }
    

    除去hashcode、checksum和count的比较外,只要updatelist中的元素一一对应相等,那么就可以认为是CacheKey相等。只要两条SQL的下列五个值相同,即可以认为是相同的SQL。

    Statement Id + Offset + Limmit + Sql + Params

    BaseExecutor的query方法继续往下走,代码如下所示:

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

    如果查不到的话,就从数据库查,在queryFromDatabase中,会对localcache进行写入。

    在query方法执行的最后,会判断一级缓存级别是否是STATEMENT级别,如果是的话,就清空缓存,这也就是STATEMENT级别的一级缓存无法共享localCache的原因。代码如下所示:

    if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
            clearLocalCache();
    }
    

    在源码分析的最后,我们确认一下,如果是insert/delete/update方法,缓存就会刷新的原因。

    SqlSession的insert方法和delete方法,都会统一走update的流程,代码如下所示:

      @Override
      public int insert(String statement, Object parameter) {
        return update(statement, parameter);
      }
    
       @Override
      public int delete(String statement) {
        return update(statement, null);
    }
    

    update方法也是委托给了Executor执行。BaseExecutor的执行方法如下所示:

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

    每次执行update前都会清空localCache。

    至此,一级缓存的工作流程讲解以及源码分析完毕。

    总结

    1. MyBatis一级缓存的生命周期和SqlSession一致。
    2. MyBatis一级缓存内部设计简单,只是一个没有容量限定的HashMap,在缓存的功能性上有所欠缺。
    3. MyBatis的一级缓存最大范围是SqlSession内部,有多个SqlSession或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为Statement。

    二级缓存

    二级缓存介绍

    在上文中提到的一级缓存中,其最大的共享范围就是一个SqlSession内部,如果多个SqlSession之间需要共享缓存,则需要使用到二级缓存。开启二级缓存后,会使用CachingExecutor装饰Executor,进入一级缓存的查询流程前,先在CachingExecutor进行二级缓存的查询,具体的工作流程如下所示。

    二级缓存开启后,同一个namespace下的所有操作语句,都影响着同一个Cache,即二级缓存被多个SqlSession共享,是一个全局的变量。

    当开启缓存后,数据的查询执行的流程就是 二级缓存 -> 一级缓存 -> 数据库。

    二级缓存配置

    要正确的使用二级缓存,需完成如下配置的。

    1. 在MyBatis的配置文件中开启二级缓存。
    <setting name="cacheEnabled" value="true"/>
    
    1. 在MyBatis的映射XML中配置cache或者 cache-ref 。

    cache标签用于声明这个namespace使用二级缓存,并且可以自定义配置。

    <cache/>
    
    • type:cache使用的类型,默认是PerpetualCache,这在一级缓存中提到过。
    • eviction: 定义回收的策略,常见的有FIFO,LRU。
    • flushInterval: 配置一定时间自动刷新缓存,单位是毫秒。
    • size: 最多缓存对象的个数。
    • readOnly: 是否只读,若配置可读写,则需要对应的实体类能够序列化。
    • blocking: 若缓存中找不到对应的key,是否会一直blocking,直到有对应的数据进入缓存。

    cache-ref代表引用别的命名空间的Cache配置,两个命名空间的操作使用的是同一个Cache。

    <cache-ref namespace="mapper.StudentMapper"/>
    

    二级缓存实验

    接下来我们通过实验,了解MyBatis二级缓存在使用上的一些特点。

    在本实验中,id为1的学生名称初始化为点点。

    实验1

    测试二级缓存效果,不提交事务,sqlSession1查询完数据后,sqlSession2相同的查询是否会从缓存中获取数据。

    @Test
    public void testCacheWithoutCommitOrClose() throws Exception {
            SqlSession sqlSession1 = factory.openSession(true); 
            SqlSession sqlSession2 = factory.openSession(true); 
            
            StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
            StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
    
            System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
    }
    

    执行结果:

    我们可以看到,当sqlsession没有调用commit()方法时,二级缓存并没有起到作用。

    实验2

    测试二级缓存效果,当提交事务时,sqlSession1查询完数据后,sqlSession2相同的查询是否会从缓存中获取数据。

    @Test
    public void testCacheWithCommitOrClose() throws Exception {
            SqlSession sqlSession1 = factory.openSession(true); 
            SqlSession sqlSession2 = factory.openSession(true); 
            
            StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
            StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
    
            System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
            sqlSession1.commit();
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
    }
    

    从图上可知,sqlsession2的查询,使用了缓存,缓存的命中率是0.5。

    实验3

    测试update操作是否会刷新该namespace下的二级缓存。

    @Test
    public void testCacheWithUpdate() throws Exception {
            SqlSession sqlSession1 = factory.openSession(true); 
            SqlSession sqlSession2 = factory.openSession(true); 
            SqlSession sqlSession3 = factory.openSession(true); 
            
            StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
            StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
            StudentMapper studentMapper3 = sqlSession3.getMapper(StudentMapper.class);
            
            System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
            sqlSession1.commit();
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
            
            studentMapper3.updateStudentName("方方",1);
            sqlSession3.commit();
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
    }
    

    我们可以看到,在sqlSession3更新数据库,并提交事务后,sqlsession2的StudentMapper namespace下的查询走了数据库,没有走Cache。

    实验4

    验证MyBatis的二级缓存不适应用于映射文件中存在多表查询的情况。

    通常我们会为每个单表创建单独的映射文件,由于MyBatis的二级缓存是基于namespace的,多表查询语句所在的namspace无法感应到其他namespace中的语句对多表查询中涉及的表进行的修改,引发脏数据问题。

    @Test
    public void testCacheWithDiffererntNamespace() throws Exception {
            SqlSession sqlSession1 = factory.openSession(true); 
            SqlSession sqlSession2 = factory.openSession(true); 
            SqlSession sqlSession3 = factory.openSession(true); 
        
            StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
            StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
            ClassMapper classMapper = sqlSession3.getMapper(ClassMapper.class);
            
            System.out.println("studentMapper读取数据: " + studentMapper.getStudentByIdWithClassInfo(1));
            sqlSession1.close();
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentByIdWithClassInfo(1));
    
            classMapper.updateClassName("特色一班",1);
            sqlSession3.commit();
            System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentByIdWithClassInfo(1));
    }
    

    执行结果:

    在这个实验中,我们引入了两张新的表,一张class,一张classroom。class中保存了班级的id和班级名,classroom中保存了班级id和学生id。我们在StudentMapper中增加了一个查询方法getStudentByIdWithClassInfo,用于查询学生所在的班级,涉及到多表查询。在ClassMapper中添加了updateClassName,根据班级id更新班级名的操作。

    当sqlsession1的studentmapper查询数据后,二级缓存生效。保存在StudentMapper的namespace下的cache中。当sqlSession3的classMapper的updateClassName方法对class表进行更新时,updateClassName不属于StudentMapper的namespace,所以StudentMapper下的cache没有感应到变化,没有刷新缓存。当StudentMapper中同样的查询再次发起时,从缓存中读取了脏数据。

    实验5

    为了解决实验4的问题呢,可以使用Cache ref,让ClassMapper引用StudenMapper命名空间,这样两个映射文件对应的SQL操作都使用的是同一块缓存了。

    执行结果:

    不过这样做的后果是,缓存的粒度变粗了,多个Mapper namespace下的所有操作都会对缓存使用造成影响。

    二级缓存源码分析

    MyBatis二级缓存的工作流程和前文提到的一级缓存类似,只是在一级缓存处理前,用CachingExecutor装饰了BaseExecutor的子类,在委托具体职责给delegate之前,实现了二级缓存的查询和写入功能,具体类关系图如下图所示。

    源码分析

    源码分析从CachingExecutor的query方法展开,源代码走读过程中涉及到的知识点较多,不能一一详细讲解,读者朋友可以自行查询相关资料来学习。

    CachingExecutor的query方法,首先会从MappedStatement中获得在配置初始化时赋予的Cache。

    Cache cache = ms.getCache();
    

    本质上是装饰器模式的使用,具体的装饰链是:

    SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache。

    以下是具体这些Cache实现类的介绍,他们的组合为Cache赋予了不同的能力。

    • SynchronizedCache:同步Cache,实现比较简单,直接使用synchronized修饰方法。
    • LoggingCache:日志功能,装饰类,用于记录缓存的命中率,如果开启了DEBUG模式,则会输出命中率日志。
    • SerializedCache:序列化功能,将值序列化后存到缓存中。该功能用于缓存返回一份实例的Copy,用于保存线程安全。
    • LruCache:采用了Lru算法的Cache实现,移除最近最少使用的Key/Value。
    • PerpetualCache: 作为为最基础的缓存类,底层实现比较简单,直接使用了HashMap。

    然后是判断是否需要刷新缓存,代码如下所示:

    flushCacheIfRequired(ms);
    

    在默认的设置中SELECT语句不会刷新缓存,insert/update/delte会刷新缓存。进入该方法。代码如下所示:

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

    MyBatis的CachingExecutor持有了TransactionalCacheManager,即上述代码中的tcm。

    TransactionalCacheManager中持有了一个Map,代码如下所示:

    private Map<Cache, TransactionalCache> transactionalCaches = new HashMap<Cache, TransactionalCache>();
    

    这个Map保存了Cache和用TransactionalCache包装后的Cache的映射关系。

    TransactionalCache实现了Cache接口,CachingExecutor会默认使用他包装初始生成的Cache,作用是如果事务提交,对缓存的操作才会生效,如果事务回滚或者不提交事务,则不对缓存产生影响。

    在TransactionalCache的clear,有以下两句。清空了需要在提交时加入缓存的列表,同时设定提交时清空缓存,代码如下所示:

    @Override
    public void clear() {
    	clearOnCommit = true;
    	entriesToAddOnCommit.clear();
    }
    

    CachingExecutor继续往下走,ensureNoOutParams主要是用来处理存储过程的,暂时不用考虑。

    if (ms.isUseCache() && resultHandler == null) {
    	ensureNoOutParams(ms, parameterObject, boundSql);
    

    之后会尝试从tcm中获取缓存的列表。

    List<E> list = (List<E>) tcm.getObject(cache, key);
    

    在getObject方法中,会把获取值的职责一路传递,最终到PerpetualCache。如果没有查到,会把key加入Miss集合,这个主要是为了统计命中率。

    Object object = delegate.getObject(key);
    if (object == null) {
    	entriesMissedInCache.add(key);
    }
    

    CachingExecutor继续往下走,如果查询到数据,则调用tcm.putObject方法,往缓存中放入值。

    if (list == null) {
    	list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
    	tcm.putObject(cache, key, list); // issue #578 and #116
    }
    

    tcm的put方法也不是直接操作缓存,只是在把这次的数据和key放入待提交的Map中。

    @Override
    public void putObject(Object key, Object object) {
        entriesToAddOnCommit.put(key, object);
    }
    

    从以上的代码分析中,我们可以明白,如果不调用commit方法的话,由于TranscationalCache的作用,并不会对二级缓存造成直接的影响。因此我们看看Sqlsession的commit方法中做了什么。代码如下所示:

    @Override
    public void commit(boolean force) {
        try {
          executor.commit(isCommitOrRollbackRequired(force));
    

    因为我们使用了CachingExecutor,首先会进入CachingExecutor实现的commit方法。

    @Override
    public void commit(boolean required) throws SQLException {
        delegate.commit(required);
        tcm.commit();
    }
    

    会把具体commit的职责委托给包装的Executor。主要是看下tcm.commit(),tcm最终又会调用到TrancationalCache。

    public void commit() {
        if (clearOnCommit) {
          delegate.clear();
        }
        flushPendingEntries();
        reset();
    }
    

    看到这里的clearOnCommit就想起刚才TrancationalCache的clear方法设置的标志位,真正的清理Cache是放到这里来进行的。具体清理的职责委托给了包装的Cache类。之后进入flushPendingEntries方法。代码如下所示:

    private void flushPendingEntries() {
        for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
          delegate.putObject(entry.getKey(), entry.getValue());
        }
        ................
    }
    

    在flushPendingEntries中,将待提交的Map进行循环处理,委托给包装的Cache类,进行putObject的操作。

    后续的查询操作会重复执行这套流程。如果是insert|update|delete的话,会统一进入CachingExecutor的update方法,其中调用了这个函数,代码如下所示:

    private void flushCacheIfRequired(MappedStatement ms)
    

    在二级缓存执行流程后就会进入一级缓存的执行流程,因此不再赘述。

    总结

    1. MyBatis的二级缓存相对于一级缓存来说,实现了SqlSession之间缓存数据的共享,同时粒度更加的细,能够到namespace级别,通过Cache接口实现类不同的组合,对Cache的可控性也更强。
    2. MyBatis在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用二级缓存的条件比较苛刻。
    3. 在分布式环境下,由于默认的MyBatis Cache实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将MyBatis的Cache接口实现,有一定的开发成本,直接使用Redis、Memcached等分布式缓存可能成本更低,安全性也更高。