Redis数据库
Redis数据库
1.Redis服务器
Redis服务器将所有数据库都保存在服务器状态server.h/redisServer结构的db数组中,db数组的每个项都是一个server.h/redisDb结构,每个redisDb结构代表一个数据库:
struct redisServer { // ... // 一个数组,保存着服务器中的所有数据库 redisDb *db; // ... //服务器的数据库数量 int dbnum; };
dbnum属性的值由服务器配置的database选项决定,默认情况下,该选项的值为16,所以Redis服务器默认会创建16个数据库。
默认情况下,Redis客户端的目标数据库为0号数据库,但客户端可以通过执行SELECT命令来切换目标数据库。
2.Redis客户端
在服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库,这个属性是一个指向redisDb结构的指针:
typedef struct redisClient { // ... //记录客户端当前正在使用的数据库 redisDb *db; // ... } redisClient;
3.Redis数据库
typedef struct redisDb { dict *dict; /* 当前数据库的键空间 */ dict *expires; /* 键的过期时间 */ dict *blocking_keys; /* 处于阻塞状态的键和相应的client(主要用于List类型的阻塞操作)*/ dict *ready_keys; /* 准备好数据可以解除阻塞状态的键和相应的client */ dict *watched_keys; /* 被watch命令监控的key和相应client */ int id; /* 数据库ID标识 */ long long avg_ttl; /* 数据库内所有键的平均TTL(生存时间) */ list *defrag_later; /*逐一尝试整理碎片的关键名称列表 */ } redisDb;
redisDb结构的dict字典保存了数据库中的所有键值对,我们将这个字典称为键空间(key space) 键空间和用户所见的数据库是直接对应的:
❑键空间的键也就是数据库的键,每个键都是一个字符串对象。
❑键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中的任意一种Redis对象。
4.Redis键过期时间和过期时间查询TTL
通过EXPIRE命令或者PEXPIRE命令,客户端可以以秒或者毫秒精度为数据库中的某个键设置生存时间(Time To Live,TTL),在经过指定的秒数或者毫秒数之后,服务器就会自动删除生存时间为0的键。
expires字段也是一个字典dict结构,字典的键为key,值为该key对应的过期时间,过期时间为long long类型整数,是以毫秒为单位的过期 UNIX 时间戳。setExpire函数的作用是为指定key设置过期时间。
/* 为指定key设置过期时间 */ void setExpire(redisDb *db, robj *key, long long when) { dictEntry *kde, *de; /* Reuse the sds from the main dict in the expire dict */ // db->dict和db->expires是共用key字符串对象的 // 取出key kde = dictFind(db->dict,key->ptr); redisAssertWithInfo(NULL,key,kde != NULL); // 取出过期时间 de = dictReplaceRaw(db->expires,dictGetKey(kde)); // 重置key的过期时间 dictSetSignedIntegerVal(de,when); }
有了过期时间戳我们就很容易判断某个key是否过期:只要将当前时间戳跟过期时间戳比较一下即可,如果当前时间戳大于过期时间戳显然该key已经过期了。
在Redis中,如果没有为一个key设置过期时间,那么该key就不会出现在db->expires字典中。也就是说db->expires字段只保存了设置有过期时间的key。
- 设置过期时间
Redis有四个不同的命令可以用于设置键的生存时间(键可以存在多久)或过期时间(键什么时候会被删除):(expire.c中)
❑EXPIRE<key><ttl>命令用于将键key的生存时间设置为ttl秒。
❑PEXPIRE<key><ttl>命令用于将键key的生存时间设置为ttl毫秒。
❑EXPIREAT<key><timestamp>命令用于将键key的过期时间设置为timestamp所指定的秒数时间戳。
❑PEXPIREAT<key><timestamp>命令用于将键key的过期时间设置为timestamp所指定的毫秒数时间戳。
EXPIREAT命令与EXPIRE命令的差别在于前者使用Unix时间作为第二个参数表示键的生存时间的截止时间。PEXPIREAT命令与EXPIREAT命令的区别是前者的时间单位是毫秒。
虽然有多种不同单位和不同形式的设置命令,但实际上EXPIRE、PEXPIRE、EXPIREAT三个命令都是使用PEXPIREAT命令来实现的:无论客户端执行的是以上四个命令中的哪一个,经过转换之后,最终的执行效果都和执行PEXPIREAT命令一样。
/* EXPIRE key seconds */ void expireCommand(client *c) { expireGenericCommand(c,mstime(),UNIT_SECONDS); } /* EXPIREAT key time */ void expireatCommand(client *c) { expireGenericCommand(c,0,UNIT_SECONDS); } /* PEXPIRE key milliseconds */ void pexpireCommand(client *c) { expireGenericCommand(c,mstime(),UNIT_MILLISECONDS); } /* PEXPIREAT key ms_time */ void pexpireatCommand(client *c) { expireGenericCommand(c,0,UNIT_MILLISECONDS); } void expireGenericCommand(redisClient *c, long long basetime, int unit) { robj *key = c->argv[1], *param = c->argv[2]; // 以毫秒为单位的unix时间戳 long long when; // 获取过期时间 if (getLongLongFromObjectOrReply(c, param, &when, NULL) != REDIS_OK) return; // 如果传入的过期时间是以秒为单位,则转换为毫秒为单位 if (unit == UNIT_SECONDS) when *= 1000; // 加上basetime得到过期时间戳 when += basetime; /* No key, return zero. */ // 取出key,如果该key不存在直接返回 if (lookupKeyRead(c->db,key) == NULL) { addReply(c,shared.czero); return; } if (when <= mstime() && !server.loading && !server.masterhost) { // 如果when指定的时间已经过期,而且当前为服务器的主节点,并且目前没有载入数据 robj *aux; redisAssertWithInfo(c,key,dbDelete(c->db,key)); server.dirty++; // 传播一个显式的DEL命令 aux = createStringObject("DEL",3); rewriteClientCommandVector(c,2,aux,key); decrRefCount(aux); signalModifiedKey(c->db,key); notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"del",key,c->db->id); addReply(c, shared.cone); return; } else { // 设置key的过期时间(when提供的时间可能已经过期) setExpire(c->db,key,when); addReply(c,shared.cone); signalModifiedKey(c->db,key); notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"expire",key,c->db->id); server.dirty++; return; } }
- TTL命令
TTL命令以秒为单位返回键的剩余生存时间,而PTTL命令则以毫秒为单位返回键的剩余生存时间
/* TTL key */ void ttlCommand(client *c) { ttlGenericCommand(c, 0); } /* PTTL key */ void pttlCommand(client *c) { ttlGenericCommand(c, 1); } void ttlGenericCommand(client *c, int output_ms) { long long expire, ttl = -1; /* 如果这个键不存在 return -2 */ if (lookupKeyReadWithFlags(c->db,c->argv[1],LOOKUP_NOTOUCH) == NULL) { addReplyLongLong(c,-2); return; } /* 键存在. Return -1 if 已经过期, or the 实际的TTL值otherwise. */ expire = getExpire(c->db,c->argv[1]); if (expire != -1) { ttl = expire-mstime(); if (ttl < 0) ttl = 0; } if (ttl == -1) { addReplyLongLong(c,-1); } else { addReplyLongLong(c,output_ms ? ttl : ((ttl+500)/1000)); } }
- 过期键删除策略
如果一个键过期了,那么它什么时候会被删除呢?这个问题有三种可能的答案,它们分别代表了三种不同的删除策略:
❑定时删除:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作。
❑惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。
❑定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。
在这三种策略中,第一种和第三种为主动删除策略,而第二种则为被动删除策略。
1)过期键的惰性删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded函数对输入键进行检查:
❑如果输入键已经过期,那么expireIfNeeded函数将输入键从数据库中删除。
❑如果输入键未过期,那么expireIfNeeded函数不做动作。
对于过期的key,Redis负责将该key删除,为了提高运行效率,Redis采取这么一种处理方式:只有当真正要访问该key时才检查该key是否过期。如果过期就删除,如果没过期就正常访问。通常我们把这种只有在访问时才检查过期的策略叫做“惰性删除”。
int expireIfNeeded(redisDb *db, robj *key) { // 获取key的过期时间 mstime_t when = getExpire(db,key); mstime_t now; // 如果该key没有过期时间,返回0 if (when < 0) return 0; // 如果服务器正在加载操作中,则不进行过期检查,返回0 if (server.loading) return 0; //如果我们处在Lua脚本的上下文中,我们假设直到Lua脚本启动时间不变的。 //通过这种方式,key只能在第一次访问而不是在脚本执行过程中过期, //从而使slave/AOF传播一致。 now = server.lua_caller ? server.lua_time_start : mstime(); // 如果当前程序运行在slave节点,该key的过期操作是由master节点控制的(master节点会发出DEL操作) // 在这种情况下该函数先返回一个正确值,即如果key未过期返回0,否则返回1。 // 真正的删除操作等待master节点发来的DEL命令后再执行 if (server.masterhost != NULL) return now > when; // 如果未过期,返回0 if (now <= when) return 0; // 如果已过期,删除该key server.stat_expiredkeys++; 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); }
对于过期key,Redis主(master)节点和附属(slave)节点有不同的处理策略,具体如下:
如果当前Redis服务器是主节点,即if (server.masterhost != NULL)语句判断为false,那么当它发现一个过期key后,会调用propagateExpire函数向所有附属节点发送一个 DEL 命令,然后再删除该key。这种做法使得对key的过期操作可以集中在一个地方处理。
如果当前Redis服务器是附属节点,即if (server.masterhost != NULL)语句判断为true,那么它立即向程序返回该key是否已经过期的信息。即便该key已经过期也不会真正的删除该key。直到该节点接到从主节点发来的DEL 命令之后,才会真正执行删除操作。
当Redis从数据库db中取出指定key的对象时,总是先调用调用expireIfNeeded函数来检查对应key是否过期,然后再从数据库中查找对象。
robj *lookupKeyRead(redisDb *db, robj *key) { return lookupKeyReadWithFlags(db,key,LOOKUP_NONE); } robj *lookupKeyReadWithFlags (redisDb *db, robj *key, int flags) { robj *val; // 如果key已过期,删除该key if (expireIfNeeded(db,key) == 1) { /*密钥过期, 如果当前为master,expireIfNeeded(),仅当密钥不存在时才返回0,所以它很安全,尽快返回NULL*/ if (server.masterhost == NULL) return NULL; /* 如果当前处于slave节点,expireIfNeeded只返回信息*/ if (server.current_client && server.current_client != server.master && server.current_client->cmd && server.current_client->cmd->flags & CMD_READONLY) { return NULL; } } // 从数据库db中找到指定key的对象 val = lookupKey(db,key, flags); if (val == NULL) // 更新“未命中”次数 server.stat_keyspace_misses++; else // 更新“命中”次数 server.stat_keyspace_hits++; return val; }
/* 该函数是为写操作而从数据库db中取出指定key的对象。 如果敢函数执行成功则返回目标对象,否则返回NULL。*/ robj *lookupKeyWrite(redisDb *db, robj *key) { // 如果key已过期,删除该key expireIfNeeded(db,key); // 从数据库db中找到指定key的对象 return lookupKey(db,key,LOOKUP_NONE); }
2)过期键的定期删除策略由expire.c/activeExpireCycle函数实现,周期性过期是通过周期心跳函数(serverCron)来触发的,每当Redis的服务器周期性操作serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。
//周期性操作中进行慢速过期键删除,执行频率同databasesCron的执行频率 //执行时长为1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100 void databasesCron(void) { if (server.active_expire_enabled && server.masterhost == NULL) { activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW); } else if (server.masterhost != NULL) { expireSlaveKeys(); } }//进行快速过期键删除,执行间隔和执行时长都为ACTIVE_EXPIRE_CYCLE_FAST_DURATION void beforeSleep(struct aeEventLoop *eventLoop) { if (server.active_expire_enabled && server.masterhost == NULL) activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST); }
void activeExpireCycle(int type) { static unsigned int current_db = 0; /* 上次定期删除遍历到的数据库id*/ static int timelimit_exit = 0; /* Time limit hit in previous call? */ static long long last_fast_cycle = 0; /* 上一次执行快速定期删除的时间点 */ int j, iteration = 0; int dbs_per_call = CRON_DBS_PER_CALL;//每次定期删除,遍历的数据库的数量
long long start = ustime(), timelimit; /*当客户端暂停时,数据集应该是静态的,不仅仅客户端的命令无法写入的,而且key到期的指令无法执行*/ if (clientsArePaused()) return; if (type == ACTIVE_EXPIRE_CYCLE_FAST) { if (!timelimit_exit) return; //快速定期删除的时间间隔是ACTIVE_EXPIRE_CYCLE_FAST_DURATION //ACTIVE_EXPIRE_CYCLE_FAST_DURATION是快速定期删除的执行时长 if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return; last_fast_cycle = start; } //我们通常应该每次迭代测试CRON_DBS_PER_CALL if (dbs_per_call > server.dbnum || timelimit_exit) dbs_per_call = server.dbnum; //慢速定期删除的执行时长 timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100; timelimit_exit = 0; if (timelimit <= 0) timelimit = 1; if (type == ACTIVE_EXPIRE_CYCLE_FAST) timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /*删除操作的执行时长 */ //当我们将密钥过期时,累积一些全局统计信息,以了解已经逻辑过期但仍存在于数据库内的密钥数量。 long total_sampled = 0;
long total_expired = 0;
for (j = 0; j < dbs_per_call; j++) { int expired; redisDb *db = server.db+(current_db % server.dbnum); current_db++; do { unsigned long num, slots; long long now, ttl_sum; int ttl_samples; iteration++; //如果没有要删除的键就转向下一个数据库 if ((num = dictSize(db->expires)) == 0) { db->avg_ttl = 0; break; } slots = dictSlots(db->expires); now = mstime(); //当槽的填充小于1%,key显得很重要,因此会等待更好的时机进行键清除。 if (num && slots > DICT_HT_INITIAL_SIZE && (num*100/slots < 1)) break; } } expired = 0; ttl_sum = 0; ttl_samples = 0; //在每个数据库中检查的键的数量 if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP) num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP; //从db->expires中随机选取num个键进行检查 while (num--) { if ((de = dictGetRandomKey(db->expires)) == NULL) break; ttl = dictGetSignedIntegerVal(de)-now; //过期检查,并对过期键进行删除 if (activeExpireCycleTryExpire(db,de,now)) expired++; if (ttl > 0) { /* We want the average TTL of keys yet not expired. */ ttl_sum += ttl; ttl_samples++; } total_sampled++; } //更新平均过期时间 if (ttl_samples) { long long avg_ttl = ttl_sum/ttl_samples; //用几个样本做一个简单的运行平均值。我们只使用当前的估计值,权重为2%和以前的估计98%的权重。 if (db->avg_ttl == 0) db->avg_ttl = avg_ttl; db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50); } //即使有很多密钥过期,我们也不能永远阻止。 因此,给定的毫秒数量返回给调用者,等待另一个活动的过期周期。 if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */ elapsed = ustime()-start; if (elapsed > timelimit) { timelimit_exit = 1; server.stat_expired_time_cap_reached_count++; break; } } } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4); //每次检查只删除ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4个过期键 } }
activeExpireCycle函数的工作模式可以总结如下:
❑函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。
❑全局变量current_db会记录当前activeExpireCycle函数检查的进度,并在下一次activeExpireCycle函数调用时,接着上一次的进度进行处理。比如说,如果当前activeExpireCycle函数在遍历10号数据库时返回了,那么下次activeExpireCycle函数执行时,将从11号数据库开始查找并删除过期键。
❑随着activeExpireCycle函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将current_db变量重置为0,然后再次开始新一轮的检查工作。
总结:
❑Redis服务器的所有数据库都保存在redisServer.db数组中,而数据库的数量则由redisServer.dbnum属性保存。
❑客户端通过修改目标数据库指针,让它指向redisServer.db数组中的不同元素来切换不同的数据库。
❑数据库主要由dict和expires两个字典构成,其中dict字典负责保存键值对,而expires字典则负责保存键的过期时间。
❑因为数据库由字典构成,所以对数据库的操作都是建立在字典操作之上的。
❑数据库的键总是一个字符串对象,而值则可以是任意一种Redis对象类型,包括字符串对象、哈希表对象、集合对象、列表对象和有序集合对象,分别对应字符串键、哈希表键、集合键、列表键和有序集合键。
❑expires字典的键指向数据库中的某个键,而值则记录了数据库键的过期时间,过期时间是一个以毫秒为单位的UNIX时间戳。
❑Redis使用惰性删除和定期删除两种策略来删除过期的键:惰性删除策略只在碰到过期键时才进行删除操作,定期删除策略则每隔一段时间主动查找并删除过期键。
❑执行SAVE命令或者BGSAVE命令所产生的新RDB文件不会包含已经过期的键。
❑执行BGREWRITEAOF命令所产生的重写AOF文件不会包含已经过期的键。
❑当一个过期键被删除之后,服务器会追加一条DEL命令到现有AOF文件的末尾,显式地删除过期键。
❑当主服务器删除一个过期键之后,它会向所有从服务器发送一条DEL命令,显式地删除过期键。
❑从服务器即使发现过期键也不会自作主张地删除它,而是等待主节点发来DEL命令,这种统一、中心化的过期键删除策略可以保证主从服务器数据的一致性。
❑当Redis命令对数据库进行修改之后,服务器会根据配置向客户端发送数据库通知。