Redis设计与实现——数据库列表与结构体
@ Shen Jianan · Saturday, Jun 4, 2016 · 11 minute read · Update at Jun 4, 2016

数据库

数据库列表

redis.h/redisServer是Redis服务器的数据结构,保存所有的服务器状态。

一个Redis服务器默认会创建16个数据库,redisServer里面有数据库列表指针和数据库数量的记录。

struct redisServer {

	//...

    // 数据库
    redisDb *db;
    
    //...
    
    int dbnum;                      /* Total number of configured DBs */

    //...
};

redis.h/redisClient在切换数据库的时候,就会将redisClient中的db指针指向具体的数据库。

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

redisDb结构

redis.h/redisDb表示一个数据库,其中包含有当前数据库所有的键值对、键的过期时间字典、阻塞中的键、可以解除阻塞的键、正被监视的键和数据库号码等信息。相对redisServer和redisClient来说是很简单的数据结构,主要就是服务于键值处理。其中的键值都只是指针,真正的键值对象都在别处分配内存。

/* Redis database representation. There are multiple databases identified
 * by integers from 0 (the default database) up to the max configured
 * database. The database number is the 'id' field in the structure. */
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;

增删改查实际上就是对dict和dict指向的键值进行的操作,在对数据库进行读写的时候,服务器还会更新命中次数、不命中次数和LRU。

struct redisServer{

	//...
	
    // 最近一次使用时钟
    unsigned lruclock:REDIS_LRU_BITS; /* Clock for LRU eviction */
	
	//...

    // 成功查找键的次数
    long long stat_keyspace_hits;   /* Number of successful lookups of keys */

    // 查找键失败的次数
    long long stat_keyspace_misses; /* Number of failed lookups of keys */
    
    //...
}

在读写之前,服务器会先检查是否过期,如果过期则需要删除这个键,下面是getCommand底层依赖的取值方法:

/*
 * 为执行读取操作而取出键 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;
}

关于WATCH键,被WATCH命令监视的键都会被标记为dirty,每修改一个键之后,都会对dirty键计数器的值增1,这个计数器会出发服务器的持久化以及复制操作。

Redis过期键删除策略

Redis使用惰性删除和定期删除两种策略。

惰性删除

db.c中的方法都是数据库级别的操作,比如过期时间、清空键、数据库大小、覆写等。

惰性删除的函数实现是db.c/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 */

    /* Don't expire anything while loading. It will be done later. */
    // 如果服务器正在进行载入,那么不进行任何过期检查
    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);
}

单单使用惰性删除会导致过期但是不再被访问的键占用大量空间,因此还需要使用定期删除来周期性地删除过期键。定期删除的函数是redis.c/activeExpireCycle,下面是被它调用的activeExpireCycleTryExpire:

/* ======================= Cron: called every 100 ms ======================== */

/* Helper function for the activeExpireCycle() function.
 * This function will try to expire the key that is stored in the hash table
 * entry 'de' of the 'expires' hash table of a Redis database.
 *
 * activeExpireCycle() 函数使用的检查键是否过期的辅佐函数。
 *
 * If the key is found to be expired, it is removed from the database and
 * 1 is returned. Otherwise no operation is performed and 0 is returned.
 *
 * 如果 de 中的键已经过期,那么移除它,并返回 1 ,否则不做动作,并返回 0 。
 *
 * When a key is expired, server.stat_expiredkeys is incremented.
 *
 * The parameter 'now' is the current time in milliseconds as is passed
 * to the function to avoid too many gettimeofday() syscalls.
 *
 * 参数 now 是毫秒格式的当前时间
 */
int activeExpireCycleTryExpire(redisDb *db, dictEntry *de, long long now) {
    // 获取键的过期时间
    long long t = dictGetSignedIntegerVal(de);
    if (now > t) {

        // 键已过期

        sds key = dictGetKey(de);
        robj *keyobj = createStringObject(key,sdslen(key));

        // 传播过期命令
        propagateExpire(db,keyobj);
        // 从数据库中删除该键
        dbDelete(db,keyobj);
        // 发送事件
        notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
            "expired",keyobj,db->id);
        decrRefCount(keyobj);
        // 更新计数器
        server.stat_expiredkeys++;
        return 1;
    } else {

        // 键未过期
        return 0;
    }
}

这里的dbDelete会删除这个数据库里面的过期时间、键值对,但是decrRefCount不一定会释放这个值的内存,因为可能有别的值引用了这个对象。只有当refCount降到0的时候,才会释放它的内存。下面是db.c/decrRefCount的函数:

/* Delete a key, value, and associated expiration entry if any, from the DB 
 *
 * 从数据库中删除给定的键,键的值,以及键的过期时间。
 *
 * 删除成功返回 1 ,因为键不存在而导致删除失败时,返回 0 。
 */
