redis是内存数据库,当内存使用达到了一定阈值,就会触发缓存淘汰策略,这和 Redis 配置文件 redis.conf 中的两个配置参数有关:
maxmemory-policy 内存淘汰策略一共八种:
LRU 算法就是指最近最少使用(Least Recently Used,LRU)算法,这是一个经典的缓存算法。
从基本原理上来说,LRU 算法会使用一个链表来维护缓存中每一个数据的访问情况,并根据数据的实时访问,调整数据在链表中的位置,然后通过数据在链表中的位置,来表示数据是最近刚访问的,还是已经有一段时间没有访问了。
LRU 算法的执行,可以分成三种情况来掌握:
所以我们可以发现,如果要严格按照 LRU 算法的基本原理来实现的话,我们需要在代码中实现如下内容:
而假设 Redis 保存的数据比较多的话,那么,这两部分的代码实现,就既需要额外的内存空间来保存链表,还会在访问数据的过程中,让 Redis 受到数据移动和链表操作的开销影响,从而就会降低 Redis 访问性能。
所以说,无论是为了节省宝贵的内存空间,还是为了保持 Redis 高性能,Redis 源码并没有严格按照 LRU 算法基本原理来实现它,而是提供了一个近似 LRU 算法的实现。
为了便于你理解,我把 Redis 对近似 LRU 算法的实现分成了三个部分:
虽然 Redis 使用了近似 LRU 算法,但是,这个算法仍然需要区分不同数据的访问时效性,也就是说,Redis 需要知道数据的最近一次访问时间。因此,Redis 就设计了 LRU 时钟来记录数据每次访问的时间戳。
Redis 在源码中对于每个键值对中的值,会使用一个 redisObject 结构体来保存指向值的指针。那么,redisObject 结构体除了记录值的指针以外,它其实还会使用 24 bits 来保存 LRU 时钟信息,对应的是 lru 成员变量。所以这样一来,每个键值对都会把它最近一次被访问的时间戳,记录在 lru 变量当中。
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; //记录LRU信息,宏定义LRU_BITS是24 bits
int refcount;
void *ptr;
} robj;
Redis 首先是设置了全局 LRU 时钟,并在键值对创建时获取全局 LRU 时钟值作为访问时间戳,以及在每次访问时获取全局 LRU 时钟值,更新访问时间戳。
全局 LRU 时钟其实是一个全局变量,在 Redis server 的运行过程中,定时默认 100 毫秒(可配置),获取一次当前时钟值,去更新全局 LRU 时钟变量,那么每次访问 key 时就会获取全局 LRU 时钟值,更新RedisObject中的 lru 访问时间戳。
需要注意的就是,全局 LRU 时钟值是以 1 秒为精度来计算的 UNIX 时间戳,如果一个数据前后两次访问的时间间隔小于 1 秒,那么这两次访问的时间戳就是一样的。因为 LRU 时钟的精度就是 1 秒,它无法区分间隔小于 1 秒的不同时间戳。
在键值对被创建或访问的时候,它的 LRU 时钟值就会被更新,记录的是当前的时间搓。
现在我们已经知道,Redis 之所以实现近似 LRU 算法的目的,是为了减少内存资源和操作时间上的开销。那么在这里,我们其实可以从两个方面来了解近似 LRU 算法的执行过程,分别是:
近似 LRU 算法的主要逻辑是在 freeMemoryIfNeeded 函数中实现的,而这个函数是 Redis 处理每个命令时都会被调用的。所以,Redis 在每个命令执行的时候都会去判断是否触发淘汰策略。
触发条件:
满足以上条件就会触发 LRU 淘汰策略(前提是 maxmemory-policy 淘汰策略设置的LRU策略)。
而如果当前 server 使用的内存量,的确已经超出 maxmemory 的上限了,那么 freeMemoryIfNeeded 函数就会执行一个 while 循环,来淘汰数据释放内存。
其实,为了淘汰数据,Redis 定义了一个数组 EvictionPoolLRU,用来保存待淘汰的候选键值对。这个数组的元素类型是 evictionPoolEntry 结构体,该结构体保存了待淘汰键值对的空闲时间 idle、对应的 key 等信息。以下代码展示了 EvictionPoolLRU 数组和 evictionPoolEntry 结构体:
static struct evictionPoolEntry *EvictionPoolLRU;
struct evictionPoolEntry {
unsigned long long idle; //待淘汰的键值对的空闲时间
sds key; //待淘汰的键值对的key
sds cached; //缓存的SDS对象
int dbid; //待淘汰键值对的key所在的数据库ID
};
该数组的大小默认是 16 个元素,也就是可以保存 16 个待淘汰的候选键值对。
上面四步是一个循环遍历的过程,也就是说一旦触发了LRU淘汰,如果没有达到我前面介绍的待释放空间,会重复执行上面的四步,直到满足待释放空间的大小要求。
其实,我们可以看到,近似 LRU 算法并没有使用耗时耗空间的链表,而是使用了固定大小的待淘汰数据集合,每次随机选择一些 key 加入待淘汰数据集合中。最后,再按照待淘汰集合中 key 的空闲时间长度,删除空闲时间最长的 key。这样一来,Redis 就近似实现了 LRU 算法的效果了。