结合源码看Redis过期策略

本文深入探讨了Redis的三种过期策略:定时过期、惰性过期和定期过期。详细介绍了每种策略的工作原理及优缺点,特别是定期过期策略的实现细节和技术考量。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

注意事项

笔者所看的源码是redis稳定版 6.2 版本的

常用的过期策略

1. 定时过期,主动过期

这个策略是需要一个过时器,对每一个key都设计一个定时器。

优点:对内存友好,但是严重消耗CPU,对CPU非常不友好,这个redis没有采用

为什么redis不采用这个过期策略呢?

想想, 每一个key都要一个定时器,是不是特别消耗CPU,而redis又是一个特别注重吞吐量的数据库,这样过期势必会大大的降低吞吐。

2. 惰性过期

只有来访问的时候才知道是否过期。就像从冰箱拿东西,只有去拿检查的时候,才知道是否过期。

redis的过期策略之一

int expireIfNeeded(redisDb *db, robj *key) {
    // 如果没有过期,直接返回
    if (!keyIsExpired(db,key)) return 0;

    /* If we are running in the context of a slave, instead of
     * evicting the expired key from the database, we 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. */
     // 如果是从节点
    if (server.masterhost != NULL) return 1;

    /* If clients are paused, we keep the current dataset constant,
     * but return to the client what we believe is the right state. Typically,
     * at the end of the pause we will properly expire the key OR we will
     * have failed over and the new primary will send us the expire. */
    // 正在选取主节点的时候不允许过期
    if (checkClientPauseTimeoutAndReturnIfPaused()) return 1;

    /* Delete the key */
    // 过期的key加一
    server.stat_expiredkeys++;
    // 传播到从服务器和AOF文件,
    propagateExpire(db,key,server.lazyfree_lazy_expire);
    // 发送事件
    notifyKeyspaceEvent(NOTIFY_EXPIRED,
        "expired",key,db->id);
    // 如果服务器在惰性删除开启,异步做key的删除,如果没有开启,就同步做删除
    int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
                                               dbSyncDelete(db,key);
    // 如果删除成功就发送通知
    if (retval) signalModifiedKey(NULL,db,key);
    return retval;
}

结合上面的代码可以知道:

  1. 从节点不能主动删除过期的key
  2. 在选取主节点的时候不能删除过期的key
  3. 一旦判断可以删除,那么redis主节点在删除之前,会发起删除通知到slave从节点
  4. 如果服务器开启懒过期,那么会异步删除过期的key。
  5. 如果删除成功就会通知客户端的监听者

3. 定期过期

每隔一段时间扫描,然后把过期的key删除。

