【Redis源码剖析】 - Redis之数据库redisDb

原创作品,转载请标明:http://blog.csdn.net/xiejingfa/article/details/51321282

Redis源码剖析系列文章汇总:传送门

今天,我们来讨论两点内容:一是Redis是如何存储类型对象的,二是Redis如何实现键的过期操作。

本文介绍的内容主要涉及db.c和redis.h两个文件。


1、redisDb介绍

Redis中存在“数据库”的概念,该结构由redis.h中的redisDb定义。我们知道Redis提供string、list、set、zset、hash五种数据类型的存储,在Redis运行时,服务器中会存在许多的不同类型的对象,当我们需要操作某个具体的对象时,首先需要快速定位到该对象。比如往一个list中插入一个元素,第一步先要在众多对象实例中找到该list,然后再进行插入操作。如何快速获取指定对象呢?在我们前面介绍过的基础数据结构中字典dict就可以实现该功能。具体的做法是:Redis每生成一个对象实例都需要关联一个key,利用dict保存key和对象实例之间的映射关系。这样就可以在O(1)的时间复杂度下根据key找到对应的对象,而Redis中的“数据库”redisDb就是对上述过程的实现。下面我们来看看该结构体的定义。

1.1、redisDb的存储结构

redisDb结构体的定义如下:

/* Redis数据库结构体 */
typedef struct redisDb {
    // 数据库键空间,存放着所有的键值对(键为key,值为相应的类型对象)
    dict *dict;                 
    // 键的过期时间
    dict *expires;              
    // 处于阻塞状态的键和相应的client(主要用于List类型的阻塞操作)
    dict *blocking_keys;       
    // 准备好数据可以解除阻塞状态的键和相应的client
    dict *ready_keys;           
    // 被watch命令监控的key和相应client
    dict *watched_keys;         
    // 数据库ID标识
    int id;
    // 数据库内所有键的平均TTL(生存时间)
    long long avg_ttl;         
} redisDb;

我们看到:

(1)、redisDb中的dict *dict成员就是将key和具体的对象(可能是string、list、set、zset、hash中任意类型之一)关联起来,存储着该数据库中所有的键值对数据。该字段又称为键空间key space。
(2)、expires成员用来存放key的过期时间。
(3)、id成员是数据库的编号,以整型表示。

接下来我们主要基于上面3个redisDb的成员来展开讲解。其余几个成员在前面我们已经介绍过,大家可以参看前面的几篇博客,这里给出给相关文章链接:

  1. blocking_keys、ready_keys: 【Redis源码剖析】 - Redis数据类型之列表List
  2. watched_keys:【Redis源码剖析】 - Redis之事务的实现原理

1.2、数据库的切换操作

当redis 服务器初始化时,会预先分配 16 个数据库,该数字配置在redis.conf配置文件中,如下:

# Set the number of databases. The default database is DB 0, you can select
# a different one on a per-connection basis using SELECT <dbid> where
# dbid is a number between 0 and 'databases'-1
databases 16

所有数据库保存到结构 redisServer 的一个成员 redisServer.db 数组中,而redisClient中存在一个名叫db的指针指向当前使用的数据库(默认为0号数据库)。

typedef struct redisClient {
    ...
    // 当前所使用的数据库
    redisDb *db;
    ...
} redisClient;

Redis提供了select 命令用于切换到指定的数据库,该命令的具体格式为:

SELECT db_index 

其中数据库索引号db_ index 用数字值指定,以 0 作为起始索引值。该命令的实现也很简单,当我们选择数据库 select number 时,程序直接通过 redisServer.db[number] 来切换数据库,源码如下:

/* SELECT命令,切换到指定数据库。*/
void selectCommand(redisClient *c) {
    long id;

    // 取得目标数据库id,如果输入值不合法则返回
    if (getLongFromObjectOrReply(c, c->argv[1], &id,
        "invalid DB index") != REDIS_OK)
        return;

    // 切换到指定数据库
    if (selectDb(c,id) == REDIS_ERR) {
        addReplyError(c,"invalid DB index");
    } else {
        addReply(c,shared.ok);
    }
}

由selectDb函数执行真正的切换操作:

