redis源码学习之服务器数据库

参考《Redis 设计与实现》 (基于redis3.0.0) 作者:黄健宏
学习redis3.2.13

toc

数据库创建

redis服务器的数据库保存在服务器状态结构server.h/redisServer的db数组中,数据库的个数由redisServer的dbnum决定

struct redisServer {
    /* General */
    //...
    redisDb *db;
    //...
    /* Configuration */
    int dbnum;                      /* Total number of configured DBs */
    //...

在函数initServer中,redisServer.db数组被创建

void initServer(void) {
    //略
    server.db = zmalloc(sizeof(redisDb)*server.dbnum);
    //略
}

server.dbnum在函数initServerConfig中被赋值

server.dbnum = CONFIG_DEFAULT_DBNUM;    //#define CONFIG_DEFAULT_DBNUM     16

数据库切换

每个redis客户端都有属于自己操作的数据库,默认情况下,0号数据库是默认目标数据库,可以通过select命令来切换目标数据库,使用方式为select index,index为数据库下标

数据库键空间与过期字典

数据库中键空间与超时结构定义如下:

typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    dict *expires;              /* Timeout of keys with a timeout set */
    //...
} redisDb;

键空间

redisDb中的成员dict被称为数据库的键空间,它是一个字典,保存了该数据库的键值对,键是字符串对象,值可以是字符串对象、列表对象、哈希对象、集合对象、有序集合对象中的一种。

增删查改

涉及到对数据库键值对的 增删改查时,都是在字典API以及存储的值对象API上操作

读写时的维护操作

  1. 查找键后,根据是否找到对应的值来增加命中与错过的次数,在info stats命令中可以查询到两个次数
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
    robj *val;
    //检查key并在其过期时将其"删除"(服务器是主节点删除key并返回已删除,否则仅返回已删除)
    if (expireIfNeeded(db,key) == 1) {
        //key过期了,并且服务器是master,返回null表示key不存在
        if (server.masterhost == NULL) return NULL;

        //key过期了,服务器是slave,同时,调用者不是master,命令时只读的,返回null表示key不存在(返回null来保持与master的一致性视图)
        if (server.current_client &&
            server.current_client != server.master &&
            server.current_client->cmd &&
            server.current_client->cmd->flags & CMD_READONLY)
        {
            return NULL;
        }
    }
    //寻找key对应的val
    val = lookupKey(db,key,flags);
    if (val == NULL)
        server.stat_keyspace_misses++;
    else
        server.stat_keyspace_hits++;
    return val;
}
  1. 在lookupKey某个key的val后,lookupKey函数内会根据实际情况更新val的lru时间(最后一次使用时间)
robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);

        /* Update the access time for the ageing algorithm.
         * Don't do it if we have a saving child, as this will trigger
         * a copy on write madness. */
        //不存在持久化子进程并且lookup标志不是“不修改lru时间”则更新val的lru
        if (server.rdb_child_pid == -1 &&
            server.aof_child_pid == -1 &&
            !(flags & LOOKUP_NOTOUCH))
        {
            val->lru = LRU_CLOCK();
        }
        return val;
    } else {
        return NULL;
    }
}

过期字典

过期字典是redisDb中的成员expires,它存储的是键空间中键与它的过期时间,过期字典中的键是一个指针,指向键空间中的某个键对象,过期字典中的值是long long整数,保存了键对应的过期时间(毫秒精度的unix时间戳)

键的生存时间与过期时间

相关命令

名词 名词解释
生存时间 键存活时间:经过制定的生存时间后,服务器会删除生存时间为0的键
过期时间 unix时间戳,制定键被删除的时间点
命令 命令解释
expire key ttl 将键key的生存时间设置为ttl秒,成功返回1,key不存在或设置失败返回0
pexpire key ttl 将键key的生存时间设置为ttl毫秒,成功返回1,key不存在或设置失败返回0
expireat key timesramp 将key的过期时间设置为timrestamp(秒),成功返回1,key不存在或设置失败返回0
pexpireat key timesramp 将key的过期时间设置为timrestamp(毫秒),成功返回1,key不存在或设置失败返回0
ttl key 返回key在当前时间的剩余生存时间(秒),key不存在返回-2,没有过期时间返回-1
pttl key 返回key在当前时间的剩余生存时间(毫秒),key不存在返回-2,没有过期时间返回-1
persist key 去掉key的过期时间,成功返回1,key不存在或没有设置生存时间返回0

过期键的判定

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

过期键删除策略介绍

