Redis 键的生存时间和过期时间

设置过期时间

Redis 有四个不同的命令可以用于设置键的生存时间(键可以存在多久)或过期时间(键什么时候被删除):

  • EXPIRE <KEY> <TTL> : 将键的生存时间设为 ttl 秒
  • PEXPIRE <KEY> <TTL> :将键的生存时间设为 ttl 毫秒
  • EXPIREAT <KEY> <timestamp> :将键的过期时间设为 timestamp 所指定的秒数时间戳
  • PEXPIREAT <KEY> <timestamp>: 将键的过期时间设为 timestamp 所指定的毫秒数时间戳.

虽然有不同的单位和不同的形式设置命令,但实际上 EXPIREPEXPIREEXPIREATPEXPIREAT四个命令内部都是调用同一个函数实现,
这可以从源码中看出, 在db.c 文件中,四个命令对应的函数如下:

void expireCommand(redisClient *c) {
    expireGenericCommand(c,mstime(),UNIT_SECONDS);
}
void expireatCommand(redisClient *c) {
    expireGenericCommand(c,0,UNIT_SECONDS);
}
void pexpireCommand(redisClient *c) {
    expireGenericCommand(c,mstime(),UNIT_MILLISECONDS);
}
void pexpireatCommand(redisClient *c) {
    expireGenericCommand(c,0,UNIT_MILLISECONDS);
}

可以看到 四个命令都是调用 expireGenericCommand这个函数,只是它们传的参数不同.

保存过期时间

在数据库结构 redisDb 中的 expires 字典保存了数据库中所有键的过期时间,这个字典成为过期字典.

  • 过期字典是一个指针,指向键空间的某个键对象
  • 过期字典的值是一个 long long 类型的整数,这个整数保存了键所指向的数据库键的过期时间–一个毫秒级的 UNIX 时间戳
typedef struct redisDb {
    ...

    dict *dict;     //数据库键空间,保存着数据库中所有键值对
    dict *expires      // 过期字典,保存着键的过期时间
    ...
} redisDb;

下图展示了一个带有过期字典的数据库例子.

此处输入图片的描述

过期字典保存了两个键值对:

  • 第一个键值对的键为 alphabet 键对象,值为毫秒级的 UNIX 时间戳
  • 第二个键值对的键为 book 键对象

移除过期时间

PERSIST 命令可以移除一个键的过期时间:

127.0.0.1:6379> set message "hello"
OK
127.0.0.1:6379> expire message 60
(integer) 1
127.0.0.1:6379> ttl message
(integer) 54
127.0.0.1:6379> persist message
(integer) 1
127.0.0.1:6379> ttl message
(integer) -1

PERSIST 就是 EXPIRE命令的反操作: 在过期字典中查找给定的键,并从过期字典中移除.
举个例子,如果数据库当前的状态如上节的图所示,当执行以下命令后:

redis> persist book
(integer) 1

数据库将更新成下图状态:

此处输入图片的描述
可以看到,当PERSIST命令执行之后,过期字典中原来的 book 键小时了.

计算并返回剩余生存时间

TTL 命令以秒为单位返回键的剩余生存时间, PTTL以毫秒为单位返回键的剩余生存时间.
两个命令都是通过计算简单过期时间和当前时间之间的差来实现的,在底层都是调用同一个函数.

void ttlCommand(redisClient *c) {
    ttlGenericCommand(c, 0);
}
void pttlCommand(redisClient *c) {
    ttlGenericCommand(c, 1);
}
void ttlGenericCommand(redisClient *c, int output_ms) {
    long long expire, ttl = -1;

    /* 如果键不存在,返回-2 */
    if (lookupKeyRead(c->db,c->argv[1]) == NULL) {
        addReplyLongLong(c,-2);
        return;
    }

    /* 如果键存在*/
    /*如果没有设置生存时间,返回 -1, 否则返回实际剩余时间 */
    expire = getExpire(c->db,c->argv[1]);
    if (expire != -1) {
        /* 过期时间减去当前时间,就是键的剩余时间*/
        ttl = expire-mstime();
        if (ttl < 0) ttl = 0;
    }
    if (ttl == -1) {
        addReplyLongLong(c,-1);
    } else {
         /*将毫秒转化为秒*/
        addReplyLongLong(c,output_ms ? ttl : ((ttl+500)/1000));
    }
}

过期键的删除策略

如果一个键是过期的, 那它什么时候会被删除?
这里有三种不同的删除策略:
- 定时删除:在设置键的过期时间时,创建一个定时事件,当过期时间到达时,由事件处理器自动执行键的删除操作。
- 惰性删除:放任键过期不管,但是在每次从 dict 字典中取出键值时,要检查键是否过期,如果过期的话,就删除它,并返回空;如果没过期,就返回键值。
- 定期删除:每隔一段时间,对 expires 字典进行检查,删除里面的过期键。

第一种和第三种为主动删除策略,第二种为被动删除策略

定时删除

