Redis过期删除策略 学习笔记

189 阅读7分钟

设置命令

Redis中,设置key的过期时间有4种命令:

  • expire key_name n: 设置该key在n秒后过期
  • pexpire key_name n: 设置该key在n毫秒后过期
  • expireat key_name n: 设置该key在某个时间戳(精确到秒)之后过期
  • pexpireat key_name n: 设置该key在某个时间戳(精确到毫秒)之后过期

当然,也可以在set时同时设置过期时间,有3种格式

  • set <key> <value> ex <n>: 设置键值对的同时,指定过期时间(精确到秒)
  • set <key> <value> px <n>: 设置键值对的同时,指定过期时间(精确到毫秒)
  • setex <key> <n> <value>: 设置键值对的同时,指定过期时间(精确到秒)

可以使用TTL命令查询某个key的剩余存活时间

> setex key1 60 value1
OK

> ttl key1
(integer) 56

取消key的过期时间,可以使用persist命令

# 取消 key1 的过期时间
> persist key1
(integer) 1

> ttl key1
(integer) -1 #表示永不过期

过期字典

每当我们对一个key设置了过期时间,Redis就会把其存储到一个过期字典中,也就是说,过期字典保存了数据库中所有key的过期时间

过期字典定义在redisDb结构中

typedef struct redisDb{
    dict *dict;        // 存放所有键值对
    dict *expires;     // 存放键的过期时间
    ...
}redisDb;

过期字典数据结构如下

  • key是一个指针,指向某个对象
  • value是一个long long型整数,记录该key的过期时间(毫秒级 Unix 时间戳)

image.png

可以看出,这实际上就是个hash表,可以在O(1)时间复杂度快速查找某个key是否过期,当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中,过期判断流程如下

image.png

三种过期删除策略

常见的过期删除策略有:

  1. 定时删除

    实现:在设置key过期时间时,创建一个定时事件,当时间到达时由事件处理器字段执行删除操作

    优点:对内存最友好,可以保证过期key被尽快删除,内存可以被尽快释放

    缺点:对CPU不友好,在过期可以比较多的情况下,删除过程可能会占用相当一部分CPU时间,在内存不紧张但CPU资源紧张的情况下,将CPU时间用于删除和当前任务无关的过期key,无疑对服务器的响应时间和吞吐量造成影响。

  2. 惰性删除

    实现:不主动删除过期key,等到每次查询时,再去检测是否已过期,如果过期则删除该key

    优点:对CPU时间最友好,因为等到访问时才会检查key是否过期,该策略只会使用很少的系统资源

    缺点:对内存不友好,如果一个key已过期,只要这个key一直没有被访问到,它所占的内存就不会释放,造成了一定的内存空间浪费。

  3. 定期删除

    实现:每隔一段时间随机从数据库中抽取一定数量的key进行检查,并删除其中的过期key

    优点:通过限制删除操作的执行时长和频率,减少删除操作对CPU的影响,同时也能定期删除一部分过期数据,避免长时间对内存的无效占用

    缺点:难以确定删除操作执行的时长和频率,如果太频繁,就变得和定时删除策略一样,对CPU不友好;如果执行得太少,就变得和惰性删除差不多,过期key占用的内存空间无法及时释放。总的来说,内存清理方面没有定时删除策略效果好,系统资源占用方面又没有惰性删除策略少

Redis的过期删除策略具体实现

Redis选择的是,「惰性删除+定期删除」两种策略搭配使用,以求在合理使用CPU时间的同时,尽量避免内存浪费

怎样实现惰性删除?

Redis在每次访问或修改key之前,都会调用expireIfNeeded函数,检查该key是否过期:

  • 如果过期,删除该key,至于选择异步删除,还是同步删除,根据 lazyfree_lazy_expire参数配置决定(Redis4.0+开始提供参数),然后返回null给客户端;
  • 如果没过期,正常返回键值对给客户端

image.png

expireIfNeeded函数代码大致如下:

