你应该知道的缓存进化史

1.背景

本文是上周去技术沙龙听了一下爱奇艺的Java缓存之路有感写出来的。先简单介绍一下爱奇艺的java缓存道路的发展吧。java

能够看见图中分为几个阶段:redis

  • 第一阶段:数据同步加redis

经过消息队列进行数据同步至redis,而后Java应用直接去取缓存 这个阶段优势是:因为是使用的分布式缓存,因此数据更新快。缺点也比较明显:依赖Redis的稳定性,一旦redis挂了,整个缓存系统不可用,形成缓存雪崩,全部请求打到DB。算法

  • 第二,三阶段:JavaMap到Guava cache

这个阶段使用进程内缓存做为一级缓存,redis做为二级。优势:不受外部系统影响,其余系统挂了,依然能使用。缺点:进程内缓存没法像分布式缓存那样作到实时更新。因为java内存有限,一定缓存得设置大小,而后有些缓存会被淘汰,就会有命中率的问题。数据库

  • 第四阶段: Guava Cache刷新

为了解决上面的问题,利用Guava Cache能够设置写后刷新时间,进行刷新。解决了一直不更新的问题,可是依然没有解决实时刷新。api

  • 第五阶段: 外部缓存异步刷新

这个阶段扩展了Guava Cache,利用redis做为消息队列通知机制,通知其余java应用程序进行刷新。数组

这里简单介绍一下爱奇艺缓存发展的五个阶段,固然还有一些其余的优化,好比GC调优,缓存穿透,缓存覆盖的一些优化等等。有兴趣的同窗能够关注公众号,联系我进行交流。缓存

原始社会 - 查库

上面说的是爱奇艺的一个进化线路,可是在你们的通常开发过程当中,第一步通常都没有redis,而是直接查库。数据结构

在流量不大的时候,查数据库或者读取文件是最为方便,也能彻底知足咱们的业务要求。app

古代社会 - HashMap

当咱们应用有必定流量以后或者查询数据库特别频繁,这个时候就能够祭出咱们的java中自带的HashMap或者ConcurrentHashMap。咱们能够在代码中这么写:框架

public class CustomerService {
    private HashMap<String,String> hashMap = new HashMap<>();
    private CustomerMapper customerMapper;
    public String getCustomer(String name){
        String customer = hashMap.get(name);
        if ( customer == null){
            customer = customerMapper.get(name);
            hashMap.put(name,customer);
        }
        return customer;
    }
}

可是这样作就有个问题HashMap没法进行数据淘汰,内存会无限制的增加,因此hashMap很快也被淘汰了。固然并非说他彻底就没用,就像咱们古代社会也不是全部的东西都是过期的,好比咱们中华名族的传统美德是永不过期的,就像这个hashMap同样的能够在某些场景下做为缓存,当不须要淘汰机制的时候,好比咱们利用反射,若是咱们每次都经过反射去搜索Method,field,性能一定低效,这时咱们用HashMap将其缓存起来,性能能提高不少。

近代社会 - LRUHashMap

在古代社会中难住咱们的问题没法进行数据淘汰,这样会致使咱们内存无限膨胀,显然咱们是不能够接受的。有人就说我把一些数据给淘汰掉呗,这样不就对了,可是怎么淘汰呢?随机淘汰吗?固然不行,试想一下你刚把A装载进缓存,下一次要访问的时候就被淘汰了,那又会访问咱们的数据库了,那咱们要缓存干吗呢?