/* 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.、
 *
 * 尝试让几个超时的密钥失效。使用的算法是自适应的,并将使用少量的CPU周期,如果有少量过期的键,否则它将采取更积极的措施,以避免过多的内存被可以从键空间中删除的键使用。
 *
 * Every expire cycle tests multiple databases: the next call will start
 * again from the next db. No more than CRON_DBS_PER_CALL databases are
 * tested at every iteration.
 *
 * 每个过期周期测试多个数据库:下一个调用将从下一个数据库再次启动。每次迭代都只测试CRON_DBS_PER_CALL数据库。
 *
 * The function can perform more or less work, depending on the "type"
 * argument. It can execute a "fast cycle" or a "slow cycle". The slow
 * cycle is the main way we collect expired cycles: this happens with
 * the "server.hz" frequency (usually 10 hertz).
 *
 * 函数可以执行更多或更少的工作,这取决于“type”参数。它可以执行 “快周期”或“慢周期”。慢周期是我们收集过期周期的主要方式:这种情况发生在“server.hz”上。频率(通常为10赫兹)。
 *
 * However the slow cycle can exit for timeout, since it used too much time.
 * For this reason the function is also invoked to perform a fast cycle
 * at every event loop cycle, in the beforeSleep() function. The fast cycle
 * will try to perform less work, but will do it much more often.
 *
 * 然而,慢周期可能超时退出,因为它使用了太多的时间。因此,在beforeSleep()函数中,也会在每个事件循环周期调用该函数来执行快速循环。快速循环将尝试执行更少的工作,但将做得更频繁。
 *
 * The following are the details of the two expire cycles and their stop
 * conditions:
 *
 * If type is ACTIVE_EXPIRE_CYCLE_FAST the function will try to run a
 * "fast" expire cycle that takes no longer than ACTIVE_EXPIRE_CYCLE_FAST_DURATION
 * microseconds, and is not repeated again before the same amount of time.
 * The cycle will also refuse to run at all if the latest slow cycle did not
 * terminate because of a time limit condition.
 *
 * 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 ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC define. In the
 * fast cycle, the check of every database is interrupted once the number
 * of already expired keys in the database is estimated to be lower than
 * a given percentage, in order to avoid doing too much work to gain too
 * little memory.
 *
 * The configured expire "effort" will modify the baseline parameters in
 * order to do more work in both the fast and slow expire cycles.
 *
 * 以下是两个到期周期及其停止条件的详细信息:
 *
 * 如果type为 ACTIVE_EXPIRE_CYCLE_FAST,
 * 则该函数将尝试运行不超过 ACTIVE_EXPIRE_CYCLE_FAST_DURATION 微秒的“快速”过期周期,并且在相同的时间量之前不会再次重复。如果最新的慢速周期,则该周期也将完全拒绝运行 由于时间限制条件未终止。
 *
 * 如果type为 ACTIVE_EXPIRE_CYCLE_SLOW,
 * 则执行该正常的过期周期,其中时间限制是由ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC定义的REDIS_HZ周期的百分比。
 * 在快速周期中,一旦数据库中已经过期的键的数量估计低于给定的百分比,就会中断对每个数据库的检查,以避免避免进行过多的工作来获得太少的内存。
 *
 * 配置的到期“effort”将修改基线参数,以便在快速和缓慢的到期周期中进行更多工作。
 */

#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* Keys for each DB loop. 每个DB循环的键,就是每次取20个 */
#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* Microseconds. 快速过期周期 1000微妙 */
#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /*   Max % of CPU to use. 正常过期周期占用CPU时间百分比 */
#define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* % of stale keys after which we do extra efforts. 主动失效周期可接受的阶段 过时密钥的百分比,在此之后我们会付出额外的工作量 */