三种删除策略

  1. 定时删除:在设置键的过期时间的同时,创建一个定时任务,当键达到过期时间时,立即执行对键的删除操作
  2. 惰性删除:放任键过期不管,但在每次从键空间获取键时,都检查取得的键是否过期,如果过期的话,就删除该键,如果没有过期,就返回该键
  3. 定期删除:每隔一点时间,程序就对数据库进行一次检查,删除里面的过期键,至于要删除多少过期键,以及要检查多少个数据库,则由算法决定

三种删除策略的优缺点

  1. 定时删除
    优点:内存最友好:保证了尽快删除过期键,释放其占用的内存
    缺点:CPU最不友好:过期键较多时,删除操作会占用大量cpu时间,cpu时间紧张时触发删除任务则会影响服务器响应时间与吞吐量
    redis未使用此删除策略
  2. 惰性删除
    优点:CPU最友好:在从键空间访问查找指定key时,仅对当前key进行判断与删除
    缺点:内存最不友好:不访问到的的过期key永远得不到删除,占用的内存得不到释放
  3. 定期删除
    优点:是前两种策略整合和折中:
  4. 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响。
  5. 定时删除策略有效地减少了因为过期键带来的内存浪费。
    缺点:难以确定删除操作执行的时长与频率:
  6. 删除操作执行得太频繁,或者执行时间太长,定期删除策略就会退化成为定时删除策略
  7. 相反,则和惰性删除策略一样
    因此使用定期删除策略,需要根据服务器的情况合理地设置删除操作的执行时长和执行频率。

redis过期键删除策略

redis服务器选用了三种策略中的惰性删除和定期删除策略,两种策略配合使用,取得了CPU使用时间与避免内存浪费之间的平衡

惰性删除策略的实现

惰性删除策略由函数expireIfNeeded实现,所有读写数据库的redis命令都会在执行前,调用该函数对输入键进行过期检查操作,避免命令接触到过期键
所有读写数据库的命令,都会在键空间中查找指定键,查找时,根据命令的操作类型,会调用到以下两种lookupkey之一,两种lookupkey中直接或间接调用了expireIfNeeded函数进行过期键检查

robj *lookupKeyRead(redisDb *db, robj *key) {
    return lookupKeyReadWithFlags(db,key,LOOKUP_NONE);  //内部调用了expireIfNeeded,见上文 
}

robj *lookupKeyWrite(redisDb *db, robj *key) {
    expireIfNeeded(db,key);
    return lookupKey(db,key,LOOKUP_NONE);
}

expireIfNeeded函数实现如下:

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

    if (when < 0) return 0; //key没有被设置过期时间

    //载入时不进行过期检查
    if (server.loading) return 0;

    //得到当前时间,跑lua脚本时以脚本启动时间,否则使用系统时间
    now = server.lua_caller ? server.lua_time_start : mstime();

    //服务器是slave,不进行key的实际删除,返回逻辑上的过期与否状态(当master发过来删除命令才真正对键进行删      //除,以保持数据的一致), 返回 0 未过期  1已过期
    if (server.masterhost != NULL) return now > when;

    //服务器是master,但key没过期,不操作
    if (now <= when) return 0;

    //对过期key进行删除
    server.stat_expiredkeys++;
    propagateExpire(db,key);    //向slave及aof文件传播key过期消息(删除命令)
    notifyKeyspaceEvent(NOTIFY_EXPIRED,
        "expired",key,db->id);
    return dbDelete(db,key);
}

定期删除策略的实现

过期键的定期删除策略由activeExpireCycle函数实现,此函数被进入事件循环前调用的函数beforeSleep,以及周期性执行的函数databasesCron调用,调用条件均为服务器是master,同样是因为slave的对键删除的取决于master的删除命令

void databasesCron(void) {
    if (server.active_expire_enabled && server.masterhost == NULL)
        activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);//
    //...
}

void beforeSleep(struct aeEventLoop *eventLoop) {
    // ...
    if (server.active_expire_enabled && server.masterhost == NULL)
        activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);//
       //...
}

activeExpireCycle函数增量式的对全部数据库进行检查,每次检查都有一个超时时间,在超时时间内会尽量检查多个数据库,对每个库按过期条件进行多次检查,每次抽取最多20个键进行检查,每1次进行超时判断,超时则退出,函数下次被调用时,从紧邻的下一个数据库开始新的检查,检查完最后一个后重新由第1个开始,如此循环