int dbDelete(redisDb *db, robj *key) {

    /* Deleting an entry from the expires dict will not free the sds of
     * the key, because it is shared with the main dictionary. */
    // 删除键的过期时间
    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;
    }
}

下面是定期删除过期键的函数activeExpireCycle,它不会强求一次性将所有的过期键删除,而是在一个规定的时间内删除一部分的过期时间,并且在一段时间后再启动。在前期的启动模式判断、变量初始化之后,计算轮到清理的数据库,并且将数据库号加一,也就是说就算数据库0没有在当前时限内处理完毕,下次也依旧会处理数据库1,相当于时间片轮转的清理方式。

在对数据库进行清理之前,代码会先检查带有过期时间的键值对数量,记录当前时间,如果所有键值对都是永不过期的或者可过期键值对占比小于1%,那就直接跳过这个数据库;如果可过期键数量M超过ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP,还要只检查ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP个键。在这之后,进行M次循环,每次循环随机取一个键,如果已经过期则删除之(调用activeExpireCycleTryExpire),更新统计数据。每进行16次遍历,就检查是否已经超过时间限制,如果超过则结束此过程。

最后,每次遍历都会进行检查。如果已经删除了带过期时间键数量的25%,则不再进行遍历,转而处理下一个数据库。

/* Try to expire a few timed out keys. The algorithm used is adaptive and
 * will use few CPU cycles if there are few expiring keys, otherwise
 * it will get more aggressive to avoid that too much memory is used by
 * keys that can be removed from the keyspace.
 *
 * 函数尝试删除数据库中已经过期的键。
 * 当带有过期时间的键比较少时,函数运行得比较保守,
 * 如果带有过期时间的键比较多,那么函数会以更积极的方式来删除过期键,
 * 从而可能地释放被过期键占用的内存。
 *
 * No more than REDIS_DBCRON_DBS_PER_CALL databases are tested at every
 * iteration.
 *
 * 每次循环中被测试的数据库数目不会超过 REDIS_DBCRON_DBS_PER_CALL 。
 *
 * This kind of call is used when Redis detects that timelimit_exit is
 * true, so there is more work to do, and we do it more incrementally from
 * the beforeSleep() function of the event loop.
 *
 * 如果 timelimit_exit 为真,那么说明还有更多删除工作要做,
 * 那么在 beforeSleep() 函数调用时,程序会再次执行这个函数。
 *
 * Expire cycle type:
 *
 * 过期循环的类型:
 *
 * If type is ACTIVE_EXPIRE_CYCLE_FAST the function will try to run a
 * "fast" expire cycle that takes no longer than EXPIRE_FAST_CYCLE_DURATION
 * microseconds, and is not repeated again before the same amount of time.
 *
 * 如果循环的类型为 ACTIVE_EXPIRE_CYCLE_FAST ,
 * 那么函数会以“快速过期”模式执行,
 * 执行的时间不会长过 EXPIRE_FAST_CYCLE_DURATION 毫秒,
 * 并且在 EXPIRE_FAST_CYCLE_DURATION 毫秒之内不会再重新执行。
 *
 * If type is ACTIVE_EXPIRE_CYCLE_SLOW, that normal expire cycle is
 * executed, where the time limit is a percentage of the REDIS_HZ period
 * as specified by the REDIS_EXPIRELOOKUPS_TIME_PERC define. 
 *
 * 如果循环的类型为 ACTIVE_EXPIRE_CYCLE_SLOW ,
 * 那么函数会以“正常过期”模式执行,
 * 函数的执行时限为 REDIS_HS 常量的一个百分比,
 * 这个百分比由 REDIS_EXPIRELOOKUPS_TIME_PERC 定义。
 */

