常用淘汰算法

1、背景

在初始开发阶段,在查数据库频率不大的情况下,我们采取的方式去直接查库,即简单又能满足需求,在遇到查数据库频率开始频繁的情况下,我们可以使用Java中自带的集合来作为缓存,但缺点就是内存无限制的增长,显然我们无法接受的,所以出现数据淘汰算法,下面列举几种淘汰算法(FIFO、LRU、LFU)。

2、算法解析

2.1 FIFO

2.1.1 简介

First Input First Output,即先进先出。这是一种传统的按执行方法,先进入的指令先完成并引退,跟着才进行第二条指令。放在我们缓存容器中就是,最先进入的缓存最先被淘汰掉。

2.1.2 实现

用FIFO算法实现,伪代码如下:

public class FIFOCache {
    private Cache cache;
    private int maxCap; 
    private LinkedList<Object> keyOrders; 
    
    public FIFOCache(Cache cache, int maxCap) {
        this.cache = cache;
        this.maxCap = maxCap;
        this.keyOrders = new LinkedList<>();
    }
    
    public void putObject(Object key, Object value) {
       keyOrders.addLast(key); 
       if(orders.size() > maxCap) {
            Object firstKey = keyOrders.delFirst();
            caches.removeObject(firstKey);
       }
       cache.putObject(key, value);
    }
}

FIFO算法对于访问频率高且加入时间早的数据而言,命中率会比较低。想象一下,当我们某个访问频率非常高的数据第一个进入,后续访问访问频率低的数据再来访问会将其挤出。

2.2 LRU

2.2.1 简介

Least recently used,最近最少使用算法。每次访问的数据都是放在队首,队列满了就淘汰队尾,其实就是淘汰最近最少访问的数据。

2.2.2 思想

LRU算法实现,一般使用 HashMap+doublyLinkedList,或者使用Java现成中的LinkedHashMap。所以这里引申一个问题,为什么需要这两种数据结构?
看一下LRU主要的操作:

  1. 首先最基本的操作就是能够从里面读数据;
  2. 能够加入新的数据,新进来的数据就是Least recently used;
  3. 如果数据已存在,则根据key更新value,并将其设置为Least recently used;
  4. 如果数据不存在,在新加入数据之前,判断是否空间是否已满,若是,则把旧数据找到并删除;
    需要一个快速查找数据的结构,那么非HashMap莫属,时间复杂度为O(1) ,其余的数据结构也是可以达到目的,例如Array,Queue,Heap,但其时间复杂度O(n)或者O(logn),不及HashMap。而单向链表,当我们在找到节点的时候并希望能够删除该节点的时候,我们可以直接获取next节点,时间复杂度是O(1),但要求prev节点时间复杂度一样,使用的便是双向链表,所以我们使用的是HashMap+doublyLinkedList。那二者之间如何关联,如下图所示:
    这里,我们可以通过哈希表的key找到对应链表的节点,如果有相应的节点,就将该节点移动到最前或最后,如果没有相应的节点,就判断是否链表长度是否已满,没有则新增节点到最前或最后与哈希表存储该节点的reference。若有,则删除该节点,并根据哈希表中Key删除reference,再新增新的节点到最前或最后以及哈希表存储该节点reference。

2.2.3 实现

以下用Java自带的LinkedHashMap实现LRU算法:

在这里插入图片描述

public class LRUCache {    
    private Cache cache; 
    private int maxCap; 
    private LinkedHashMap<Object> keyOrders; 
    private Object earliestKey;
    
    public LRUCache(Cache cache, int maxCap) {
        this.cache = cache;
        this.maxCap = maxCap;
        keyOrders = new LinkedHashMap<Object,Object>(maxCap, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<Object, Object> earliest) {
                boolean fullFlag = size() > maxCap;
                if(fullFlag) {
                    earliestKey = earliest.getKey();
                }
                return fullFlag;
            }
        };
    }
    
    public void putObject(Object key, Object value) {
        cache.putObject(key, value);
        keyOrders.put(key, key);
        if (earliestKey != null) {
            cache.removeObject(earliestKey);
            earliestKey = null;
         }
    }
    
    @Override
    public Object getObject(Object key) {
        keyOrders.get(key);
        return cache.getObject(key);
    }
    
}    

