Redis 学习笔记(篇六):数据库

Redis 是一个使用 C 语言编写的 NoSql 的数据库,本篇就讲解在 Redis 中数据库是如何存储的?以及和数据库有关的一些操作。

Redis 中的所有数据库都保存在 redis.h/redisServer 结构中的 db 数组中,如下:

struct redisServer {
    ......

    // 数据库
    redisDb *db;

    ......
}

Redis 默认会创建 16 个数据库,每个数据库互不影响。

切换数据库

每个 Redis 客户端也都有自己的目标数据库,默认情况下,Redis客户端的目标数据库是 0 号数据库。但客户端也可以通过 select 命令来切换目标数据库。

在服务器内部,客户端状态 redisClient 结构的 db 属性记录了客户端当前的目标数据库,如下:

typedef struct redisClient {

    // 套接字描述符
    int fd;

    // 当前正在使用的数据库
    redisDb *db;

    // 当前正在使用的数据库的 id (号码)
    int dictid;

    // 客户端的名字
    robj *name;             /* As set by CLIENT SETNAME */

} redisClient;

假如某个客户端的目标数据库为 1 号数据库,那么这个客户端所对应的客户端状态和服务器状态之间的关系如下(出自《Redis设计与实现第二版》第九章:数据库):

《Redis设计与实现第二版》

注意: 到目前为止,Redis 仍然没有返回客户端目标数据库的命令,所以尽量不要在项目中使用多数据库,以免造成混乱。

数据库键空间

Redis 是一个键值对数据库服务器,服务器中的每个数据库都由一个 redis.h/redisDb 结构表示,具体结构如下:

typedef struct redisDb {

    // 数据库键空间,保存着数据库中的所有键值对
    dict *dict;                 /* The keyspace for this DB */

    // 键的过期时间,字典的键为键,字典的值为过期事件 UNIX 时间戳
    dict *expires;              /* Timeout of keys with a timeout set */

    // 正处于阻塞状态的键
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP) */

    // 可以解除阻塞的键
    dict *ready_keys;           /* Blocked keys that received a PUSH */

    // 正在被 WATCH 命令监视的键
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */

    struct evictionPoolEntry *eviction_pool;    /* Eviction pool of keys */

    // 数据库号码
    int id;                     /* Database ID */

    // 数据库的键的平均 TTL ,统计信息
    long long avg_ttl;          /* Average TTL, just for stats */

} redisDb;

键空间(db 属性)和用户所见的数据库是直接对应的:

  • 键空间的键也就是数据库的键,每个键都是一个字符串对象。
  • 键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中的任意一种 Redis 对象。

而在数据库中添加、修改、删除键也都是操作的 db 字典。

键的过期策略

Redis 中设置键的过期时间有四种写法:

  • expire key t1 :表示将键 key 的生存时间设置为 t1 秒。
  • pexpire key t1 :表示将键 key 的生存时间设置为 t1 毫秒。
  • expireat key t1 :表示将键 key 的过期时间设置为 t1 所指定的秒数时间戳。
  • pexpireat key t1 :表示将键 key 的过期时间设置为 t1 所指定的毫秒数时间戳。

虽然有 4 种不同的写法,但这些做的都是一件事,所以可以抽成一个统一的方法。而实际上 Redis 也正是这么做的,expire、pexpire、expireat 三个命令都是使用 pexpireat 命令来实现的。

Redis 如何存储键的过期时间呢?

redisDb 结构的 expires 字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典:

  • 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键)。
  • 过期字典的值是一个 long long 类型的整数,这个整数保存了键所指向的数据库键的过期时间(一个毫秒精度的 UNIX 时间戳)。

Redis 如何移除键的过期时间呢?

命令是: persist key

Redis 数据库做的操作也仅仅是在 expires 字典中删除对应的键值对。

Redis 如何判断一个键是否过期呢?

通过 expires 字典,程序可以用以下步骤检查一个给定键是否过期:

  1. 检查给定键是否存在于过期字典:如果存在,那么取得键的过期时间。
  2. 检查当前 UNIX 时间戮是否大于键的过期时间:如果是的话,那么键已经过期;否则的话,键未过期。

Redis 具体是如何删除一个过期的键呢?

