java从零手写实现redis(一)如何实现固定大小的缓存?java
java从零手写实现redis(三)redis expire 过时原理node
java从零手写实现redis(三)内存数据如何重启不丢失?git
java从零手写实现redis(四)添加监听器github
java从零手写实现redis(五)过时策略的另外一种实现思路redis
java从零手写实现redis(六)AOF 持久化原理详解及实现算法
java从零手写实现redis(七)LRU 缓存淘汰策略详解api
从零开始手写 redis(八)朴素 LRU 淘汰算法性能优化缓存
前两节咱们分别实现了 LRU 算法,而且进行了性能优化。性能优化
本节做为 LRU 算法的最后一节,主要解决一下缓存污染的问题。数据结构
LRU算法全称是最近最少使用算法(Least Recently Use),普遍的应用于缓存机制中。
当缓存使用的空间达到上限后,就须要从已有的数据中淘汰一部分以维持缓存的可用性,而淘汰数据的选择就是经过LRU算法完成的。
LRU算法的基本思想是基于局部性原理的时间局部性:
若是一个信息项正在被访问,那么在近期它极可能还会被再次访问。
java 从零开始手写 redis(七)redis LRU 驱除策略详解及实现
当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操做会致使LRU命中率急剧降低,缓存污染状况比较严重。
LRU-K中的K表明最近使用的次数,所以LRU能够认为是LRU-1。
LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。
相比LRU,LRU-K须要多维护一个队列,用于记录全部缓存数据被访问的历史。只有当数据的访问次数达到K次的时候,才将数据放入缓存。
当须要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。
数据第一次被访问时,加入到历史访问列表,若是数据在访问历史列表中没有达到K次访问,则按照必定的规则(FIFO,LRU)淘汰;
当访问历史队列中的数据访问次数达到K次后,将数据索引从历史队列中删除,将数据移到缓存队列中,并缓存数据,缓存队列从新按照时间排序;
缓存数据队列中被再次访问后,从新排序,须要淘汰数据时,淘汰缓存队列中排在末尾的数据,即“淘汰倒数K次访问离如今最久的数据”。
LRU-K具备LRU的优势,同时还能避免LRU的缺点,实际应用中LRU-2是综合最优的选择。
因为LRU-K还须要记录那些被访问过、但尚未放入缓存的对象,所以内存消耗会比LRU要多。
Two queues(如下使用2Q代替)算法相似于LRU-2,不一样点在于2Q将LRU-2算法中的访问历史队列(注意这不是缓存数据的)改成一个FIFO缓存队列,即:2Q算法有两个缓存队列,一个是FIFO队列,一个是LRU队列。
当数据第一次访问时,2Q算法将数据缓存在FIFO队列里面,当数据第二次被访问时,则将数据从FIFO队列移到LRU队列里面,两个队列各自按照本身的方法淘汰数据。
新访问的数据插入到FIFO队列中,若是数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰;
若是数据在FIFO队列中再次被访问到,则将数据移到LRU队列头部,若是数据在LRU队列中再次被访问,则将数据移动LRU队列头部,LRU队列淘汰末尾的数据。
MQ算法根据访问频率将数据划分为多个队列,不一样的队列具备不一样的访问优先级,其核心思想是:优先缓存访问次数多的数据。
详细的算法结构图以下,Q0,Q1....Qk表明不一样的优先级队列,Q-history表明从缓存中淘汰数据,但记录了数据的索引和引用次数的队列:
新插入的数据放入Q0,每一个队列按照LRU进行管理,当数据的访问次数达到必定次数,须要提高优先级时,将数据从当前队列中删除,加入到高一级队列的头部;为了防止高优先级数据永远不会被淘汰,当数据在指定的时间里没有被访问时,须要下降优先级,将数据从当前队列删除,加入到低一级的队列头部;须要淘汰数据时,从最低一级队列开始按照LRU淘汰,每一个队列淘汰数据时,将数据从缓存中删除,将数据索引加入Q-history头部。
若是数据在Q-history中被从新访问,则从新计算其优先级,移到目标队列头部。
Q-history按照LRU淘汰数据的索引。
MQ须要维护多个队列,且须要维护每一个数据的访问时间,复杂度比LRU高。
对比点 | 对比 |
---|---|
命中率 | LRU-2 > MQ(2) > 2Q > LRU |
复杂度 | LRU-2 > MQ(2) > 2Q > LRU |
代价 | LRU-2 > MQ(2) > 2Q > LRU |
实际上上面的几个算法,思想上大同小异。
核心目的:解决批量操做致使热点数据失效,缓存被污染的问题。
实现方式:增长一个队列,用来保存只访问一次的数据,而后根据次数不一样,放入到 LRU 中。
只访问一次的队列,能够是 FIFO 队列,能够是 LRU,咱们来实现一下 2Q 和 LRU-2 两种实现。
实际上就是咱们之前的 FIFO + LRU 两者的结合。
public class CacheEvictLru2Q<K,V> extends AbstractCacheEvict<K,V> { private static final Log log = LogFactory.getLog(CacheEvictLru2Q.class); /** * 队列大小限制 * * 下降 O(n) 的消耗,避免耗时过长。 * @since 0.0.13 */ private static final int LIMIT_QUEUE_SIZE = 1024; /** * 第一次访问的队列 * @since 0.0.13 */ private Queue<K> firstQueue; /** * 头结点 * @since 0.0.13 */ private DoubleListNode<K,V> head; /** * 尾巴结点 * @since 0.0.13 */ private DoubleListNode<K,V> tail; /** * map 信息 * * key: 元素信息 * value: 元素在 list 中对应的节点信息 * @since 0.0.13 */ private Map<K, DoubleListNode<K,V>> lruIndexMap; public CacheEvictLru2Q() { this.firstQueue = new LinkedList<>(); this.lruIndexMap = new HashMap<>(); this.head = new DoubleListNode<>(); this.tail = new DoubleListNode<>(); this.head.next(this.tail); this.tail.pre(this.head); } }
数据淘汰的逻辑:
当缓存大小,已经达到最大限制时执行:
(1)优先淘汰 firstQueue 中的数据
(2)若是 firstQueue 中数据为空,则淘汰 lruMap 中的数据信息。
这里有一个假设:咱们认为被屡次访问的数据,重要性高于被只访问了一次的数据。
@Override protected ICacheEntry<K, V> doEvict(ICacheEvictContext<K, V> context) { ICacheEntry<K, V> result = null; final ICache<K,V> cache = context.cache(); // 超过限制,移除队尾的元素 if(cache.size() >= context.size()) { K evictKey = null; //1. firstQueue 不为空,优先移除队列中元素 if(!firstQueue.isEmpty()) { evictKey = firstQueue.remove(); } else { // 获取尾巴节点的前一个元素 DoubleListNode<K,V> tailPre = this.tail.pre(); if(tailPre == this.head) { log.error("当前列表为空,没法进行删除"); throw new CacheRuntimeException("不可删除头结点!"); } evictKey = tailPre.key(); } // 执行移除操做 V evictValue = cache.remove(evictKey); result = new CacheEntry<>(evictKey, evictValue); } return result; }
当数据被删除时调用:
这个逻辑和之前相似,只是多了一个 FIFO 队列的移除。
/** * 移除元素 * * 1. 获取 map 中的元素 * 2. 不存在直接返回,存在执行如下步骤: * 2.1 删除双向链表中的元素 * 2.2 删除 map 中的元素 * * @param key 元素 * @since 0.0.13 */ @Override public void removeKey(final K key) { DoubleListNode<K,V> node = lruIndexMap.get(key); //1. LRU 删除逻辑 if(ObjectUtil.isNotNull(node)) { // A<->B<->C // 删除 B,须要变成: A<->C DoubleListNode<K,V> pre = node.pre(); DoubleListNode<K,V> next = node.next(); pre.next(next); next.pre(pre); // 删除 map 中对应信息 this.lruIndexMap.remove(node.key()); } else { //2. FIFO 删除逻辑(O(n) 时间复杂度) firstQueue.remove(key); } }
当数据被访问时,提高数据的优先级。
(1)若是在 lruMap 中,则首先移除,而后放入到头部
(2)若是不在 lruMap 中,可是在 FIFO 队列,则从 FIFO 队列中移除,添加到 LRU map 中。
(3)若是都不在,直接加入到 FIFO 队列中便可。
/** * 放入元素 * 1. 若是 lruIndexMap 已经存在,则处理 lru 队列,先删除,再插入。 * 2. 若是 firstQueue 中已经存在,则处理 first 队列,先删除 firstQueue,而后插入 Lru。 * 1 和 2 是不一样的场景,可是代码其实是同样的,删除逻辑中作了二种场景的兼容。 * * 3. 若是不在一、2中,说明是新元素,直接插入到 firstQueue 的开始便可。 * * @param key 元素 * @since 0.0.13 */ @Override public void updateKey(final K key) { //1.1 是否在 LRU MAP 中 //1.2 是否在 firstQueue 中 DoubleListNode<K,V> node = lruIndexMap.get(key); if(ObjectUtil.isNotNull(node) || firstQueue.contains(key)) { //1.3 删除信息 this.removeKey(key); //1.4 加入到 LRU 中 this.addToLruMapHead(key); return; } //2. 直接加入到 firstQueue 队尾 // if(firstQueue.size() >= LIMIT_QUEUE_SIZE) { // // 避免第一次访问的列表一直增加,移除队头的元素 // firstQueue.remove(); // } firstQueue.add(key); }
这里我想到了一个优化点,限制 firstQueue 的一直增加,由于遍历的时间复杂度为 O(n),因此限制最大的大小为 1024。
若是超过了,则把 FIFO 中的元素先移除掉。
不过只移除 FIFO,不移除 cache,会致使两者的活跃程度不一致;
若是同时移除,可是 cache 的大小尚未知足,可能会致使超出用户的预期,这个能够做为一个优化点,暂时注释掉。
ICache<String, String> cache = CacheBs.<String,String>newInstance() .size(3) .evict(CacheEvicts.<String, String>lru2Q()) .build(); cache.put("A", "hello"); cache.put("B", "world"); cache.put("C", "FIFO"); // 访问一次A cache.get("A"); cache.put("D", "LRU"); Assert.assertEquals(3, cache.size()); System.out.println(cache.keySet());
[DEBUG] [2020-10-03 13:15:50.670] [main] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: B, value: world, type: evict [D, A, C]
FIFO 中的缺点仍是比较明显的,须要 O(n) 的时间复杂度作遍历。
并且命中率和 LRU-2 比起来仍是会差一点。
这里 LRU map 出现了屡次,咱们为了方便,将 LRU map 简单的封装为一个数据结构。
咱们使用双向链表+HashMap 实现一个简单版本的。
node 节点和之前一致:
public class DoubleListNode<K,V> { /** * 键 * @since 0.0.12 */ private K key; /** * 值 * @since 0.0.12 */ private V value; /** * 前一个节点 * @since 0.0.12 */ private DoubleListNode<K,V> pre; /** * 后一个节点 * @since 0.0.12 */ private DoubleListNode<K,V> next; //fluent getter & setter }
咱们根据本身的须要,暂时定义 3 个最重要的方法。
/** * LRU map 接口 * @author binbin.hou * @since 0.0.13 */ public interface ILruMap<K,V> { /** * 移除最老的元素 * @return 移除的明细 * @since 0.0.13 */ ICacheEntry<K, V> removeEldest(); /** * 更新 key 的信息 * @param key key * @since 0.0.13 */ void updateKey(final K key); /** * 移除对应的 key 信息 * @param key key * @since 0.0.13 */ void removeKey(final K key); /** * 是否为空 * @return 是否 * @since 0.0.13 */ boolean isEmpty(); /** * 是否包含元素 * @param key 元素 * @return 结果 * @since 0.0.13 */ boolean contains(final K key); }
咱们基于 DoubleLinkedList + HashMap 实现。
就是把上一节中的实现整理一下便可。
import com.github.houbb.cache.api.ICacheEntry; import com.github.houbb.cache.core.exception.CacheRuntimeException; import com.github.houbb.cache.core.model.CacheEntry; import com.github.houbb.cache.core.model.DoubleListNode; import com.github.houbb.cache.core.support.struct.lru.ILruMap; import com.github.houbb.heaven.util.lang.ObjectUtil; import com.github.houbb.log.integration.core.Log; import com.github.houbb.log.integration.core.LogFactory; import java.util.HashMap; import java.util.Map; /** * 基于双向列表的实现 * @author binbin.hou * @since 0.0.13 */ public class LruMapDoubleList<K,V> implements ILruMap<K,V> { private static final Log log = LogFactory.getLog(LruMapDoubleList.class); /** * 头结点 * @since 0.0.13 */ private DoubleListNode<K,V> head; /** * 尾巴结点 * @since 0.0.13 */ private DoubleListNode<K,V> tail; /** * map 信息 * * key: 元素信息 * value: 元素在 list 中对应的节点信息 * @since 0.0.13 */ private Map<K, DoubleListNode<K,V>> indexMap; public LruMapDoubleList() { this.indexMap = new HashMap<>(); this.head = new DoubleListNode<>(); this.tail = new DoubleListNode<>(); this.head.next(this.tail); this.tail.pre(this.head); } @Override public ICacheEntry<K, V> removeEldest() { // 获取尾巴节点的前一个元素 DoubleListNode<K,V> tailPre = this.tail.pre(); if(tailPre == this.head) { log.error("当前列表为空,没法进行删除"); throw new CacheRuntimeException("不可删除头结点!"); } K evictKey = tailPre.key(); V evictValue = tailPre.value(); return CacheEntry.of(evictKey, evictValue); } /** * 放入元素 * * (1)删除已经存在的 * (2)新元素放到元素头部 * * @param key 元素 * @since 0.0.12 */ @Override public void updateKey(final K key) { //1. 执行删除 this.removeKey(key); //2. 新元素插入到头部 //head<->next //变成:head<->new<->next DoubleListNode<K,V> newNode = new DoubleListNode<>(); newNode.key(key); DoubleListNode<K,V> next = this.head.next(); this.head.next(newNode); newNode.pre(this.head); next.pre(newNode); newNode.next(next); //2.2 插入到 map 中 indexMap.put(key, newNode); } /** * 移除元素 * * 1. 获取 map 中的元素 * 2. 不存在直接返回,存在执行如下步骤: * 2.1 删除双向链表中的元素 * 2.2 删除 map 中的元素 * * @param key 元素 * @since 0.0.13 */ @Override public void removeKey(final K key) { DoubleListNode<K,V> node = indexMap.get(key); if(ObjectUtil.isNull(node)) { return; } // 删除 list node // A<->B<->C // 删除 B,须要变成: A<->C DoubleListNode<K,V> pre = node.pre(); DoubleListNode<K,V> next = node.next(); pre.next(next); next.pre(pre); // 删除 map 中对应信息 this.indexMap.remove(key); } @Override public boolean isEmpty() { return indexMap.isEmpty(); } @Override public boolean contains(K key) { return indexMap.containsKey(key); } }
LRU 的实现保持不变。咱们直接将 FIFO 替换为 LRU map 便可。
为了便于理解,咱们将 FIFO 对应为 firstLruMap,用来存放用户只访问了一次的元素。
将原来的 LRU 中存入访问了 2 次及其以上的元素。
其余逻辑和 2Q 保持一致。
定义两个 LRU,用来分别存储访问的信息
public class CacheEvictLru2<K,V> extends AbstractCacheEvict<K,V> { private static final Log log = LogFactory.getLog(CacheEvictLru2.class); /** * 第一次访问的 lru * @since 0.0.13 */ private final ILruMap<K,V> firstLruMap; /** * 2次及其以上的 lru * @since 0.0.13 */ private final ILruMap<K,V> moreLruMap; public CacheEvictLru2() { this.firstLruMap = new LruMapDoubleList<>(); this.moreLruMap = new LruMapDoubleList<>(); } }
和 lru 2Q 模式相似,这里咱们优先淘汰 firstLruMap 中的数据信息。
@Override protected ICacheEntry<K, V> doEvict(ICacheEvictContext<K, V> context) { ICacheEntry<K, V> result = null; final ICache<K,V> cache = context.cache(); // 超过限制,移除队尾的元素 if(cache.size() >= context.size()) { ICacheEntry<K,V> evictEntry = null; //1. firstLruMap 不为空,优先移除队列中元素 if(!firstLruMap.isEmpty()) { evictEntry = firstLruMap.removeEldest(); log.debug("从 firstLruMap 中淘汰数据:{}", evictEntry); } else { //2. 不然从 moreLruMap 中淘汰数据 evictEntry = moreLruMap.removeEldest(); log.debug("从 moreLruMap 中淘汰数据:{}", evictEntry); } // 执行缓存移除操做 final K evictKey = evictEntry.key(); V evictValue = cache.remove(evictKey); result = new CacheEntry<>(evictKey, evictValue); } return result; }
/** * 移除元素 * * 1. 屡次 lru 中存在,删除 * 2. 初次 lru 中存在,删除 * * @param key 元素 * @since 0.0.13 */ @Override public void removeKey(final K key) { //1. 屡次LRU 删除逻辑 if(moreLruMap.contains(key)) { moreLruMap.removeKey(key); log.debug("key: {} 从 moreLruMap 中移除", key); } else { firstLruMap.removeKey(key); log.debug("key: {} 从 firstLruMap 中移除", key); } }
/** * 更新信息 * 1. 若是 moreLruMap 已经存在,则处理 more 队列,先删除,再插入。 * 2. 若是 firstLruMap 中已经存在,则处理 first 队列,先删除 firstLruMap,而后插入 Lru。 * 1 和 2 是不一样的场景,可是代码其实是同样的,删除逻辑中作了二种场景的兼容。 * * 3. 若是不在一、2中,说明是新元素,直接插入到 firstLruMap 的开始便可。 * * @param key 元素 * @since 0.0.13 */ @Override public void updateKey(final K key) { //1. 元素已经在屡次访问,或者第一次访问的 lru 中 if(moreLruMap.contains(key) || firstLruMap.contains(key)) { //1.1 删除信息 this.removeKey(key); //1.2 加入到屡次 LRU 中 moreLruMap.updateKey(key); log.debug("key: {} 屡次访问,加入到 moreLruMap 中", key); } else { // 2. 加入到第一次访问 LRU 中 firstLruMap.updateKey(key); log.debug("key: {} 为第一次访问,加入到 firstLruMap 中", key); } }
实际上使用 LRU-2 的代码逻辑反而变得清晰了一些,主要是由于咱们把 lruMap 做为独立的数据结构抽离了出去。
ICache<String, String> cache = CacheBs.<String,String>newInstance() .size(3) .evict(CacheEvicts.<String, String>lru2Q()) .build(); cache.put("A", "hello"); cache.put("B", "world"); cache.put("C", "FIFO"); // 访问一次A cache.get("A"); cache.put("D", "LRU"); Assert.assertEquals(3, cache.size()); System.out.println(cache.keySet());
为了便于定位分析,源代码实现的时候,加了一点日志。
[DEBUG] [2020-10-03 14:39:04.966] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: A 为第一次访问,加入到 firstLruMap 中 [DEBUG] [2020-10-03 14:39:04.967] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: B 为第一次访问,加入到 firstLruMap 中 [DEBUG] [2020-10-03 14:39:04.968] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: C 为第一次访问,加入到 firstLruMap 中 [DEBUG] [2020-10-03 14:39:04.970] [main] [c.g.h.c.c.s.e.CacheEvictLru2.removeKey] - key: A 从 firstLruMap 中移除 [DEBUG] [2020-10-03 14:39:04.970] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: A 屡次访问,加入到 moreLruMap 中 [DEBUG] [2020-10-03 14:39:04.972] [main] [c.g.h.c.c.s.e.CacheEvictLru2.doEvict] - 从 firstLruMap 中淘汰数据:EvictEntry{key=B, value=null} [DEBUG] [2020-10-03 14:39:04.974] [main] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: B, value: world, type: evict [DEBUG] [2020-10-03 14:39:04.974] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: D 为第一次访问,加入到 firstLruMap 中 [D, A, C]
对于 LRU 算法的改进咱们主要作了两点:
(1)性能的改进,从 O(N) 优化到 O(1)
(2)批量操做的改进,避免缓存污染
其实除了 LRU,咱们还有其余的淘汰策略。
咱们须要考虑下面的问题:
A 数据被访问了 10 次,B 数据被访问了 2 次。那么两者谁是热点数据呢?
若是你认为确定 A 是热点数据,这里其实是另外一种淘汰算法,基于 LFU 的淘汰算法,认为访问次数越多,就越是热点数据。
咱们下一节共同窗习下 LFU 淘汰算法的实现。
开源地址: https://github.com/houbb/cache
以为本文对你有帮助的话,欢迎点赞评论收藏关注一波,你的鼓励,是我最大的动力~
目前咱们经过两次优化,解决了性能问题,和批量致使的缓存污染问题。
不知道你有哪些收获呢?或者有其余更多的想法,欢迎留言区和我一块儿讨论,期待与你的思考相遇。