LRU算法避免了FIFO的问题,每次访问都会把数据放在我们的队尾,如果需要淘汰数据的话,就淘汰队首即可。当存在热点数据的时候,LRU的效率是很高的,但这存在一个问题,会存在偶然性使命中率急剧下降的情况。举个例子,在60秒内,某个热点数据在前59秒高频率访问,但在第60秒没有被访问,而是其余大量不同低频数据来访问,当队列满的时候,就会造成该热点数据被淘汰。

2.3 LFU

2.3.1 简介

Least frequently used,最近最少频率使用算法,该算法对LRU算法进行了优化,利用额外的空间记录每个数据的使用频率,然后选出频率最低进行淘汰。根据频率淘汰,不会出现大量进进来的挤压掉老的,如果在数据的访问的模式不随时间变化时候,LFU将会提供绝佳的命中率,这样就避免了LRU不能处理时间段的问题。

2.3.2 思想

实现逻辑:

  1. 在链表开始插入数据,每插入一次就计数一次。
  2. 按照次数重新排列链表。
  3. 如果次数相同,那么就按照插入的时间,淘汰链表队尾数据。
    在这里插入图片描述

2.3.3 实现

基于上面的设计思路,其实主要就是比较数据被访问次数,所以在节点我们需要维护访问时间和访问次数这两个属性,伪代码如下:

public class LFUCache {

    private int maxCap;
    private Map<Object, Node> caches;
    
    @Data
    public static class Node{
        private Object key;
        private Object value;
        private long time;
        private int count;
    }
    
    public LFUCache(int maxCap){
        this.maxCap= maxCap;
        this.map = new LinkedHashMap<>();
    }
    
     public void put(K key, V value) {
        Node node = caches.get(key);
        if (node == null) {
            if (caches.size() >= maxCap) {
                // 删除count最少的key,如果count一样,就删除最早时间的
            }
            // 创建新节点
        } else {
            // 次数+1并更新时间
        }
        sort();
    }
        
    public Object get(Object key) {
        Node node = caches.get(key);
        if (node != null) {
            // 次数+1并更新时间
            sort();
            return node.value;
        }
        return null;
    }
}

如果数据随着时间推移而访问次数越少的话,新进来的数据会被快速淘汰,因为刚刚进来的频率最低,之前老缓存的频率太高。并且它需要额外空间维护频率这个属性,如果建立一个HashMap维护这个属性,当数据量大的情况下,那么这个HashMap也会十分巨大。

3、缓存算法的使用

上面说了常用的淘汰算法,那么我们常用的缓存用了哪些淘汰算法及优化操作呢?

3.1 Guava Cache

3.1.1 简介

Guava是Google团队开源的一款 Java 核心增强库,包含集合、并发原语、缓存、IO、反射等工具箱,性能和稳定性上都有保障,应用十分广泛。Guava Cache是基于LRU算法实现,那它的实现原理是如何?

3.1.2 实现思想

从上面使用HashMap+doublyLinkedList可以实现LRU算法来淘汰缓存数据,而Guava也是借鉴HashMap+双向链表的思想,但这里存在线程不安全问题,所以Guava Cache也借鉴了基于JKD1.7版本实现的ConcurrentHashMap分段锁的思想,也就可以总结Guava Cache是通过ConcurrentHashMap+双向链表实现的。

3.1.3 具体实现

上面说了,Guava Cache采用类似于分段锁的思想实现,其中一共涉及到三个Queue其中包括:AccessQueue和WriteQueue,以及RecentQueue。AccessQueue和WriteQueue就是双向链表;而RecentQueue才是真正的Queue,它就是CocurrentLinkedQueue。
  • 先来说一下AccessQueue和WriteQueue。

    在使用的时候,可以配置concurrencyLevel这个参数,即设置分为多少Segment,而每个Segment又维护着两个队列,这两个队列都是Gauva Cache实现的双向链表,并且是线程不安全,而要操作这两个队列,就必须获取Segment的Lock的场景下才能使用。
    [图片]

    其中accessQueue维护读队列,用来我们进行访问时间的淘汰,如果当这个Segment超过最大容量,例如我配置concurrencyLevel为4,而size是100,那么每个Segment超过25就会将accessQueue队列的第一个元素进行淘汰。writeQueue维护写队列,队头代表着写得早的数据,队尾代表写得晚的数据,使用方法如下:

