MyBatis设计思想(4)——缓存模块

63 阅读7分钟

MyBatis设计思想(4)——缓存模块

一. 缓存概述

相信大家对于缓存都不陌生,MyBatis也提供了缓存的功能,在执行查询语句时首先尝试从缓存获取,避免频繁与数据库交互,大大提升了查询效率。MyBatis有所谓的一级缓存和二级缓存,这个会在后面的核心流程中详细阐述,这里仅讨论缓存的内部实现。

首先看下MyBatis的Cache接口,定义了缓存的基本行为:

public interface Cache {

  //获取缓存的唯一id
  String getId();

  //保存元素
  void putObject(Object key, Object value);

  //获取元素
  Object getObject(Object key);

  //删除元素
  Object removeObject(Object key);

  //清空缓存
  void clear();

  //获取缓存大小
  int getSize();
}

我们知道,缓存的本质其实就是一个Map,MyBatis的缓存最基础的实现PerpetualCache,也是使用了一个HashMap来保存数据:

/**
 * @author Clinton Begin
 *
 * 缓存的基础实现,使用HashMap来保存数据
 */
public class PerpetualCache implements Cache {

  private final String id;  //缓存的唯一id

  private final Map<Object, Object> cache = new HashMap<>();  //内部使用HashMap维护缓存数据

  public PerpetualCache(String id) {
    this.id = id;
  }

  @Override
  public String getId() {
    return id;
  }

  @Override
  public int getSize() {
    return cache.size();
  }

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

这个缓存的实现看上去平平无奇,几乎任何人都能写得出来。那么现在问题来了,作为一个成熟的ORM框架,MyBatis势必要为缓存提供各种额外的扩展功能,比如淘汰策略、锁同步功能、定时清空功能、防击穿、打印日志等。那么,如何在缓存的基础实现上,动态扩展这些功能呢?

二. 通过继承扩展

想要对一个类进行功能上的扩展,我们第一时间就会想到继承。的确,通过继承可以很方便地在现有的类上增加额外的功能。如果我们想要为缓存增加LRU淘汰策略,只需要新建一个LruCache类,继承PerpetualCache即可。同理,想要具有打印日志功能的缓存,就需要再创建一个LoggingCache类,这种解决方案看上去可以满足需求。

但是问题是,缓存的能力是动态组合和扩展的。在实际使用中,常常会见到下面这种形式的缓存配置:

<!--开启二级缓存配置-->
<cache eviction="LRU" flushInterval="60000" blocking="true" size="512"/>

这里开启了二级缓存,设置了容量上限的512,淘汰策略是LRU,每60s清空,且缓存为空时通过阻塞式从DB中查询数据,避免大量缓存击穿。这样就要求缓存实现类动态扩展LRU、定时清空、阻塞查询的功能。这样一来,如果依然通过继承的方式实现,就需要再创建LruScheduledBlockingCache类。而且,由于所有功能是动态增加的,你事先并不知道客户端会选择哪几个功能,那么就需要提前把所有功能排列组合地实现一遍,如LruScheduledCache、ScheduledBlockingCache、LruBlockingCache… 而且,每扩展一个新的功能,就需要把所有已有的缓存再排列组合一遍,最终的结果就是类爆炸。

组合优于继承。

三. 装饰器模式

装饰器模式最大的作用,就是为已有的组件动态地扩展新的功能。

在这里插入图片描述

  1. IComponent:定义了所有组件和装饰器的公共行为。
  2. ConcreteComponent:具体的组件,实现IComponent,是需要被装饰的原始对象,新功能或者附加功能都是通过装饰器添加到该类的对象上的。
  3. ComponentDecorator(Optional):抽象装饰器,定义了装饰器的核心行为。
  4. ComponentDecoratorImpl:具体的装饰器,对ComponentDecorator进行功能的扩展。

MyBatis缓存模块的设计就采用了装饰器模式。最基础的缓存实现PerpetualCache就是ConcreteComponent,并且在此基础上提供了多种ComponentDecoratorImpl,对缓存的功能进行扩展。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QhRZSaFu-1595151903883)(/Users/zhangshenao/Desktop/mybatis/cache_decorators.png)]

基于这种装饰器模式的设计,想为缓存进行功能的动态扩展就变得十分容易了。上面那个具有多种功能的二级缓存,就可以采用这种方式创建:

Cache cache = new ScheduledCache(new BlockingCache(new LruCache(new PerpetualCache())));

这样一来,客户端就可以任意增加自己想要的缓存功能。相较于继承,装饰器模式使得组件在运行期可以根据需要动态的添加功能,甚至对添加的新功能进行自由的组合,十分灵活且扩展性强。

四. 几个有趣的缓存装饰器

