redis-缓存回收策略
Redis 缓存使用了内存保存数据,使数据的存储和读取都得到了极大的提升,但是由于计算机中“内存”的造价却在磁盘的数百倍之上;这也导致我们无法使用 Redis 缓存所有的数据;
那样也衍生出一个问题:当Redis中缓存的数据大小,达到上限后,redis 会作出怎样的操作?
为了解决这个问题,redis 有着自身维护的一套 缓存数据的淘汰机制;其实简单来说 就是分成两步:
- 根据指定规则筛选出可以"放弃"的 key值
- 释放对应key值的空间,用于保存新的Key值
如何设置 redis 的内存暂用值
Redis 中有一个 maxmemory 的概念,主要是为了将 redis 的使用内存限定在一个固定的大小,当使用内存超出限定值后,根据 maxmemory-policy 配置的策略进行内存回收;
maxmemory 的设定值有如下两种方式:
第一种:通过 congfig set 命令进行设置:
127.0.0.1:6379> config get maxmemory
1) "maxmemory"
2) "0"
127.0.0.1:6379> config set maxmemory 1024MB
OK
127.0.0.1:6379> config get maxmemory
1) "maxmemory"
2) "1073741824"
第二种:通过 redis.conf 文件修改
maxmemory 1024MB
注意: 不配置 maxmemory 的值时,将默认为0;
64 bit系统:maxmemory 设置为 0 表示不限制 Redis 内存使用
32 bit系统:maxmemory 设置为 0 表示限制 Redis 内存 不能超过 3G。
(造成这个现在的其实是系统对内存使用的限制造成的,并不是redis造成的,有兴趣可以了解下 不同 bit 系统对内存的利用)
Redis 缓存有哪些策略
Redis4.0 之前共实现了6种内存策略,之后又增加了两种内存策略;主要可以分成两类:
- 不进行数据淘汰的策略,只有 noeviction 这一种
- 会淘汰key值的7种
会进行淘汰的 7 种策略,我们可以再进一步根据淘汰候选数据集的范围把它们分成两类:
- 在设置了过期时间的数据中进行淘汰,包括 volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis 4.0 后新增)四种。
- 在所有数据范围内进行淘汰,包括 allkeys-lru、allkeys-random、allkeys-lfu(Redis 4.0 后新增)三种。
如下图:
具体解析下:
- noeviction 策略 : 当 Redis 缓存达到了 maxmemory 配置的值后,再有写入请求到来时,redis 将不再提供写入服务,直接响应错误
- volatile-ttl 策略 : 在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除
- volatile-random 策略 : 在设置了过期时间的键值对中,进行随机删除
- volatile-lru 策略 :使用 LRU 算法筛选设置了过期时间的键值对
- volatile-lfu 策略 : 使用 LFU 算法选择设置了过期时间的键值对
- allkeys-random 策略,从所有键值对中随机选择并删除数据;
- allkeys-lru 策略,使用 LRU 算法在所有数据中进行筛选。
- allkeys-lfu 策略,使用 LFU 算法在所有数据中进行筛选。
注意: volatile-random、volatile-ttl、volatile-lru 和 volatile-lfu 这四种淘汰策略。它们筛选的候选数据范围,被限制在已经设置了过期时间的键值对上。也正因为此,即使缓存没有写满,这些数据如果过期了,也会被删除。反之:allkeys-lru、allkeys-random、allkeys-lfu 这3种淘汰策略。他们的筛选的候选数据范围是全部key,所以他们淘汰的key可能是没到期的。
浅谈下LRU 算法
由上面可知 volatile-lru 和 allkeys-lru 策略都是使用了LRU算法。
1. 那什么是LRU算法呢?
LRU(least recently used) 其实是一种缓存置换的算法,在linux内存管理也常有体现。即在缓存有限的情况下,如果有新的数据需要加入缓存,则我们需要先将最不可能被访问到的数据移除,以腾出空间缓存新数据;但是我们无法预知到缓存的数据哪些将不会被访问。。。所以我们只能基于如下的规则来实现算法:
如果一个KEY经常被访问,那么这个KEY的idle time(空闲时间)应该是最小的,而且再次访问的概率应该是最大的;但是另外一个角度解读,这个不是必然事件,idle time 最大不能代表这个 key 就不会再被访问。
2. 那具体是如何筛选出 idle time 最大的key 呢?
我们举一个例子(其中一种较为简单的方式),来看看 LRU 算法是如何做到的:
假设:我们的内存页能存储的数据为 5 个;我们将存储的数据结构用 双向链表 和 hashmap(这个是为了保证存储和读取都可以实现 O(1) ), 双向链表两端 分别标记为 MRU端 和 LRU 端
如上图所示:
我们数据页分别保存有 KEY1 到 KEY5 5个数据如果KEY4被访问时候,我们遍历 Hashmap 确认了当前数据已经存在,于是把他的 KEY4 节点移动到 MRU端 (链表头部);(因为是双线链表+hashmap机构,所以更改关系非常方便。不需要整个连进行遍历移动)生成新的双线链表; 然后,当 新数据 KEY6 写入时,因为内存页已经满了,所以我们把 LRU端 (链表尾部) 的一个元素移除,然后再在 MRU端 插入新的元素
缺点:
上面的 LRU 算法实现方式不是很复杂,但是通过双向链表 和 hashmap 来管理所有的缓存数据,显然有着一些致命的缺陷;这会带来大量的额外的内存空间损耗,而且我们都知道在 redis 中是“单线程”的。如果没次的读写都需要维护这个链表的移动和hashmap关系,这会大大加大了 Redis 服务的计算时长,进而降低了 Redis 缓存的性能,这也是我们不能接受的。
Redis 中 LRU 的实现
根据上述,显然 Redis 并没有使用双线链表实现LRU算法。
首先我们可以看看 redisObj 结构体(其实 redis 整体上是一个大的dict,key是一个string,而value是一个redisObj)
/*
* Redis 对象
*/
typedef struct redisObject {
// 类型
unsigned type:4;
// 对齐位
unsigned notused:2;
// 编码方式
unsigned encoding:4;
// LRU 时间(相对于 server.lruclock)
unsigned lru:LRU_BITS;
// 引用计数
int refcount;
// 指向对象的值
void *ptr;
} robj;
我们可以看到有一个LRU 的字段,而这个字段是通过调用 lookupKey 方法来获取值的
robj *lookupKey(redisDb *db, robj *key, int flags) {
...
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { //如果配置的是lfu方式,则更新lfu
updateLFU(val);
} else {
val->lru = LRU_CLOCK();//否则按lru方式更新
}
...
}
其实可以简单理解,redis 内部维护了一个 公共的 LRU 时钟,定时刷新对应的值(就类比:我们的时钟的秒针,一直在走);当 Redis的dict中来获取数值的时候,就会把当前lru时钟的值返回回去,作为当前的值返回回去。
那么我们缓存的数据中每一个都有一个自己访问的的时间值,换句话说,根据这个 LRU 时钟的时间戳,我们可以清楚的定位到每一个key的最后访问时间,我们也可以算出他的 idle time;最后我们只需要变量 所有Key 的idle time 我们就可以知道 应该淘汰哪个Key了。
然而淘汰时总不能挨个遍历dict中的所有槽,逐个比较 LRU 值大小吧,每一次都遍历,单进程的redis 读写估计会跟乌龟一样慢。
我们上面有说过 LRU 算法本质其实也是概率性的,那么实现的时候索性就将概率性贯彻到底。。
Redis初始的实现算法很简单,随机从dict中取出五个key,淘汰一个lru字段值最小的。(随机选取的key是个可配置的参数maxmemory-samples,默认值为5).
后来在redis3.0中又改进了一版,引入了一个Pool的概念,第一次随机选取的key都会放入一个pool中,pool中的key是按lru大小顺序排列的。接下来每次随机选取的keylru值必须小于pool中最小的lru才会继续放入,直到将pool放满。放满之后,每次如果有新的key需要放入,需要将pool中lru最大的一个key取出。
最后,淘汰的时候,直接从pool中退出lru 最小的 淘汰即可
有了解的应该知道Redis执行命令的时候,会调用processCommand 函数 (有兴趣可以看下)
int processCommand(redisClient *c) {
....
/* Handle the maxmemory directive.
*
* First we try to free some memory if possible (if there are volatile
* keys in the dataset). If there are not the only thing we can do
* is returning an error. */
// 如果设置了最大内存,那么检查内存是否超过限制,并做相应的操作
if (server.maxmemory) {
// 如果内存已超过限制,那么尝试通过删除过期键来释放内存
int retval = freeMemoryIfNeeded();
// 如果即将要执行的命令可能占用大量内存(REDIS_CMD_DENYOOM)
// 并且前面的内存释放失败的话
// 那么向客户端返回内存错误
if ((c->cmd->flags & REDIS_CMD_DENYOOM) && retval == REDIS_ERR) {
flagTransaction(c);
addReply(c, shared.oomerr);
return REDIS_OK;
}
}
....
}
其实上面可以看到 最后释放内存 是通过了 freeMemoryIfNeeded 函数来操作的
具体的分析可以看下这个文章 Redis源码解析(11) 内存淘汰策略
maxmemory-samples 该如何配置
因为 LRU 算法本身就具有概率性的,而Redis 作者在实现的方式也是基于概率的;那么 在两个都是概率性的情况下,LRU 算法 在实际使用中是否会极大的偏离了我们的预想期呢?
作者做了个试验,结果如下图:
上图分为3个不同的色带:
- 浅灰色带是被逐出的对象。
- 灰带是未逐出的对象。
- 绿带是添加的对象。
如果左上图1,我们定义为 LRU 算法 的最理想情况,新增加的key和最近被访问的key都不应该被逐出;
我们可以看到 maxmemory-samples 设置为5的时候 Redis 2.8 服务展现出来的结果,还是有点差强人意的,
但是当作者在 Redis 3.0 后引入了pool 的概念后,展现出来的结果有了较大的提升。至少新加入的 key 被回收回去的情况有了很大的提升。
当我们在 Redis 3.0 服务中 将 maxmemory-samples 的值提升到 10 的时候,已经非常接近理想值了。。
但是如果你认真阅读了上文,你会清楚知道 maxmemory-samples 越大 对服务器的 cpu 都会有较大的消耗的;
所以作者在最后也给出了自己的建议:
However you can raise the sample size to 10 at the cost of some additional CPU usage in order to closely approximate true LRU, and check if this makes a difference in your cache misses rate.
但其实 如果服务器性能不是特别优秀,其实使用默认值:5; 就够了。。。
参考:
Redis LRU算法的随机说明 :http://antirez.com/news/109
源码分析查考: https://blog.csdn.net/weixin_...
Redis用作LRU缓存 : https://redis.io/topics/lru-c...
LRU原理和Redis实现——一个今日头条的面试题:https://zhuanlan.zhihu.com/p/...