Redis的缓存淘汰策略与key过期删除策略是两个不同的概念。缓存淘汰策略是指当redis内存超出设置的maxmemory的值时,会根据用户使用的缓存淘汰策略,删除一部分缓存(即使你的key没有设置过期时间,依然会被清除),从而腾出一部分内存空间,提供正常的读写服务。key过期策略针对的是设置了过期时间的key,因为你设置的key即使过期了,也不会立即被删除,而是会通过使用定期删除+惰性删除相结合的方式来清除过期key。下面就缓存淘汰策略与key过期删除策略来展开分析。
Redis底层实现实际上也是大量的对象、函数,只不过是C实现的(结构体中使用指针可以优雅的实现对象)。一个Redis数据库对象就是用一个redis.h/redisDb结构表示,Redis服务器对象将所有的数据库都保存在服务器状态redis.h/redisServer结构的redisDb数组中,默认长度为16。每个redisDb结构中都维护了一个dict字典,dict字典所使用的哈希表由dict.h/dictht结构定义(本质就是数组+链表,dict的实现思想跟Java中Concurrenthashmap比较相似),dictht里面维护了一个dict.h/dictEntry相当于hashmap中的Node,这个字典维护了这个数据库中所有的键值对,所以也叫做键空间:
struct redisServer{ redisDb *db; /* 一个数组,保存着服务器中的所有数据库 */ int dbnum; /* 服务器的数据库数量,它的值由redis启动初始化时读取的服务器配置database的值决定 */ };
/* Redis database representation. There are multiple databases identified
* by integers from 0 (the default database) up to the max configured
* database. The database number is the 'id' field in the structure. */
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */ 该数据库中所有的键值对
dict *expires; /* Timeout of keys with a timeout set */ 设置了过期时间的所有键,key为键的引用,v为过期时间
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
} redisDb;typedef struct dict{ dictType *type; //直线dictType结构,dictType结构中包含自定义的函数,使得key和value能够存储任何类型的数据 void *privdata; //私有数据,保存着dictType结构中函数的 参数 dictht ht[2]; //两张哈希表 ,rehash时哈希表扩容来回复制 long rehashidx; //rehash的标记,rehashidx == -1,表示没有进行 rehash int itreators; //正在迭代的迭代器数量 } dict;
typedef struct dictEntry{
void *key; //key
union{
void* val;
uint64_t u64;
int64_t s64;
double d;
}v; //val
struct dictEntry *next; //指向下一个节点,用来解决 哈希冲突
} dictEntry;
键空间的键也是数据库的键,键的数据结构是字符串类型,值的数据结构可以是五大数据类型中的某一种。我们对Redis数据库的所有操作增删改查最终都反应在对键空间的操作(每个redis客户端都有自己的目标数据库,每当客户端执行数据库写命令或者数据库读命令的时候,目标数据库redisDb就会成为这些命令的操作对象,默认使用0号数据库。在服务器内部,表示客户端状态的redisClient结构中的db属性记录了客户端当前的目标数据库,这个属性是一个指向redisDb的指针。)。
redis底层是C写的,之所以没有使用C++中定义的数据类型对象的一个原因,可能是为了根据程序需要定制自己的数据类型对象(redis五大数据类型)更能节省内存空间。因为数据结构自己设计,数据类型自己实现,程序需求没有比自己更了解的,一个数据类型怎么实现更能节省空间只有自己知道,大神从来都是寂寞的。
对于持续运行的服务器来说,服务器需要定期对自身的资源和状态进行检查和整理,从而让服务器维持在一个健康稳定的状态,这类操作统称为常规操作(cron job),在Redis中常规操作由redis.c/serverCron实现,它主要执行以下操作:
/* This is our timer interrupt, called server.hz times per second.
* Here is where we do a number of things that need to be done asynchronously.
* For instance:
*
* - Active expired keys collection (it is also performed in a lazy way on lookup). //清理数据库中的过期键值对
* - Software watchdog.
* - Update some statistic. //更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等
* - Incremental rehashing of the DBs hash tables. //对不合理的数据库进行大小调整(数据库扩容)。
* - Triggering BGSAVE / AOF rewrite, and handling of terminated children. //尝试进行AOF或 RDB持久化操作
* - Clients timeout of different kinds. //关闭和清理连接失效的客户端
* - Replication reconnection.//如果处于集群模式的话,对集群进行定期同步和连接测试
* - Many more... //如果服务器是主节点的话,对附属节点进行定期同步
*
* Everything directly called here will be called server.hz times per second,
* so in order to throttle execution of things we want to do less frequently
* a macro is used: run_with_period(milliseconds) { .... }
*/
Redis 将 serverCron 作为时间事件来运行, 从而确保它每隔一段时间就会自动运行一次, 又因为 serverCron 需要在 Redis 服务器运行期间一直定期运行, 所以它是一个循环时间事件: serverCron 会一直定期执行,直到服务器关闭为止。
Redis的定期删除会在Redis的周期性执行任务(serverCron)中进行,默认每隔100ms就随机从redisDb的expires字典中抽取一些设置了过期时间的key,检查其是否过期,如果过期就删除(Redis定期触发的清理策略,由位于src/redis.c的activeExpireCycle(void)函数来完成),而且是发生在Redis的master节点,因为slave节点会通过主节点的DEL命令同步过来达到删除key的目的。为什么是随机抽取而不是全部遍历,这是因为redis中如果存了几十万条数据,遍历会使CPU负载过大。定期策略同样也会存在过期数据不能被及时处理掉的问题。
Redis过期Key清理的机制对清理的频率和最大时间都有限制,在尽量不影响正常服务的情况下,进行过期Key的清理,以达到长时间服务的性能最优。redis会把设置了过期时间的key放在单独的字典中,每隔一段时间执行一次删除(在redis.conf配置文件设置hz:1s刷新的频率)过期key的操作。
具体的算法如下:
- Redis配置项hz定义了serverCron任务的执行周期,默认为10,即CPU空闲时每秒执行10次;(尽量不影响正常服务)
- 每次过期key清理的时间不超过CPU时间的25%,即若hz=1,则一次清理时间最大为250ms,若hz=10,则一次清理时间最大为25ms;(限制最大清理时间)
- 清理时依次遍历所有的db(默认配置数是16);
- 每次从db中随机取20个key(ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP),判断是否过期,若过期,则逐出;
- 若有5个以上key过期,则重复步骤4,否则遍历下一个db;
- 在清理过程中,若达到了25%CPU时间,退出清理过程;
这是一个基于概率的简单算法,基本的假设是抽出的样本能够代表整个key空间,redis持续清理过期的数据直至将要过期的key的百分比降到了25%以下(每次清理随机取20个key,若多于5个key过期,则继续重复该操作,所以会有25%一说)。这也意味着在长期来看任何给定的时刻已经过期但仍占据着内存空间的key的量最多为每秒的写操作量除以4。
- 由于算法采用的随机取key判断是否过期的方式,故几乎不可能清理完所有的过期Key;
- 调高hz参数可以提升清理的频率,过期key可以更及时的被删除,但hz太高会增加CPU时间的消耗(限制清理频率),为了保证不会循环过度,导致卡顿,扫描时间上限默认不超过25ms(即默认hz=10)。
所以,系统中应避免大量的key同时过期,给要过期的key设置一个随机范围。如果的大量的key在同一时间过期,redis会持续扫描过期字典(第四步、第五步),直到过期字典中key变得稀疏才会停止,这就会导致线上读写请求出现明显卡顿,因为redis是单线程的,在频繁扫描过期key并不停删除过期key(删除过程中管理器还需要频繁的回收内存页,这也会产生一定的CPU消耗)的过程中会大量占用线程的处理时间。当客户端请求过来时,服务器刚好进入过期扫描状态,客户端的请求至少要等待25ms,如果用户将超时时间设置的很短(小于25ms),那么就会出现大量的链接因为超时关闭,业务端出现大量异常。
1、所以大量key过期导致redis出现长时间的卡顿,不能对外提供服务,进而导致大量链接超时关闭,最终导致业务层出现大量异常。
2、另一个后果是大量key过期导致压力最终转移到数据库,造成数据库压力激增,产生雪崩。
被动策略(惰性删除):Redis在操作涉及key的读取数据的命令时都会调用 expireIfNeeded,它存在的意义就是在读取数据之前先检查一下它有没有失效,如果失效了就删除它。这种策略对CPU友好(因为只有在必要时刻才会执行删除操作,并且不会在其他过期key上浪费CPU时间),但是这种策略对内存不友好,这会导致redis内存中的一些过期后未被操作过的数据持续增加,而且还有可能存在一些key永远不会被再次访问到。这就会导致被占用的内存持续增加,里面的无用数据(垃圾数据)增多。最终会因为一些key扫描不到导致内存泄漏。
总结:所以最好的方式是定期删除+惰性删除两种策略组合使用:定期删除不必扫描所有的过期key,浪费CPU资源。惰性删除也不必担心因为某些key扫描不到而导致内存泄露。
对redisDb ---> dict字典的操作
/*<--------------获取key->val------------------->*/
//从dict字典中获取key的val,并更新val的lru,用于lru的内存策略
robj *lookupKey(redisDb *db, robj *key);
//封装了lookupKey,增加了过期判断和miss记录,用于读命令的调用
robj *lookupKeyRead(redisDb *db, robj *key) {
robj *val;
//如果key已经过期,从db->dict中删除
if (expireIfNeeded(db,key) == 1) { //expireIfNeeded判断了如果为slave且已经过期,返回的值=1
if (server.masterhost == NULL) return NULL; //server是master,过期返回null
//server是slave,client是master表示主从同步,该访问节点为slave,命令为只读
if (server.current_client &&server.current_client != server.master &&
server.current_client->cmd &&server.current_client->cmd->flags & CMD_READONLY) {
return NULL; //在redis3.2版本之后添加了该段处理,作者对lookupKeyRead做了相应的修改,增加了key是否过期以及
//对主从库的判断,如果key已过期,当前访问的master则返回null;当前访问的是从库,且执行的是只读
//命令也返回null(老版本从库真实的返回该操作的结果,如果该key过期后主库没有删除,就返回为null
}
}
val = lookupKey(db,key);
//更新server的key的miss和hit
if (val == NULL)
server.stat_keyspace_misses++;
else
server.stat_keyspace_hits++;
return val;//返回key的value
}
//封装了lookupKeyRead,当查找不到key是返回客户端指定reply,用于读命令的调用
robj *lookupKeyReadOrReply(client *c, robj *key, robj *reply);
//封装了lookupKey,增加了过期判断,用于写命令的调用
robj *lookupKeyWrite(redisDb *db, robj *key) {
expireIfNeeded(db,key);
return lookupKey(db,key);
}
//封装了lookupKeyWrite,当查找不到key时返回客户端指定reply,用于写命令的调用
robj *lookupKeyWriteOrReply(client *c, robj *key, robj *reply);
/*<--------------获取key->val------------------->*/
//添加键值队到数据库
void dbAdd(redisDb *db, robj *key, robj *val) {
sds copy = sdsdup(key->ptr);
int retval = dictAdd(db->dict, copy, val);
//val是list对象,将key从bloking_keysh转移到ready_keys字典中
if (val->type == OBJ_LIST) signalListAsReady(db, key);
//集群模式下,将key-slot信息记录到slots_to_keys
if (server.cluster_enabled) slotToKeyAdd(key);
}
//修改数据库中key的val
void dbOverwrite(redisDb *db, robj *key, robj *val) {
dictEntry *de = dictFind(db->dict,key->ptr);
serverAssertWithInfo(NULL,key,de != NULL);
dictReplace(db->dict, key->ptr, val);
}
//删除数据库中key-val
int dbDelete(redisDb *db, robj *key) {
//删除expires中的key信息
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
if (dictDelete(db->dict,key->ptr) == DICT_OK) {
//集群模式下删除,key-slot的信息
if (server.cluster_enabled) slotToKeyDel(key);
return 1;
} else {
return 0;
}
}
//设置val为字符串对象的键值队
void setKey(redisDb *db, robj *key, robj *val) {
if (lookupKeyWrite(db,key) == NULL) {
dbAdd(db,key,val);
} else {
dbOverwrite(db,key,val);
}//增加字符串对象的引用计数
incrRefCount(val);
removeExpire(db,key);//从expires中移除key-timeout
signalModifiedKey(db,key);//触发被监视的键被修改的信息
}
int dbExists(redisDb *db, robj *key);//判断key是否存在数据库
robj *dbRandomKey(redisDb *db);//从数据库随机返回一个key
对redisDb ----> expires字典的操作
int removeExpire(redisDb *db, robj *key);//从expires中删除key的信息
void setExpire(redisDb *db, robj *key, long long when);//设置key的过期时间
long long getExpire(redisDb *db, robj *key);//获取key的过期时间
//判断设置过期时间的key是否过期,删除过期的key,
int expireIfNeeded(redisDb *db, robj *key) {
mstime_t when = getExpire(db,key); //获取key的失效时间
mstime_t now; //定义当前时间
if (when < 0) return 0; //如果key没有设置失效时间,不删除
if (server.loading) return 0; //如果当前redis服务器正在加载RDB文件,不删除
now = server.lua_caller ? server.lua_time_start : mstime();
if (server.masterhost != NULL) return now > when; //如果当前Redis为slave,告知调用者key是否过期,不删除
if (now <= when) return 0; //master节点key没过期,不删除
//如果发现master的key确实已经过期,先更改删除的过期key的统计个数,然后对过期key进行传播,最后执行删除key的操作
server.stat_expiredkeys++; //记录server删除的过期健的个数
propagateExpire(db,key,server.lazyfree_lazy_expire); // 过期健的传播
notifyKeyspaceEvent(NOTIFY_EXPIRED,"expired",key,db->id);
//删除过期的键
return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) : dbSyncDelete(db,key);
}
//过期健传播
void propagateExpire(redisDb *db, robj *key, int lazy) {
robj *argv[2];
argv[0] = lazy ? shared.unlink : shared.del;
argv[1] = key;
incrRefCount(argv[0]);
incrRefCount(argv[1]);
if (server.aof_state != AOF_OFF) //检查Redis是否开启了AOF持久化,如果是,将过期健以删除命令写入aof文件
feedAppendOnlyFile(server.delCommand,db->id,argv,2);
/* 传播过期健给slave,如果该redis节点有slave节点,就向它的所有slave节点发送del失效key的命令。这也是为什么在
expireIfNeeded中如果判断slave不删除key的原因,同时为了防止访问到slave节点的过期key问题,在Redis3.2版本中在
expireIfNeeded添加了slave节点过期返回1,lookUpKeyRead中添加了判断如果访问的slave节点的key已经过期,则返回null
的操作,从而解决了访问到从节点过期的key的问题。*/
if (listlength(server.salves))
replicationFeedSlaves(server.slaves,db->id,argv,2);
decrRefCount(argv[0]);
decrRefCount(argv[1]);
}
首先要了解redis服务器里两个事件:
1、文件事件:redis处理客户端命令,套接字读写,键值对写入读取等;
2、时间事件:redis内部垃圾回收事件,垃圾回收调用serverCron函数,需要定期执行
Redis单线程逻辑上先处理文件事件,后处理时间事件,实际执行的时间事件的时间可能略晚于时间事件所设定的时间,而且如果文件事件执行时间过长会break留到下次继续执行,把控制权交给时间事件。反之时间事件会调用子线程执行,让出控制权给文件事件。不会出现抢占等事件。
看下图,100ms执行一次时间事件,在85到130毫秒之间处理的是文件事件,时间事件被推迟到了131毫秒。从这个设计上可以看出redis对文件事件处理的优先级最高。
所以单线程只是对业务逻辑来说的,调用子线程证明用了其他线程了(实际上执行BGSAVE命令的时候触发持久化是Redis单线程又启动了一个线程,因此这里来看Redis也不是纯粹的单线程的)。
问题二:持久化对过期key的处理
1)从内存数据库持久化数据到RDB文件,持久化key之前,会检查是否过期,过期的key不进入RDB文件
2)从RDB文件恢复数据到内存数据库,数据载入数据库之前,会对key先进行过期检查,如果过期,不导入数据库(主库情况)
1)从内存数据库持久化数据到AOF文件:当key过期后,还没有被删除,此时进行执行持久化操作(该key是不会进入aof文件的,因为没有发生修改命令)当key过期后,在发生删除操作时,程序会向aof文件追加一条del命令(在将来的以aof文件恢复数据的时候该过期的键就会被删掉)
2)AOF重写:重写时,会先判断key是否过期,已过期的key不会重写到aof文件
问题三:主从模式下,从库过期的key仍能被访问到的解决方案
SWAPT(交换分区):SWAPT是系统在物理磁盘上虚拟出来的内存区域,当内存数据溢出时,溢出的数据就会放到交换分区里。虽然交换分区为我们提供了更大的空间,但是它本质上是物理磁盘上的空间,物理磁盘读写速度远远低于物理内存读写速度,这就极大的拖慢了系统的性能,并且频繁的读写物理磁盘,还会造成磁盘的寿命缩短和性能下降。所以我们要限制最大使用内存。
(1)volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。
(2)volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰。
(3)volatile-random:从已设置过期时间的数据集中任意选择数据淘汰。
(4)volatile-lfu:从已设置过期时间的数据集挑选使用频率最低的数据淘汰。
(5)allkeys-lru:从数据集中挑选最近最少使用的数据淘汰
(6)allkeys-lfu:从数据集中挑选使用频率最低的数据淘汰。
(7)allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
(8) no-enviction(驱逐):禁止驱逐数据,这也是默认策略。意思是当内存不足以容纳新入数据时,新写入操作就会报错,请 求可以继续进行,线上任务也不能持续进行,采用no-enviction策略可以保证数据不被丢失。
这八种大体上可以分为4中,lru、lfu、random、ttl。