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