private static LoadingCache<String, RateLimiter> limitCaches = CacheBuilder.newBuilder()
        .maximumSize(100)
        .expireAfterWrite(12, TimeUnit.HOURS) // 
        .build(new CacheLoader<String, RateLimiter>() {
            @Override
            public RateLimiter load(String key) throws Exception {
                double perSecond = LxRateLimitUtil.getCacheKeyPerSecond(key);
                return RateLimiter.create(perSecond);
            }
        });
  • 那RecencyQueue队列是什么?

    上面我们已经提到到过,AccessQueue被设计成了线程不安全,每次访问的时候都需要获取Segment中的Lock,然后才能够安全地将数据移动AccessQueue的前面去,显然不可取,会导致性能低下。因此Guava Cache为了确保性能,引入RecencyQueue这个同步队列,在读取数据的时候,将所有被访问的数据都添加到RecencyQueue中,接着在能获取锁的情况下,再把RecencyQueue中的数据移动到AccessQueue中。
    [图片]

所以Guava Cache通过RecentQueue和AccessQueue的结合就实现了在确保get的高性能的场景下还能记录对数据的访问,从而实现LRU算法。

3.2.2 实现思想

上面算法解析说了,LFU算法能带来最佳的缓存命中率,但有两个缺点,其一是需要给每条记录维护频率信息,每次访问都要更新,这是一笔开销。其二是最先频繁访问的数据可能会一直占用着缓存,后期访问频繁的数据则无法被命中。相比之下,LRU并不需要维护昂贵的缓存频率信息,同时数据能够随时间变化的反应出来。所以集两家之长的W-TinyLFU算法就被发明了,TinyLFU维护了近期访问记录的频率信息,作为一个过滤器,当新数据来访问时,只有满足TinyLFU要求的记录才可以被插入缓存。那它是如何解决上面两个问题呢?

3.2.3 具体实现

W-TinyLFU主要干了两件事:
1、采用 Count–Min Sketch 算法降低频率信息带来的内存消耗;
2、维护一个PK机制保障新上的热点数据能够缓存。
在W-TinyLFU中使用Count-Min Sketch(布隆过滤器的一种变种)记录数据的访问频率,如下图:
在这里插入图片描述

存储数据时,对key进行多次 hash 函数运算后,二维数组不同位置存储频率,读取某个key的访问次数时,会比较所有位置上的频率值,取最小值返回。对于所有key的访问频率之和有个最大值,当达到最大值时,会进行reset即对各个缓存key的频率除以2,这个算法可以较好的适应时间段的访问频率。

	而Caffeine 实际实现的时候是用一维 long 型数组,每个 long 型数字切分成16份,每份4bit,默认15次为最高访问频率,每个key实际 hash 了四次,落在不同 long 型数字的16份中某个位置。

那Caffeine是如何淘汰数据的?在Caffeine中有三个记录引用的LRU队列:

  • Eden队列:这个队列中记录的是新到的数据,防止突发流量由于之前没有访问频率,而导致被淘汰。比如有一部新剧上线,在最开始其实是没有访问频率的,防止上线之后被其他缓存淘汰出去。
  • Probation队列:如果该队列满了,取队首和队尾进行 PK,队首数据是最先进入队列的,称为受害者,队尾的数据称为攻击者,比较两者频率大小,大胜小汰。
  • Protected队列:在这个队列中,暂时不会被淘汰,但如果Probation队列没有数据了或者Protected数据满了,你也将会被面临淘汰的尴尬局面。当然想要变成这个队列,需要在Probation访问一次之后,就会提升为Protected队列。

在这里插入图片描述

	总的来说,通过 reset 衰减,避免历史热点数据由于频率值比较高一直淘汰不掉,并且通过对访问队列分成三段,这样避免了新加入的热点数据早早地被淘汰掉。

3.3 Redis

众所周知,Redis作为知名内存型NOSQL,极大提升了程序访问数据的性能,高性能互联网应用里,几乎都能看到Redis的身影。同本地Cache一样,也有着其相应的淘汰策略。

3.3.1 删除策略

