redis是基于内存存贮的数据库,所以所有的数据都是贮存在内存中,而且redis的架构是单线程的,如何在内存中创建并使得数据库高效的进行存贮显得尤为的困难。关于redis中数据库如何单线程的高效存贮值得我们来探讨,先来看看redis对数据库结构的定义:
typedef struct redisDb {
// 数据库键空间,保存着数据库中的所有键值对
dict *dict;
// 键的过期时间,字典的键为键,字典的值为过期事件 UNIX 时间戳
dict *expires;
// 正处于阻塞状态的键
dict *blocking_keys;
// 可以解除阻塞的键
dict *ready_keys;
// 正在被 WATCH 命令监视的键,如果被监视的键修改后会被标记为dirty数据,即脏数据
dict *watched_keys;
struct evictionPoolEntry *eviction_pool;
// 数据库号码
int id;
// 数据库的键的平均生存时间
long long avg_ttl;
} redisDb;
在数据库的定义中可以看到,dict字典保存着所有的键值对,也就是说,redis保存所有的数据都保存在dict字典中,dict中键保存着所有的键对象,也就是字符串对象,而dict中值保存着字符串对象、列表对象、哈希对象、集合对象以及有序集合对象。之所以采用字典来保存所有的键,是因为它查找键的时间复杂度为O(1)。
键空间管理
因为redis中所有数据都是以键值对的形式存放,所以redis中对数据的命令操作都是基于字典中键的操作展开的,数据库对键的管理操作如下:
//对某个键取值操作
robj *lookupKey(redisDb *db, robj *key) {
// 查找键空间
dictEntry *de = dictFind(db->dict,key->ptr);
// 节点存在
if (de) {
// 取出值
robj *val = dictGetVal(de);
// 更新时间信息(只在不存在子进程时执行,防止破坏 copy-on-write 机制),费时操作
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1)
val->lru = LRU_CLOCK();
// 返回值
return val;
} else {
// 节点不存在
return NULL;
}
}
//为执行读取操作而取出键 key 在数据库 db 中的值。并根据是否成功找到值,更新服务器的命中/不命中信息。找到时返回值对象,没找到返回 NULL 。
robj *lookupKeyRead(redisDb *db, robj *key) {
robj *val;
// 检查 key 释放已经过期
expireIfNeeded(db,key);
// 从数据库中取出键的值
val = lookupKey(db,key);
// 更新命中/不命中信息
if (val == NULL)
server.stat_keyspace_misses++;
else
server.stat_keyspace_hits++;
// 返回值
return val;
}
//为执行写入操作而取出键 key 在数据库 db 中的值。 找到时返回值对象,没找到返回 NULL 。
robj *lookupKeyWrite(redisDb *db, robj *key) {
// 删除过期键
expireIfNeeded(db,key);
// 查找并返回 key 的值对象
return lookupKey(db,key);
}
//增加键
void dbAdd(redisDb *db, robj *key, robj *val) {
// 复制键名
sds copy = sdsdup(key->ptr);
// 尝试添加键值对
int retval = dictAdd(db->dict, copy, val);
// 如果键已经存在,那么停止
redisAssertWithInfo(NULL,key,retval == REDIS_OK);
// 如果开启了集群模式,那么将键保存到槽里面
if (server.cluster_enabled) slotToKeyAdd(key);
}
//为已存在的键关联一个新值。调用者负责对新值 val 的引用计数进行增加。 如果键不存在,那么函数停止。
void dbOverwrite(redisDb *db, robj *key, robj *val) {
dictEntry *de = dictFind(db->dict,key->ptr);
// 节点必须存在,否则中止
redisAssertWithInfo(NULL,key,de != NULL);
// 覆写旧值
dictReplace(db->dict, key->ptr, val);
}
//设置键对应的值,不管键是否存在
void setKey(redisDb *db, robj *key, robj *val) {
// 添加或覆写数据库中的键值对
if (lookupKeyWrite(db,key) == NULL) {
dbAdd(db,key,val);
} else {
dbOverwrite(db,key,val);
}
//对值对象引用计数加1
incrRefCount(val);
// 移除键的过期时间
removeExpire(db,key);
// 发送键修改通知
signalModifiedKey(db,key);
}
//删除键
//从数据库中删除给定的键,键的值,以及键的过期时间。
int dbDelete(redisDb *db, robj *key) {
// 删除键的过期时间
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
// 删除键值对
if (dictDelete(db->dict,key->ptr) == DICT_OK) {
// 如果开启了集群模式,那么从槽中删除给定的键
if (server.cluster_enabled) slotToKeyDel(key);
return 1;
} else {
// 键不存在
return 0;
}
}
/*
* 清空服务器的所有数据。
*/
long long emptyDb(void(callback)(void*)) {
int j;
long long removed = 0;
// 清空所有数据库
for (j = 0; j < server.dbnum; j++) {
// 记录被删除键的数量
removed += dictSize(server.db[j].dict);
// 删除所有键值对
dictEmpty(server.db[j].dict,callback);
// 删除所有键的过期时间
dictEmpty(server.db[j].expires,callback);
}
// 如果开启了集群模式,那么还要移除槽记录
if (server.cluster_enabled) slotToKeyFlush();
// 返回键的数量
return removed;
}
/*
* 将客户端的目标数据库切换为 id 所指定的数据库
*/
int selectDb(redisClient *c, int id) {
// 确保 id 在正确范围内
if (id < 0 || id >= server.dbnum)
return REDIS_ERR;
// 切换数据库(更新指针)
c->db = &server.db[id];
return REDIS_OK;
}
//随机一个不过期的键
robj *dbRandomKey(redisDb *db) {
dictEntry *de;
while(1) {
sds key;
robj *keyobj;
// 从键空间中随机取出一个键节点
de = dictGetRandomKey(db->dict);
// 数据库为空
if (de == NULL) return NULL;
// 取出键
key = dictGetKey(de);
// 为键创建一个字符串对象,对象的值为键的名字
keyobj = createStringObject(key,sdslen(key));
// 检查键是否带有过期时间
if (dictFind(db->expires,key)) {
// 如果键已经过期,那么将它删除,并继续随机下个键
if (expireIfNeeded(db,keyobj)) {
decrRefCount(keyobj);
continue; /* search for another key. This expired. */
}
}
// 返回被随机到的键(的名字)
return keyobj;
}
}
在对键进行读写操作时,都会检查键是否过期,当该键过期时,不进行操作,而且访问每个键后,都会更新最后一次访问该键的时间。下面给出清除整个数据库、切换数据库等命令的实现:
/*
* 清空客户端指定的数据库命令,FLUSHDB命令
*/
void flushdbCommand(redisClient *c) {
server.dirty += dictSize(c->db->dict);
// 发送通知
signalFlushedDb(c->db->id);
// 清空指定数据库中的 dict 和 expires 字典
dictEmpty(c->db->dict,NULL);
dictEmpty(c->db->expires,NULL);
// 如果开启了集群模式,那么还要移除槽记录
if (server.cluster_enabled) slotToKeyFlush();
//发送修改成功到客户端
addReply(c,shared.ok);
}
/*
* 清空服务器中的所有数据库命令,FLUSHALL命令
*/
void flushallCommand(redisClient *c) {
// 发送通知
signalFlushedDb(-1);
// 清空所有数据库
server.dirty += emptyDb(NULL);
addReply(c,shared.ok);
// 如果正在保存新的 RDB ,那么取消保存操作
if (server.rdb_child_pid != -1) {
kill(server.rdb_child_pid,SIGUSR1);
rdbRemoveTempFile(server.rdb_child_pid);
}
// 更新 RDB 文件
if (server.saveparamslen > 0) {
// rdbSave() 会清空服务器的 dirty 属性
// 但为了确保 FLUSHALL 命令会被正常传播,
// 程序需要保存并在 rdbSave() 调用之后还原服务器的 dirty 属性
int saved_dirty = server.dirty;
rdbSave(server.rdb_filename);
server.dirty = saved_dirty;
}
server.dirty++;
}
//删除命令,DEL命令
void delCommand(redisClient *c) {
int deleted = 0, j;
// 遍历所有输入键
for (j = 1; j < c->argc; j++) {
// 先删除过期的键
expireIfNeeded(c->db,c->argv[j]);
// 尝试删除键
if (dbDelete(c->db,c->argv[j])) {
// 删除键成功,发送通知
signalModifiedKey(c->db,c->argv[j]);
notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,
"del",c->argv[j],c->db->id);
server.dirty++;
// 成功删除才增加 deleted 计数器的值
deleted++;
}
}
// 发送返被删除键的数量到客户端
addReplyLongLong(c,deleted);
}
//检查是否键存在命令,EXITS命令
void existsCommand(redisClient *c) {
// 检查键是否已经过期,如果已过期的话,那么将它删除
// 这可以避免已过期的键被误认为存在
expireIfNeeded(c->db,c->argv[1]);
// 在数据库中查找
if (dbExists(c->db,c->argv[1])) {
addReply(c, shared.cone);
} else {
addReply(c, shared.czero);
}
}
//切换数据库命令,SELECT命令
void selectCommand(redisClient *c) {
long id;
// 不合法的数据库号码
if (getLongFromObjectOrReply(c, c->argv[1], &id,
"invalid DB index") != REDIS_OK)
return;
if (server.cluster_enabled && id != 0) {
addReplyError(c,"SELECT is not allowed in cluster mode");
return;
}
// 切换数据库
if (selectDb(c,id) == REDIS_ERR) {
addReplyError(c,"invalid DB index");
} else {
addReply(c,shared.ok);
}
}
//随机返回一个不过期的键命令,RANDOMKEY命令
void randomkeyCommand(redisClient *c) {
robj *key;
// 随机返回键
if ((key = dbRandomKey(c->db)) == NULL) {
addReply(c,shared.nullbulk);
return;
}
addReplyBulk(c,key);
//键对象引用计数加1
decrRefCount(key);
}
在redis源码中以Command结尾的函数,是其对应的命令的实现。总的来说,对键空间的维护操作有如下几点:
- 在读写一个键时,都会对键进行读取操作,服务器会根据键是否存在来更新键空间的命中与不命中次数;
- 读取一个键时,都会更新LRU,即最后一次使用时间,通过这个属性可计算该键闲置的时长,当服务器的内存达到上限时,会优先回收闲置时间长的键;
- 读取一个键时,都会检查该键是否过期;如果一个键是处在被WATCH中,那么只要修改了该键,那么该键会被认定为dirty数据,会引发持久化操作。
键的生存时间(Time to Live,TTL)
在redis中,为了防止键的长久不用造成内存的浪费,引进了一个字典expires,该字典记录键的过期时间(设置了过期时间的键),当键到了其过期时间时,redis会自动删除这个键。对键生存时间的底层操作:
/*
* 移除键 key 的过期时间
*/
int removeExpire(redisDb *db, robj *key) {
// 确保键带有过期时间
redisAssertWithInfo(NULL,key,dictFind(db->dict,key->ptr) != NULL);
// 删除过期时间
return dictDelete(db->expires,key->ptr) == DICT_OK;
}
/*
* 将键 key 的过期时间设为 when
*/
void setExpire(redisDb *db, robj *key, long long when) {
dictEntry *kde, *de;
// 取出键
kde = dictFind(db->dict,key->ptr);
redisAssertWithInfo(NULL,key,kde != NULL);
// 根据键取出键的过期时间
de = dictReplaceRaw(db->expires,dictGetKey(kde));
// 设置键的过期时间
// 这里是直接使用整数值来保存过期时间,不是用 INT 编码的 String 对象
dictSetSignedIntegerVal(de,when);
}
//返回给定 key 的过期时间。 如果键没有设置过期时间,那么返回 -1 。
long long getExpire(redisDb *db, robj *key) {
dictEntry *de;
// 获取键的过期时间
// 如果过期时间不存在,那么直接返回
if (dictSize(db->expires) == 0 ||
(de = dictFind(db->expires,key->ptr)) == NULL) return -1;
redisAssertWithInfo(NULL,key,dictFind(db->dict,key->ptr) != NULL);
// 返回过期时间
return dictGetSignedIntegerVal(de);
}
/*
* 将过期时间传播到附属节点和 AOF 文件。主节点中过期时,
* 主节点会向所有附属节点和 AOF 文件传播一个显式的 DEL 命令。
* 这种做法使得对键的过期可以集中在一处处理,
* 因为 AOF 以及主节点和附属节点之间的链接,都可以保证操作的执行顺序,
* 所以即使有写操作对过期键执行,所有数据都还是 consistent 的。
*/
void propagateExpire(redisDb *db, robj *key) {
robj *argv[2];
// 构造一个 DEL key 命令
argv[0] = shared.del;
argv[1] = key;
incrRefCount(argv[0]);
incrRefCount(argv[1]);
// 传播到 AOF
if (server.aof_state != REDIS_AOF_OFF)
feedAppendOnlyFile(server.delCommand,db->id,argv,2);
// 传播到所有附属节点
replicationFeedSlaves(server.slaves,db->id,argv,2);
decrRefCount(argv[0]);
decrRefCount(argv[1]);
}
/*
* 检查 key 是否已经过期,如果是的话,将它从数据库中删除。
* 返回 0 表示键没有过期时间,或者键未过期。
* 返回 1 表示键已经因为过期而被删除了。
*/
int expireIfNeeded(redisDb *db, robj *key) {
// 取出键的过期时间
mstime_t when = getExpire(db,key);
mstime_t now;
// 没有过期时间
if (when < 0) return 0;
// 如果服务器正在进行载入,那么不进行任何过期检查
if (server.loading) return 0;
now = server.lua_caller ? server.lua_time_start : mstime();
// 当服务器运行在 replication 模式时
// 附属节点并不主动删除 key
// 它只返回一个逻辑上正确的返回值
// 真正的删除操作要等待主节点发来删除命令时才执行
// 从而保证数据的同步
//简单来说,判断该服务器是否是主服务器,不是则返回1,且不删除过期键,
if (server.masterhost != NULL) return now > when;
// 运行到这里,表示键带有过期时间,并且服务器为主节点
// 如果未过期,返回 0
if (now <= when) return 0;
//服务器中的过期键数加一
server.stat_expiredkeys++;
// 向 AOF 文件和附属节点传播过期信息
propagateExpire(db,key);
// 发送事件通知
notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
"expired",key,db->id);
// 将过期键从数据库中删除
return dbDelete(db,key);
}
其中注意的是expireIfNeeded函数,如果该数据库所对应的服务器是从服务器,而不是主服务器,那么删除过期键时不对其真正进行删除,等到主服务器删除该键并传播删除命令时,从服务器才删除,这样就保证了主从服务器的数据一致性。下面给出对键生存时间操作命令的实现:
//设置键的生存时间
/* 这个函数是 EXPIRE 、 PEXPIRE 、 EXPIREAT 和 PEXPIREAT 命令的底层实现函数。
* 命令的第二个参数可能是绝对值,也可能是相对值。
* 当执行 *AT 命令时, basetime 为 0 ,在其他情况下,它保存的就是当前的绝对时间。 unit 用于指定 argv[2] (传入过期时间)的格式,
* 它可以是 UNIT_SECONDS 或 UNIT_MILLISECONDS ,
* basetime 参数则总是毫秒格式的。
*/
void expireGenericCommand(redisClient *c, long long basetime, int unit) {
robj *key = c->argv[1], *param = c->argv[2];
long long when;
// 取出 when 参数
if (getLongLongFromObjectOrReply(c, param, &when, NULL) != REDIS_OK)
return;
// 如果传入的过期时间是以秒为单位的,那么将它转换为毫秒
if (unit == UNIT_SECONDS) when *= 1000;
when += basetime;
// 取出键
if (lookupKeyRead(c->db,key) == NULL) {
addReply(c,shared.czero);
return;
}
/*
* 在载入数据时,或者服务器为附属节点时,
* 即使 EXPIRE 的 TTL 为负数,或者 EXPIREAT 提供的时间戳已经过期,
* 服务器也不会主动删除这个键,而是等待主节点发来显式的 DEL 命令。
* 程序会继续将(一个可能已经过期的 TTL)设置为键的过期时间,
* 并且等待主节点发来 DEL 命令。
*/
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 {
// 设置键的过期时间
// 如果服务器为从节点,或者服务器正在载入,
// 那么这个 when 有可能已经过期的
setExpire(c->db,key,when);
addReply(c,shared.cone);
signalModifiedKey(c->db,key);
notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"expire",key,c->db->id);
//脏数据加1
server.dirty++;
return;
}
}
//移除键的过期时间,即PERSIST命令
void persistCommand(redisClient *c) {
dictEntry *de;
// 取出键
de = dictFind(c->db->dict,c->argv[1]->ptr);
if (de == NULL) {
// 键没有过期时间
addReply(c,shared.czero);
} else {
// 键带有过期时间,那么将它移除
if (removeExpire(c->db,c->argv[1])) {
addReply(c,shared.cone);
server.dirty++;
// 键已经是持久的了
} else {
addReply(c,shared.czero);
}
}
}
/*
* 返回键的剩余生存时间。
* output_ms 指定返回值的格式:
* - 为 1 时,返回毫秒,即PTTL命令
* - 为 0 时,返回秒,即TTL命令
*/
void ttlGenericCommand(redisClient *c, int output_ms) {
long long expire, ttl = -1;
// 取出键
if (lookupKeyRead(c->db,c->argv[1]) == NULL) {
addReplyLongLong(c,-2);
return;
}
// 取出过期时间
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 {
// 返回 TTL
// (ttl+500)/1000 计算的是渐近秒数
addReplyLongLong(c,output_ms ? ttl : ((ttl+500)/1000));
}
}
如果是主节点命令且键已过期,那么进行传播del命令进行删除,否则进行键的生存时间赋值操作。
数据库的其它命令实现
下面列出常用的对键操作命令的实现:
//DBSIZE命令
void dbsizeCommand(redisClient *c) {
addReplyLongLong(c,dictSize(c->db->dict));
}
//返回最后一个保存的键
void lastsaveCommand(redisClient *c) {
addReplyLongLong(c,server.lastsave);
}
//判断哪种类型,TYPE命令
void typeCommand(redisClient *c) {
robj *o;
char *type;
o = lookupKeyRead(c->db,c->argv[1]);
if (o == NULL) {
type = "none";
} else {
switch(o->type) {
case REDIS_STRING: type = "string"; break;
case REDIS_LIST: type = "list"; break;
case REDIS_SET: type = "set"; break;
case REDIS_ZSET: type = "zset"; break;
case REDIS_HASH: type = "hash"; break;
default: type = "unknown"; break;
}
}
addReplyStatus(c,type);
}
//关闭客户端
void shutdownCommand(redisClient *c) {
int flags = 0;
if (c->argc > 2) {
addReply(c,shared.syntaxerr);
return;
} else if (c->argc == 2) {
// 停机时不进行保存
if (!strcasecmp(c->argv[1]->ptr,"nosave")) {
flags |= REDIS_SHUTDOWN_NOSAVE;
// 停机时进行保存
} else if (!strcasecmp(c->argv[1]->ptr,"save")) {
flags |= REDIS_SHUTDOWN_SAVE;
} else {
addReply(c,shared.syntaxerr);
return;
}
}
/* When SHUTDOWN is called while the server is loading a dataset in
* memory we need to make sure no attempt is performed to save
* the dataset on shutdown (otherwise it could overwrite the current DB
* with half-read data).
*
* Also when in Sentinel mode clear the SAVE flag and force NOSAVE. */
if (server.loading || server.sentinel_mode)
flags = (flags & ~REDIS_SHUTDOWN_SAVE) | REDIS_SHUTDOWN_NOSAVE;
if (prepareForShutdown(flags) == REDIS_OK) exit(0);
addReplyError(c,"Errors trying to SHUTDOWN. Check logs.");
}
//修改键名,RENAME命令
void renameGenericCommand(redisClient *c, int nx) {
robj *o;
long long expire;
/* To use the same key as src and dst is probably an error */
// 来源键和目标键不能相同
if (sdscmp(c->argv[1]->ptr,c->argv[2]->ptr) == 0) {
addReply(c,shared.sameobjecterr);
return;
}
// 取出来源键
if ((o = lookupKeyWriteOrReply(c,c->argv[1],shared.nokeyerr)) == NULL)
return;
// 增加引用计数,因为后面目标键也会引用这个对象
// 如果不增加的话,当来源键被删除时,这个值对象也会被删除
incrRefCount(o);
// 取出来源键的过期时间
expire = getExpire(c->db,c->argv[1]);
// 检查目标键是否存在
if (lookupKeyWrite(c->db,c->argv[2]) != NULL) {
// 如果目标键存在,并且执行的是 RENAMENX ,那么直接返回
if (nx) {
decrRefCount(o);
addReply(c,shared.czero);
return;
}
// 如果执行的是 RENAME ,那么删除已有的目标键
/* Overwrite: delete the old key before creating the new one
* with the same name. */
dbDelete(c->db,c->argv[2]);
}
// 将来源键的值对象和目标键进行关联
dbAdd(c->db,c->argv[2],o);
// 如果有过期时间,那么为目标键设置过期时间
if (expire != -1) setExpire(c->db,c->argv[2],expire);
// 删除来源键
dbDelete(c->db,c->argv[1]);
signalModifiedKey(c->db,c->argv[1]);
signalModifiedKey(c->db,c->argv[2]);
notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"rename_from",
c->argv[1],c->db->id);
notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"rename_to",
c->argv[2],c->db->id);
server.dirty++;
addReply(c,nx ? shared.cone : shared.ok);
}
//MOVE命令实现,将某个键迁移到另一个数据库
void moveCommand(redisClient *c) {
robj *o;
redisDb *src, *dst;
int srcid;
if (server.cluster_enabled) {
addReplyError(c,"MOVE is not allowed in cluster mode");
return;
}
/* Obtain source and target DB pointers */
// 源数据库
src = c->db;
// 源数据库的 id
srcid = c->db->id;
// 切换到目标数据库
if (selectDb(c,atoi(c->argv[2]->ptr)) == REDIS_ERR) {
addReply(c,shared.outofrangeerr);
return;
}
// 目标数据库
dst = c->db;
// 切换回源数据库
selectDb(c,srcid); /* Back to the source DB */
/* If the user is moving using as target the same
* DB as the source DB it is probably an error. */
// 如果源数据库和目标数据库相等,那么返回错误
if (src == dst) {
addReply(c,shared.sameobjecterr);
return;
}
/* Check if the element exists and get a reference */
// 取出要移动的对象
o = lookupKeyWrite(c->db,c->argv[1]);
if (!o) {
addReply(c,shared.czero);
return;
}
/* Return zero if the key already exists in the target DB */
// 如果键已经存在于目标数据库,那么返回
if (lookupKeyWrite(dst,c->argv[1]) != NULL) {
addReply(c,shared.czero);
return;
}
// 将键添加到目标数据库中
dbAdd(dst,c->argv[1],o);
// 增加对对象的引用计数,避免接下来在源数据库中删除时 o 被清理
incrRefCount(o);
/* OK! key moved, free the entry in the source DB */
// 将键从源数据库中返回
dbDelete(src,c->argv[1]);
server.dirty++;
addReply(c,shared.cone);
}
总结
- redis所有的键值对都存在一个dict字典中,键的过期时间存在expires字典中;
- 从服务器发现了过期键也不会删除,等待主服务器的del命令后才删除;
- redis采用dirty属性来记录被WATCH命令监视的键的修改情况,并引发持久化操作。
- 一个服务器有多个数据库,键之间可迁移。