缓存过期

缓存过期

Redis 系列目录:https://www.cnblogs.com/binarylei/p/11721921.html

缓存过期

Redis 缓存过期有 2 种策略,被动过期和主动过期。

  1. 被动过期删除:客户端访问 Redis 时,发现 key 已经过期,需要删除数据
  2. 主动过期删除:Redis 主线程有一个定时任务,默认没 100ms 执行一次,每次主动淘汰一批已经过期的数据。

接下来,我会依次分析被动过期和主动过期在 Redis 中是如何实现的。

数据结构

我们先看一下 KEY 的过期时间在 Reids 中是如何存储的。首先,为了方便查找,Redis 会将所有设置了过期时间的 KEY 单独存放。其次,KEY 的过期时间保存在 hash 节点中。

src/server.h/redisDb 保存所有的数据 dict,及设置过期时间的 KEY 集合 expires

dict *dict;     /* 数据key->value */
dict *expires;  /* 设置了过期时间的key */

src/dict.h/dictEntry 保存 KEY 的过期时间 s64

int64_t s64;	/* 过期时间 */

同时,src/db.c 定义了一系列的方法操作 KEY 的过期时间:

  • setExpire:设置 KEY 的过期时间。
  • getExpire:获取 KEY 的过期时间。
  • keyIsExpired:判断 KEY 是否过期。

有了这些基础,我们再来看过期删除是如何实现的。

被动删除

被动删除,客户端访问时才会淘汰过期数据,相比主动淘汰,更加节省 CPU。Redis 增删改查等数据操作,都会先调用 expireIfNeeded 方法判断数据是否过期,如果过期了,就会先把过期的数据淘汰,再执行客户端命令。下面是被动过期的执行流程。

图1 被动过期删除流程

可以看到,Reids 的 GET、SET、HSET、KEYS 等操作,都会先调用 expireIfNeeded 方法。我们看一下这个方法。src/db.c

int expireIfNeeded(redisDb *db, robj *key) {
    /* 判断key是否过期 */
    if (!keyIsExpired(db,key)) return 0;

    /* slave节点的数据是从master同步过来的,不主动淘汰key */
    if (server.masterhost != NULL) return 1;

    /* 删除过期的key */
    if (server.lazyfree_lazy_expire) {
        dbAsyncDelete(db,key);
    } else {
        dbSyncDelete(db,key);
    }
    return 1;
}

expireIfNeeded 方法也很简单,先判断 KEY 是否过期,如果过期了就删除该数据。需要注意的是,slave 节点是不能主动删除数据的,它的数据必须都从 master 同步过来,等 master 淘汰了该过期数据就会同步到从节点,此时 slave 就会删除该数据。否则,就有可能出现主从数据不一致的现象。

主动删除

和被动过期删除需要等客户端访问不同;主动删除策略,在 Reids 比较闲的时候,会主动扫描一定数据的 KEY,淘汰过期的数据。下图是主动删除的执行流程:

图2 主动过期删除流程

前面我们说了,Redis 将所有设置了过期时间的 KEY,都单独存放在 expires 字典表中。所以,主动删除其实就是遍历这个 expires 字典表,发现数据过期了就删除。当然,Reids 为了考虑性能,每次执行时都有扫描个数和时间的限制,超过了就主动退出,下一次再继续。我们看一下可以的代码 src/expire.c/activeExpireCycle 方法。