void activeExpireCycle(int type) {
    /* This function has some global state in order to continue the work
     * incrementally across calls. */
    // 静态变量,用来累积函数连续执行时的数据
    static unsigned int current_db = 0; /* Last DB tested. */
    static int timelimit_exit = 0;      /* Time limit hit in previous call? */
    static long long last_fast_cycle = 0; /* When last fast cycle ran. */

    unsigned int j, iteration = 0;
    // 默认每次处理的数据库数量
    unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL;
    // 函数开始的时间
    long long start = ustime(), timelimit;

    // 快速模式
    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        /* Don't start a fast cycle if the previous cycle did not exited
         * for time limt. Also don't repeat a fast cycle for the same period
         * as the fast cycle total duration itself. */
        // 如果上次函数没有触发 timelimit_exit ,那么不执行处理
        if (!timelimit_exit) return;
        // 如果距离上次执行未够一定时间,那么不执行处理
        if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return;
        // 运行到这里,说明执行快速处理,记录当前时间
        last_fast_cycle = start;
    }

    /* We usually should test REDIS_DBCRON_DBS_PER_CALL per iteration, with
     * two exceptions:
     *
     * 一般情况下,函数只处理 REDIS_DBCRON_DBS_PER_CALL 个数据库,
     * 除非:
     *
     * 1) Don't test more DBs than we have.
     *    当前数据库的数量小于 REDIS_DBCRON_DBS_PER_CALL
     * 2) If last time we hit the time limit, we want to scan all DBs
     * in this iteration, as there is work to do in some DB and we don't want
     * expired keys to use memory for too much time. 
     *     如果上次处理遇到了时间上限,那么这次需要对所有数据库进行扫描,
     *     这可以避免过多的过期键占用空间
     */
    if (dbs_per_call > server.dbnum || timelimit_exit)
        dbs_per_call = server.dbnum;

    /* We can use at max ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC percentage of CPU time
     * per iteration. Since this function gets called with a frequency of
     * server.hz times per second, the following is the max amount of
     * microseconds we can spend in this function. */
    // 函数处理的微秒时间上限
    // ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 默认为 25 ,也即是 25 % 的 CPU 时间
    timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
    timelimit_exit = 0;
    if (timelimit <= 0) timelimit = 1;

    // 如果是运行在快速模式之下
    // 那么最多只能运行 FAST_DURATION 微秒 
    // 默认值为 1000 (微秒)
    if (type == ACTIVE_EXPIRE_CYCLE_FAST)
        timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* in microseconds. */

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

        /* Increment the DB now so we are sure if we run out of time
         * in the current DB we'll restart from the next. This allows to
         * distribute the time evenly across DBs. */
        // 为 DB 计数器加一,如果进入 do 循环之后因为超时而跳出
        // 那么下次会直接从下个 DB 开始处理
        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;

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

            /* When there are less than 1% filled slots getting random
             * keys is expensive, so stop here waiting for better times...
             * The dictionary will be resized asap. */
            // 这个数据库的使用率低于 1% ,扫描起来太费力了(大部分都会 MISS)
            // 跳过,等待字典收缩程序运行
            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 计数器
            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++;
            }

            /* Update the average TTL stats for this database. */
            // 为这个数据库更新平均 TTL 统计数据
            if (ttl_samples) {
                // 计算当前平均值
                long long avg_ttl = ttl_sum/ttl_samples;
                
                // 如果这是第一次设置数据库平均 TTL ,那么进行初始化
                if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
                /* Smooth the value averaging with the previous one. */
                // 取数据库的上次平均 TTL 和今次平均 TTL 的平均值
                db->avg_ttl = (db->avg_ttl+avg_ttl)/2;
            }

            /* We can't block forever here even if there are many keys to
             * expire. So after a given amount of milliseconds return to the
             * caller waiting for the other active expire cycle. */
            // 我们不能用太长时间处理过期键,
            // 所以这个函数执行一定时间之后就要返回

            // 更新遍历次数
            iteration++;

            // 每遍历 16 次执行一次
            if ((iteration & 0xf) == 0 && /* check once every 16 iterations. */
                (ustime()-start) > timelimit)
            {
                // 如果遍历次数正好是 16 的倍数
                // 并且遍历的时间超过了 timelimit
                // 那么断开 timelimit_exit
                timelimit_exit = 1;
            }

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

            /* We don't repeat the cycle if there are less than 25% of keys
             * found expired in the current DB. */
            // 如果已删除的过期键占当前总数据库带过期时间的键数量的 25 %
            // 那么不再遍历
        } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
    }
}

AOF、EDB和复制功能对过期键的处理

RDB模式

执行SAVE或者BGSAVE的时候,程序会将未过期的键值保存到新创建的RDB文件中。而当启动Redis的时候,只要开启了RDB功能,那么就会载入RDB文件。

  • 如果是主服务器,那么载入时会对保存的键进行检查,只读取未过期的键。
  • 如果是从服务器,那么不会检查是否过期。不过主服务器在和从服务器同步的时候会清空从服务器的数据,所以一般来说也没什么影响。

AOF模式

当以AOF持久化模式运行时,键只有被删除的时候才会向AOF文件追加一条DEL命令。

在执行AOF重写的过程中,程序也会检查键是否过期,只读取未过期的键。

复制模式

当运行在复制模式下时,从服务器的过期键删除操作由主服务器控制。

  • 主服务器在删除过期键的时候会主动向所有的从服务器发送DEL命令
  • 从服务器读写键值的时候,依然会返回过期键,只有在接到DEL命令的时候才会删除过期键。

数据库通知

数据库通知是Redis 2.8新增的功能,可以让客户端通过订阅的方式获得键的变化与命令执行情况。发送通知的功能是通过notify.c/notifyKeyspaceEvent函数实现的。

About Me

2018.02至今 杭州嘉云数据 算法引擎

2017.6-2017.12 菜⻦网络-⼈工智能部-算法引擎

2016.09-2018.06 南京大学研究生

2015.07-2015.09 阿里巴巴-ICBU-实习

2012.09-2016.06 南京大学本科