我们知道数据库键的过期时间都保存在过期字典中,又知道了如何根据过期时间去判断一个键是否过期,现在剩下的问题是:如果一个键过期了,那么它什么时候会被删除呢?

这个问题有三种可能的答案,它们分别代表了三种不同的删除策略:

  • 定时删除:在设置键的过期时间的同时,创建一个定时器(timer) 。让定时器在键的过期时间来临时,立即执行对键的侧除操作。
  • 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就侧除该键;如果没有过期,就返回该键。
  • 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。

在这三种策略中,第一种和第三种为主动删除策略,而第二种为被动侧除策略。而无论是哪种策略都有其优点和缺点。

对于定时删除来说:

  • 优点是可以保证过期键会尽可能快的被删除,并释放过期键所占用的内存;
  • 缺点则是会占用一部分 CPU 时间,尤其是当键非常多的时候,占用的 CPU 时间也会增多,这是不可忍受的。

对于惰性删除来说:

  • 程序只会在取出键是进行检查,所以优点是几乎不不占用 CPU 时间;
  • 缺点则是可能会造成内存泄漏,比如当键过期之后永远不再访问,这时候就是内存泄漏了。

对于定期删除来说,则是以上两种策略的一种整合,定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响;除此之外,通过定期删除过期键,定期删除策略有效地减少了因为过期键而带来的内存浪费。
定期删除策略的难点是确定删除操作执行的时长和频率:

  • 如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至于将 CPU 时间过多地消耗在侧除过期键上面。
  • 如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况。

因此,如果采用定期侧除策略的话,服务器必须根据情况,合理地设置删除操作的执行时长和执行频率。

而 Redis 中则使用了定期删除和惰性删除两种策略,很好的在 CPU 和内存上面取得了一个平衡。

惰性删除

过期键的惰性删除策略由 db.c/expireIfNeeded 函数实现,所有读写数据库的 Redis 命令在执行之前都会调用 expireIfNeeded 函数对输入键进行检查:

  • 如果输人键已经过期,那么 expireIfNeeded 函数将输入键从数据库中删除。
  • 如果输人键未过期,那么 expireIfNeeded 函数不做动作。

expireIfNeeded 函数就像一个过滤器,它可以在命令真正执行之前,过滤掉过期的输人键,从而避免命令接触到过期键。函数的具体代码如下:

/*
* 检查 key 是否已经过期,如果是的话,将它从数据库中删除。
*
* 返回 0 表示键没有过期时间,或者键未过期。
*
* 返回 1 表示键已经因为过期而被删除了。
*/
int expireIfNeeded(redisDb *db, robj *key) {

    // 取出键的过期时间
    mstime_t when = getExpire(db,key);
    mstime_t now;

    // 没有过期时间
    if (when < 0) return 0; /* No expire for this key */

    // 如果服务器正在进行载入,那么不进行任何过期检查
    if (server.loading) return 0;

    /* If we are in the context of a Lua script, we claim that time is
    * blocked to when the Lua script started. This way a key can expire
    * only the first time it is accessed and not in the middle of the
    * script execution, making propagation to slaves / AOF consistent.
    * See issue #1525 on Github for more information. */
    now = server.lua_caller ? server.lua_time_start : mstime();

    /* If we are running in the context of a slave, return ASAP:
    * the slave key expiration is controlled by the master that will
    * send us synthesized DEL operations for expired keys.
    *
    * Still we try to return the right information to the caller, 
    * that is, 0 if we think the key should be still valid, 1 if
    * we think the key is expired at this time. */
    // 当服务器运行在 replication 模式时
    // 附属节点并不主动删除 key
    // 它只返回一个逻辑上正确的返回值
    // 真正的删除操作要等待主节点发来删除命令时才执行
    // 从而保证数据的同步
    if (server.masterhost != NULL) return now > when;

    // 运行到这里,表示键带有过期时间,并且服务器为主节点

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

    /* Delete the key */
    server.stat_expiredkeys++;

    // 向 AOF 文件和附属节点传播过期信息
    propagateExpire(db,key);

    // 发送事件通知
    notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
        "expired",key,db->id);

    // 将过期键从数据库中删除
    return dbDelete(db,key);
}