void activeExpireCycle(int type) {
    /* Adjust the running parameters according to the configured expire
     * effort. The default effort is 1, and the maximum configurable effort
     * is 10.
     * 根据配置的失效时间调整运行参数。 默认工作量为1,最大可配置工作量为10。
     */
    unsigned long
            effort = server.active_expire_effort - 1, /* Rescale from 0 to 9. */
    // 每次循环取的键数  值为 20 + 20 / 4 * 努力程度
    // (20 到 25 之间, 这取决于 server.active_expire_effort 的配置的值为多少)
    // 因为 server.active_expire_effort 默认为1 ,所以 config_keys_per_loop 默认为 20
    config_keys_per_loop = ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP +
                           ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP / 4 * effort,
    // 快速过期周期 值为 1000 + 1000 / 4 * 努力程度
    // (1000 微秒 到 1250 微秒之间, 这取决于 server.active_expire_effort 的配置的值为多少)
    // 因为 server.active_expire_effort 默认为1 ,所以 config_cycle_fast_duration 默认为 1000
    config_cycle_fast_duration = ACTIVE_EXPIRE_CYCLE_FAST_DURATION +
                                 ACTIVE_EXPIRE_CYCLE_FAST_DURATION / 4 * effort,
    // 正常周期 CPU 占比百分比 值为 25 + 2 * 额外工作量(25 到 43, 这取决于 server.active_expire_effort 的配置的值为多少)
    // 默认 25
    config_cycle_slow_time_perc = ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC +
                                  2 * effort,
    // 回收阈值 值为 10 - 努力程度 (1- 10 这取决于 server.active_expire_effort 的配置的值为多少 默认10)
    config_cycle_acceptable_stale = ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE -
                                    effort;

    /* This function has some global state in order to continue the work
     * incrementally across calls.
     * 这个函数有一些全局状态,以便在调用之间增量地继续工作。*/
    static unsigned int current_db = 0; /* Next DB to test. 下一个DB偏移量 */
    static int timelimit_exit = 0;      /* Time limit hit in previous call? 前一次调用是否超过了 时间限制*/
    static long long last_fast_cycle = 0; /* When last fast cycle ran.上一次快速周期结束时间 */

    int j, iteration = 0;
    // CRON_DBS_PER_CALL 每次定期收集调用需要扫描数据库总数 值为16
    int dbs_per_call = CRON_DBS_PER_CALL;
    // start 为开始时间 timelimit 是回收过期key时间限制,elapsed 已过多少时间
    long long start = ustime(), timelimit, elapsed;

    /* When clients are paused the dataset should be static not just from the
     * POV of clients not being able to write, but also from the POV of
     * expires and evictions of keys not being performed.
     *
     * 当客户端暂停时,数据集应该是静态的,不仅是因为客户端无法写入的POV,而且还应该是由于过期的POV和未执行密钥退出而导致的。
     * 这个是选取主节点的过程,删除过期的键是不允许的
     */
    if (checkClientPauseTimeoutAndReturnIfPaused()) return;
    // 如果是快速过期删除模式
    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        /* Don't start a fast cycle if the previous cycle did not exit
         * for time limit, unless the percentage of estimated stale keys is
         * too high. Also never repeat a fast cycle for the same period
         * as the fast cycle total duration itself.
         * 如果前一个周期没有在时间限制内退出,则不要开始快速周期,除非估计的陈旧key的百分比过高。
         * 也不要在与快速循环总持续时间本身相同的时间段内重复快速循环。*/
        // stat_expired_stale_perc 是可能已过期的key的百分比 如果小于回收阈值 直接返回
        if (!timelimit_exit &&
            server.stat_expired_stale_perc < config_cycle_acceptable_stale)
            return;
        // 如果开始时间小于上一个 快速周期结束时间
        if (start < last_fast_cycle + (long long) config_cycle_fast_duration * 2)
            return;
        // 如果可以回收,那么上一次结束时间就是开始时间,
        last_fast_cycle = start;
    }

    /* We usually should test CRON_DBS_PER_CALL per iteration, with
     * two exceptions:
     *
     * 1) Don't test more DBs than we have.
     * 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.
     * 通常,我们应该在每次迭代中测试CRON_DBS_PER_CALL,但有两个例外:
     * 1)不要测试比我们更多的数据库。
     * 2)如果上次我们达到了时间限制,则我们希望扫描此迭代中的所有数据库,因为某些数据库中有工作要做,并且我们不希望过期的键占用过多的内存。*/
    // 如果允许调用的数据库数大于16 或者 前一次已经命中
    if (dbs_per_call > server.dbnum || timelimit_exit)
        // 赋值
        dbs_per_call = server.dbnum;

    /* We can use at max 'config_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.
     * 我们可以使用每次迭代的最大CPU时间百分比“ config_cycle_slow_time_perc”。 由于此函数以每秒server.hz次的频率被调用,因此以下是我们可以在此函数中花费的最大微秒数。*/
    // 允许回收的最大时间 值为 默认25% * 1百万微妙(1s) / 服务器赫兹 (默认10)
    // 所以下面也有默认值 默认为 25000 微妙
    timelimit = config_cycle_slow_time_perc * 1000000 / server.hz / 100;
    // 是否超过时间限制
    timelimit_exit = 0;
    // 校验过期回收时间限制
    if (timelimit <= 0) timelimit = 1;

    if (type == ACTIVE_EXPIRE_CYCLE_FAST)
        // 如果是快速收集模式, 收集时间限制在一毫秒
        timelimit = config_cycle_fast_duration; /* in microseconds. */

    /* Accumulate some global stats as we expire keys, to have some idea
     * about the number of keys that are already logically expired, but still
     * existing inside the database.
     * 随着key的过期,累积一些全局统计信息,以了解已在逻辑上过期但仍存在于数据库中的密钥的数量。*/
    // 总共抽取的样本数量
    long total_sampled = 0;
    // 总的过期key数量
    long total_expired = 0;
    // 循环扫描库,但是前提是这个库前一次没有命中
    for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
        /* Expired and checked in a single loop. */
        // 这个库每一次采样 选取的样本,和这个库每一次采样过期的key数
        unsigned long expired, sampled;
        // 获取当前db
        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。*/
        current_db++;

        /* Continue to expire if at the end of the cycle there are still
         * a big percentage of keys to expire, compared to the number of keys
         * we scanned. The percentage, stored in config_cycle_acceptable_stale
         * is not fixed, but depends on the Redis configured "expire effort".
         *
         * 如果在周期结束时,与我们扫描的密钥数量相比,仍有很大一部分密钥要过期,请继续过期。
         * 存储在 config_cycle_acceptable_stale 中的百分比不是固定的,而是取决于Redis配置的“expire effort”。*/
        do {
            unsigned long num, slots;
            long long now, ttl_sum;
            int ttl_samples;
            iteration++;

            /* If there is nothing to expire try next DB ASAP. */
            // 如果这个db过期hash表里没有什么要收集的,把库里的平均收集时间设置为0,结束循环,直接扫下一个DB
            // num是已经使用的槽
            if ((num = dictSize(db->expires)) == 0) {
                // 这个数据库的平均收集时间为0
                db->avg_ttl = 0;
                break;
            }
            // 过期字典里的槽大小
            slots = dictSlots(db->expires);
            // 现在时间
            now = mstime();

            /* When there are less than 1% filled slots, sampling the key
             * space is expensive, so stop here waiting for better times...
             * The dictionary will be resized asap.
             * 当少于1%的已填充插槽时,对密钥空间进行采样非常昂贵,因此请在这里等待更好的时间...字典将尽快调整大小。*/
            // 如果槽数大于初始化的hash表大小并且过期字典已经使用的槽很少,也直接扫下一个DB
            // 很少 有用的槽乘以一百比上 槽数都小于1
            if (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;
            sampled = 0;
            // 生存时间总和
            ttl_sum = 0;
            // 生存样本数
            ttl_samples = 0;
            // num 已经使用的槽 设置为 20 或者小于20
            if (num > config_keys_per_loop)
                num = config_keys_per_loop;

            /* Here we access the low level representation of the hash table
             * for speed concerns: this makes this code coupled with dict.c,
             * but it hardly changed in ten years.
             *
             * Note that certain places of the hash table may be empty,
             * so we want also a stop condition about the number of
             * buckets that we scanned. However scanning for free buckets
             * is very fast: we are in the cache line scanning a sequential
             * array of NULL pointers, so we can scan a lot more buckets
             * than keys in the same time.
             *
             * 在这里,出于速度方面的考虑,我们访问哈希表的低级表示形式:这使该代码与dict.c结合在一起,但十年来几乎没有改变。
             *
             * 注意,哈希表的某些位置可能为空,因此我们还需要一个关于我们扫描的存储桶数量的停止条件。
             * 但是,扫描空闲存储桶的速度非常快:我们正在高速缓存行中扫描NULL指针的顺序数组,因此我们可以同时扫描比键更多的存储桶。*/
            long max_buckets = num * 20;
            long checked_buckets = 0;
            // 如果本次样本数小于要取的数,已经检查的桶小于最大可扫描的桶数,循环
            while (sampled < num && checked_buckets < max_buckets) {
                for (int table = 0; table < 2; table++) {
                    // 第一个table 并且过期字典没有再hash 直接跳出循环
                    // 每一个字典有了两张hash table
                    //  ht[0]指向旧哈希表, ht[1]指向扩容后的新哈希表. 这个就是说,这个字典没有新hash表,所以不用扫描
                    if (table == 1 && !dictIsRehashing(db->expires)) break;
                    // idx是游标 下面语句就是取 每一个table 每一个桶对应的链表
                    unsigned long idx = db->expires_cursor;
                    idx &= db->expires->ht[table].sizemask;
                    dictEntry *de = db->expires->ht[table].table[idx];
                    // 生存时间
                    long long ttl;

                    /* Scan the current bucket of the current table. */
                    checked_buckets++;
                    // 循环 桶对应的链表
                    while (de) {
                        /* Get the next entry now since this entry may get
                         * deleted. */
                        dictEntry *e = de;
                        de = de->next;
                        // 生存时间是key 可以生存到的时间 减去 现在时间
                        ttl = dictGetSignedIntegerVal(e) - now;
                        // 判断并回收key 过期数加1
                        if (activeExpireCycleTryExpire(db, e, now)) expired++;
                        if (ttl > 0) {
                            // 如果 key 没有过期
                            /* We want the average TTL of keys yet
                             * not expired. */
                            // 总共生存时间累加
                            ttl_sum += ttl;
                            // 生存的样本数加一
                            ttl_samples++;
                        }
                        // 样本数加一
                        sampled++;
                    }
                }
                // 游标移动
                db->expires_cursor++;
            }
            total_expired += expired;
            total_sampled += sampled;

            /* Update the average TTL stats for this database. */
            // 如果生存的样本大于0
            if (ttl_samples) {
                // 计算 平均生存时间 设置db 平均生存时间
                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%.
                 * 用几个样本做一个简单的移动平均数。 我们仅使用权重为2%的当前估计值和权重为98%的先前估计值。*/
                if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
                db->avg_ttl = (db->avg_ttl / 50) * 49 + (avg_ttl / 50);
            }

            /* 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.
             * 即使有很多要过期的密钥,我们也不能在这里永远阻止。 因此,在给定的毫秒数后,返回到调用方,等待另一个活动的到期周期。*/
            // 判断有木有扫描完所有db
            if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
                // 消耗的时间
                elapsed = ustime() - start;
                // 如果消耗的时间 大于限制时间,直接退出
                if (elapsed > timelimit) {
                    timelimit_exit = 1;
                    // 服务器过早退出过期循环数加1
                    server.stat_expired_time_cap_reached_count++;
                    break;
                }
            }
            /* We don't repeat the cycle for the current database if there are
             * an acceptable amount of stale keys (logically expired but yet
             * not reclaimed). 如果存在可接受数量的过期key(逻辑上已过期但尚未回收),我们将不为当前数据库重复该周期。*/
            // 本次扫描的样本为0(这个数据库没有数据),  或者过期数大于10% 就一直扫描
        } while (sampled == 0 ||
                 (expired * 100 / sampled) > config_cycle_acceptable_stale);
    }
    // 已经过了多少时间
    elapsed = ustime() - start;
    // 设置服务器累计使用的微妙数
    server.stat_expire_cycle_time_used += elapsed;
    // 仅当经过时间>= 达到配置的阈值时,才添加样本。潜在的添加样本
    latencyAddSampleIfNeeded("expire-cycle", elapsed / 1000);

    /* Update our estimate of keys existing but yet to be expired.
     * Running average with this sample accounting for 5%. */
    double current_perc;
    if (total_sampled) {
        // 计算本次扫描秒的key百分比
        current_perc = (double) total_expired / total_sampled;
    } else
        current_perc = 0;
    // 设置 可能已过期的key的百分比
    server.stat_expired_stale_perc = (current_perc * 0.05) +
                                     (server.stat_expired_stale_perc * 0.95);
}