void activeExpireCycle(int type) {
    static unsigned int current_db = 0;
    int j, iteration = 0;
    int dbs_per_call = CRON_DBS_PER_CALL;
    long long start = ustime(), timelimit, elapsed;  // ustime 时间单位为微秒

    /* 计算最大执行时间,默认25ms。 */
    timelimit = config_cycle_slow_time_perc*1000000/server.hz/100;

	/* 1.遍历redis db,current_db指向下一个要扫描的数据库 */
    for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
        unsigned long expired, sampled;

        redisDb *db = server.db+(current_db % server.dbnum);
        current_db++;

		/* 开始扫描该数据库下的key */
        do {
            unsigned long num, slots;
            long long now, ttl_sum;
            int ttl_samples;
            iteration++;

            /* 2.1没有设置过期时间的KEY,直接返回 */
            if ((num = dictSize(db->expires)) == 0) {
                break;
            }
            slots = dictSlots(db->expires);
            now = mstime();

            /* 2.2计算设置过期KEY的占比,如果小于10%,设置过期的KEY不多扫描一次不划算,等待扩容 */
            if (slots > DICT_HT_INITIAL_SIZE &&
                (num*100/slots < 1)) break;
            
            expired = 0;	// 过期KEY个数
            sampled = 0;	// 扫描KEY个数

            if (num > config_keys_per_loop)
                num = config_keys_per_loop;		// 最大扫描的KEY个数

			/* 3.这里开始真是扫描key进行过期淘汰,每次默认最多扫描20个key,400个桶。
			 * 3.1根据expires_cursor找到当前要扫描的hash桶位置
			 * 3.2依次扫描该hash桶的所有key,判断是否过期
			 * 3.3统计扫描到的key个数sampled和过期的个数expired
			 * 3.4扫描下一个桶expires_cursor++,直至扫描的key个数超过默认的20个
			 */
            long max_buckets = num*20;
            long checked_buckets = 0;
            while (sampled < num && checked_buckets < max_buckets) {
                for (int table = 0; table < 2; table++) {
                    if (table == 1 && !dictIsRehashing(db->expires)) break;

                    unsigned long idx = db->expires_cursor;
                    idx &= db->expires->ht[table].sizemask;
                    dictEntry *de = db->expires->ht[table].table[idx];
                    long long ttl;

                    checked_buckets++;
                    while(de) {
                        dictEntry *e = de;
                        de = de->next;

                        ttl = dictGetSignedIntegerVal(e)-now;
						/* 4.activeExpireCycleTryExpire进行缓存淘汰 */
                        if (activeExpireCycleTryExpire(db,e,now)) expired++;
                        sampled++;
                    }
                }
                db->expires_cursor++;
            }

            /* 5.计算执行时间,判断是否超时。每迭代16次统计一次执行时间 */
            if ((iteration & 0xf) == 0) {
                elapsed = ustime()-start;
                if (elapsed > timelimit) {
                    timelimit_exit = 1;
                    break;
                }
            }
        /* 6.如果缓存淘汰率大于10%继续,说明过期数据很多,可以牺牲一下可用性,多淘汰一点过期数据 */
        } while (sampled == 0 ||  (expired*100/sampled) > config_cycle_acceptable_stale);
    }
}

这些步骤里面,我希望你重点关注一下 Redis 是如何在性能和可用性上做抉择的。如果想将过期数据都淘汰,必然这段时间服务是不可用的,牺牲了可用性。所以,Redis 设置了每次扫描的 KEY 个数和扫描时长,超出了就主动退出。同时,如果设置过期时间的 KEY 不多(小于 1%),那淘汰的数据也不会多,扫描一次必然性价比不高,此时直接等待字典表扩容即可。相反,如果发现过期的数据较多(淘汰率大于10%),则继续扫描直至超时。

接下来,我们看一下主动删除是什么时候执行的。Redis 有一个主线程的定时任务,默认 1s 执行 10 次。每次执行时都会扫描过期数据、hash resize缩容、rehash数据迁移等。感兴趣的可以看一下代码,src/server.c 下的 serverCron 和 databasesCron 方法。

图3 主动过期删除定时任务

总结

今天,我重点分析了 Reids 缓存过期删除的两种策略,我们复习一下。

1.被动过期删除:数据增删改查时都会调用 expireIfNeeded 方法,判断缓存是否过期。

2.主动过期删除:默认每 100ms 扫描一次设置了过期时间的数据,activeExpireCycle 方法为了避免阻塞应用,每次只会扫描部分数据。

讲到这里,Reids 缓存过期删除的源码我就介绍完了。我建议你特别关注下 Redis 在性能和可用性的平衡,架构设计往往鱼和熊掌不可兼得。最后,我用一张思维导图来帮助你理解和记忆这两种过期删除策略:

文章推荐:


每天用心记录一点点。内容也许不重要,但习惯很重要!

posted on 2024-03-31 11:49  binarylei  阅读(54)  评论(0编辑  收藏  举报

导航