Redis LRU机制
LRU概述
LRU 是 Least Recently Used 的缩写,即最近最少使用,是内存管理的一种页面置换算法。算法的核心是:如果一个数据在最近一段时间内没有被访问到,那么它在将来被访问的可能性也很小。换言之,当内存达到极限时,应该把内存中最久没有被访问的数据淘汰掉。
Java LRU实现
实现 LRU 算法除了需要 key/value 字典外,还需要附加一个链表,链表中的元素按照一定的顺序进行排列。当空间满的时候,会踢掉链表尾部的元素。当字典的某个元素被访问
时,它在链表中的位置会被移动到表头。所以链表的元素排列顺序就是元素最近被访问的时间顺序。
这里可以使用 HashMap 存储 key,这样可以做到 set 和 get key的时间都是 O(1),而 HashMap 的 Value 指向双向链表实现的 LRU 的 Node 节点。
其中 h 代表双向链表的表头,t 代表尾部。首先预先设置 LRU 的容量,如果存储满了,可以通过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,都可以通过 O(1)的效率把新的节点增加到对头,或者把已经存在的节点移动到队头。
具体操作步骤
set(key, value),首先在HashMap找到Key对应的节点,如果节点存在,更新节点的值,并把这个节点移动队头。如果不存在,需要构造新的节点,并且尝试把节点塞到队头,如果LRU空间不足,则通过tail淘汰掉队尾的节点,同时在HashMap中移除 Key。
get(key),通过HashMap找到LRU链表节点,把节点插入到队头,返回缓存的值
Redis 近似LRU算法
Redis 使用的是一种近似 LRU 算法,它跟 LRU 算法还不太一样。之所以不使用 LRU 算法,是因为需要消耗大量的额外的内存,需要对现有的数据结构进行较大的改造。近似LRU 算法则很简单,在现有数据结构的基础上使用随机采样法来淘汰元素,能达到和 LRU 算法非常近似的效果。
Redis 为实现近似 LRU 算法,它给每个 key 增加了一个额外的小字段,这个字段的长度是 24 个 bit。每个对象的每次被访问都会记录下当前服务器的LRU时钟,然后用服务器的LRU时钟减去对象本身的时钟,得到的就是这个对象没有被访问的时间间隔(也称空闲时间),空闲时间最大的就是需要淘汰的对象。
淘汰策略
当 Redis 执行写操作时,发现内存超出 maxmemory,就会执行一次LRU 淘汰算法。这个算法也很简单,就是随机采样出 5(可以配置) 个 key,然后淘汰掉最旧的 key,如果淘汰后内存还是超出 maxmemory,那就继续随机采样淘汰,直到内存低于maxmemory 为止。
如何采样就是看 maxmemory-policy 的配置,如果是 allkeys 就是从所有的 key 字典中随机,如果是volatile就从带过期时间的 key 字典中随机。每次采样多少个key看的是maxmemory_samples的配置,默认为 5。
Redis缓存淘汰策略与Redis键的过期删除策略并不完全相同,前者是在Redis内存使用超过maxmemory使用的淘汰策略;而后者是通过定期删除+惰性删除两者结合的方式淘汰内存过期键的。
Redis LRU行为配置参数
Redis系统中与LRU功能相关的配置参数有三个:
- maxmemory: 该参数即为缓存数据占用的内存限制。当缓存的数据消耗的内存超过这个数值限制时,将触发数据淘汰。该数据配置为0时,表示缓存的数据量没有限制, 即LRU功能不生效
- maxmemory_policy: 淘汰策略,定义参与淘汰的数据的类型和属性
- maxmemory_samples: 随机采样的精度, 该数值配置越大, 越接近于真实的LRU算法,但是数值越大, 消耗的CPU计算时间越多,执行效率越低
Redis LRU执行过程
Redis数据库结构定义
Redis在每个数据库结构中使用了两个不同的哈希表来管理缓存数据。 Redis数据库结构的定义为(redis.h):
1 | typedef struct redisDb { |
哈希表expires
存储具有超时属性的数据,哈希表dict
既存储没有超时属性的数据, 也存储具有超时属性的数据,即哈希表dict
中存储的是全量的缓存数据。
淘汰逻辑
在函数freeMemoryIfNeeded实现淘汰缓存的数据,其实现为(redis.c):
1 | int freeMemoryIfNeeded(void) { |
局部变量mem_tofree
表示需要淘汰的内存,局部变量mem_freed
表示已经淘汰的内存。
循环执行while (mem_freed < mem_tofree)
淘汰缓存数据,该循环中的逻辑可以概括为:
- 从全局的0号数据库开始(Redis默认有16个全局的数据库),根据淘汰策略,选择该数据库中的哈希表。如果该哈希表为空, 选择下一个全局数据库
- 根据淘汰策略,在相应哈希表中找到一个待淘汰的key,从该数据库对象中删除该key所对应的缓存数据
- 如果没有找到待淘汰的key,即无法淘汰所需的缓存数据大小函数直接返回错误
- 如果当前访问的是最后一个全局数据库, 并且已经淘汰了所需的缓存数据,则该函数成功返回.如果没有淘汰所需的缓存数据,则返回步骤1,并且从0号数据库重新淘汰
- 如果当前访问的不是最后一个全局数据库, 则返回步骤1, 从当前数据库的下一个数据库继续淘汰缓存数据
1、如果淘汰策略是allkeys-random或者volatile-random,则从相应数据库中随机选择一个key进行淘汰
2、如果淘汰策略是allkeys-lru或者volatile-lru,则根据配置的采样值maxmemory_samples
,随机从数据库中选择maxmemory_samples
个key, 淘汰其中热度最低的key对应的缓存数据
3、如果淘汰策略是volatile-ttl,则根据配置的采样值maxmemory_samples,随机从数据库中选择maxmemory_samples个key,淘汰其中最先要超时的key对应的缓存数据
从数据库的哈希表结构中随机返回一个key的执行函数为dictGetRandomKey,执行过程分为两步:
- 在哈希表中随机选择一个非空的桶(bucket)
- 在该桶的冲突链表中随机选择一个节点
更新缓存对象热度值
Redis中的对象结构定义为(redis.h):
1 | typedef struct redisObject { |
即对象结构中存在一个lru字段, 且使用了unsigned的低24位(REDIS_LRU_BITS定义的值)
Redis命令访问缓存的数据时,均会调用函数lookupKey去更新对象lru值,其实现为(db.c):
1 | robj *lookupKey(redisDb *db, robj *key) { |
对象的lru值会被设置为全局的server.lruclock
值。当然,在对象创建的时候也会将该lru字段设置为全局的server.lruclock
。
lookupKey()不会直接被 redis 命令调用,往往是通过lookupKeyRead()、lookupKeyWrite() 、lookupKeyReadWithFlags() 间接调用的。这个函数的作用是通过传入的 key 查找对应的 redis 对象,并且会在条件满足时设置上 LRU 时钟。
而全局的server.lruclock
是在定时任务函数serverCron
中调用函数updateLRUClock
更新的(redis.c):
1 | void updateLRUClock(void) { |
而全局的server.unixtime是在函数serverCron中调用函数updateCachedTime更新的(redis.c):
1 | /* We take a cached value of the unix time in the global state because with |
Redis系统中全局变量server.hz设置为10,则serverCron的调度周期为100毫秒。也就是说,全局变量server.lruclock会每隔100毫秒得到更新,该字段也和对象结构的lru字段一样,也是使用了unsigned的低24位。
所以函数lookupKey中更新缓存数据的lru热度值时,不是调用的系统函数获得的当前时间戳,而是该值的一个近似值server.lruclock
, 这样不用每次调用系统函数,可以提高执行效率。
函数estimateObjectIdleTime
评估指定对象的lru热度,其实现为(object.c):
1 | /* Given an object returns the min number of seconds the object was never |
其思想就是对象的lru热度值和全局的server.lruclock
的差值越大, 该对象热度越低。但是,因为全局时钟只有24位,按秒为单位来表示才能存储194天,server.lruclock
数值有可能发生溢出(超过REDIS_LRU_CLOCK_MAX则溢出), 所以对象的lru数值可能大于server.lruclock
数值,所以可能会出现key的时钟大于全局时钟的情况,如果这种情况出现,那么就两个相加而不是相减来求最久的key。
Redis3.0新特性-淘汰池
在Redis 3.0以后增加了LRU淘汰池evictionPool,进一步提高了与LRU算法的近似效果。关于淘汰池:它是一个数组,数组大小等于MAXMEMORY_EVICTION_POOL_SIZE=16
,在每一次淘汰循环中,新随机得到的key列表会和淘汰池中的key进行合并,淘汰掉最旧的key后保留剩余较旧的key列表放入淘汰池中待下一次循环使用。
淘汰池下LRU执行过程
- 在每一次处理客户端命令(如set、add)时,当 maxmemory 的值非 0,则检测是否有需要回收的内存。如果有则执行下面步骤;
- 随机从大字典中取出 maxmemory_samples 个键(实际取到的数量取决于大字典原本的大小),然后用一个长度为 16 (由宏 MAXMEMORY_EVICTION_POOL_SIZE 指定) 的 evictionPool (回收池)对这几个键进行筛选,筛选出 idletime (空闲时间)最长的键,并且按照 idletime 从小到大的顺序排列在 evictionPool中;
- 从 evictionPool 池中取出 idletime 最大且在字典中存在的键作为 bestkey 执行删除,并且从 evictionPool 池中移除;
以上 evictionPool 扮演的是大顶堆的角色,并且在 Redis 服务器启动后一直存在。
freeMemoryIfNeeded函数优化
1 |
|
- evictionPoolEntry 是回收池中的元素结构体,由一个空闲时间 idle 和 键名key 组成
- eviction_pool 是数据库对象 db 的成员,代表回收池,为evictionPoolEntry 类型的数组,数组长度由宏 MAXMEMORY_EVICTION_POOL_SIZE 指定,默认值为 16
回收池eviction_pool更新
这是LRU 算法的核心,首先从目标字典中随机采样出server.maxmemory_samples
个键,缓存在samples数组中,然后一个一个取出来,并且和回收池中的已有的键对比空闲时间,从而更新回收池。更新的过程首先,利用遍历找到每个键的实际插入位置 k ,然后,总共涉及四种情况如下:
- 回收池已满,且当前插入的元素的空闲时间最小,则不作任何操作;
- 回收池未满,且将要插入的位置 k 原本没有键,则可直接执行插入操作;
- 回收池未满,且将要插入的位置 k 原本已经有键,则将当前第 k 个以后的元素往后挪一个位置,然后执行插入操作;
- 回收池已满,则将当前第 k 个以前的元素往前挪一个位置,然后执行插入操作
big key删除
当你删除一个超级大的key时,那么这个删除命令是很具有杀伤性的,会导致Redis暂时卡顿。在4.0以后引入了unlink指令,它是一个后台异步线程,不会阻塞当前线程。当然在此大家不要担心多线程竞争写的问题,当unlink指令接收到后,其他线程将无法看到这部分key的。
当有大量缓存集中失效的话就会导致缓存服务持续卡顿,因为这时候Redis定时任务在删除一些过期的key,不能正常的对外提供服务,可能会造成缓存雪崩。