page contents

如何设计一个本地缓存?

前言 最近在看 Mybatis 的源码,刚好看到缓存这一块,Mybatis 提供了一级缓存和二级缓存;一级缓存相对来说比较简单,功能比较齐全的是二级缓存,基本上满足了一个缓存该有的功能;当然如果拿...

前言

最近在看 Mybatis 的源码,刚好看到缓存这一块,Mybatis 提供了一级缓存和二级缓存;一级缓存相对来说比较简单,功能比较齐全的是二级缓存,基本上满足了一个缓存该有的功能;当然如果拿来和专门的缓存框架如 ehcache 来对比可能稍有差距;本文我们将来整理一下实现一个本地缓存都应该需要考虑哪些东西。

考虑点

考虑点主要在数据用何种方式存储,能存储多少数据,多余的数据如何处理等几个点,下面我们来详细的介绍每个考虑点,以及该如何去实现;

1. 数据结构

首要考虑的就是数据该如何存储,用什么数据结构存储,最简单的就直接用 Map 来存储数据;或者复杂的如 redis 一样提供了多种数据类型哈希,列表,集合,有序集合等,底层使用了双端链表,压缩列表,集合,跳跃表等数据结构;

2. 对象上限

因为是本地缓存,内存有上限,所以一般都会指定缓存对象的数量比如 1024,当达到某个上限后需要有某种策略去删除多余的数据;

3. 清除策略

上面说到当达到对象上限之后需要有清除策略,常见的比如有 LRU(最近最少使用)、FIFO(先进先出)、LFU(最近最不常用)、SOFT(软引用)、WEAK(弱引用) 等策略;

4. 过期时间

除了使用清除策略,一般本地缓存也会有一个过期时间设置,比如 redis 可以给每个 key 设置一个过期时间,这样当达到过期时间之后直接删除,采用清除策略 + 过期时间双重保证;

5. 线程安全

像 redis 是直接使用单线程处理,所以就不存在线程安全问题;而我们现在提供的本地缓存往往是可以多个线程同时访问的,所以线程安全是不容忽视的问题;并且线程安全问题是不应该抛给使用者去保证;

6. 简明的接口

提供一个傻瓜式的对外接口是很有必要的,对使用者来说使用此缓存不是一种负担而是一种享受;提供常用的 get,put,remove,clear,getSize 方法即可;

7. 是否持久化

这个其实不是必须的,是否需要将缓存数据持久化看需求;本地缓存如 ehcache 是支持持久化的,而 guava 是没有持久化功能的;分布式缓存如 redis 是有持久化功能的,memcached 是没有持久化功能的;

8. 阻塞机制

在看 Mybatis 源码的时候,二级缓存提供了一个 blocking 标识,表示当在缓存中找不到元素时,它设置对缓存键的锁定;这样其他线程将等待此元素被填充,而不是命中数据库;其实我们使用缓存的目的就是因为被缓存的数据生成比较费时,比如调用对外的接口,查询数据库,计算量很大的结果等等;这时候如果多个线程同时调用 get 方法获取的结果都为 null,每个线程都去执行一遍费时的计算,其实也是对资源的浪费;最好的办法是只有一个线程去执行,其他线程等待,计算一次就够了;但是此功能基本上都交给使用者来处理,很少有本地缓存有这种功能;

如何实现

以上大致介绍了实现一个本地缓存我们都有哪些需要考虑的地方,当然可能还有其他没有考虑到的点;下面继续看看关于每个点都应该如何去实现,重点介绍一下思路;

1. 数据结构

本地缓存最常见的是直接使用 Map 来存储,比如 guava 使用 ConcurrentHashMap,ehcache 也是用了 ConcurrentHashMap,Mybatis 二级缓存使用 HashMap 来存储:



  1. Map<ObjectObject> cache = new ConcurrentHashMap<ObjectObject>()

Mybatis 使用 HashMap 本身是非线程安全的,所以可以看到起内部使用了一个 SynchronizedCache 用来包装,保证线程的安全性;
当然除了使用 Map 来存储,可能还使用其他数据结构来存储,比如 redis 使用了双端链表,压缩列表,整数集合,跳跃表和字典;当然这主要是因为 redis 对外提供的接口很丰富除了哈希还有列表,集合,有序集合等功能;

2. 对象上限

本地缓存常见的一个属性,一般缓存都会有一个默认值比如 1024,在用户没有指定的情况下默认指定;当缓存的数据达到指定最大值时,需要有相关策略从缓存中清除多余的数据这就涉及到下面要介绍的清除策略;

3. 清除策略

配合对象上限之后使用,场景的清除策略如:LRU(最近最少使用)、FIFO(先进先出)、LFU(最近最不常用)、SOFT(软引用)、WEAK(弱引用);
LRU:Least Recently Used 的缩写最近最少使用,移除最长时间不被使用的对象;常见的使用 LinkedHashMap 来实现,也是很多本地缓存默认使用的策略;
FIFO:先进先出,按对象进入缓存的顺序来移除它们;常见使用队列 Queue 来实现;
LFU:Least Frequently Used 的缩写大概也是最近最少使用的意思,和 LRU 有点像;区别点在 LRU 的淘汰规则是基于访问时间,而 LFU 是基于访问次数的;可以通过 HashMap 并且记录访问次数来实现;
SOFT:软引用基于垃圾回收器状态和软引用规则移除对象;常见使用 SoftReference 来实现;
WEAK:弱引用更积极地基于垃圾收集器状态和弱引用规则移除对象;常见使用 WeakReference 来实现;

