如何设计一个本地缓存

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: 如何设计一个本地缓存

前言


最近在看 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 来存储:

Map<Object, Object> cache = new ConcurrentHashMap<Object, Object>()

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 是否已经过期,如果过期则删除,类似如下代码:

if(System.currentTimeMillis() - lastClear > clearInterval) {
      clear();
}

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


5. 线程安全


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

publicsynchronizedvoid putObject(Object key, Objectobject) {
...省略...
}
@Override
publicsynchronizedObject getObject(Object key) {
...省略...
}


6. 简明的接口


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

1. publicinterfaceCache{
a. String getId();
b. void putObject(Object key, Object value);
c. Object getObject(Object key);
d. Object removeObject(Object key);
e. void clear();
f. int getSize();
g. 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;
a. 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. redis 更是将持久化功能发挥到极致,慢慢的有点像数据库了;提供了 AOF 和 RDB 两种持久化方式;当然很多情况下可以配合使用两种方式;
1. diskPersistent="false" //是否持久化磁盘缓存


8. 阻塞机制


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

public class Memoizerl<A, V> implements Computable<A, V> {
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();
    private final Computable<A, V> c;
    public Memoizerl(Computable<A, V> c) {
        this.c = c;
    }
    @Override
    public V compute(A arg) throws InterruptedException, ExecutionException {
        while (true) {
            Future<V> f = cache.get(arg);
            if (f == null) {
                Callable<V> eval = new Callable<V>() {
                    @Override
                    public V call() throws Exception {
                        return c.compute(arg);
                    }
                };
                FutureTask<V> ft = new FutureTask<V>(eval);
                f = cache.putIfAbsent(arg, ft);
                if (f == null) {
                    f = ft;
                    ft.run();
                }
                try {
                    return f.get();
                } catch (CancellationException e) {
                    cache.remove(arg, f);
                }
            }
        }
    }
}

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


总结


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


相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
2月前
|
存储 缓存 边缘计算
有哪些缓存方式?
有哪些缓存方式?
|
6月前
|
缓存 数据库 UED
软件体系结构 - 缓存技术(9)缓存穿透
【4月更文挑战第20天】软件体系结构 - 缓存技术(9)缓存穿透
107 13
|
6月前
|
存储 缓存
本地缓存和分布式缓存区别
【2月更文挑战第16天】
436 2
本地缓存和分布式缓存区别
|
6月前
|
存储 缓存 数据库
【后端面经】【缓存】33|缓存模式:缓存模式能不能解决缓存一致性问题?
【5月更文挑战第9天】面试准备中,熟悉缓存模式如Cache Aside、Read Through、Write Through、Write Back、Singleflight,以及删除缓存和延迟双删策略,能解决缓存一致性、穿透、击穿和雪崩问题。在自我介绍时展示对缓存模式的理解,例如Cache Aside模式,它是基础模式,读写由业务控制,先写数据库以保证数据准确性,但无法解决所有一致性问题。Read Through模式在缓存未命中时自动从数据库加载数据,可异步加载优化响应时间,但也存在一致性挑战。
55 0
|
6月前
|
存储 缓存 算法
说说什么是本地缓存、分布式缓存以及多级缓存,它们各自的优缺点?
说说什么是本地缓存、分布式缓存以及多级缓存,它们各自的优缺点?
|
6月前
|
存储 缓存 NoSQL
设计缓存系统:缓存穿透,缓存击穿,缓存雪崩解决方案分析
设计缓存系统:缓存穿透,缓存击穿,缓存雪崩解决方案分析
82 1
|
11月前
|
缓存 NoSQL 关系型数据库
缓存的设计方式
缓存的设计方式
|
存储 缓存 JavaScript
本地缓存的区别与联系
本地缓存的区别与联系
|
存储 缓存 数据库
如果不知道这4种缓存模式,敢说懂缓存吗?
在系统架构中,缓存可谓提供系统性能的最简单方法之一,稍微有点开发经验的同学必然会与缓存打过交道,最起码也实践过。
|
XML 存储 缓存
设计一个缓存策略,动态缓存热点数据
写在前面,因为我们最近的大作业项目需要用到热点排行这个功能,因为我们是要使用Elasticsearch来存储数据,然后最初设想是在ES中实现这个热点排行的功能,但是经过仔细思考,在我们这个项目中使用ES来做热点排行是一个很蠢的方式,因为我们这只是一个很小的排行,所以最终我们还是使用Redis来实现热点排行
462 1
设计一个缓存策略,动态缓存热点数据