因此聪明的人们就发明了几种淘汰算法,下面列举下常见的三种FIFO,LRU,LFU(还有一些ARC,MRU感兴趣的能够自行搜索):

  • FIFO:先进先出,在这种淘汰算法中,先进入缓存的会先被淘汰。这种可谓是最简单的了,可是会致使咱们命中率很低。试想一下咱们若是有个访问频率很高的数据是全部数据第一个访问的,而那些不是很高的是后面再访问的,那这样就会把咱们的首个数据可是他的访问频率很高给挤出。
  • LRU:最近最少使用算法。在这种算法中避免了上面的问题,每次访问数据都会将其放在咱们的队尾,若是须要淘汰数据,就只须要淘汰队首便可。可是这个依然有个问题,若是有个数据在1个小时的前59分钟访问了1万次(可见这是个热点数据),再后一分钟没有访问这个数据,可是有其余的数据访问,就致使了咱们这个热点数据被淘汰。
  • LFU:最近最少频率使用。在这种算法中又对上面进行了优化,利用额外的空间记录每一个数据的使用频率,而后选出频率最低进行淘汰。这样就避免了LRU不能处理时间段的问题。

上面列举了三种淘汰策略,对于这三种,实现成本是一个比一个高,一样的命中率也是一个比一个好。而咱们通常来讲选择的方案居中便可,即实现成本不是过高,而命中率也还行的LRU,如何实现一个LRUMap呢?咱们能够经过继承LinkedHashMap,重写removeEldestEntry方法,便可完成一个简单的LRUMap。

class LRUMap extends LinkedHashMap {

        private final int max;
        private Object lock;

        public LRUMap(int max, Object lock) {
            //无需扩容
            super((int) (max * 1.4f), 0.75f, true);
            this.max = max;
            this.lock = lock;
        }

        /**
         * 重写LinkedHashMap的removeEldestEntry方法便可
         * 在Put的时候判断,若是为true,就会删除最老的
         * @param eldest
         * @return
         */
        @Override
        protected boolean removeEldestEntry(Map.Entry eldest) {
            return size() > max;
        }

        public Object getValue(Object key) {
            synchronized (lock) {
                return get(key);
            }
        }
        public void putValue(Object key, Object value) {
            synchronized (lock) {
                put(key, value);
            }
        }

       

        public boolean removeValue(Object key) {
            synchronized (lock) {
                return remove(key) != null;
            }
        }
        public boolean removeAll(){
            clear();
            return true;
        }
    }

在LinkedHashMap中维护了一个entry(用来放key和value的对象)链表。在每一次get或者put的时候都会把插入的新entry,或查询到的老entry放在咱们链表末尾。 能够注意到咱们在构造方法中,设置的大小特地设置到max*1.4,在下面的removeEldestEntry方法中只须要size>max就淘汰,这样咱们这个map永远也走不到扩容的逻辑了,经过重写LinkedHashMap,几个简单的方法咱们实现了咱们的LruMap。

现代社会 - Guava cache

在近代社会中已经发明出来了LRUMap,用来进行缓存数据的淘汰,可是有几个问题:

  • 锁竞争严重,能够看见个人代码中,Lock是全局锁,在方法级别上面的,当调用量较大时,性能必然会比较低。
  • 不支持过时时间
  • 不支持自动刷新

因此谷歌的大佬们对于这些问题,按捺不住了,发明了Guava cache,在Guava cache中你能够以下面的代码同样,轻松使用:

public static void main(String[] args) throws ExecutionException {
        LoadingCache<String, String> cache = CacheBuilder.newBuilder()
                .maximumSize(100)
                //写以后30ms过时
                .expireAfterWrite(30L, TimeUnit.MILLISECONDS)
                //访问以后30ms过时
                .expireAfterAccess(30L, TimeUnit.MILLISECONDS)
                //20ms以后刷新
                .refreshAfterWrite(20L, TimeUnit.MILLISECONDS)
                //开启weakKey key 当启动垃圾回收时,该缓存也被回收
                .weakKeys()
                .build(createCacheLoader());
        System.out.println(cache.get("hello"));
        cache.put("hello1", "我是hello1");
        System.out.println(cache.get("hello1"));
        cache.put("hello1", "我是hello2");
        System.out.println(cache.get("hello1"));
    }
    public static com.google.common.cache.CacheLoader<String, String> createCacheLoader() {
        return new com.google.common.cache.CacheLoader<String, String>() {
            @Override
            public String load(String key) throws Exception {
                return key;
            }
        };
    }

我将会从guava cache原理中,解释guava cache是如何解决LRUMap的几个问题的。