命令调用 expireIfNeeded 来删除过期键的过程和 get 命令的执行过程如下(出自《Redis设计与实现第二版》第九章:数据库):

《Redis设计与实现第二版》

定期删除

过期键的定期删除策略由 redis.c/activeExpireCycle 函数实现,调用流程为 serverCron() -> databasesCron() -> activeExpireCycle()。核心代码如下(为了方便查看核心部分,对代码进行了截取):

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    ......

    // 对数据库执行各种操作
    databasesCron();

    ......
}

// 对数据库执行删除过期键,调整大小,以及主动和渐进式 rehash
void databasesCron(void) {

    // 函数先从数据库中删除过期键,然后再对数据库的大小进行修改

    /* Expire keys by random sampling. Not required for slaves
    * as master will synthesize DELs for us. */
    // 如果服务器不是从服务器,那么执行主动过期键清除
    if (server.active_expire_enabled && server.masterhost == NULL)
        // 清除模式为 CYCLE_SLOW ,这个模式会尽量多清除过期键
        activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);

    /* Perform hash tables rehashing if needed, but only if there are no
    * other processes saving the DB on disk. Otherwise rehashing is bad
    * as will cause a lot of copy-on-write of memory pages. */
    // 在没有 BGSAVE 或者 BGREWRITEAOF 执行时,对哈希表进行 rehash
    if (server.rdb_child_pid == -1 && server.aof_child_pid == -1) {

        ......
        
    }
}
void activeExpireCycle(int type) {

    ......

    // 遍历数据库
    for (j = 0; j < dbs_per_call; j++) {
        int expired;
        // 指向要处理的数据库
        redisDb *db = server.db+(current_db % server.dbnum);

        // 为 DB 计数器加一,如果进入 do 循环之后因为超时而跳出
        // 那么下次会直接从下个 DB 开始处理
        current_db++;

        do {
            unsigned long num, slots;
            long long now, ttl_sum;
            int ttl_samples;

            // 获取数据库中带过期时间的键的数量
            // 如果该数量为 0 ,直接跳过这个数据库
            if ((num = dictSize(db->expires)) == 0) {
                db->avg_ttl = 0;
                break;
            }
            // 获取数据库中键值对的数量
            slots = dictSlots(db->expires);
            // 当前时间
            now = mstime();

            // 这个数据库的使用率低于 1% ,扫描起来太费力了(大部分都会 MISS)
            // 跳过,等待字典收缩程序运行
            if (num && slots > DICT_HT_INITIAL_SIZE &&
                (num*100/slots < 1)) break;

            /*  
            * 样本计数器
            */
            // 已处理过期键计数器
            expired = 0;
            // 键的总 TTL 计数器
            ttl_sum = 0;
            // 总共处理的键计数器
            ttl_samples = 0;

            // 每次最多只能检查 LOOKUPS_PER_LOOP 个键
            if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
                num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;

            // 开始遍历数据库
            while (num--) {
                dictEntry *de;
                long long ttl;

                // 从 expires 中随机取出一个带过期时间的键
                if ((de = dictGetRandomKey(db->expires)) == NULL) break;
                // 计算 TTL
                ttl = dictGetSignedIntegerVal(de)-now;
                // 如果键已经过期,那么删除它,并将 expired 计数器增一
                if (activeExpireCycleTryExpire(db,de,now)) expired++;
                if (ttl < 0) ttl = 0;
                // 累积键的 TTL
                ttl_sum += ttl;
                // 累积处理键的个数
                ttl_samples++;
            }

            ......

            // 已经超时了,返回
            if (timelimit_exit) return;

            // 如果已删除的过期键占当前总数据库带过期时间的键数量的 25 %
            // 那么不再遍历
        } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
    }
}

几点说明:

  1. serverCron() 函数是 Redis 的定时器,默认每隔 100ms 运行一次。
  2. 在 databasesCron() 函数中不只进行了删除过期键还进行了 rehash 操作。
  3. 如果服务器不是从服务器,才会执行主动过期键清除。
  4. ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 在 redis.h 中定义,为 20。也就是说一次删除20个过期键。如果不超过设定的时间,每个库可以删除多次。
posted @ 2019-07-26 12:17  风中抚雪  阅读(441)  评论(0编辑  收藏  举报