前言
内存不是无限大的,当我们向 Redis 写入的数据量超过了最大内存限制,Redis 就会启用缓存淘汰策略。
首先,我们可以配置 Redis 实例最大的内存限制:
maxmemory 100MB
然后,再配置缓存淘汰策略:
maxmemory-policy volatile-lru
Redis LRU 淘汰策略有两种:
- allkeys-lru:针对所有的 Key 执行 LRU 淘汰算法
- volatile-lru:仅针对设置了过期时间的 Key 执行 LRU 淘汰算法
配置缓存淘汰策略是有必要的,在使用缓存数据库时,我们要重点关注的一个指标就是缓存命中率,如果数据只会被访问一次,那它的缓存是没有意义的。所以我们要尽可能的提高缓存数据库的性价比,也就是提高缓存命中率。
提高缓存命中率最直接的方式,就是尽可能的保证缓存里的总是热数据,冷数据随着时间推移慢慢淘汰掉。所以我们需要一个缓存淘汰算法,那就是 LRU。
LRU
LRU 是 Least Recently Used 缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰,也常被用在缓存数据库。
LRU 最简单的实现方式就是把元素按照访问时间串联成一条链表,链头放的是最近一次访问的元素,链尾放的是最久没被访问的元素,链表中的元素只要被访问就把它移动到链头位置。然后再限制链表的长度,当有新元素被访问时,就把它加入到链头,然后把链尾的元素移除掉即可。
Redis LRU实现
如果 Redis 按照严格的 LRU 算法来实现缓存淘汰,那它要针对所有的 Key 维护一条 LRU 链表,这会带来两个问题:
- LRU 链表本身会占用大量内存空间
- 维护 LRU 链表需要额外的开销
本来内存就不够用,现在还要维护一条 LRU 链表;高访问量下,LRU 链表节点势必会频繁移动,开销不容忽视,这会影响到 Redis 的吞吐量。
所以,Redis 并没有实现严格的 LRU 算法,而是提供了一种近似 LRU 算法实现。
Redis 是键值对数据库,为了更好的管理键值对,它会为每个键值对创建一个 RedisObject 对象,其中有 24 Bit 用来存储 LRU 时间戳。
c
typedef struct redisObject {
unsigned type:4; // 对外的对象类型 例如:string list hash
unsigned encoding:4; // 对象的编码类型 例如:ziplist skiplist intset
unsigned lru:LRU_BITS; // 对象最近一次访问的时间戳 用于LRU缓存淘汰
int refcount; // 对象引用次数
void *ptr; // 指向底层数据结构的指针
} robj;
RedisObject 在创建时就会写入 LRU 时间戳(除了 LRU,Redis 还支持 LFU):
c
robj *createObject(int type, void *ptr) {
robj *o = zmalloc(sizeof(*o));
o->type = type;
o->encoding = OBJ_ENCODING_RAW;
o->ptr = ptr;
o->refcount = 1;
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
// LFU淘汰策略
o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
} else {
// 写入LRU时间戳
o->lru = LRU_CLOCK();
}
return o;
}
LRU 时间戳是通过 LRU 时钟获取的,方法是getLRUClock() 。Redis 会以 100ms/次 的频率记录 Unix 时间戳,然后除以 LRU 时钟单位 1000,即以秒为单位,保留低 24 位时间戳。
c
unsigned int getLRUClock(void) {
return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
}
为什么不直接获取系统时间,而是定时任务按照一定的频率记录时间?
这是因为获取系统时间要发起一次系统调用,有一定的开销。而读取 LRU 时间戳又是非常频繁的操作,出于对性能的考虑,Redis 选择周期性的读取一次系统时间。
除了创建对象时记录 LRU 时间戳,后续每次访问也会更新时间戳,访问 Key 最终会调用lookupKey() 。Redis 考虑的非常周到,如果有活跃的子进程,就会跳过更新时间戳,因为会导致 Linux 发生大量的写时复制。
c
robj *lookupKey(redisDb *db, robj *key, int flags) {
dictEntry *de = dictFind(db->dict,key->ptr);
if (de) {
robj *val = dictGetVal(de);
// 如果有子进程则跳过更新,会导致大量的写时复制
if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){
// 更新 LRU/LFU 时间戳
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
updateLFU(val);
} else {
val->lru = LRU_CLOCK();
}
}
return val;
} else {
return NULL;
}
}
至此,我们知道 Redis 会在 RedisObject 里用 24 Bit 空间来记录键值对访问的 LRU 时间戳。我们再重新审视一下这个 LRU 时间戳,它只有 24 Bit,就算以秒为单位,也最多能表示 16777215 秒,约 194 天。一旦超过这个范围,就会发生数据溢出,此时就会出现:一个对象明明已经闲置 194 天了,但是 Redis 认为它刚刚才被访问过。这种情况下,LRU 算法就会出现问题,好在发生的概率不高。
有了 LRU 时间戳,接下来就是查找闲置时间最久的 Key 然后删除了。
因为没有维护 LRU 链表,如果 Redis 真的要严格按照闲置时间来淘汰缓存,就需要遍历整个库,这个开销太大了。Redis 的做法是,随机采样一批 Key,然后把闲置时间最久的 Key 删除掉。采样数越大,淘汰的结果就越准确,但是开销也会越大。默认的采样数是 5,可配置:
maxmemory-samples 5
缓存淘汰的方法是performEvictions() ,它会在processCommand() 里被触发,即 Redis 执行命令都会触发。Redis 首先判断当前是否可以安全淘汰?即 Server 没有在加载数据,lua脚本也没超时:
c
if (!isSafeToPerformEvictions()) return EVICT_OK;
接着会评估内存使用情况,同时计算需要释放的内存大小mem_tofree ,只有当使用内存超出限制时才会继续往下走:
c
if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)
return EVICT_OK;
如果内存确实超出了限制,再判断配置的缓存淘汰策略,如果是不淘汰,则停止,否则继续往下走。
c
if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
return EVICT_FAIL;
接着,Redis 会计算一下缓存淘汰执行的一个超时时间,避免要释放的内存比较大时,这个操作阻塞太久。如果超出了时间限制,Redis 会提交一个异步事件来继续执行,因为内存总是要释放的。
c
unsigned long eviction_time_limit_us = evictionTimeLimitUs();
接着 Redis 会在 while 循环内不断的淘汰 Key,直到满足释放的内存大小。因为 Redis 是随机采样的,所以需要有一个容器来承接采样到的一批 Key,为此 Redis 设计了一个结构体:
c
struct evictionPoolEntry {
unsigned long long idle; // 闲置时间
sds key; // 键名
sds cached; // 缓存的键名
int dbid; // 所属库
};
Redis 默认会创建长度 16 的 evictionPoolEntry 数组来承接被淘汰的候选键值对:
c
static struct evictionPoolEntry *EvictionPoolLRU;
接下来就是填充 EvictionPoolLRU,Redis 会遍历数据库,然后对哈希表随机采样,填充 EvictionPoolLRU。针对不同的淘汰策略,采样的哈希表也不一样。如果是针对所有 Key,采样的是db->dict 全局哈希表;如果针对设置了过期时间的 Key,采样的就是db->expires 设置了过期时间的 Key 的哈希表。被填充的 EvictionPoolLRU 最终会按照 Key 的闲置时间升序排列。
c
for (i = 0; i < server.dbnum; i++) {
db = server.db+i;
/**
* 根据淘汰策略 访问不同的哈希表
* db->dict: 全局哈希表 所有Key
* db->expires: 设置了过期时间的Key的哈希表
*/
dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ?
db->dict : db->expires;
if ((keys = dictSize(dict)) != 0) {
// 对哈希表随机采样,填充EvictionPoolLRU数组,按照空闲时间升序
evictionPoolPopulate(i, dict, db->dict, pool);
total_keys += keys;
}
}
采样完成后,Redis 会倒序遍历 EvictionPoolLRU,优先淘汰闲置时间最久的 Key,最终会选择一个淘汰的 Key 并赋值给bestkey ,然后把候选键值对从 EvictionPoolLRU 移除。
c
for (k = EVPOOL_SIZE-1; k >= 0; k--) {
if (pool[k].key == NULL) continue;
bestdbid = pool[k].dbid;
// 查找键值对 对应的哈希表节点
if (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) {
de = dictFind(server.db[pool[k].dbid].dict,
pool[k].key);
} else {
de = dictFind(server.db[pool[k].dbid].expires,
pool[k].key);
}
// 从池子里移除
if (pool[k].key != pool[k].cached)
sdsfree(pool[k].key);
pool[k].key = NULL;
pool[k].idle = 0;
if (de) {
// 最终选择淘汰的Key
bestkey = dictGetKey(de);
break;
} else {
/* Ghost... Iterate again. */
}
}
最后就是删除被选中的bestkey ,释放内存,Redis 支持配置是否要惰性删除。
lazyfree-lazy-eviction no
然后根据配置来选择是同步删除,还是异步删除。对于 BigKey 同于删除会有阻塞风险。
c
if (server.lazyfree_lazy_eviction)
dbAsyncDelete(db,keyobj);
else
dbSyncDelete(db,keyobj);
最后,每淘汰 16 个 Key,Redis 就会检查一下执行是否超时。一旦超出时间限制,为了不阻塞主线程,Redis 会中断执行,然后提交一个异步事件继续处理。
c
if (keys_freed % 16 == 0) {
if (elapsedUs(evictionTimer) > eviction_time_limit_us) {
if (!isEvictionProcRunning) {
isEvictionProcRunning = 1;
aeCreateTimeEvent(server.el, 0,
evictionTimeProc, NULL, NULL);
}
break;
}
}
尾巴
出于对内存占用和性能的考虑,Redis 没有实现严格的 LRU 缓存淘汰算法,而是提供了一种近似实现。Redis 会在 RedisObject 用 24 Bit 记录 LRU 时间戳,当内存使用量超出限制时,Redis 将执行缓存淘汰。根据配置的淘汰策略,在全局哈希表或设置了过期时间的 Key 的哈希表里随机采样一批键值对,然后根据空闲时间排序,最后把空闲时间最久的 Key 给淘汰掉。