锁竞争

guava cache采用了相似ConcurrentHashMap的思想,分段加锁,在每一个段里面各自负责本身的淘汰的事情。在Guava根据必定的算法进行分段,这里要说明的是,若是段太少那竞争依然很严重,若是段太多会容易出现随机淘汰,好比大小为100的,给他分100个段,那也就是让每一个数据都独占一个段,而每一个段会本身处理淘汰的过程,因此会出现随机淘汰。在guava cache中经过以下代码,计算出应该如何分段。

int segmentShift = 0;
    int segmentCount = 1;
    while (segmentCount < concurrencyLevel && (!evictsBySize() || segmentCount * 20 <= maxWeight)) {
      ++segmentShift;
      segmentCount <<= 1;
    }

上面segmentCount就是咱们最后的分段数,其保证了每一个段至少10个Entry。若是没有设置concurrencyLevel这个参数,那么默认就会是4,最后分段数也最多为4,例如咱们size为100,会分为4段,每段最大的size是25。 在guava cache中对于写操做直接加锁,对于读操做,若是读取的数据没有过时,且已经加载就绪,不须要进行加锁,若是没有读到会再次加锁进行二次读,若是尚未须要进行缓存加载,也就是经过咱们配置的CacheLoader,我这里配置的是直接返回Key,在业务中一般配置从数据库中查询。 以下图所示:

过时时间

相比于LRUMap多了两种过时时间,一个是写后多久过时expireAfterWrite,一个是读后多久过时expireAfterAccess。颇有意思的事情是,在guava cache中对于过时的Entry并无立刻过时(也就是并无后台线程一直在扫),而是经过进行读写操做的时候进行过时处理,这样作的好处是避免后台线程扫描的时候进行全局加锁。看下面的代码:

public static void main(String[] args) throws ExecutionException, InterruptedException {
        Cache<String, String> cache = CacheBuilder.newBuilder()
                .maximumSize(100)
                //写以后5s过时
                .expireAfterWrite(5, TimeUnit.MILLISECONDS)
                .concurrencyLevel(1)
                .build();
        cache.put("hello1", "我是hello1");
        cache.put("hello2", "我是hello2");
        cache.put("hello3", "我是hello3");
        cache.put("hello4", "我是hello4");
        //至少睡眠5ms
        Thread.sleep(5);
        System.out.println(cache.size());
        cache.put("hello5", "我是hello5");
        System.out.println(cache.size());
    }
输出:
4 
1

从这个结果中咱们知道,在put的时候才进行的过时处理。特别注意的是我上面concurrencyLevel(1)我这里将分段最大设置为1,否则不会出现这个实验效果的,在上面一节中已经说过,咱们是以段位单位进行过时处理。在每一个Segment中维护了两个队列:

final Queue<ReferenceEntry<K, V>> writeQueue;

  
    final Queue<ReferenceEntry<K, V>> accessQueue;

writeQueue维护了写队列,队头表明着写得早的数据,队尾表明写得晚的数据。 accessQueue维护了访问队列,和LRU同样,用来咱们进行访问时间的淘汰,若是当这个Segment超过最大容量,好比咱们上面所说的25,超过以后,就会把accessQueue这个队列的第一个元素进行淘汰。

void expireEntries(long now) {
      drainRecencyQueue();

      ReferenceEntry<K, V> e;
      while ((e = writeQueue.peek()) != null && map.isExpired(e, now)) {
        if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
          throw new AssertionError();
        }
      }
      while ((e = accessQueue.peek()) != null && map.isExpired(e, now)) {
        if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
          throw new AssertionError();
        }
      }
    }

上面就是guava cache处理过时Entries的过程,会对两个队列一次进行peek操做,若是过时就进行删除。通常处理过时Entries能够在咱们的put操做的先后,或者读取数据时发现过时了,而后进行整个Segment的过时处理,又或者进行二次读lockedGetOrLoad操做的时候调用。