下面介绍几个缓存装饰器,个人觉得还挺有意思的:

  1. LruCache

    MyBatis很好地使用了JDK的LinkedHashMap,LinkedHashMap支持按照访问顺序排序,将最近被访问到的元素放在队列头部,这样就天然支持了LRU。

    /**
     * Lru (least recently used) cache decorator.
     *
     * @author Clinton Begin
     *
     * LRU缓存装饰器,依赖了LinkedHashMap的实现
     */
    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);
      }
    
      @Override
      public String getId() {
        return delegate.getId();
      }
    
      @Override
      public int getSize() {
        return delegate.getSize();
      }
    
      public void setSize(final int size) {
        //这里使用了LinkedHashMap保存所有Cache Key
        //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();    //如果缓存大小超过容量上限,则记录待淘汰的key,在下次插入元素时删除
            }
            return tooBig;
          }
        };
      }
    
      @Override
      public void putObject(Object key, Object value) {
        delegate.putObject(key, value);
    
        //插入元素后,删除过期的元素
        cycleKeyList(key);
      }
    
      @Override
      public Object getObject(Object key) {
        keyMap.get(key); // touch
        return delegate.getObject(key);
      }
    
      @Override
      public Object removeObject(Object key) {
        return delegate.removeObject(key);
      }
    
      @Override
      public void clear() {
        delegate.clear();
        keyMap.clear();
      }
    
      //删除过期key
      private void cycleKeyList(Object key) {
        keyMap.put(key, key);
        if (eldestKey != null) {
          delegate.removeObject(eldestKey);
          eldestKey = null;
        }
      }
    
    }
    
  2. BlockingCache

    BlockingCache的作用是,当缓存Miss时,对线程加锁,保证同一时刻只有一个线程去DB执行查询操作,这样就避免了高并发场景下,缓存失效造成的大量击穿。

    /**
     * Simple blocking decorator
     *
     * Simple and inefficient version of EhCache's BlockingCache decorator.
     * It sets a lock over a cache key when the element is not found in cache.
     * This way, other threads will wait until this element is filled instead of hitting the database.
     *
     * @author Eduardo Macarron
     *
     * 缓存的阻塞装饰器: 当缓存Miss时,对线程加锁,保证同时只有一个线程去DB执行查询操作,这样就避免了高并发场景下,缓存失效造成的大量击穿。
     *
     */
    public class BlockingCache implements Cache {
    
      private long timeout;
      private final Cache delegate;
      private final ConcurrentHashMap<Object, ReentrantLock> locks;   //使用ConcurrentHashMap,按照Cache Key粒度加锁
    
      public BlockingCache(Cache delegate) {
        this.delegate = delegate;
        this.locks = new ConcurrentHashMap<>();
      }
    
      @Override
      public String getId() {
        return delegate.getId();
      }
    
      @Override
      public int getSize() {
        return delegate.getSize();
      }
    
      @Override
      public void putObject(Object key, Object value) {
        try {
          delegate.putObject(key, value);
        } finally {
          //释放锁
          releaseLock(key);
        }
      }
    
      @Override
      public Object getObject(Object key) {
        //每次查询时,首先尝试加锁
        acquireLock(key);
        Object value = delegate.getObject(key);
        if (value != null) {
          releaseLock(key);
        }
        return value;
      }
    
      @Override
      public Object removeObject(Object key) {
        // despite of its name, this method is called only to release locks
        releaseLock(key);
        return null;
      }
    
      @Override
      public void clear() {
        delegate.clear();
      }
    
      private ReentrantLock getLockForKey(Object key) {
        return locks.computeIfAbsent(key, k -> new ReentrantLock());
      }
    
      private void acquireLock(Object key) {
        Lock lock = getLockForKey(key);
        if (timeout > 0) {
          try {
            boolean acquired = lock.tryLock(timeout, TimeUnit.MILLISECONDS);
            if (!acquired) {
              throw new CacheException("Couldn't get a lock in " + timeout + " for the key " +  key + " at the cache " + delegate.getId());
            }
          } catch (InterruptedException e) {
            throw new CacheException("Got interrupted while trying to acquire lock for key " + key, e);
          }
        } else {
          lock.lock();
        }
      }
    
      private void releaseLock(Object key) {
        ReentrantLock lock = locks.get(key);
        if (lock.isHeldByCurrentThread()) {
          lock.unlock();
        }
      }
    
      public long getTimeout() {
        return timeout;
      }
    
      public void setTimeout(long timeout) {
        this.timeout = timeout;
      }
    }
    

    但是,BlockingCache是每次查询时都会进行加锁操作,尽管是根据Cache Key的细粒度锁,但是对性能还是有一定的影响,这个类的作者Eduardo Macarron也说了,这是一个简单且低效的版本。

  3. ScheduledCache

    ScheduledCache的作用就是增加了缓存的定时清空功能。这个清空是lazy的,即在每次get和put操作时,会校验距离上次执行clear操作的时间是否已超过clearInterval。如果超过,则执行一次clear。

    /**
     * @author Clinton Begin
     *
     * 具有定时清空功能的缓存装饰器
     * lazy模式,在每次get和put操作时,会校验距离上次执行clear操作的时间是否已超过clearInterval。如果超过,则执行一次clear
     */
    public class ScheduledCache implements Cache {
    
      private final Cache delegate;
      protected long clearInterval;
      protected long lastClear;
    
      public ScheduledCache(Cache delegate) {
        this.delegate = delegate;
        this.clearInterval = TimeUnit.HOURS.toMillis(1);
        this.lastClear = System.currentTimeMillis();
      }
    
      public void setClearInterval(long clearInterval) {
        this.clearInterval = clearInterval;
      }
    
      @Override
      public String getId() {
        return delegate.getId();
      }
    
      @Override
      public int getSize() {
        clearWhenStale();
        return delegate.getSize();
      }
    
      @Override
      public void putObject(Object key, Object object) {
        clearWhenStale();
        delegate.putObject(key, object);
      }
    
      @Override
      public Object getObject(Object key) {
        return clearWhenStale() ? null : delegate.getObject(key);
      }
    
      @Override
      public Object removeObject(Object key) {
        clearWhenStale();
        return delegate.removeObject(key);
      }
    
      @Override
      public void clear() {
        lastClear = System.currentTimeMillis();
        delegate.clear();
      }
    
      @Override
      public int hashCode() {
        return delegate.hashCode();
      }
    
      @Override
      public boolean equals(Object obj) {
        return delegate.equals(obj);
      }
    
    
      //校验清空时间
      private boolean clearWhenStale() {
        if (System.currentTimeMillis() - lastClear > clearInterval) {
          clear();
          return true;
        }
        return false;
      }
    
    }
    