4. 过期时间

设置过期时间,让缓存数据在指定时间过后自动删除;常见的过期数据删除策略有两种方式:被动删除和主动删除;
被动删除:每次进行 get/put 操作的时候都会检查一下当前 key 是否已经过期,如果过期则删除,类似如下代码:



  1. if(System.currentTimeMillis() - lastClear > clearInterval) {

  2. clear();

  3. }

主动删除:专门有一个 job 在后台定期去检查数据是否过期,如果过期则删除,这其实可以有效的处理冷数据;

5. 线程安全

尽量用线程安全的类去存储数据,比如使用 ConcurrentHashMap 代替 HashMap;或者提供相应的同步处理类,比如 Mybatis 提供了 SynchronizedCache:



  1. publicsynchronizedvoid putObject(Object key, Objectobject) {

  2. ...省略...

  3. }


  4. @Override

  5. publicsynchronizedObject getObject(Object key) {

  6. ...省略...

  7. }

6. 简明的接口

提供常用的 get,put,remove,clear,getSize 方法即可,比如 Mybatis 的 Cache 接口:



  1. publicinterfaceCache{

    1. String getId();

    2. void putObject(Object key, Object value);

    3. Object getObject(Object key);

    4. Object removeObject(Object key);

    5. void clear();

    6. int getSize();

    7. ReadWriteLock getReadWriteLock();

  2. }

再来看看 guava 提供的 Cache 接口,相对来说也是比较简洁的:



  1. publicinterfaceCache<K, V> {

  2. V getIfPresent(@CompatibleWith("K") Object key);

  3. V get(K key, Callable<? extends V> loader) throwsExecutionException;

    1. ImmutableMap<K, V> getAllPresent(Iterable<?> keys);

  4. void put(K key, V value);

  5. void putAll(Map<? extends K, ? extends V> m);

  6. void invalidate(@CompatibleWith("K") Object key);

  7. void invalidateAll(Iterable<?> keys);

  8. void invalidateAll();

  9. long size();

  10. CacheStats stats();

  11. ConcurrentMap<K, V> asMap();

  12. void cleanUp();

  13. }

7. 是否持久化

持久化的好处是重启之后可以再次加载文件中的数据,这样就起到类似热加载的功效;比如 ehcache 提供了是否持久化磁盘缓存的功能,将缓存数据存放在一个. data 文件中;



  1. diskPersistent="false" //是否持久化磁盘缓存

redis 更是将持久化功能发挥到极致,慢慢的有点像数据库了;提供了 AOF 和 RDB 两种持久化方式;当然很多情况下可以配合使用两种方式;

8. 阻塞机制

除了在 Mybatis 中看到了 BlockingCache 来实现此功能,之前在看 <> 的时候其中有实现一个很完美的缓存,大致代码如下:



  1. publicclassMemoizerl<A, V>implementsComputable<A, V>{

  2. privatefinalMap<A,Future<V>> cache =newConcurrentHashMap<A,Future<V>>();

  3. privatefinalComputable<A, V> c;


  4. publicMemoizerl(Computable<A, V> c){

  5. this.c = c;

  6. }


  7. @Override

  8. public V compute(A arg)throwsInterruptedException,ExecutionException{

  9. while(true){

  10. Future<V> f = cache.get(arg);

  11. if(f ==null){

  12. Callable<V>eval=newCallable<V>(){

  13. @Override

  14. public V call()throwsException{

  15. return c.compute(arg);

  16. }

  17. };

  18. FutureTask<V> ft =newFutureTask<V>(eval);

  19. f = cache.putIfAbsent(arg, ft);

  20. if(f ==null){

  21. f = ft;

  22. ft.run();

  23. }

  24. try{

  25. return f.get();

  26. }catch(CancellationException e){

  27. cache.remove(arg, f);

  28. }

  29. }

  30. }

  31. }

  32. }


compute 是一个计算很费时的方法,所以这里把计算的结果缓存起来,但是有个问题就是如果两个线程同时进入此方法中怎么保证只计算一次,这里最核心的地方在于使用了 ConcurrentHashMap 的 putIfAbsent 方法,同时只会写入一个 FutureTask;

总结

本文大致介绍了要设计一个本地缓存都需要考虑哪些点:数据结构,对象上限,清除策略,过期时间,线程安全,阻塞机制,实用的接口,是否持久化;当然肯定有其他考虑点,欢迎补充。

  • 发表于 2020-01-07 17:22
  • 阅读 ( 595 )

你可能感兴趣的文章

相关问题

0 条评论

请先 登录 后评论
Pack
Pack

1135 篇文章

作家榜 »

  1. 轩辕小不懂 2403 文章
  2. 小柒 1470 文章
  3. Pack 1135 文章
  4. Nen 576 文章
  5. 王昭君 209 文章
  6. 文双 71 文章
  7. 小威 64 文章
  8. Cara 36 文章