void evictEntries(ReferenceEntry<K, V> newest) {
      ///... 省略无用代码

      while (totalWeight > maxSegmentWeight) {
        ReferenceEntry<K, V> e = getNextEvictable();
        if (!removeEntry(e, e.getHash(), RemovalCause.SIZE)) {
          throw new AssertionError();
        }
      }
    }
/**
**返回accessQueue的entry
**/
ReferenceEntry<K, V> getNextEvictable() {
      for (ReferenceEntry<K, V> e : accessQueue) {
        int weight = e.getValueReference().getWeight();
        if (weight > 0) {
          return e;
        }
      }
      throw new AssertionError();
    }

上面是咱们驱逐Entry的时候的代码,能够看见访问的是accessQueue对其队头进行驱逐。而驱逐策略通常是在对segment中的元素发生变化时进行调用,好比插入操做,更新操做,加载数据操做。

自动刷新

自动刷新操做,在guava cache中实现相对比较简单,直接经过查询,判断其是否知足刷新条件,进行刷新。

其余特性

在Guava cache中还有一些其余特性:

虚引用

在Guava cache中,key和value都能进行虚引用的设定,在Segment中的有两个引用队列:

final @Nullable ReferenceQueue<K> keyReferenceQueue;

  
    final @Nullable ReferenceQueue<V> valueReferenceQueue;

这两个队列用来记录被回收的引用,其中每一个队列记录了每一个被回收的Entry的hash,这样回收了以后经过这个队列中的hash值就能把之前的Entry进行删除。

删除监听器

在guava cache中,当有数据被淘汰时,可是你不知道他究竟是过时,仍是被驱逐,仍是由于虚引用的对象被回收?这个时候你能够调用这个方法removalListener(RemovalListener listener)添加监听器进行数据淘汰的监听,能够打日志或者一些其余处理,能够用来进行数据淘汰分析。

在RemovalCause记录了全部被淘汰的缘由:被用户删除,被用户替代,过时,驱逐收集,因为大小淘汰。

guava cache的总结

细细品读guava cache的源码总结下来,其实就是一个性能不错的,api丰富的LRU Map。爱奇艺的缓存的发展也是基于此之上,经过对guava cache的二次开发,让其能够进行java应用服务之间的缓存更新。

走向将来-caffeine

guava cache的功能的确是很强大,知足了绝大多数的人的需求,可是其本质上仍是LRU的一层封装,因此在众多其余较为优良的淘汰算法中就相形见绌了。而caffeine cache实现了W-TinyLFU(LFU+LRU算法的变种)。下面是不一样算法的命中率的比较:

其中Optimal是最理想的命中率,LRU和其余算法相比的确是个弟弟。而咱们的W-TinyLFU 是最接近理想命中率的。固然不只仅是命中率caffeine优于了guava cache,在读写吞吐量上面也是完爆guava cache。

这个时候你确定会好奇为啥这么caffeine这么牛逼呢?别着急下面慢慢给你道来。

W-TinyLFU

上面已经说过了传统的LFU是怎么一回事。在LFU中只要数据访问模式的几率分布随时间保持不变时,其命中率就能变得很是高。这里我仍是拿爱奇艺举例,好比有部新剧出来了,咱们使用LFU给他缓存下来,这部新剧在这几天大概访问了几亿次,这个访问频率也在咱们的LFU中记录了几亿次。可是新剧总会过气的,好比一个月以后这个新剧的前几集其实已通过气了,可是他的访问量的确是过高了,其余的电视剧根本没法淘汰这个新剧,因此在这种模式下是有局限性。因此各类LFU的变种出现了,基于时间周期进行衰减,或者在最近某个时间段内的频率。一样的LFU也会使用额外空间记录每个数据访问的频率,即便数据没有在缓存中也须要记录,因此须要维护的额外空间很大。

能够试想咱们对这个维护空间创建一个hashMap,每一个数据项都会存在这个hashMap中,当数据量特别大的时候,这个hashMap也会特别大。

再回到LRU,咱们的LRU也不是那么一无可取,LRU能够很好的应对突发流量的状况,由于他不须要累计数据频率。