五. CacheKey的设计

既然说到了缓存,就不得不提缓存Key的设计问题。MyBatis涉及到的查询场景十分复杂,查询的操作SQL语句、SQL参数等等信息,都会影响到缓存是否命中,使用简单的String做为缓存Key是肯定不行了,那么该如何设计呢?

MyBatis定义了CacheKey类,封装了所有影响缓存命中的因素,主要包括:

  1. mappedStatment的id(Cache id)
  2. 指定查询结果集的范围(分页信息)
  3. 查询所使用的SQL语句
  4. 用户传递给SQL语句的实际参数值

CacheKey封装了这些信息,并重写了hashCode()和equals()方法:

/**
 * @author Clinton Begin
 *
 * MyBatis定义的CacheKey
 */
public class CacheKey implements Cloneable, Serializable {

  private static final long serialVersionUID = 1146682552656046210L;

  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_MULTIPLIER = 37;
  private static final int DEFAULT_HASHCODE = 17;

  private final int multiplier;   //乘积因子

  private int hashcode;     //hashCode

  private long checksum;    //校验和

  private int count;    //影响因素数量

  // 8/21/2017 - Sonarlint flags this as needing to be marked transient. While true if content is not serializable, this
  // is not always true and thus should not be marked transient.
  private List<Object> updateList;  //影响因素

  public CacheKey() {
    this.hashcode = DEFAULT_HASHCODE;
    this.multiplier = DEFAULT_MULTIPLIER;
    this.count = 0;
    this.updateList = new ArrayList<>();
  }

  public CacheKey(Object[] objects) {
    this();
    updateAll(objects);
  }

  public int getUpdateCount() {
    return updateList.size();
  }

  //每次增加CacheKey的影响因素,都会重新计算一遍内部的各种校验值
  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);
  }

  public void updateAll(Object[] objects) {
    for (Object o : objects) {
      update(o);
    }
  }

  //判断两个CacheKey是否相同
  @Override
  public boolean equals(Object object) {
    if (this == object) {
      return true;
    }
    if (!(object instanceof CacheKey)) {
      return false;
    }

    final CacheKey cacheKey = (CacheKey) object;

    if (hashcode != cacheKey.hashcode) {
      return false;
    }
    if (checksum != cacheKey.checksum) {
      return false;
    }
    if (count != cacheKey.count) {
      return false;
    }

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

  @Override
  public int hashCode() {
    return hashcode;
  }

  @Override
  public String toString() {
    StringJoiner returnValue = new StringJoiner(":");
    returnValue.add(String.valueOf(hashcode));
    returnValue.add(String.valueOf(checksum));
    updateList.stream().map(ArrayUtil::toString).forEach(returnValue::add);
    return returnValue.toString();
  }

  @Override
  public CacheKey clone() throws CloneNotSupportedException {
    CacheKey clonedCacheKey = (CacheKey) super.clone();
    clonedCacheKey.updateList = new ArrayList<>(updateList);
    return clonedCacheKey;
  }

}

如果两个CacheKey的hashCode()相等,且equals()方法返回true,则认为是同一个查询操作,可以直接从缓存中获取数据。