int expireIfNeeded(redisDb *db, robj *key) {
    mstime_t when = getExpire(db, key);
    mstime_t now;

    if (when < 0) return 0; // 如果 key 没有过期时间,直接返回 0

    /* 获取当前时间 */
    now = mstime();

    /* 如果当前时间小于过期时间,说明 key 仍然有效 */
    if (now <= when) return 0;

    /* Key 已过期,进行删除 */
    if (server.masterhost != NULL) return 1; // 复制节点不删除 key(交给主节点处理)

    server.stat_expiredkeys++; // 统计删除的 key 数量
    propagateExpire(db, key, server.lazyfree_lazy_expire ? PROPAGATE_ASYNC : PROPAGATE_SYNC);

    // 同步删除或异步删除
    if (server.lazyfree_lazy_expire)
        dbAsyncDelete(db, key);
    else
        dbSyncDelete(db, key);

    notifyKeyspaceEvent(NOTIFY_EXPIRED, "expired", key, db->id);
    return 1;
}

怎样实现定期删除?

定期删除策略的做法:每隔一段时间,随机从数据库中抽取一定数量的key进行检查,并删除其中的过期key

1、时间间隔设置多长?

Redis中默认是每秒进行10次过期检查,间隔可修改配置文件redis.conf中的hz参数

  • 默认值:10(即 每秒 10 次

  • 取值范围:1 ~ 500(Redis 4.0之后支持高达 500)

  • 作用

    • 影响 serverCron()运行频率,进而影响定期删除过期键的频率。
    • hz 越高,Redis 扫描 expires 字典的频率越高,过期键更快被删除,但 CPU 占用也会增加。

查看当前hz配置值:CONFIG GET hz

如何修改 hz 配置

临时修改(立即生效,但重启后失效):CONFIG SET hz 100

持久化修改(写入 redis.conf,重启后仍生效):echo "hz 100" >> /etc/redis/redis.conf

注意:每次检查数据库并不是遍历过期字典中的所有key,而是随机抽取一定数量的key进行检查

2、随机抽查的数量是多少?

每次随机抽查的 key 数量由 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 这个宏定义决定,默认值为20,如果 hz = 10,那么每秒大约扫描 10 × 20 = 200 个 key。

如果 Redis 负载较高,可能减少扫描次数,以降低 CPU 负担

定期删除实际上是一个循环过程,本轮检查的过期key占比(已过期key数量/随机抽取的key数量)超过25%就会重复执行,否则等待下一轮检查

但为了保证不会死循环或循环过度,导致线程卡死,还增加设置了一个时间上限,默认25s,流程图如下

image.png

activeExpireCycle的源码大致如下:

void activeExpireCycle(int type) {
    int j, iteration = 0; 
    int dbs_per_call = CRON_DBS_PER_CALL;  // 每次最多检查 16 个数据库
    long long start = ustime(), timelimit; // 记录当前时间和最大执行时间
    static unsigned int current_db = 0;    // 记录上次遍历到哪个数据库

    /* 确定此次定期删除的最大执行时长 */
    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; // 快速模式下的最大执行时间
    } else {
        // 计算定期删除的最大执行时间:1/hz 秒
        timelimit = server.hz ? 1000000 / server.hz : 100000;
    }

    /* 遍历多个数据库(DB),进行过期 key 检查 */
    for (j = 0; j < dbs_per_call; j++) {
        redisDb *db = server.db + (current_db % server.dbnum); // 选择当前要检查的 DB
        current_db++;  // 记录下次要检查的 DB

        int expired; // 记录当前批次删除的 key 数量
        do {
            long long now = mstime(); // 获取当前时间(毫秒)
            int ttl_test = 20; // 每轮随机抽查 20 个 key
            expired = 0; // 统计删除的过期 key 数量

            /* 在当前 DB 中随机检查 20 个 key,删除过期 key */
            while (ttl_test--) {
                dictEntry *de = dictGetRandomKey(db->expires); // 随机获取一个可能过期的 key
                if (de == NULL) break; // 如果没有 key 了,则跳出循环

                long long ttl = dictGetSignedIntegerVal(de) - now; // 计算 key 的剩余 TTL
                if (ttl <= 0) { // 过期 key
                    dbDelete(db, dictGetKey(de)); // 删除过期 key
                    expired++; // 统计删除 key 数量
                }
            }

            iteration++; // 记录总迭代次数

            /* 如果已过期 key 占比 > 25%,继续删除 */
        } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP / 4);

        /* 如果定期删除任务的总执行时间超过 timelimit,则提前退出 */
        if (ustime() - start > timelimit) break;
    }
}