Redis内存管理(淘汰策略与过期键删除策略)
Redis
的内存用完了会发生什么?
如果达到设置的上限(默认noeviction
)时,Redis
的写命令会返回oom
错误信息(读命令还可以正常返回)。
redis.exceptions.ResponseError, OOM command not allowed when used memory > 'maxmemory'
或者配置内存淘汰机制,当Redis
达到内存上限时会冲刷掉旧的内容。
一、内存说明
1.1 内存信息
查看Redis
内存相关信息使用命令:info memory
。
属性名 | 属性说明 |
---|---|
used_memory | Redis分配器分配的内存总量,也就是内部存储的所有数据内存占用量 |
used_memory_human | 以可读的格式返回used_memory |
used_memory_rss | 从操作系统的角度显示Redis进程占用的物理内存总量 |
used_memory_rss_human | used_memory_rss的用户宜读格式的显示 |
used_memory_peak | 内存使用的最大值,表示used_memory的峰值 |
used_memory_peak_human | 以可读的格式返回used_memory_peak的值 |
used_memory_lua | Lua引擎所消耗的内存大小 |
mem_fragmentation_ratio | used_memory_rss/used_memory的比值,可以代表内存碎片率 |
maxmemory | Redis能够使用的最大内存上限,0表示没有限制,以字节为单位 |
当mem_fragmentation_ratio > 1
时,说明有部分内存并没有用于数据存储,而是被内存碎片所消耗,如果该值很大,说明碎片率严重。当mem_fragmentation_ratio < 1
时,这种情况一般出现在操作系统把Redis
内存交换(swap
)到硬盘导致,出现这种情况要格外关注,由于硬盘速度远远慢于内存,Redis
性能会变得很差,甚至僵死。
当Redis
内存超出可以获得内存时,操作系统会进行swap
,将旧的页写入硬盘。从硬盘读写大概比从内存读写要慢5
个数量级。used_memory
指标可以帮助判断Redis
是否有被swap
的风险或者它已经被swap
。
建议要设置和内存一样大小的交换区,如果没有交换区,一旦Redis
突然需要的内存大于当前操作系统可用内存时,Redis
会因为out of memory
而被Linix Kernel
的OOM Killer
直接杀死。虽然当Redis的数据被换出(swap out
) 时,Redis
的性能会变差,但是总比直接被杀死的好。
1.2 内存组成
- 自身内存:
Redis
自身运行所消耗的内存,一般很小。 - 对象内存:这是
Redis
消耗内存最大的一块,存储着用户所有的数据。 - 缓冲内存:缓冲内存主要包括:客户端缓冲、复制积压缓冲区、
AOF
缓冲区。- 客户端缓冲:是指客户端连接
Redis
之后,输入或者输出数据的缓冲区,其中输出缓冲可以通过配置参数参数client-output-buffer-limit
控制。 - 复制积压缓冲区:一个可重用的固定大小缓冲区用于实现部分复制功能,根据
repl-backlog-size
参数控制,默认1MB
。对于复制积压缓冲区整个主节点只有一个,所有的从节点共享此缓冲区,因此可以设置较大的缓冲区空间,如100MB
,这部分内存投入是有价值的,可以有效避免全量复制。 AOF
缓冲区:这部分空间用于在Redis
重写期间保存最近的写入命令,AOF缓冲区空间消耗用户无法控制,消耗的内存取决于AOF
重写时间和写入命令量,这部分空间占用通常很小。
- 客户端缓冲:是指客户端连接
- 内存碎片:当然,这是所有内存分配器无法避免的通病,但是可以优化。
二、淘汰策略
Redis
的内存淘汰策略是指在Redis
的用于缓存的内存不足时,怎么处理需要新写入且需要申请额外空间的数据。
全局的键空间选择性移除
- noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。(默认)
- allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的
key
。(常用) - allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个
key
。 - allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最近频率最少使用的
key
。
设置过期时间的键空间选择性移除
- volatile-lru(least recently used):当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的
key
。 - volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个
key
。 - volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的
key
优先移除。 - volatile-lfu(least frequently used):当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近频率最少使用的
key
。
lfu
是Redis 4.0
后的策略。
2.1 缓存污染
缓存污染:访问频率低或者不会被再次访问的数据,但又占据了缓存空间。
要解决缓存污染的关键点是能识别出只访问一次或者访问次数很少的数据。
从能否解决缓存污染这一维度来分析Redis
的8
种缓存淘汰策略:
缓存淘汰策略 | 分析 | 解决缓存污染 |
---|---|---|
noeviction | 不淘汰数据 | 不能 |
volatile-ttl | 当缓存写满时,会淘汰剩余存活时间最短的数据,避免滞留在缓存中,造成污染。 | 能 |
volatile-random | 随机选择数据,无法把不再访问的数据筛选出来,会造成缓存污染。 | 不能 |
allkeys-random | ||
volatile-lru | LRU策略只考虑数据的访问时效,对只访问一次的数据,不能很快筛选出来。 | 不能 |
allkeys-lru | ||
volatile-lfu | LFU策略在LRU策略基础上进行了优化,筛选数据时优先筛选并淘汰访问次数少的数据。 | 能 |
allkeys-lfu |
三、淘汰算法
3.1 FIFO
FIFO
(First in First out
)先进先出。可以理解为是一种类似队列的算法实现。
最先进来的数据,被认为在未来被访问的概率也是最低的,因此,当规定空间用尽且需要放入新数据的时候,会优先淘汰最早进来的数据。
- 优点:最简单、最公平的一种数据淘汰算法,逻辑简单清晰,易于实现。
- 缺点:这种算法逻辑设计所实现的缓存的命中率是比较低的,因为没有任何额外逻辑能够尽可能的保证常用数据不被淘汰掉。
下面简单演示了FIFO
的工作过程,假设存放元素尺寸是3
,且队列已满,放置元素顺序如下图所示,当来了一个新的数据“ldy”后,因为元素数量到达了阈值,则首先要进行太淘汰置换操作,然后加入新元素,操作如图展示:
3.2 LRU
LRU
(The Least Recently Used
)最近最久未使用算法。相比于FIFO
算法智能些。
如果一个数据最近很少被访问到,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰最久未被访问的数据。
- 优点:
LRU
可以有效的对访问比较频繁的数据进行保护,也就是针对热点数据的命中率提高有明显的效果。 - 缺点:对于周期性、偶发性的访问数据,有大概率可能造成缓存污染,也就是置换出去了热点数据,把这些偶发性数据留下了,从而导致
LRU
的数据命中率急剧下降。
下图展示了LRU
简单的工作过程,访问时对数据的提前操作,以及数据满且添加新数据的时候淘汰的过程的展示如下:
3.2.1 算法实现
在Java
中,其实LinkedHashMap
已经实现了LRU
缓存淘汰算法,需要在构造函数第三个参数传入true
,表示按照时间顺序访问。可以直接继承LinkedHashMap
来实现。
import java.util.LinkedHashMap;
import java.util.Map;
public class LruCache<K, V> extends LinkedHashMap<K, V> {
/**
* 容量限制
*/
private int capacity;
LruCache(int capacity) {
// 初始大小,0.75是装载因子,true是表示按照访问时间排序
super(capacity, 0.75f, true);
// 缓存最大容量
this.capacity = capacity;
}
/**
* 重写removeEldestEntry方法,如果缓存满了,则把链表头部第一个节点和对应的数据删除。
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
测试类
public class Test {
public static void main(String[] args) {
LruCache<String, String> cache = new LruCache(3);
cache.put("keyA", "valueA");
System.out.println("put keyA");
System.out.println(cache);
System.out.println("=========================");
cache.put("keyB", "valueB");
System.out.println("put keyB");
System.out.println(cache);
System.out.println("=========================");
cache.put("keyC", "valueC");
System.out.println("put keyC");
System.out.println(cache);
System.out.println("=========================");
cache.get("keyA");
System.out.println("get keyA");
System.out.println(cache);
System.out.println("=========================");
cache.put("keyD", "valueD");
System.out.println("put keyD");
System.out.println(cache);
}
}
运行结果如下:
put keyA
{keyA=valueA}
=========================
put keyB
{keyA=valueA, keyB=valueB}
=========================
put keyC
{keyA=valueA, keyB=valueB, keyC=valueC}
=========================
get keyA
{keyB=valueB, keyC=valueC, keyA=valueA}
=========================
put keyD
{keyC=valueC, keyA=valueA, keyD=valueD}
我们可以使用双向链表和哈希表进行实现,哈希表用于存储对应的数据,双向链表用于数据被使用的时间先后顺序。
在访问数据时,如果数据已存在缓存中,则把该数据的对应节点移到链表尾部。如此操作,在链表头部的节点则是最近最少使用的数据。
当需要添加新的数据到缓存时,如果该数据已存在缓存中,则把该数据对应的节点移到链表尾部;如果不存在,则新建一个对应的节点,放到链表尾部;如果缓存满了,则把链表头部第一个节点和对应的数据删除。
import java.util.HashMap;
import java.util.Map;
public class LruCache<K, V> {
/**
* 头结点
*/
private Node head;
/**
* 尾结点
*/
private Node tail;
/**
* 容量限制
*/
private int capacity;
/**
* key和数据的映射
*/
private Map<K, Node> map;
LruCache(int capacity) {
this.capacity = capacity;
this.map = new HashMap<>();
}
public V put(K key, V value) {
Node node = map.get(key);
// 数据存在,将节点移动到队尾
if (node != null) {
V oldValue = node.value;
// 更新数据
node.value = value;
moveToTail(node);
return oldValue;
} else {
Node newNode = new Node(key, value);
// 数据不存在,判断链表是否满
if (map.size() == capacity) {
// 如果满,则删除队首节点,更新哈希表
map.remove(removeHead().key);
}
// 放入队尾节点
addToTail(newNode);
map.put(key, newNode);
return null;
}
}
public V get(K key) {
Node node = map.get(key);
if (node != null) {
moveToTail(node);
return node.value;
}
return null;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("LruCache{");
Node curr = this.head;
while (curr != null) {
if(curr != this.head){
sb.append(',').append(' ');
}
sb.append(curr.key);
sb.append('=');
sb.append(curr.value);
curr = curr.next;
}
return sb.append('}').toString();
}
private void addToTail(Node newNode) {
if (newNode == null) {
return;
}
if (head == null) {
head = newNode;
tail = newNode;
} else {
// 连接新节点
tail.next = newNode;
newNode.pre = tail;
// 更新尾节点指针为新节点
tail = newNode;
}
}
private void moveToTail(Node node) {
if (tail == node) {
return;
}
if (head == node) {
head = node.next;
head.pre = null;
} else {
// 调整双向链表指针
node.pre.next = node.next;
node.next.pre = node.pre;
}
node.pre = tail;
node.next = null;
tail.next = node;
tail = node;
}
private Node removeHead() {
if (head == null) {
return null;
}
Node res = head;
if (head == tail) {
head = null;
tail = null;
} else {
head = res.next;
head.pre = null;
res.next = null;
}
return res;
}
class Node {
K key;
V value;
Node pre;
Node next;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
}
结果如下:
put keyA
LruCache{keyA=valueA}
=========================
put keyB
LruCache{keyA=valueA, keyB=valueB}
=========================
put keyC
LruCache{keyA=valueA, keyB=valueB, keyC=valueC}
=========================
get keyA
LruCache{keyB=valueB, keyC=valueC, keyA=valueA}
=========================
put keyD
LruCache{keyC=valueC, keyA=valueA, keyD=valueD}
3.2.2 LRU变种
如上所述,对于偶发性、周期性的数据没有良好的抵抗力,很容易就造成缓存的污染,影响命中率,因此衍生出了很多的LRU
算法的变种,用以处理这种偶发冷数据突增的场景,目的就是当判别数据为偶发或周期的冷数据时,不会存入空间内,从而降低热数据的淘汰率,比如:LRU-K
、Two Queues
等。
3.2.2.1 LRU-K
LRU-K
中的K
其实是指最近访问元素的次数,传统LRU
与此对比则可以认为传统LRU
是LRU-1
。其核心思想就是将访问一次就能替代的“1”提升为"K"。
LRU-K
算法需要维护两个队列:历史队列和缓存队列。
- 历史队列:用于记录元素的访问次数,当尚未达到
K
次时则继续保存,直至历史队列满了,采用的淘汰策略(FIFO
、LRU
)进行淘汰。 - 缓存队列:当历史队列中的元素访问次数达到
K
的时候,才会进入缓存队列。当该队列面了之后,淘汰最后一个元素,也就是第K次访问距离现在最久的那个元素。
原理解析
元素第一次被访问,添加到历史队列中。当历史队列中的元素满了,根据一定的缓存策略(FIFO
、LRU
)进行淘汰老的元素。
当历史队列中的某个元素第k
次访问时,该元素从历史队列中出栈,并存放至缓存队列。
缓存队列中的元素再次被访问k
次时,历史队列中该元素出栈,并且更新缓存队列中该元素的位置。
当缓存队列需要淘汰元素时,淘汰最后一个元素,也就是第k
次访问距离现在最久的那个元素。
小结
它的命中率要比LRU
要高,但是因为需要维护一个历史队列,因此内存消耗会比LRU
多。
实际应用中LRU-2
是综合各种因素后最优的选择,LRU-3
或者更大的K
值命中率会高,但适应性差,需要大量的数据访问才能将历史访问记录清除掉。
3.2.2.2 Two Queues
Two Queues
(2Q
)其实是LRU-K
的一个具体版本:LRU-2
。并且2Q
的历史队列是采用FIFO
的方法进行缓存的。
原理解析
同样维护两个队列:历史队列(采用FIFO
的淘汰策略)和缓存队列(采用LRU-1
的淘汰策略)。
- 新访问的数据插入到
FIFO
队列; - 如果数据在
FIFO
队列中一直没有被再次访问,则最终按照FIFO
规则淘汰; - 如果数据在
FIFO
队列中被再次访问,则将数据移到LRU
队列头部; - 如果数据在
LRU
队列再次被访问,则将数据移到LRU
队列头部; LRU
队列淘汰末尾的数据。
小结
缺点跟LRU-K
一致,其实2Q
算法就是LRU-2
,并且历史队列采用了FIFO
的淘汰策略。
3.2.2.3 Multi Queue
Multi Queue
(MQ
)算法其实是2Q
算法的一个扩展。2Q
算法维护两个队列,而MQ
算法根据访问频率将数据划分为多个队列,不同的队列具有不同的访问优先级。其核心思想是:优先缓存访问次数多的数据。
原理解析
MQ
算法将缓存划分为多个LRU
队列,每个队列对应不同的访问优先级。访问优先级是根据访问次数计算出来的,
详细的算法结构图如下,Q0,Q1....Qk
代表不同的优先级队列,Q-history
代表从缓存中淘汰数据,但记录了数据的索引和引用次数的队列:
如上图,算法详细描述如下:
- 新插入的数据放入
Q0
; - 每个队列按照
LRU
管理数据; - 当数据的访问次数达到一定次数,需要提升优先级时,将数据从当前队列删除,加入到高一级队列的头部;
- 为了防止高优先级数据永远不被淘汰,当数据在指定的时间里访问没有被访问时,需要降低优先级,将数据从当前队列删除,加入到低一级的队列头部;
- 需要淘汰数据时,从最低一级队列开始按照
LRU
淘汰;每个队列淘汰数据时,将数据从缓存中删除,将数据索引加入Q-history
头部; - 如果数据在
Q-history
中被重新访问,则重新计算其优先级,移到目标队列的头部; Q-history
按照LRU
淘汰数据的索引。
如果在高优先级队列中一个页面长时间没有被访问,当新页面进栈,没位置存放时,该长时间未被访问的页面就出栈,进入低一级优先级的队列中。
MQ
算法需要维护多个队列以及多个页面的数据,成本较大。
3.3 LFU
LFU
(The Least Frequently Used
)最近很少使用算法,与LRU
的区别在于LRU
是以时间衡量,LFU
是以时间段内的次数
如果一个数据在一定时间内被访问的次数很低,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰时间段内访问次数最低的数据。
- 优点:
LFU
也可以有效的保护缓存,相对场景来讲,比LRU
有更好的缓存命中率。因为是以次数为基准,所以更加准确,自然能有效的保证和提高命中率。 - 缺点:因为
LFU
需要记录数据的访问频率,因此需要额外的空间;当访问模式改变的时候,算法命中率会急剧下降,这也是他最大弊端。
下面描述了LFU
的简单工作过程,首先是访问元素增加元素的访问次数,从而提高元素在队列中的位置,降低淘汰优先级,后面是插入新元素的时候,因为队列已经满了,所以优先淘汰在一定时间间隔内访问频率最低的元素。
3.2.1 算法实现
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class LfuCache<K, V> {
/**
* 容量限制
*/
private int capacity;
/**
* 当前最小使用次数
*/
private int minUsedCount;
/**
* key和数据的映射
*/
private Map<K, Node> map;
/**
* 数据频率和对应数据组成的链表
*/
private Map<Integer, List<Node>> usedCountMap;
public LfuCache(int capacity) {
this.capacity = capacity;
this.minUsedCount = 1;
this.map = new HashMap<>();
this.usedCountMap = new HashMap<>();
}
public V get(K key) {
Node node = map.get(key);
if (node == null) {
return null;
}
// 增加数据的访问频率
addUsedCount(node);
return node.value;
}
public V put(K key, V value) {
Node node = map.get(key);
if (node != null) {
// 如果存在则增加该数据的访问频次
V oldValue = node.value;
node.value = value;
addUsedCount(node);
return oldValue;
} else {
// 数据不存在,判断链表是否满
if (map.size() == capacity) {
// 如果满,则删除队首节点,更新哈希表
List<Node> list = usedCountMap.get(minUsedCount);
Node delNode = list.get(0);
list.remove(delNode);
map.remove(delNode.key);
}
// 新增数据并放到数据频率为1的数据链表中
Node newNode = new Node(key, value);
map.put(key, newNode);
List<Node> list = usedCountMap.get(1);
if (list == null) {
list = new LinkedList<>();
usedCountMap.put(1, list);
}
list.add(newNode);
minUsedCount = 1;
return null;
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("LfuCache{");
List<Integer> usedCountList = this.usedCountMap.keySet().stream().collect(Collectors.toList());
usedCountList.sort(Comparator.comparingInt(i -> i));
int count = 0;
for (int usedCount : usedCountList) {
List<Node> list = this.usedCountMap.get(usedCount);
if (list == null) {
continue;
}
for (Node node : list) {
if (count > 0) {
sb.append(',').append(' ');
}
sb.append(node.key);
sb.append('=');
sb.append(node.value);
sb.append("(UsedCount:");
sb.append(node.usedCount);
sb.append(')');
count++;
}
}
return sb.append('}').toString();
}
private void addUsedCount(Node node) {
List<Node> oldList = usedCountMap.get(node.usedCount);
oldList.remove(node);
// 更新最小数据频率
if (minUsedCount == node.usedCount && oldList.isEmpty()) {
minUsedCount++;
}
node.usedCount++;
List<Node> set = usedCountMap.get(node.usedCount);
if (set == null) {
set = new LinkedList<>();
usedCountMap.put(node.usedCount, set);
}
set.add(node);
}
class Node {
K key;
V value;
int usedCount = 1;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
}
结果如下:
put keyA
LfuCache{keyA=valueA(UsedCount:1)}
=========================
put keyB
LfuCache{keyA=valueA(UsedCount:1), keyB=valueB(UsedCount:1)}
=========================
put keyC
LfuCache{keyA=valueA(UsedCount:1), keyB=valueB(UsedCount:1), keyC=valueC(UsedCount:1)}
=========================
get keyA
LfuCache{keyB=valueB(UsedCount:1), keyC=valueC(UsedCount:1), keyA=valueA(UsedCount:2)}
=========================
put keyD
LfuCache{keyC=valueC(UsedCount:1), keyD=valueD(UsedCount:1), keyA=valueA(UsedCount:2)}
3.4 小结
FIFO
(先进先出算法):判断被存储的时间,离目前最远的数据优先被淘汰,可以使用队列实现。LRU
(最近最少使用算法):判断最近被使用的时间,目前最远的数据优先被淘汰,可以使用双向链表和哈希表实现。LFU
(最不经常使用算法):在一段时间内,数据被使用次数最少的,优先被淘汰,可以使用双哈希表实现。
其他算法
自适应缓存替换算法(ARC
):在IBM%2BAlmaden
研究中心开发,这个缓存算法同时跟踪记录LFU
和LRU
,以及驱逐缓存条目,来获得可用缓存的最佳使用。
最近最常使用算法(MRU
):这个缓存算法最先移除最近最常使用的条目。一个MRU
算法擅长处理一个条目越久,越容易被访问的情况。
四、过期键的删除策略
4.1 关于键的过期时间或生存时间
Redis
数据库提供了常用的EXPIRE
命令或者PEXPIRE
命令,用户可以使用这两个命令以秒或者毫秒为精度为数据库中的某个键设置生存时间。在经过指定的时间后,Redis
服务器就会自动删除生存时间为0
的键。
可以设置键的生存时间的命令如下:
# 该命令用于将键Key的生存时间设置为ttl秒
EXPIRE <key> <ttl>
# 该命令用于将键Key的生存时间设置为ttl毫秒
PEXPIRE <key> <ttl>
# 该命令用于将键Key的生存时间设置为timstamp所指定的秒数时间戳
EXPIREAT <key> <timstamp>
# 该命令用于将键Key的生存时间设置为timstamp所指定的毫秒数时间戳。
PEXPIREAT <key> <timstamp>
虽然有四种不同的命令用于指定过期时间,但是实际上,无论使用哪一种命令,最终都会转换为PEXPIREAT
命令来执行。
那么,Redis
是如何存储过期时间的呢?
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
dict *expires; /* Timeout of keys with a timeout set */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
unsigned long expires_cursor; /* Cursor of the active expire cycle. */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
我们可以通过以上源码看出,redisDb
结构的expires
这个字典保存了数据库中所有的过期时间,我们叫这个字典为过期字典。
每当我们为一个数据库的某一个键添加过期时间就会在该字典中添加一个键值对,键为这个需要添加过期时间的键,值为过期时间的时间戳。相反,如果删除一个键的过期时间,也会相应的操作这个字典,删除该键对应的过期时间键值对。如图所示:
4.2 过期删除策略
如果一个键过期了,那么什么时候被删除呢?
关于这个问题,可以实现的有一下三种方案(Redis
只采用了其中两种):
4.2.1 定时删除
设置键的过期时间的同时,创建一个定时器,让定时器在键过期时间来临时,立即执行对键的删除操作。
优点:这种删除策略对于内存来说是友好的,因为这种删除方式可以保证过期的键尽可能快的被删除掉,并释放过期键所占用的内存。
缺点:
- 这种删除策略对
CPU
时间不友好,在过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分的CPU
时间,在内存不紧张的但是CPU
时间紧张的情况下,这无疑会对服务器的响应时间和吞吐量造成影响。 - 创建一个定时器需要用到
Redis
服务器中的时间时间,而当前时间时间的实现方式为无序链表,查找一个事件的时间复杂度为O(N)
,所以说,如果采用这种策略,并不能高效的处理大量的时间事件。
4.2.2 惰性删除
放任过期不管,但是每次从键空间中获取值的时候,检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。
优点:这种删除策略对于CPU
来说是友好的,程序只会在取出键的时候才会对键进行过期检查,这样可以保证对键的删除操作仅限于当前处理的键,这个策略不会在删除其他过期的键上花费任何的时间。
缺点:显而易见的,这种删除策略对于内存来说是十分不友好的。因为如果大量的过期键,长期不使用的情况下,就会造成大量的内存被无效的键占用。我们甚至可以将这中情况看作是内存泄露。
4.2.3 定期删除
每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键,至于要删除多少个过期键,以及要检查多少个数据库则由算法决定。
针对定期删除来说,这种策略实际上是定时删除和惰性删除这两种策略的折中和整合。定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行时长和频率来减少删除操作对CPU
时间的影响。除此之外,通过定期删除过期键,定期删除策略有效的减少了因为过期键而带来的内存浪费。
当然,这种策略的难点就在于如何确定删除的时长和频率。比如,如果设定的删除太频繁或者执行删除的时间太长,就直接回退化为定时删除。如果删除的频率过低或者指定的时间太短,定期删除又会和惰性删除一样,造成内存浪费的情况。
4.3 Redis采用的过期键删除策略
Redis
数据库实际上采用了两种删除策略:定期删除和惰性删除。通过这两种删除策略的配合使用,服务器可以很好的在合理使用CPU
时间和避免内存空间浪费之间取得平衡。
那么,Redis
数据库是如何实现这两种删除策略的呢?
惰性删除策略的实现:
过期键的删除策略由expireIfNeeded
函数实现,所有读写数据库的Redis
命令都会在执行钱调用该函数进行检查。
int expireIfNeeded(redisDb *db, robj *key) {
if (!keyIsExpired(db,key)) return 0;
if (server.masterhost != NULL) return 1;
/* Delete the key */
server.stat_expiredkeys++;
propagateExpire(db,key,server.lazyfree_lazy_expire);
notifyKeyspaceEvent(NOTIFY_EXPIRED, "expired", key, db -> id);
int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key)
: dbSyncDelete(db,key);
if (retval) signalModifiedKey(NULL,db,key);
return retval;
}
我们可以看出,如果输入键已经过期,那么expireIfNeed
函数将输入键从数据库删除。如果输入键没有过期,则不会做其他动作。所以,每个命令的实现函数都必须能同时处理键存在和不存在两种情况。
定期删除策略的实现
该策略由activeExpireCycle
函数实现,每当服务器周期性的操作serverCron
函数执行的时候,activeExpireCycle
函数就会被调用,在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires
字典中随你检查一部分的过期时间,并删除其中的过期键。
具体代码实现由于太多,有感兴趣可以去看一下,redis6
在expire.c
中,redis3
在redis.c
中。
4.4 AOF、RDB对过期键的处理
- 生成RDB文件
在执行SAVE
或者BGSAVE
命令创建一个新的RDB
文件的时候,程序会对数据库中的过期键进行检查,过期的键不会被保存到新创建的RDB
文件中。
- 载入RDB文件
载入的时候分为两种情况:
- 服务器以主服务器运行。当服务器以主服务器运行的时候,会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期的键则会被忽略,所以过期键对载入
RDB
文件的主服务器不会造成影响。 - 服务器以从服务器运行。当服务器以从服务器运行的时候,会将文件中保存的所有键进行保存,不论是否过期。但是由于主从服务器进行数据同步的时候,从服务器的数据库就会被清空,所以一般来讲,过期键载入
RDB
文件的从高服务器也不会造成影响。
- AOF文件的写入
当数据库中某个键已经过期,但是它还没有被惰性删除或者定期删除,那么AOF
文件不会因为这个过期键而产生任何影响。当过期键被惰性删除或者定期删除之后,程序会向AOF
文件追加一条DEL
命令进行显示的删除。
- AOF文件的重写:
重写的时候程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF
文件中。
当服务器运行在主从复制模式下的时候,从服务器的过期键删除动作是由主服务器控制的。主服务器在删除一个过期键后,会显示的向所有从服务器发送一个DEL
命令,命令从服务器删除这个键;从服务器在执行客户端发送的命令的时候,即使遇到过期的键也不会将过期的键进行删除,是继续像处理未过期的键一样来处理过期键;从服务器只有在接到主服务器发送来的DEL
命令的时候才会删除过期键。
五、总结
Redis
的内存淘汰策略的选取并不会影响过期的key
的处理。内存淘汰策略用于处理内存不足时的需要申请额外空间的数据;过期策略用于处理过期的缓存数据。