因此W-TinyLFU结合了LRU和LFU,以及其余的算法的一些特色。

频率记录

首先要说到的就是频率记录的问题,咱们要实现的目标是利用有限的空间能够记录随时间变化的访问频率。在W-TinyLFU中使用Count-Min Sketch记录咱们的访问频率,而这个也是布隆过滤器的一种变种。以下图所示: 若是须要记录一个值,那咱们须要经过多种Hash算法对其进行处理hash,而后在对应的hash算法的记录中+1,为何须要多种hash算法呢?因为这是一个压缩算法一定会出现冲突,好比咱们创建一个Long的数组,经过计算出每一个数据的hash的位置。好比张三和李四,他们两有可能hash值都是相同,好比都是1那Long[1]这个位置就会增长相应的频率,张三访问1万次,李四访问1次那Long[1]这个位置就是1万零1,若是取李四的访问评率的时候就会取出是1万零1,可是李四命名只访问了1次啊,为了解决这个问题,因此用了多个hash算法能够理解为long[][]二维数组的一个概念,好比在第一个算法张三和李四冲突了,可是在第二个,第三个中很大的几率不冲突,好比一个算法大概有1%的几率冲突,那四个算法一块儿冲突的几率是1%的四次方。经过这个模式咱们取李四的访问率的时候取全部算法中,李四访问最低频率的次数。因此他的名字叫Count-Min Sketch。

这里和之前的作个对比,简单的举个例子:若是一个hashMap来记录这个频率,若是我有100个数据,那这个HashMap就得存储100个这个数据的访问频率。哪怕我这个缓存的容量是1,由于Lfu的规则我必须所有记录这个100个数据的访问频率。若是有更多的数据我就有记录更多的。

在Count-Min Sketch中,我这里直接说caffeine中的实现吧(在FrequencySketch这个类中),若是你的缓存大小是100,他会生成一个long数组大小是和100最接近的2的幂的数,也就是128。而这个数组将会记录咱们的访问频率。在caffeine中他规则频率最大为15,15的二进制位1111,总共是4位,而Long型是64位。因此每一个Long型能够放16种算法,可是caffeine并无这么作,只用了四种hash算法,每一个Long型被分为四段,每段里面保存的是四个算法的频率。这样作的好处是能够进一步减小Hash冲突,原先128大小的hash,就变成了128X4。

一个Long的结构以下: 咱们的4个段分为A,B,C,D,在后面我也会这么叫它们。而每一个段里面的四个算法我叫他s1,s2,s3,s4。下面举个例子若是要添加一个访问50的数字频率应该怎么作?咱们这里用size=100来举例。

  1. 首先肯定50这个hash是在哪一个段里面,经过hash & 3一定能得到小于4的数字,假设hash & 3=0,那就在A段。
  2. 对50的hash再用其余hash算法再作一次hash,获得long数组的位置。假设用s1算法获得1,s2算法获得3,s3算法获得4,s4算法获得0。
  3. 而后在long[1]的A段里面的s1位置进行+1,简称1As1加1,而后在3As2加1,在4As3加1,在0As4加1。

这个时候有人会质疑频率最大为15的这个是否过小?不要紧在这个算法中,好比size等于100,若是他全局提高了1000次就会全局除以2衰减,衰减以后也能够继续增长,这个算法再W-TinyLFU的论文中证实了其能够较好的适应时间段的访问频率。

读写性能

在guava cache中咱们说过其读写操做中夹杂着过时时间的处理,也就是你在一次Put操做中有可能还会作淘汰操做,因此其读写性能会受到必定影响,能够看上面的图中,caffeine的确在读写操做上面完爆guava cache。主要是由于在caffeine,对这些事件的操做是经过异步操做,他将事件提交至队列,这里的队列的数据结构是RingBuffer,不清楚的能够看看这篇文章,你应该知道的高性能无锁队列Disruptor。而后经过会经过默认的ForkJoinPool.commonPool(),或者本身配置线程池,进行取队列操做,而后在进行后续的淘汰,过时操做。