定时删除策略对内存是最友好的: 因为它保证过期键会在第一时间被删除, 过期键所消耗的内存会立即被释放。

这种策略的缺点是, 它对 CPU 时间是最不友好的: 因为删除操作可能会占用大量的 CPU 时间 —— 在内存不紧张、但是 CPU 时间非常紧张的时候 (比如说,进行交集计算或排序的时候), 将 CPU 时间花在删除那些和当前任务无关的过期键上, 这种做法毫无疑问会是低效的。

除此之外, 目前 Redis 事件处理器对时间事件的实现方式 —— 无序链表, 查找一个时间复杂度为 O(N)O(N) —— 并不适合用来处理大量时间事件。

惰性删除

惰性删除对 CPU 时间来说是最友好的: 它只会在取出键时进行检查, 这可以保证删除操作只会在非做不可的情况下进行 —— 并且删除的目标仅限于当前处理的键, 这个策略不会在删除其他无关的过期键上花费任何 CPU 时间。

惰性删除的缺点是, 它对内存是最不友好的: 如果一个键已经过期, 而这个键又仍然保留在数据库中, 那么 dict 字典和 expires 字典都需要继续保存这个键的信息, 只要这个过期键不被删除, 它占用的内存就不会被释放。

在使用惰性删除策略时, 如果数据库中有非常多的过期键, 但这些过期键又正好没有被访问的话, 那么它们就永远也不会被删除(除非用户手动执行), 这对于性能非常依赖于内存大小的 Redis 来说, 肯定不是一个好消息。

举个例子, 对于一些按时间点来更新的数据, 比如日志(log), 在某个时间点之后, 对它们的访问就会大大减少, 如果大量的这些过期数据积压在数据库里面, 用户以为它们已经过期了(已经被删除了), 但实际上这些键却没有真正的被删除(内存也没有被释放), 那结果肯定是非常糟糕。

定期删除

从上面对定时删除和惰性删除的讨论来看, 这两种删除方式在单一使用时都有明显的缺陷: 定时删除占用太多 CPU 时间, 惰性删除浪费太多内存。

定期删除是这两种策略的一种折中:

它每隔一段时间执行一次删除操作,并通过限制删除操作执行的时长和频率,籍此来减少删除操作对 CPU 时间的影响。
另一方面,通过定期删除过期键,它有效地减少了因惰性删除而带来的内存浪费。

Redis 使用的策略

Redis 使用的过期键删除策略是惰性删除加上定期删除, 这两个策略相互配合,可以很好地在合理利用 CPU 时间和节约内存空间之间取得平衡。

因为前面已经说了这两个策略的概念了,下面两节就来探讨这两个策略在 Redis 中的具体实现。

过期键的惰性删除策略

实现过期键惰性删除策略的核心是 db.c/expireIfNeeded 函数 —— 所有命令在读取或写入数据库之前,程序都会调用 expireIfNeeded 对输入键进行检查, 并将过期键删除,expireIfNeeded 的作用是, 如果输入键已经过期的话, 那么将键、键的值、键保存在 expires 字典中的过期时间都删除掉。

看看实际的代码:

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

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

    /* 如果服务器正在加载数据,稍后再处理 */
    if (server.loading) return 0;

    ...

    /* 没有过期 */
    if (now <= when) return 0;

    /* 删除键和过期时间 */
    server.stat_expiredkeys++;
    /*将删除命令传播到 AOF 文件和附属节点*/
    propagateExpire(db,key);
    notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
        "expired",key,db->id);
    return dbDelete(db,key);
}

过期键的定期删除策略

对过期键的定期删除由 redis.c/activeExpireCycle 函执行: 每当 Redis 的例行处理程序 serverCron 执行时, activeExpireCycle 都会被调用 —— 这个函数在规定的时间限制内, 尽可能地遍历各个数据库的 expires 字典, 随机地检查一部分键的过期时间, 并删除其中的过期键。

实际代码:

void activeExpireCycle(int type) {
    ...

    /*遍历数据库(不一定能全部都遍历完,看时间是否足够)*/  
    for (j = 0; j < dbs_per_call; j++) {
        int expired;
        redisDb *db = server.db+(current_db % server.dbnum);
        do {
            ...
            /* 数据库为空,跳出 while ,处理下个 DB */
            if ((num = dictSize(db->expires)) == 0) {
                db->avg_ttl = 0;
                break;
            }
            slots = dictSlots(db->expires);
            now = mstime();
            ...
            ...
            while (num--) {
                dictEntry *de;
                long long ttl;
                /*随机取出一个带 TTL 的键*/
                if ((de = dictGetRandomKey(db->expires)) == NULL) break;
                ttl = dictGetSignedIntegerVal(de)-now;
                /*检查键是否过期,如果是的话,将它删除*/
                if (activeExpireCycleTryExpire(db,de,now)) expired++;
                if (ttl < 0) ttl = 0;
                ttl_sum += ttl;
                ttl_samples++;
            }
        ...
        ...
        } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
    }
}

我的博客 ygmyth.github.io

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值