大致的步骤

  1. 16 个库挨个扫描
  2. 每一个库,取20个(默认)设置了过期时间的 key。
  3. 检查删除20个里面过期的key。
  4. 如果每次过期的大于所取样本的10%(默认)或 没有过期(样本数为0)的,那么循环第一步 和 第二步。
终止扫描的条件

全局扫描秒终止条件:

  • 使用的时间大于限制时间(默认是25ms / 25000微妙),如果是快速过期 时间更短(默认 1ms),这个终止是立即终止,不循环库了。
  • 每一个库都扫描完了。

每一个库终止条件:

  • 取的样本不为0,并且过期比例小于10% 就不扫描了

需要注意的是。

  1. 定期扫描发生的时候会在 redis 哈希扩容时触发
  2. 定期扫描会在 aeMain-主服务器循环 和 processEventsWhileBlocked-在RDB / AOF加载期间处理客户端 时调用。

为什么要上限25毫秒和10%的阈值

前文说过了,redis是高吞吐量的,不限制过期回收的时间,那么redis单线程吞吐量势必会大大降低。

定期扫描 25毫秒的限制和10%阈值的结合,可以分散过期处理的压力。过期的键没有10%,代表没有继续清理的必要。

关于异步删除

异步删除,是先unlink 某一个key,然后后台线程在慢慢的删除。unlink之后,客户端就读不出key的值了。

