Redis缓存淘汰策略

Redis缓存淘汰策略

    前言

    长期将Redis作为缓存使用,难免会遇到内存空间存储瓶颈,当Redis内存超出物理内存限制时,内存数据就会与磁盘产生频繁交换,使Redis性能急剧下降。此时如何淘汰无用数据释放空间,存储新数据就变得尤为重要了。解决这个问题就涉及到缓存系统的一个重要机制,即缓存数据的淘汰机制

    一、淘汰机制实现步骤

    简单来说,数据淘汰机制包括两步

    1、根据一定的策略,筛选出对应用访问来说“不重要”的数据;

    2、将这些数据从缓存中删除,为新来的数据腾出空间,

    Redis3.0版本支持的淘汰策略有6种,自Redis 4.0版本开始新增volatile-lfu和allkeys-lfu这两种策略。

    二、淘汰机制分类

    1、按照是否会进行数据淘汰分类

    1)不进行数据淘汰的策略,只有 noeviction 这一种。

    2)会进行淘汰的 7 种其他策略。

    2、根据淘汰候选数据集的范围分类

    1)在设置了过期时间的数据中进行淘汰,包括 volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis 4.0 后新增)四种。

    2)在所有数据范围内进行淘汰,包括 allkeys-lru、allkeys-random、allkeys-lfu三种。

    三、淘汰策略详情

    

     默认情况下,Redis 在使用的内存空间超过 maxmemory 值时,并不会淘汰数据,也就是设定的 noeviction 策略。对应到 Redis 缓存,也就是指,一旦缓存被写满了,再有写请求来时,Redis 不再提供服务,而是直接返回错误。

     1、noeviction:一旦缓存被写满了,再有写请求来时,Redis 不再提供服务,而是直接返回错误。Redis 用作缓存时,实际的数据集通常都是大于缓存容量的,总会有新的数据要写入缓存,这个策略本身不淘汰数据,也就不会腾出新的缓存空间,我们不把它用在 Redis 缓存中。

     2、volatile-ttl 在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。

     3、volatile-random 就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。

     4、volatile-lru 会使用 LRU 算法(下文具体介绍)在设置了过期时间的键值对中进行筛选。

     5、volatile-lfu 会使用 LFU 算法(下文具体介绍)在设置了过期时间的键值对中进行筛选。

     6、allkeys-random 策略,从所有键值对中随机选择并删除数据。

     7、allkeys-lru 策略,使用 LRU 算法在所有数据中进行筛选。

     8、allkeys-lfu 策略,使用 LFU 算法在所有数据中进行筛选。

     四、淘汰算法

     1. LRU(Least recently Used)算法

     1)什么是LRU算法?

     LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

     这种算法基于一种假设:长期不被使用的数据,在未来被用到的几率也不大。因此,当数据所占内存达到一定阈值时,我们要移除掉最近最少被使用的数据。

     2)传统的LRU算法的实现 - 哈希链表

     举个例子:在一个用户系统中,由于业务方对用户信息的查询频率很高。在内存中创建了一个哈希表作为缓存,key为用户ID,value为用户信息。每当查询一个用户会先在哈希表中进行查询,以此来提高访问的性能。

     那么,我们如何知道哈希表中那些Key-Value最近被访问过,哪些没被访问过?如果给每一个value都加上时间戳,然后去遍历整个哈希表。这样可能会带来性能开销的问题。

     这就涉及到LRU算法的精妙所在。在LRU算法中,使用了一种有趣的数据结构,这种数据结构就是哈希链表。

     分析:我们知道哈希表是由Key-Value组成,在逻辑上,这些Key-Value是无所谓排列顺序的。在哈希链表中,这些Key-Value不再是彼此无关的存在,而是被一个链条串了起来。每一个Key-Value都有它的前驱Key-Value和后继Key-Value,就像双向链表中的节点一样。

     这样一来,原本无序的哈希表就拥有了固定的排列顺序。依靠哈希链表的有序性,我们可以把Key-Value按照最后的使用时间进行排序。

     下面用代码简单实现一下:

  1 public class LruCache {
  2     private Node head;
  3     private Node end;
  4 
  5     /**
  6      * 缓存存储上限
  7      */
  8     private final int limit;
  9 
 10     private final HashMap<String, Node> hashMap;
 11 
 12     public LruCache(int limit) {
 13         this.limit = limit;
 14         hashMap = new HashMap<>();
 15     }
 16 
 17     public String get(String key) {
 18         Node node = hashMap.get(key);
 19         if (node == null) {
 20             return null;
 21         }
 22         refreshNode(node);
 23         return node.value;
 24     }
 25 
 26 
 27     public void put(String key, String value) {
 28         Node node = hashMap.get(key);
 29         if (node == null) {
 30             //如果key不存在,插入key-value
 31             if (hashMap.size() >= limit) {
 32                 String oldKey = removeNode(head);
 33                 hashMap.remove(oldKey);
 34             }
 35 
 36             node = new Node(key, value);
 37             addNode(node);
 38             hashMap.put(key, node);
 39         } else {
 40             //如果key存在,刷新key-value
 41             node.value = value;
 42             refreshNode(node);
 43         }
 44     }
 45 
 46     /**
 47      * 清除key
 48      *
 49      * @param key
 50      */
 51     public void remove(String key) {
 52         Node node = hashMap.get(key);
 53         if (node == null) {
 54             return;
 55         }
 56 
 57         removeNode(node);
 58         hashMap.remove(key);
 59     }
 60 
 61     /**
 62      * 刷新被访问的节点位置
 63      *
 64      * @param node 被访问的节点
 65      */
 66     public void refreshNode(Node node) {
 67         if (node == end) {
 68             return;
 69         }
 70 
 71         //移除节点
 72         removeNode(node);
 73 
 74         //重新插入节点
 75         addNode(node);
 76     }
 77 
 78 
 79     /**
 80      * 删除节点
 81      *
 82      * @param node 要删除的节点
 83      */
 84     public String removeNode(Node node) {
 85         if (node == head && node == end) {
 86             //移除唯一的节点
 87             head = null;
 88             end = null;
 89         } else if (node == end) {
 90             //移除尾节点
 91             end = end.pre;
 92             end.next = null;
 93         } else if (node == head) {
 94             //移除头节点
 95             head = head.next;
 96             head.pre = null;
 97         } else {
 98             //移除中间节点
 99             node.pre.next = node.next;
100             node.next.pre = node.pre;
101         }
102 
103         return node.key;
104     }
105 
106     /**
107      * 尾部插入节点
108      *
109      * @param node 要插入的节点
110      */
111     public void addNode(Node node) {
112         if (end != null) {
113             end.next = node;
114             node.pre = end;
115             node.next = null;
116         }
117 
118         end = node;
119         if (head == null) {
120             head = node;
121         }
122     }
123 
124     public static class Node {
125 
126         public Node pre;
127         public Node next;
128         public String key;
129         public String value;
130 
131         Node(String key, String value) {
132             this.key = key;
133             this.value = value;
134         }
135     }
136 
137     public static void main(String[] args) {
138         LruCache lruCache = new LruCache(5);
139         lruCache.put("001", "用户1信息");
140         lruCache.put("002", "用户2信息");
141         lruCache.put("003", "用户3信息");
142         lruCache.put("004", "用户4信息");
143         lruCache.put("005", "用户5信息");
144         lruCache.get("002");
145         lruCache.put("004", "用户4信息更新");
146         lruCache.put("006", "用户6信息");
147         lruCache.remove("003");
148         System.out.println(lruCache.get("001"));
149         System.out.println(lruCache.get("006"));
150     }
151 
152 }
153 
154 // 运行结果
155 null
156 用户6信息

 

     3)Redis中的LRU算法

 

     在 Redis 中,LRU 算法被做了简化,它并没有采用传统的哈希链表来实现 LRU,而是使用了一种近似 LRU 的随机采样方法。以减轻数据淘汰对缓存性能的影响。

     Redis 并不会为每个数据项记录精确的访问时间戳,而是通过 lru 字段和 LRU_CLOCK 来实现近似 LRU 的效果。这种方式减少了内存开销和复杂度,同时在性能和效果之间取得了平衡。

     具体做法如下:

  • Redis 内部维护了一个 LRU_CLOCK,它是一个全局时钟,用于近似表示时间的流逝。
  • 每个键值对(entry)中都有一个 lru 字段,这个字段记录了键值对最后一次被访问时的 LRU_CLOCK 值。
  • 当需要进行内存淘汰时,Redis 并不会遍历所有的键值对来找出最久未使用的项,而是采用随机采样的方式,随机检查一部分键值对,淘汰其中 lru 值最小的(即最近最少使用的)项。

     以下是一个简化的示例,展示了全局时钟和键值对 lru 字段的工作原理:

 1 // 全局时钟
 2 unsigned int lru_clock = getCurrentLRUClock();
 3 
 4 // 获取当前时钟值(假设每秒更新一次)
 5 unsigned int getCurrentLRUClock() {
 6     return (unsigned int)(time(NULL) / 1000 & 0xFFFF);
 7 }
 8 
 9 // 更新键值对的 lru 字段
