过期键删除
- EXPIRE:设置键的存活时间
- TTL:获取键的剩余存活时间
- PERSIST:移除键的过期时间
可以通过EXPIRE设置键的过期时间,但redis底层是怎样存储这个过期时间的呢?又怎样将过期的键删除呢?
保存键的过期时间
typedef struct redisDb{
//...
// 过期字典,保存键的过期时间
dict *expires;
//...
}
redisDb中的expires字典保存着所有键的过期时间。key是指向键的指针,value是long类型的整数,毫秒级的UNIX时间戳。
与之对应的PERSIST就是把expires字典中的键删除掉。
过期键的删除策略
redis采用的是定期删除加惰性删除的策略
惰性删除策略
过期键的惰性删除策略由db.c中的expireifNeeded函数实现,读写数据库的redis命令执行之前都会调用expireifNeeded函数对输入键进行检查。
定期删除策略
过期键的定期删除策略由redis.c中的activeExpireCycle函数实现,当redis服务器周期性调用redis中的serverCron函数时,activeExpireCycle会被调用。在规定时间内,分多次遍历服务器中的各个数据库,从expires字典中随机检查一部分键过期时间,并删除过期键。
从库过期策略
从库不会进行过期扫描,从库对过期的处理是被动的。主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。
redis的内存淘汰策略
noeviction 不会继续服务写请求 (DEL 请求可以继续服务),读请求可以继续进行。这样可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略。
volatile-lru 尝试淘汰设置了过期时间的 key,最少使用的 key 优先被淘汰。没有设置过期时间的 key 不会被淘汰,这样可以保证需要持久化的数据不会突然丢失。
volatile-ttl 跟上面一样,除了淘汰的策略不是 LRU,而是 key 的剩余寿命 ttl 的值,ttl 越小越优先被淘汰。
volatile-random 跟上面一样,不过淘汰的 key 是过期 key 集合中随机的 key。
allkeys-lru 区别于 volatile-lru,这个策略要淘汰的 key 对象是全体的 key 集合,而不只是过期的 key 集合。这意味着没有设置过期时间的 key 也会被淘汰。
allkeys-random 跟上面一样,不过淘汰的策略是随机的 key。
volatile-lfu 从所有配置了过期时间的键中驱逐使用频率最少的键
allkeys-lfu 从所有键中驱逐使用频率最少的键
LRU 算法
实现LRU算法除了需要 key/value 字典外,还需要附加一个链表,链表中的元素按照一定的顺序进行排列。当空间满的时候,会踢掉链表尾部的元素。当字典的某个元素被访问时,它在链表中的位置会被移动到表头。所以链表的元素排列顺序就是元素最近被访问的时间顺序。
位于链表尾部的元素就是不被重用的元素,所以会被踢掉。位于表头的元素就是最近刚刚被人用过的元素,所以暂时不会被踢。
redis中LRU算法的实现
Redis为了节省内存使用,和通常的LRU算法实现不太一样,Redis使用了采样的方法来模拟一个近似LRU算法
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS;
int refcount;
void *ptr;
} robj;
之前说lru属性记录了对象被最后一次访问的时间,但是实际上它不是记录的一个时间戳,而是记录的当前服务器的 LRU 时钟。
LRU 时钟
#define LRU_BITS 24
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1)
#define LRU_CLOCK_RESOLUTION 1000
unsigned int getLRUClock(void) {
return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
}
左边表示自然时钟,右边表示redis的LRU时钟。
- 自然时钟最大值为 11:59:59,LRU 时钟最大值为 LRU_CLOCK_MAX = 2^24 - 1;
- 自然时钟的最小刻度为 1秒, LRU 时钟的最小刻度为 1000 毫秒;
- 自然时钟的一个轮回是 12小时,LRU 时钟的一个轮回是 2^24 * 1000 毫秒(一轮的计算方式是:( 时钟最大值 + 1 ) * 最小刻度);
因为 LRU_CLOCK_MAX 是 2 的幂减 1,即它的二进制表示全是 1,所以这里的 & 其实是取模的意思。那么 getLRUClock 函数的含义就是定位到 LRU 时钟的某个刻度。
不懂&取模的看这边blog.csdn.net/weixin_4377…
更新服务器LRU 时钟
Redis会在serverCron()中调用updateLRUClock定期的更新LRU时钟,更新的频率和hz参数有关,默认为100ms一次,如下
#define REDIS_LRU_CLOCK_MAX ((1<<REDIS_LRU_BITS)-1) /* Max value of obj->lru */
#define REDIS_LRU_CLOCK_RESOLUTION 1 /* LRU clock resolution in seconds */
void updateLRUClock(void) {
server.lruclock = (server.unixtime/REDIS_LRU_CLOCK_RESOLUTION) &
REDIS_LRU_CLOCK_MAX;
}
Unix 时间戳对 2^24 取模的结果,大约 194 天清零一次(取模为0),可以想象成闹钟转了一圈。
计算键的空闲时间
unsigned long estimateObjectIdleTime(robj *o) {
if (server.lruclock >= o->lru) {
return (server.lruclock - o->lru) * REDIS_LRU_CLOCK_RESOLUTION;
} else {
return ((REDIS_LRU_CLOCK_MAX - o->lru) + server.lruclock) *
REDIS_LRU_CLOCK_RESOLUTION;
}
}
正常情况下对象的 LRU 字段不会超过server.lruclock的值,只需要用服务器的时间减去对象的lru时间即可。
超过了则说明LRU时钟已经清零过一次了,用最大时间减去对象的时间然后加上服务器的时间。
很显然这种计算方式在键很长时间没有访问的时候,空闲时间可能有误。
- 拿现实的时钟举例,对象时钟的刻度指向12-1的上午6点,服务器时钟是12-1的下午5点,计算出空闲时间是11小时。
- 对象时钟的刻度指向12-1的上午4点,服务器时钟是12-1的下午5点,计算出的空闲时间是1个小时,而实际上是13个小时。
可以看出如果服务器的时钟没超过对象的时钟一整圈是计算无误的。redis中使用24bit可以表示的最长时间大约是194天,也就是说如果连续194天没有访问了,Redis计算该key的idle时间是有误的,但是这种情况应该非常罕见。
淘汰过程
redis中的lru算法并不是严格淘汰空闲时间最长的键,而是采用随机采样的方式。
Redis 3.0中增加了一个eviction pool的结构,eviction pool是一个数组,保存了之前随机选取的key及它们的idle时间,数组里面的key按idle时间升序排序。
当内存满了需要淘汰数据时,会随机采样5个key(可以配置),然后更新到eviction pool里面,如果新选取的key的idle时间比eviction pool里面idle时间最小的key还要小,那么就不会把它插入到eviction pool里面。
当淘汰策略是volatile-lru或allkeys-lru时,先更新eviction pool,然后淘汰掉eviction pool里面的最后一个元素所对应的key。如果淘汰后内存还是超出 maxmemory,那就继续随机采样淘汰,直到内存低于 maxmemory 为止。
LFU算法
以上介绍了lru算法,但是下面一种场景时:
~~~~~A~~~~~A~~~~~A~~~~A~~~~~A~~~~~A~~|
~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~|
~~~~~~~~~~C~~~~~~~~~C~~~~~~~~~C~~~~~~|
~~~~~D~~~~~~~~~~D~~~~~~~~~D~~~~~~~~~D|
使用lru策略时,D的空闲时间是最短的,也被认为是最值得保留的,但实际上D的调用频率是最长的,保留优先级应该是 B > A > C = D
redis4.0之后增加了两种淘汰策略volatile-lfu和allkeys-lfu,就是根据使用频率最少的键淘汰。
在LRU算法中,24 bits的lru是用来记录LRU time的,在LFU中也可以使用这个字段,不过是分成16 bits与8 bits使用。
16bit(ldt):用来存储上一次 logc 的更新时间,因为只有 16 位,所以精度不可能很高,精度是分钟。它取的是分钟时间戳对 2^16 进行取模,大约每隔 45 天就会折返。
8bit(LOG_C):8位最多只能存255,所以这里不是存的键访问的次数,而是访问的量级。还会随着时间衰减。
有2个配置可以调整LFU算法
lfu-log-factor 10
lfu-decay-time 1
lfu-log-factor:可以调整计数器counter的增长速度,lfu-log-factor越大,counter增长的越慢。 lfu-decay-time:是一个以分钟为单位的数值,可以调整counter的减少速度,越大衰减的越慢。如果为0,不衰减。
#db.c
robj *lookupKey(redisDb *db, robj *key, int flags) {
dictEntry *de = dictFind(db->dict,key->ptr);
if (de) {
robj *val = dictGetVal(de);
/* Update the access time for the ageing algorithm.
* Don't do it if we have a saving child, as this will trigger
* a copy on write madness. */
if (server.rdb_child_pid == -1 &&
server.aof_child_pid == -1 &&
!(flags & LOOKUP_NOTOUCH))
{
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
updateLFU(val);
} else {
val->lru = LRU_CLOCK();
}
}
return val;
} else {
return NULL;
}
}
可以看到如果策略是LFU会走updateLFU函数
void updateLFU(robj *val) {
unsigned long counter = LFUDecrAndReturn(val);
counter = LFULogIncr(counter);
val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}
unsigned long LFUDecrAndReturn(robj *o) {
unsigned long ldt = o->lru >> 8;
unsigned long counter = o->lru & 255;
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
if (num_periods)
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter;
}
LFUDecrAndReturn:获取衰减后的LOG_C。
- lru向右移8位,获取ldt时间
- lru取模255,获取LOG_C
- LFUTimeElapsed计算当前时间与ldt的差值,用差值除以lfu_decay_time(衰减周期),获取num_periods(应该衰减的数)
- 如果空闲太久num_periods > counter,直接返回0,否则counter - num_periods算出衰减后的值。
/* Return the current time in minutes, just taking the least significant
* 16 bits. The returned time is suitable to be stored as LDT (last decrement
* time) for the LFU implementation. */
unsigned long LFUGetTimeInMinutes(void) {
return (server.unixtime/60) & 65535;
}
/* Given an object last access time, compute the minimum number of minutes
* that elapsed since the last access. Handle overflow (ldt greater than
* the current 16 bits minutes time) considering the time as wrapping
* exactly once. */
unsigned long LFUTimeElapsed(unsigned long ldt) {
unsigned long now = LFUGetTimeInMinutes();
if (now >= ldt) return now-ldt;
return 65535-ldt+now;
}
/* Logarithmically increment a counter. The greater is the current counter value
* the less likely is that it gets really implemented. Saturate it at 255. */
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
double r = (double)rand()/RAND_MAX;
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;
return counter;
}
LFULogIncr:增加LOG_C
可以看出LOG_C并不是线性+1的。而是取一个0-1之间的随机数r与p比较,当r<p时,才增加LOG_C。可以看出counter越大,升级几率越低。
淘汰过程
LRU,LFU,TTL的淘汰过程都差不多,都是先随机采样到淘汰池中,然后从淘汰池中淘汰一个。LRU部分已经说明了就不重复赘述了。
后记
疑问:
updateLFU函数中最后一行val->lru = (LFUGetTimeInMinutes()<<8) | counter;这行的意思是只更新了LOG_C的值,不去更新ldt的值的话?
那这个函数LFUDecrAndReturn算出来是错的吧,num_periods值会越来越大,LOG_C的值最后只会为0?那么更新LOG_C的意义在哪呢?
我看其他文章说ldt的值只会在内存达到 maxmemory 时,触发淘汰策略时更新,但那时候怎么知道它最后一次的访问时间呢?评论区大佬解答一下?
参考链接:
LRU:
developer.aliyun.com/article/630…
developer.aliyun.com/article/644…
blog.csdn.net/WhereIsHero…
LFU:
www.zhangshengrong.com/p/zD1yQg6b1…