在介绍淘汰策略之前,先介绍下Redis的过期删除策略,Redis对于过期的Key,有两种删除策略:

  1. 定期删除
    将每个设置了过期时间的 key 放入到一个独立的字典中,以后会定期遍历这个字典来删除到期的 key。默认是每隔 100ms就随机抽取N(N可通过配置设置)个过期时间的key,检查其是否过期,如果过期就删除。
  2. 惰性删除
    在访问这个key的时候,redis对key的过期时间进行检查,如果过期了就立即删除,不返回任何数据。

定期删除可能会导致很多过期key到了时间并没有被删除掉。所以就有了惰性删除。假如你的过期 key,靠定期删除没有被删除掉,还停留在内存里,除非你的系统去查一下那个 key,才会被redis给删除掉。这就是所谓的惰性删除,即当你主动去查过期的key时,如果发现key过期了,就立即进行删除,不返回任何东西。

3.3.2 淘汰策略
从Redis 4.0开始,共有以下8种数据淘汰机制,通过在Redis配置文件中设置 Redis 最大使用内存大小,当数据超过设置的内存大小时,就会执行相应的淘汰策略。

  1. noeviction:默认策略,不淘汰数据;大部分写命令都将返回错误。
  2. volatile-lru:从设置了过期时间的数据中根据 LRU 算法挑选数据淘汰。
  3. volatile-random:从设置了过期时间的数据中随机挑选数据淘汰。
  4. volatile-ttl:从设置了过期时间的数据中,挑选越早过期的数据进行删除。
  5. volatile-lfu:从设置了过期时间的数据中根据 LFU 算法挑选数据淘汰(4.0及以上版本可用)。
  6. allkeys-random:从所有数据中随机挑选数据淘汰。
  7. allkeys-lru:从所有数据中根据 LRU 算法挑选数据淘汰。
  8. allkeys-lfu:从所有数据中根据 LFU 算法挑选数据淘汰(4.0及以上版本可用)。
    我们可以看到,除 noeviction 比较特殊外,allkeys 开头的将从所有数据中进行淘汰,volatile 开头的将从设置了过期时间的数据中进行淘汰,用一张图概括就是:
    在这里插入图片描述

如何配置相应的淘汰策略呢?
1、优先使用allkeys-lru策略,充分发挥LRU算法的优势。
2、有明显冷热数据区分,就用allkeys-lru,否则建议使用allkeys-random。
3、如果存在某些业务配置数据,可以使用 volatile-lru策略,并且不给这些数据设置过期时间,这样数据就一直不会被删除。
总体来说策略的选择需要考虑的是,在筛选数据的时候,是否会筛选出被再次访问的数据,这直接决定了缓存效率的高低。

3.3.3 淘汰算法

Redis算法有多种:lru、random、ttl、lfu ,其中Redis采用的是近似LRU算法,被做了简化。

  • 近似LRU算法实现
    Redis维护一个全局24位时钟(每个一段时间会更新),每个Key对象内部也维护着一个24位时钟,这是用来对比该Key过期与否。与常规的LRU实现并不相同,常规LRU会准确的淘汰掉队头的元素,但是Redis的LRU并不维护队列,只是根据配置的策略要么从所有的key中随机选择N个(N可以配置)要么从所有的设置了过期时间的key中选出N个键,然后再从这N个键中选出最久没有使用的一个key进行淘汰。
  • 近似LRU算法优势
  1. 性能问题,随机采样N个Key,不用为所有数据维护一个大链表。
  2. 内存占用问题,抽样排序可以有效降低内存的占用。
  3. 实际效果基本相等。
  4. 在近似情况下提供可自配置的取样率来提升精准(配置参数 maxmemory-samples),设置的越大,越接近真实的LRU算法。

4、缓存设计和使用

缓存分为进程内缓存和分布式缓存两种。

4.1 解决的问题

  1. CPU占用。
  2. 数据库IO占用。

4.2 进程内缓存

4.2.1 简介

进程内缓存有很多种,例如ConcurrentHashMap,LRUMap,Ehcache,Guava Cache,Caffeine等,通过上下比较可知,Caffeine不管在命中率还是读写性能都比较好的,所以这里推荐使用Caffeine,而且也有丰富的API。

在这里插入图片描述

4.2.2 使用