void activeExpireCycle(int type) {
    //用于记录前次执行情况的静态局部变量,本次执行接上次后继续
    static unsigned int current_db = 0;     //即将要进行过期处理的数据库下标,为前次处理相邻的后一个数据库的下标
    static int timelimit_exit = 0;          //前次执行是否超时
    static long long last_fast_cycle = 0;   //前次快速模式执行的启动时间

    int j, iteration = 0;
    int dbs_per_call = CRON_DBS_PER_CALL;   //#define CRON_DBS_PER_CALL 16 
    long long start = ustime(), timelimit;

    //客户端暂停时不执行
    if (clientsArePaused()) return;
    //符合快速模式
    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        if (!timelimit_exit) return;    //前次执行没有超时,本次不执行
        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;

    //计算执行的时间上限(微妙)  ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC为25   本函数的cpu执行时间占比  
    //server.hz是serverCron()函数每一秒的调用频率(本函数在serverCron的子函数中被调用,故可看做本函数的调用频率)
    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; //#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000
    //对多个数据库进行检查
    for (j = 0; j < dbs_per_call; j++) {
        int expired;
        redisDb *db = server.db+(current_db % server.dbnum);    

        //先增加下标来保证,即使本次activeExpireCycle()执行超时返回了,下次执行能从下个数据库开始,以在每个数据库上均匀分配时间
        current_db++;

        /* Continue to expire if at the end of the cycle more than 25%
         * of the keys were expired. */
        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();

            //过期字典是空,同时过期字典发生过扩容,但过期字典的使用率负载因子小于0.1,跳过当前数据库,检查下一数据库
            //负载因子小于0.1时,dictGetRandomKey函数内部可能会循环太多次,代价太昂贵,跳过当前数据库,检查下一数据库
            if (num && slots > DICT_HT_INITIAL_SIZE &&
                (num*100/slots < 1)) break;

            /* The main collection cycle. Sample random keys among keys
             * with an expire set, checking for expired ones. */
            expired = 0;
            ttl_sum = 0;
            ttl_samples = 0;
            //每次最多检查20个过期键
            if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP) //#define ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 20
                num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;
            //检查num个键的过期情况,并记录在expired
            while (num--) {
                dictEntry *de;
                long long ttl;

                if ((de = dictGetRandomKey(db->expires)) == NULL) break;    //随机抽取一个设置了过期时间的键
                ttl = dictGetSignedIntegerVal(de)-now;                      //计算生存时间
                //函数内判断过期情况,并执行过期键的删除、传播过期消息(slaves、aof)、发出通知
                if (activeExpireCycleTryExpire(db,de,now)) expired++;   //键如果过期累加过期键个数
                if (ttl > 0) {
                    /* We want the average TTL of keys yet not expired. */
                    ttl_sum += ttl;
                    ttl_samples++;
                }
            }

            /* Update the average TTL stats for this database. */
            if (ttl_samples) {
                long long avg_ttl = ttl_sum/ttl_samples;

                /* Do a simple running average with a few samples.
                 * We just use the current estimate with a weight of 2%
                 * and the previous estimate with a weight of 98%. */
                if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
                db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50);
            }

            iteration++;    //遍历次数
            if ((iteration & 0xf) == 0) { //每遍历16次,进行一次超时检查
                long long elapsed = ustime()-start;

                latencyAddSampleIfNeeded("expire-cycle",elapsed/1000);
                if (elapsed > timelimit) timelimit_exit = 1;
            }
            if (timelimit_exit) return; //超时则退出,等待activeExpireCycle函数下一次执行

            //过期键大于5个(最大随机抽取的25%),继续检查
        } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
    }
}

aof、rdb及复制功能对过期键的处理

rdb文件对过期键的处理

生成rdb文件
创建新rdb文件时,数据库中的键会被检查,过期的键不会被存入新创建的rdb文件中
载入rdb文件
启动redis服务器时,在开启rdb功能的情况下,进行rdb文件载入:

  • 服务器是master,只载入未过期的键
  • 服务器是slave,载入rdb文件中的全部键,进行主从数据同步时,slave的数据库会被清空,所以载入了过期键也不会造成影响

aof文件对过期键的处理

aof文件写入
过期键被删除后,会向aof文件追加del命令,显示记录该键被删除
aof重写
执行aof重写时,会对键进行检查,已过期的键不会被保存到重写后的aof文件中

复制功能对过期键的处理

当服务器运行在复制模式下时,slave的过期键删除动作由master控制(代码参见expireIfNeeded):

  • master因惰性或定期删除操作,删除一个过期键后,会显示地向所有slave发送del命令,告知slave删除该过期键
  • slave在执行客户端的读命令时,碰到过期键也不会删除,只会返回逻辑上的没有,客户端不会读到过期键(这是与3.0版本不同之处),只有当收到master发送的del命令,才删除过期键(见上文的函数lookupKeyRead、lookupKeyWrite调用链),

由master控制slave统一删除,目的是保证主从数据的一致性





原创不易,转载请注明出处,谢谢
原文地址:https://www.cnblogs.com/Keeping-Fit/p/14439394.html