从内存淘汰策略和过期键删除来聊一聊Redis内存管理机制
我们说过Redis是基于内存操作的数据库,所有的数据保存在内存中,所以读写速度非常的快。既然数据都存放在内存中,内存毕竟有限,远远比不上磁盘的存储空间,所以如果不及时清理掉一部分没有用的数据,就会导致内存不足。这篇文章就来聊一聊Redis内存淘汰策略和过期键的删除。
Redis是可以在redis.conf这个配置文件中设置最大占用内存的,如果不设置最大内存大小或者设置最大内存大小为0,在64位操作系统下不限制内存大小,在32位操作系统下最多使用3GB内存。一般生产环境设置内存为最大物理内存的四分之三。
当然也可以直接通过命令maxmemory
来设置最大占用内存
通过info memory
命令可以查看redis内存使用情况
如果说超过了设置的最大占用内存,在没有设置过期时间的情况下,就会导致OOM内存溢出
Redis为了防止OOM的发生就自己的一套内存淘汰策略
- noeviction:不会删除任何的key,当内存不足时写入新的数据,直接报OOM异常
- allkeys-lru:对所有的key使用LRU算法进行删除
- volatile-lru:对所有设置了过期时间的key使用LRU算法进行删除
- allkeys-lfu:对所有key使用LFU算法进行删除
- volatile-lfu:对所有设置了过期时间的key使用LFU算法进行删除
- allkeys-random:对所有key随机删除
- volatile-random:对所有设置了过期时间的key随机删除
- volatile-ttl:删除马上要过期的key
一共8种内存淘汰策略,你可能想这么多怎么记得住?
不要慌,我们可以两个维度、四个方面来考虑
两个维度:
- 从过期键中选择
- 从所有键中选择
4个方面:
- LRU:最近最少使用
- LFU:使用最不频繁
- random:随机
- ttl:存活时间
我们先说说这4个方面,random没什么好说的,就是随机选择呗,再就是ttl存活时间,看哪个key最快过期,也就是剩下的存活时间最短,就把这个key删除。所以把重心放在LRU算法和LFU算法上。
LRU算法
LRU指的是最近最少使用,Redis实际上使用的是一种近似的LRU算法,跟LRU算法还不太一样。之所以不使用LRU算法,是因为实现LRU算法需要维护一个链表,会消耗大量的额外内存,而近似LRU算法使用的是随机采样法来淘汰元素。
近似LRU算法会给每个key增加一个额外的长度为24bit的小字段,也就是用来记录最后一次被访问的时间戳。当Redis执行写操作时,如果内存超出设置的最大占用内存,就会执行一次LRU内存淘汰算法。会随机采样出5(默认)个key,然后淘汰掉最旧的key,如果淘汰后内存还是超出最大内存,那就继续随机采样淘汰,直到内存够用为止。
采样值越大,近似LRU算法的性能就会越接近严格的LRU算法,而且Redis还在近似LRU算法中,增加了淘汰池,淘汰池是一个大小为采样值的数组,在每一次淘汰循环中,新的随机得出的key列表会和淘汰池中的列表进行融合,然后淘汰掉最旧的那一个key,保留剩下的比较旧的key放入到淘汰池中等待下一次的比较淘汰。
LRU算法的底层实现一般都是使用HashMap+双向链表来实现的
第一种方法,我们可以直接继承LinkedHashMap这个类,直接API实现,这个类相当于一个天然的LRU算法的实现类
第二种方法就是纯手写了,使用HashMap+双向链表实现,HashMap集合用来存放key-Node(key/value),链表中每一个节点都封装一个key/value键值对,链表尾节点始终是要删除的那个节点,也就是最近最少使用的那个节点,而头节点是新插入进来的节点。
public class LRUCacheDemo2 {
class Node<K, V> {
K key;
V value;
Node<K, V> prev;
Node<K, V> next;
public Node() {
this.prev = this.next = null;
}
public Node(K key, V value) {
this.key = key;
this.value = value;
this.prev = this.next = null;
}
}
class DoubleLinkedList<K, V> {
Node<K, V> head;
Node<K, V> tail;
public DoubleLinkedList() {
head = new Node<>();
tail = new Node<>();
head.next = tail;
tail.prev = head;
}
//头插法,新添加的节点成为头节点
public void addHead(Node<K, V> node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
}
//把刚访问过的节点移除
public void removeNode(Node<K, V> node) {
node.next.prev = node.prev;
node.prev.next = node.next;
node.next = null;
node.prev = null;
}
public Node getLast() {
return tail.prev;
}
}
private int cacheSize;
Map<Integer, Node<Integer, Integer>> map;
DoubleLinkedList<Integer, Integer> doubleLinkedList;
public LRUCacheDemo2(int cacheSize) {
this.cacheSize = cacheSize;
map = new HashMap<>();
doubleLinkedList = new DoubleLinkedList<>();
}
public int get(int key) {
if (!map.containsKey(key)) {
return -1;
}
//get操作相当于访问一个节点,把刚刚访问的节点删除,然后重新插入到头节点的位置,相当于这个节点最近刚访问过,这样链表尾节点就是最少使用的那个节点
Node<Integer, Integer> node = map.get(key);
doubleLinkedList.removeNode(node);
doubleLinkedList.addHead(node);
return node.value;
}
public void put(int key, int value) {
if (map.containsKey(key)) {
Node<Integer, Integer> node = map.get(key);
node.value = value;
map.put(key, node);
//如果key已存在,相当于更新了value值,也相当于最近刚刚访问了一个节点,所以需要再把这个节点移动到头节点位置
doubleLinkedList.removeNode(node);
doubleLinkedList.addHead(node);
} else {
//如果超出内存大小,就先删除尾节点,也就是最近最少使用的那个节点,然后在map和链表中添加这个新节点
if (map.size() == cacheSize) {
Node lastNode = doubleLinkedList.getLast();
map.remove(lastNode.key);
doubleLinkedList.removeNode(lastNode);
}
Node<Integer, Integer> node = new Node<>(key, value);
map.put(key, node);
doubleLinkedList.addHead(node);
}
}
}
LFU算法
LFU算法指的是淘汰最近访问频率最低的那个key,比LRU算法更加精准的表示了一个key被访问的热度。如果一个key长时间不被访问,只是刚刚偶尔被访问了一下,那么在LRU算法下,这个偶尔被访问的key是不容易被淘汰掉的,但是使用LFU算法,需要计算最近一段时间内这个key的访问频率,即使某个key被偶尔访问,也很有可能被淘汰掉。
Redis的所有对象头结构中都有一个24bit的小字段,LRU算法用来记录最后一次被访问的时间戳,实际上就是通过比较对象的空闲时间来决定哪个key被淘汰的。
而LFU算法,24bit用来存储两个值,分别是ldt和logc
8bit的logc用来存储访问频次,存储的是频次的对数值,并且这个值会随着时间衰减,如果它的值比较小,就很容易被回收。为了确保新创建的对象不被回收,新对象会被初始化为一个大于零的值吗,默认为5。
16bit的ldt用来存储上一次logc的更新时间,它取得是分钟时间戳对2的16次方取模。同LRU模式一样,也可以计算出对象的空闲时间。与LRU模式不同的是,ldt不是在对象被访问时更新的,而是在redis的淘汰逻辑进行时进行更新的。每次淘汰都是随机策略,随机挑选出若干个key,更新这个key的“热度“。淘汰掉”热度“最低的key。
ldt更新的同时也会一同衰减logc的值。
衰减算法是:(将现有的logc值-对象的空闲时间)/ 衰减系数(默认为1)
换句话说,LFU算法和LRU算法相比,实际上LRU算法对象头结构中的24bit的字段全用来记录更新的时间戳,而LFU相当于切分出来了8bit用来记录访问的次数,有了更新的时间戳,有了访问次数,所以LFU算法就能根据计算出的访问频率来淘汰访问频率最低的那个key了。
内存淘汰策略的4个方面中最重要的LRU和LFU我们已经讲完了,接下来我们就从两个维度来继续来讲讲内存淘汰策略。从所有键中选择进行淘汰,这个维度没有什么好说的,淘汰策略面对所有key都一视同仁,所以我们主要来说一说从过期键中选择需要淘汰的key。
我们一般在向Redis数据库中保存一个数据的时候,会设置数据的过期时间,因为毕竟内存是有限的,如果数据没有设置过期时间,那么这个数据就会一直存在于内存中,迟早内存会被耗尽报OOM。我们可以使用expire
命令给指定key设置过期时间,使用ttl
命令查看key还有多长时间过期,到期的key会被删除掉。
设置过期时间除了能够清除没有的数据,释放内存,还有一些业务场景会需要设置过期时间,比如验证码1分钟之内有效,用户登录网站token一天之内有效等等。那么Redis到底是如何判断数据是否过期的呢?
实际上Redis是通过一个叫做过期字典来保存数据的过期时间的,我们之前文章讲过,字典的底层实现实际上是Hash表。过期字典的key指向数据库中某个key,过期字典的value则是一个long long类型的整数,用来保存key所指向的数据库中的对应的key的过期时间
typedef struct redisDb {
...
dict *dict; //数据库键空间,保存着数据库中所有键值对
dict *expires // 过期字典,保存着键的过期时间
...
} redisDb;
那么如果Redis判断某个数据已经到期了,Redis是如何删除掉这个数据的呢?这就涉及到了过期键的删除策略。
定时删除策略
在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。立即删除能够保证内存中数据的最大新鲜度,也就是说能够及时的删除过期数据释放内存。但是删除操作会占用CPU,如果CPU本来就忙于处理自己的任务,还需要挤出时间来执行删除操作,会造成CPU额外的压力,尤其是同一时间大量的数据过期,就会占用CPU相当长的一段时间。
所以说定时删除策略对内存是友好的,但是对CPU是不友好的,相当于拿时间换空间。
惰性删除策略
当数据到达过期时间时,我们可以先不做删除处理,等待下一次再次访问这个数据时,再进行删除操作。这有点像懒汉式的单例模式。但是这种惰性删除有一个问题,如果一个键过期了,这个键占用的内存空间并不会及时的得到释放,如果同一时间有大量键过期,并且这些过期键又恰好在一段时间内没有再被访问过,那么就会导致这些过期键不会被删除,内存也得不到释放,从某种程度上来说可以将这种情况看作是内存泄漏。
惰性删除只会在再次访问这个键的时候才进行删除,这对CPU是友好的,但是这会使得内存不能得到及时的释放,是对内存不友好的。相当于拿空间换时间。
定期删除策略
定期删除策略是对上述两种方法的折中。定期删除策略会每隔一段时间执行一次删除过期数据的操作,并通过限制删除操作执行的时长和频率来减少删除操作对占用CPU时间的影响。
定期删除策略的难点在于如何确定删除操作执行的时长和频率。
Redis默认每秒进行10次过期扫描,过期扫描不会遍历过期字典中的所有的key,而是采用了一种随机采样的方式:
- 从过期字典中随机选出20个key
- 删除20个key中已经过期的key
- 如果过期的key比例超过1/4,则继续随机选出20个key再次扫描
所以说定期指的是周期性,删除指的是随机抽查删除,定期删除就是周期性的抽查key,删除已过期的key。
当然,定期删除虽然结合了定时删除和惰性删除,但也不是完美的。如果定期删除时,某些key从来没有被抽查到过,就会导致大量过期的key堆积在内存中,导致Redis内存不能得到及时的释放。
从过期键的3种删除策略可以看出,虽然能够比较有效的删除已经过期的数据,但是总会漏掉一些没能及时删除的过期键,造成内存不能及时释放,也就是说很可能会发生OOM,只不过是时间问题罢了。这时候就需要我们上面讲的Redis内存淘汰机制来兜底了。
所以,Redis过期键删除策略和内存淘汰策略共同形成了Redis内存管理机制!