首先我们知道,进程内缓存受内存大小的限制,并且如果是多服务的话,某个服务本地缓存更新,其它服务更是无法知道其更新了,所以进程内缓存一般使用在什么地方:

  • 数据量不大且更新频率低。
  • 如果数据有一定的更新频率,可以将进程内缓存的过期时间/刷新时间设置为较短。
    用法如下:
  1. 引入依赖。
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
  1. 开启缓存支持。
@EnableCaching // 使用@EnableCaching注解让Spring Boot开启对缓存的支持
@SpringBootApplication
public class BootstrapApplication {
    public static void main(String[] args) {
        SpringApplication.run(BootstrapApplication.class, args);
    }
}
  1. 新建Caffeine。
private static final Cache<String, PagingResponse<TopicsVo>> INDEX_CACHE = Caffeine.newBuilder()
        .expireAfterAccess(3, TimeUnit.SECONDS)
        .initialCapacity(10)
        .maximumSize(1000)
        .build();
  1. Caffeine使用。
// 缓存存储
INDEX_CACHE.put(key, value);
// 缓存获取
INDEX_CACHE.getIfPresent(key);
  1. 如需使用refreshAfterWrite配置还必须指定一个CacheLoader,如下:
// 在配置文件中,配置refreshAfterWrite=10s
@Bean
public CacheLoader<Object, Object> cacheLoader() {
    CacheLoader<Object, Object> cacheLoader = new CacheLoader<Object, Object>() {
        @Override
        public Object load(Object key) throws Exception {
            return null;
        }
        @Override
        public Object reload(Object key, Object oldValue) throws Exception {
            // dosomething
            return oldValue;
        }
    };
    return cacheLoader;
}

Caffeine配置说明:

  • initialCapacity=[integer]: 初始的缓存空间大小
  • maximumSize=[long]: 缓存的最大条数
  • maximumWeight=[long]: 缓存的最大权重
  • expireAfterAccess=[duration]: 最后一次写入或访问后经过固定时间过期
  • expireAfterWrite=[duration]: 最后一次写入后经过固定时间过期
  • refreshAfterWrite=[duration]: 创建缓存或者最近一次更新缓存后经过固定的时间间隔,刷新缓存
  • weakKeys: 打开key的弱引用
  • weakValues:打开value的弱引用
  • softValues:打开value的软引用
  • recordStats:开发统计功能
    PS:
  • expireAfterWrite和expireAfterAccess同事存在时,以expireAfterWrite为准。
  • maximumSize和maximumWeight不可以同时使用
  • weakValues和softValues不可以同时使用

4.2.3 实践效果

4.3 分布式缓存

4.3.1 简介

分布式缓存也有以下几种:MemCache,Redis,Tair。

在这里插入图片描述

如果服务对延迟比较敏感,Map/Set数据也比较多的话,比较适合Redis,其支持丰富的数据结构,读写性能很高,但是数据全内存,要考虑资源成本,支持持久化。所以一般我们使用Redis。

	Redis缓存的做法、效果与Caffeine类似。

4.4 多级缓存

既然有了Redis缓存,为何还会需要Caffeine、Guava这些的进程内缓存呢?有两个原因:

  1. Redis服务如果挂了,那么请求就会直接打到DB上,很容易造成缓存雪崩。
  2. 访问Redis会有一定的网络I/O以及序列化反序列化,虽然性能很高但是不会比本地方法快,可以将最热的数据存放在本地,以便进一步加快访问速度。
    如下图所示,即是我们最常见的做法。

在这里插入图片描述

用Caffeine作为一级缓存,Redis作为二级缓存。
但是,如果部署多台机器,对于Caffeine的缓存,有数据更新,只能删除更新数据的那台机器上的缓存,其他机器只能通过超时来过期缓存,对于Redis的缓存更新,其他机器立马可见,但是也必须要设置超时时间,其时间比Caffeine的过期长。如果对数据实时性有很高要求,那么就需要在上图进一步优化:

在这里插入图片描述

	通过Redis的pub/sub,可以通知其他进程缓存对此缓存进行删除。如果Redis挂了或者订阅机制不靠谱,依靠超时设定,依然可以做兜底处理。

5、总结

每一需求的实现,有时候并不是使用的思路、技术不对,而是因为有其它更好的。多对比,就多收获。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值