固然读写也是有不一样的队列,在caffeine中认为缓存读比写多不少,因此对于写操做是全部线程共享一个Ringbuffer。

对于读操做比写操做更加频繁,进一步减小竞争,其为每一个线程配备了一个RingBuffer:

数据淘汰策略

在caffeine全部的数据都在ConcurrentHashMap中,这个和guava cache不一样,guava cache是本身实现了个相似ConcurrentHashMap的结构。在caffeine中有三个记录引用的LRU队列:

  • Eden队列:在caffeine中规定只能为缓存容量的%1,若是size=100,那这个队列的有效大小就等于1。这个队列中记录的是新到的数据,防止突发流量因为以前没有访问频率,而致使被淘汰。好比有一部新剧上线,在最开始实际上是没有访问频率的,防止上线以后被其余缓存淘汰出去,而加入这个区域。伊甸区,最舒服最安逸的区域,在这里很难被其余数据淘汰。

  • Probation队列:叫作缓刑队列,在这个队列就表明你的数据相对比较冷,立刻就要被淘汰了。这个有效大小为size减去eden减去protected。

  • Protected队列:在这个队列中,能够稍微放心一下了,你暂时不会被淘汰,可是别急,若是Probation队列没有数据了或者Protected数据满了,你也将会被面临淘汰的尴尬局面。固然想要变成这个队列,须要把Probation访问一次以后,就会提高为Protected队列。这个有效大小为(size减去eden) X 80% 若是size =100,就会是79。

这三个队列关系以下:

  1. 全部的新数据都会进入Eden。
  2. Eden满了,淘汰进入Probation。
  3. 若是在Probation中访问了其中某个数据,则这个数据升级为Protected。
  4. 若是Protected满了又会继续降级为Probation。

对于发生数据淘汰的时候,会从Probation中进行淘汰,会把这个队列中的数据队头称为受害者,这个队头确定是最先进入的,按照LRU队列的算法的话那他其实他就应该被淘汰,可是在这里只能叫他受害者,这个队列是缓刑队列,表明立刻要给他行刑了。这里会取出队尾叫候选者,也叫攻击者。这里受害者会和攻击者作PK,经过咱们的Count-Min Sketch中的记录的频率数据有如下几个判断:

  • 若是攻击者大于受害者,那么受害者就直接被淘汰。
  • 若是攻击者<=5,那么直接淘汰攻击者。这个逻辑在他的注释中有解释: 他认为设置一个预热的门槛会让总体命中率更高。
  • 其余状况,随机淘汰。

如何使用

对于熟悉Guava的玩家来讲若是担忧有切换成本,那么你彻底就多虑了,caffeine的api借鉴了Guava的api,能够发现其基本如出一辙。

public static void main(String[] args) {
        Cache<String, String> cache = Caffeine.newBuilder()
                .expireAfterWrite(1, TimeUnit.SECONDS)
                .expireAfterAccess(1,TimeUnit.SECONDS)
                .maximumSize(10)
                .build();
        cache.put("hello","hello");
    }

顺便一提的是,愈来愈多的开源框架都放弃了Guava cache,好比Spring5。在业务上我也本身曾经比较过Guava cache和caffeine最终选择了caffeine,在线上也有不错的效果。因此不用担忧caffeine不成熟,没人使用。

最后

本文主要讲了爱奇艺的缓存之路和本地缓存的一个发展历史(从古至今到将来),以及每一种缓存的实现基本原理。固然要使用好缓存光是这些仅仅不够,好比本地缓存如何在其余地方更改了以后同步更新,分布式缓存,多级缓存等等。后面也会专门写一节介绍这个如何用好缓存。对于Guava cache和caffeine的原理后面也会专门抽出时间写这两个的源码分析,若是感兴趣的朋友能够关注公众号第一时间查阅更新文章。

最后打个广告,若是你以为这篇文章对你有文章,能够关注个人技术公众号,你的关注和转发是对我最大的支持,O(∩_∩)O

相关文章
相关标签/搜索