/* 切换为参数id指定的数据库,如果操作成功返回REDIS_OK,否则返回REDIS_ERR。   */
int selectDb(redisClient *c, int id) {
    // 验证参数id是否正确,server.dbnum默认值为16
    if (id < 0 || id >= server.dbnum)
        return REDIS_ERR;
    // 切换数据库,就是简单地设置指针
    c->db = &server.db[id];
    return REDIS_OK;
}

2、redisDb中的键空间

在【Redis源码剖析】系列文章中,我们前面花了很多篇幅介绍了Redis中内置数据结构和数据类型的实现原理,这里我们就要把它们综合起来,看看如何利用这些数据结构组成一个初步的key-value系统。

2.1、键空间的存储结构

键空间实际就是一个字典dict结构,存储着该库所有的键值对数据,其中字典dict的key是一个字符串对象,字典dict的值可能是string、list、set、zset、hash中任意类型之一的对象实例。弄明白键空间的底层结构,我们不难画出其存储结构:

这里写图片描述

2.2、键空间的相关操作

Redis提供了许多与key相关的操作命令,这些命令我们先前没有涉及到,接下来简单介绍一下:

命令作用
del key删除key
exists key检查key是否存在
randomkey从当前数据库中随机返回一个key
keys pattern查找所有符合给定模式的key
scan cursor迭代当前数据库中的key
dbsize返回当前数据库中key的数量
type key返回key所存储的值的类型
rename key newkey修改key的名称
renamenx key newkey当newkey不存在时,修改key的名称为newkey
move key db将当前数据库中的key移动到给定的数据库中

由于键空间是一个字典dict结构,所以对键空间的操作基本上就是对字典dict的操作,主要包含下面这些函数:

// 从Redis数据库db中取出指定key的对象
robj *lookupKey(redisDb *db, robj *key);
// 为读操作而从数据库db中取出指定key的对象
robj *lookupKeyRead(redisDb *db, robj *key);
// 为写操作而从数据库db中取出指定key的对象
robj *lookupKeyWrite(redisDb *db, robj *key);
// 带有回复功能的lookupKeyRead函数
robj *lookupKeyReadOrReply(redisClient *c, robj *key, robj *reply);
// 带有回复功能的lookupKeyWrite函数
robj *lookupKeyWriteOrReply(redisClient *c, robj *key, robj *reply);
// 往数据库db中添加一个由参数key和参数val指定的键值对
void dbAdd(redisDb *db, robj *key, robj *val);
// 重写一个key关联的值,即为一个存在的key设置一个新值
void dbOverwrite(redisDb *db, robj *key, robj *val);
// 为一个key设置新值
void setKey(redisDb *db, robj *key, robj *val);
// 判断某个key是否存在
int dbExists(redisDb *db, robj *key);
// 随机从数据库db中取出一个key并返回
robj *dbRandomKey(redisDb *db);
// 从数据库中删除一个给定的key、相应的值value以及该key的过期时间
int dbDelete(redisDb *db, robj *key);

这些操作并没有什么难点,大家可以参看后文提供的加以注释的源码进行学习,这里就不一一解释每个函数的实现。

3、键的过期操作

Redis支持为给定的key设置过期时间,并提供以下命令实现该功能:

命令作用
expire key seconds以秒为单位为给定key设置过期时间
expireat key timestamp以秒为单位为给定key设置过期时间戳
pexpire key milliseconds以毫秒为单位设置key的过期时间
pexpireat key milliseconds以毫秒为单位设置key过期时间戳
persist key移除key的过期时间,key将持久保存
pttl key以毫秒为单位返回key的剩余过期时间
ttl key以秒为单位返回key的剩余生存时间

接下来,我们就来介绍一下Redis如果保存key的过期信息和如何删除过期key的。

3.1、底层结构

前面我们讲过,key的过期时间保存在redisDb结构中的expires字段中:

typedef struct redisDb {
    ...               
    // 键的过期时间
    dict *expires;              
    ...        
} redisDb;

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。

有一点需要提出来的是:redisDb中的db->dict和db->expires两个字典是共享同一个key的,即它们都指向了同一个key字符串,而不是将同一个key复制两份。这点利用指针很容易实现。

3.2、相关操作

3.2.1、设置过期时间

Redis提供了expire、expireat、pexpire、pexpireat四个命令来设置key的过期时间,这四个命令底层都是通过调用expireGenericCommand函数来实现的,该函数的原型如下:

void expireGenericCommand(redisClient *c, long long basetime, int unit)

