设置过期时间
Redis 有四个不同的命令可以用于设置键的生存时间(键可以存在多久)或过期时间(键什么时候被删除):
EXPIRE <KEY> <TTL>
: 将键的生存时间设为 ttl 秒PEXPIRE <KEY> <TTL>
:将键的生存时间设为 ttl 毫秒EXPIREAT <KEY> <timestamp>
:将键的过期时间设为timestamp
所指定的秒数时间戳PEXPIREAT <KEY> <timestamp>
: 将键的过期时间设为timestamp
所指定的毫秒数时间戳.
虽然有不同的单位和不同的形式设置命令,但实际上 EXPIRE
、PEXPIRE
、EXPIREAT
、PEXPIREAT
四个命令内部都是调用同一个函数实现,
这可以从源码中看出, 在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