zoukankan      html  css  js  c++  java
  • 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个过期键。如果不超过设定的时间,每个库可以删除多次。
  • 相关阅读:
    软件架构实现
    UVa644
    如何理解Hibernate中的HibernateSessionFactory类
    在pcDuino上使用蓝牙耳机玩转音乐
    Java Web----Java Web的数据库操作(三)
    Pylons Controller里面Session.commit()总是出现rollback
    ORACLE的SQL JOIN方式小结
    关于数据库学习进阶的一点体悟
    IO is frozen on database xxx, No user action is required
    ORACLE等待事件:enq: TX
  • 原文地址:https://www.cnblogs.com/wind-snow/p/11249489.html
Copyright © 2011-2022 走看看