其中:

参数basetime用来指明基准时间,对于expireat和pexpireat命令,basetime的值为0,对于expire和pexpire命令,basetime的值为当前时间。参数basetime总是以毫秒为单位的。

参数unit用于指定argv[2]的格式:UNIT_SECONDS或者UNIT_MILLISECONDS,前者指明以秒为单位计算,后者指明以毫秒为单位计算。

从expireGenericCommand函数的实现上我们可以看出,虽然expire、expireat、pexpire、pexpireat四个命令分别使用了不同的单位(秒/毫秒)、不同的形式(过期时间/过期时间戳)来设置key的过期时间,但最后都会转换为统一的形式即以毫秒为单位的UNIX 时间戳作为过期时间戳。

void expireGenericCommand(redisClient *c, long long basetime, int unit) {
    robj *key = c->argv[1], *param = c->argv[2];
    // 以毫秒为单位的unix时间戳
    long long when; /* unix time in milliseconds when the key will expire. */

    // 获取过期时间
    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++;

        /* Replicate/AOF this as an explicit DEL. */
        // 传播一个显式的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;
    }
}

3.2.2、删除过期key

对于过期的key,Redis负责将该key删除,为了提高运行效率,Redis采取这么一种处理方式:只有当真正要访问该key时才检查该key是否过期。如果过期就删除,如果没过期就正常访问。通常我们把这种只有在访问时才检查过期的策略叫做“惰性删除”。

具体实现上,Redis对所有读写数据库的命令在执行之前都会调用expireIfNeeded函数来检查对应key是否过期。该函数如下:

int expireIfNeeded(redisDb *db, robj *key) {
    // 获取key的过期时间
    mstime_t when = getExpire(db,key);
    mstime_t now;

    // 如果该key没有过期时间,返回0
    if (when < 0) return 0; /* No expire for this key */

    /* Don't expire anything while loading. It will be done later. */
    // 如果服务器正在加载操作中,则不进行过期检查,返回0
    if (server.loading) return 0;

    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;

    /* Return when this key has not expired */
    // 如果未过期,返回0
    if (now <= when) return 0;

    /* Delete the key */
    // 如果已过期,删除该key
    server.stat_expiredkeys++;
    propagateExpire(db,key);
    notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
        "expired",key,db->id);
    return dbDelete(db,key);
}

在expireIfNeeded函数中,我们看到对于过期key,Redis主(master)节点和附属(slave)节点有不同的处理策略,具体如下:

  1. 如果当前Redis服务器是主节点,即if (server.masterhost != NULL)语句判断为false,那么当它发现一个过期key后,会调用propagateExpire函数向所有附属节点发送一个 DEL 命令,然后再删除该key。这种做法使得对key的过期操作可以集中在一个地方处理。
  2. 如果当前Redis服务器是附属节点,即即if (server.masterhost != NULL)语句判断为true,那么它立即向程序返回该key是否已经过期的信息。即便该key已经过期也不会真正的删除该key。直到该节点接到从主节点发来的DEL 命令之后,才会真正执行删除操作。

我们再来看看下面这两个函数。当Redis从数据库db中取出指定key的对象时,总是先调用调用expireIfNeeded函数来检查对应key是否过期,然后再从数据库中查找对象。

/*  该函数是为读操作而从数据库db中取出指定key的对象。
    如果敢函数执行成功则返回目标对象,否则返回NULL。
    该函数最后还根据是否成功取出对象更新服务器的命中/不命中次数。 */
robj *lookupKeyRead(redisDb *db, robj *key) {
    robj *val;

    // 如果key已过期,删除该key
    expireIfNeeded(db,key);
    // 从数据库db中找到指定key的对象
    val = lookupKey(db,key);
    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);
}

上面就是Redis中对于过期键的删除策略,但是这并不完整。我们还需要考虑另外一种情况:如果数据库db中有许多过期key,但又没有任何一个客户端访问这些key,那么在使用“惰性删除”策略下它们就永远也不会被删除。

为了解决这个问题Redis还会定期检查过期键并将其删除,这个过程由redis.c文件中的activeExpireCycle函数执行。关于该函数,等以后我们分析到redis.c文件时再解释。


db.c文件的源码:https://github.com/xiejingfa/the-annotated-redis-2.8.24/blob/master/db.c

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值