10 void updateLRU(Entry *entry) {
11     entry->lru = getCurrentLRUClock();
12 }
13 
14 // 随机选择一些键值对进行 LRU 淘汰
15 void lruEviction() {
16     for (int i = 0; i < SAMPLE_SIZE; i++) {
17         Entry *entry = getRandomEntry();
18         if (entry->lru < oldest_lru) {
19             oldest_lru = entry->lru;
20             candidate = entry;
21         }
22     }
23     evict(candidate);
24 }

     Redis 在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。

 

     Redis 提供了一个配置参数 maxmemory-samples,这个参数就是 Redis 选出的数据个数 N。例如,我们执行如下命令,可以让 Redis 选出 100 个数据作为候选数据集:

 

     CONFIG SET maxmemory-samples 100

 

     当需要再次淘汰数据时,Redis 需要挑选数据进入第一次淘汰时创建的候选集合。这儿的挑选标准是:能进入候选集合的数据的 lru 字段值必须小于候选集合中最小的 lru 值。当有新数据进入候选数据集后,如果候选数据集中的数据个数达到了 maxmemory-samples,Redis 就把候选数据集中 lru 字段值最小的数据淘汰出去。

 

     这样一来,Redis 缓存不用为所有的数据维护一个大链表,也不用在每次数据访问时都移动链表项,提升了缓存的性能。这种设计使得 Redis 在实际应用中既能高效地进行内存管理,又不会因为维护精确的 LRU 信息而造成过大的性能开销

 

    4)LRU算法存在的问题

    当应用对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。此时,因为这些被查询的数据刚刚被访问过,所以 lru 字段值都很大。而其他的热点数据就会被淘汰掉。

     2. LFU(Least Frequently Used)算法

     Redis 从 4.0 版本开始增加了 LFU 淘汰策略。与 LRU 策略相比,LFU 策略中会从两个维度来筛选并淘汰数据:

     1、数据访问的时效性(访问时间离当前时间的远近)

     2、数据的被访问次数

     LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。

    应用对大量的数据进行一次全体读取,因为这些数据不会被再次访问,所以它们的访问次数不会再增加。因此,LFU 策略会优先把这些访问次数低的数据淘汰出缓存。就解决了上面提到的LRU存在的问题。

   总结:这种缓存淘汰策略的目标是在有限的缓存空间内,尽量保留那些被频繁访问的数据,提高缓存命中率,从而提升系统性能。

    3、TTL算法

    Redis 数据集数据结构中保存了键值对过期时间的表,即 redisDb.expires。与 LRU 数据淘汰机制类似,TTL 数据淘汰机制中会先从过期时间的表中随机挑选几个键值对,取出其中 ttl 比较小的键值对淘汰。同样,TTL淘汰策略并不是面向所有过期时间的表中最快过期的键值对,而只是随机挑选的几个键值对。

    4、随机算法

    在随机淘汰的场景下获取待删除的键值对,随机找hash桶再次hash指定位置的dictEntry即可。

    五、Redis.conf中配置淘汰策略

1 #设置Redis 内存大小的限制,我们可以设置maxmemory ,当数据达到限定大小后,会选择配置的策略淘汰数据
2 
3 maxmemory 300mb
4 
5  
6 
7 #设置Redis的淘汰策略。
8 
9 maxmemory-policy volatile-lru

     Redis中的淘汰机制都是几近于算法实现的,主要从性能和可靠性上做平衡,所以并不是完全可靠,所以开发者们在充分了解Redis淘汰策略之后,还应在平时多主动设置或更新key的expire时间,主动删除没有价值的数据,提升Redis整体性能和空间。

 

   参考链接:

    https://www.jianshu.com/p/3981610b645a

 

posted @ 2021-10-06 17:45  欢乐豆123  阅读(1258)  评论(0编辑  收藏  举报