<think>首先,用户的问题是:“我想了解Redis过期策略的实现原理请问Redis过期策略实现原理源码分析定时删除惰性删除”。所以,我需要专注于Redis过期策略,包括定时删除和惰性删除的原理和源码分析。系统级指令:-所有行内数学表达式必须使用$...$格式。但Redis过期策略不涉及数学表达式,所以可能不需要。-独立公式必须使用$$...$$格式并单独成段。同样,可能不适用。-LaTeX语法正确:确保在需要时使用正确格式。-使用中文回答。-生成相关问题:在回答末尾添加相关问题。-回答中引用的段落末尾自然地添加引用标识,例如[^1]。用户可见层:-回答结构清晰,帮助用户逐步解决问题。-尽量保证回答真实可靠。-参考提供的引用:[1],[2],[3],[4]。这些是关于Redis过期策略的,我需要基于这些来构建回答。提供的引用摘要:-[1]:定期删除策略,周期性扫描删除过期键,分批进行,避免阻塞,占用CPU资源。-[2]:Redis删除过期数据的两种方式:惰性删除和通过定时任务定期删除。-[3]:深入源码剖析过期删除策略。-[4]:惰性删除的优点:只在取值时删除当前key,占用CPU少。用户问题聚焦于实现原理和源码分析,所以我需要整合这些信息。回答结构:1.**介绍Redis过期策略**:简要解释为什么需要过期策略。2.**惰性删除(LazyDeletion)**:解释原理、优缺点。3.**定时删除(PeriodicDeletion)**:解释原理、实现细节、源码分析。4.**源码分析**:基于引用[3],深入一些源码细节。5.**总结**:结合两种策略的优势。6.**生成相关问题**:在末尾添加相关问题。使用中文回答。自然地添加引用标识:当引用信息时,在段落末尾加[^数字]。现在,构建回答大纲:-**步骤1:引言**-Redis作为内存数据库,设置键过期时间来控制内存使用。过期策略确保过期键被删除。-主要策略:惰性删除和定期删除(定时删除)。-**步骤2:惰性删除**-原理:在访问键时检查是否过期,如果过期则删除。-优点:只在需要时删除,CPU占用少。-缺点:如果键不被访问,可能积累过期数据。-引用[4]支持。-**步骤3:定期删除**-原理:周期性运行任务,扫描并删除过期键。-实现:分批扫描数据库,避免阻塞。-源码分析:基于引用[3],描述Redis如何实现定时任务,比如在server.c或expire.c中的函数。-引用[1]和[2]支持。-**步骤4:源码分析细节**-例如,Redis使用一个定时器事件来处理定期删除。-关键函数:如activeExpireCycle,在expire.c中。-描述工作流程:随机选择键,检查过期时间,删除。-**步骤5:总结**-两种策略互补:惰性删除处理访问时删除,定期删除主动清理。-确保内存效率和性能。-**步骤6:相关问题**-生成3-5个相关问题。确保回答真实可靠:基于提供的引用,避免猜测。最终,添加相关问题。</think>### Redis 过期策略的实现原理及源码分析 Redis 作为一款高性能的内存键值数据库,需要有效管理内存资源。键(key)可以设置过期时间(TTL),当键过期后,Redis 采用两种策略删除它们:**惰性删除(Lazy Deletion)**和**定期删除(Periodic Deletion)**(也称为定时删除)。这两种策略互补工作,确保内存不被过期数据占用,同时平衡性能。下面我将逐步解释原理,并结合源码分析(基于 Redis 开源代码,版本参考 6.x)。 #### 1. **过期策略概述** Redis过期策略不是实时删除过期键,而是通过惰性删除和定期删除结合实现: - **惰性删除**:在访问键时检查并删除过期键,CPU 占用低。 - **定期删除**:周期性扫描数据库,主动删除过期键,防止内存积累。 这种设计避免了实时删除的性能开销,同时保证内存效率[^2]。 #### 2. **惰性删除(Lazy Deletion)的实现原理** 惰性删除是 Redis 的默认策略,核心思想是“按需删除”。当用户尝试读取一个键(如通过 `GET` 命令)时,Redis 会先检查该键是否过期。如果过期,则立即删除并返回空值;否则,正常返回数据。 - **优点**:删除操作只发生在键被访问时,且仅处理当前键,因此 CPU 占用极少,对性能影响小[^4]。 - **缺点**:如果过期键长期不被访问,会占用内存,可能导致内存泄露。 - **源码分析**: - 关键函数位于 `db.c` 文件中的 `lookupKeyReadWithFlags` 函数。当读取键时,会调用 `expireIfNeeded` 函数检查过期状态。 - `expireIfNeeded` 函数(在 `expire.c` 中)逻辑: - 检查键的过期时间(存储在 Redis 对象的 `expires` 字典中)。 - 如果过期,删除键并返回 1;否则返回 0。 - 删除操作最终调用 `dbSyncDelete` 或 `dbAsyncDelete` 函数,处理同步或异步删除。 - 示例伪代码简化: ```c int expireIfNeeded(redisDb *db, robj *key) { if (!keyIsExpired(db, key)) return 0; // 未过期,不处理 deleteExpiredKeyAndPropagate(db, key); // 删除键并传播删除事件 return 1; } ``` 在访问路径中(如 `GET` 命令),会先执行此检查,确保只有被访问的过期键被清理[^4]。 #### 3. **定期删除(Periodic Deletion)的实现原理** 定期删除是主动策略Redis 周期性运行后台任务,扫描并删除过期键。这解决了惰性删除的不足,防止过期键堆积。 - **原理**: - Redis 使用一个定时任务(时间事件),每 100 毫秒(可配置)执行一次。 - 每次任务只扫描部分数据库和键(分批进行),避免一次性扫描导致服务阻塞。 - 任务随机选择数据库和键,检查过期时间,如果过期则删除。 - **优点**:主动清理内存,减少过期数据积累。 - **缺点**:占用 CPU 资源,尤其在大量键过期时,可能影响性能[^1]。 - **源码分析**: - 核心函数是 `activeExpireCycle`,位于 `expire.c` 文件中。该函数由 Redis 的事件循环(`serverCron` 函数,在 `server.c` 中)定期调用。 - `activeExpireCycle` 工作流程: 1. **分批扫描**:每次任务限制扫描时间(默认 25 毫秒)和键数量(基于配置),避免阻塞主线程。Redis 使用自适应算法调整扫描量。 2. **随机选择键**:从每个数据库的过期字典(`expires`)中随机选取键,检查过期时间。 3. **删除处理**:如果键过期,调用删除函数(如 `dbDelete`),并更新统计信息。 4. **进度跟踪**:记录扫描位置,确保下次任务从断点继续。 - 关键代码片段(简化): ```c void activeExpireCycle(int type) { // 遍历数据库(分批次) for (j = 0; j < dbs_per_call; j++) { // 随机选择键检查 while (sampled < max_samples) { key = dictGetRandomKey(db->expires); if (keyIsExpired(db, key)) { deleteExpiredKey(db, key); // 删除过期键 expired++; } sampled++; } } // 调整下次扫描参数,避免过度占用 CPU updateExpireCycleParams(); } ``` 在 `serverCron` 函数中,`activeExpireCycle` 被定期触发,确保系统在高负载下也能平滑运行[^1][^3]。 #### 4. **策略结合与性能优化** Redis 将惰性删除和定期删除结合使用: - **互补性**:惰性删除处理高频访问键,定期删除清理“冷数据”。这种设计在大多数场景下高效,内存占用和 CPU 开销平衡[^2][^3]。 - **源码中的优化**: - **自适应扫描**:`activeExpireCycle` 根据系统负载动态调整扫描频率和范围。例如,如果过期键比例高,增加扫描量。 - **内存回收**:删除操作可能触发异步内存回收(如 `jemalloc`),减少碎片。 - **配置参数**:通过 `hz` 参数控制定时任务频率(默认 10,即每秒 10 次),用户可调整以平衡性能。 #### 5. **总结** Redis过期策略通过惰性删除和定期删除协同工作:惰性删除确保访问时的低延迟,定期删除主动维护内存健康。源码实现(如 `expireIfNeeded` 和 `activeExpireCycle` 函数)体现了高效的分批处理和自适应机制。这种设计在保证高性能的同时,最大限度减少资源浪费,适合高并发场景[^1][^2][^3][^4]。 如果您有具体配置或性能问题,我可以进一步深入分析! ### 相关问题 1. Redis 的定期删除策略如何避免阻塞主线程? 2. 在哪些场景下惰性删除可能导致问题?如何优化? 3. Redis 过期策略与其他内存数据库(如 Memcached)有何区别? 4. 如何监控 Redis过期键删除性能?有哪些关键指标? [^1]: Redis 源码解析 - Redis 键的过期处理策略。定期删除是一种主动清理过期键的策略Redis会周期性地执行一个任务,扫描一部分数据库中的键,检查并删除那些已经过期的键。这个过程是分批进行的,以避免因一次性扫描全部数据库而导致的阻塞问题。定期删除策略通过主动寻找并删除过期键,有助于确保Redis内存中不会积累大量未使用的过期数据,但需要注意的是,它可能会在执行期间占用一定的CPU资源。 实现细节。 [^2]: 追求性能极致 - Redis过期数据的删除策略。通过前面的章节,我们知道,Redis 是一个kv型数据库,我们所有的数据都是存放在内存中的,但是内存是有大小限制的,不可能无限制的增量。 想要把不需要的数据清理掉,一种办法是直接删除,这个咱们前面章节有详细说过;另外一种就是设置过期时间,缓存过期后,由Redis系统自行删除。 这边需要注意的是,缓存过期之后,并不是马上删除的,那Redis是怎么删除过期数据的呢?主要通过两个方式 惰性删除通过定时任务,定期选取部分数据删除。 [^3]: 深入浅出-Redis过期删除策略手术式源码刨析,小白也能看懂。之前就说了要来西索Redis,现在来辣! 本文的部分基础内容参考自《小林Coding》,深入的地方根据源代码进行剖析。 Redis源码地址:https://github.com/redis/redis.git 过期删除策略。 [^4]: Redis过期策略及实现原理。优点:删除操作只发生在通过key取值的时候发生,而且只删除当前key,所以对CPU时间的占用是比较少的,而且此时的删除是已经到了非做不可的地步(如果此时还不删除的话,我们就会获取到了已经过期的key了)。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值