在Redis技术交流群里,总结了一下Redis从2.8~4.0关于过期键相关的fix记录,非常有帮助,但有些东西未尽详细,本文将进行详细说明。
先从一个问题来看,运行环境如下:
Redis: 2.8.19
db0:keys=10000000,expires=10000000
主从结构
原因先不说,本文来探讨下Redis2.8-4.0版本迭代中,针对过期键的fix,看看能不能找到答案。
一、过期功能回顾
当你执行了一条setex命令后,Redis会向内部的dict和expires哈希结构中分别插入数据:
dict------dict[key]:value
expires---expires[key]:timeout
例如:
127.0.0.1:6379> setex hello 120 world
OK
127.0.0.1:6379> info
# 该数据库中设置为过期键并且未被删除的总量(如果曾设置为过期键且删除则不计入)
db0:keys=1,expires=1,avg_ttl=41989
# 历史上每一次删除过期键就做一次加操作,记录删除过期键的总数。
expired_keys:0
二、Redis过期键的删除策略:
当键值过期后,Redis是如何处理呢?综合考虑Redis的单线程特性,有两种策略:惰性删除和定时删除。
1.惰性删除策略:
在每次执行key相关的命令时,都会先从expires中查找key是否过期,下面是3.0.7的源码(db.c):
下面是读写key相关的入口:
robj *lookupKeyRead(redisDb *db, robj *key) {
robj *val;
expireIfNeeded(db,key);
val = lookupKey(db,key);
......
return val;
}
robj *lookupKeyWrite(redisDb *db, robj *key) {
expireIfNeeded(db,key);
return lookupKey(db,key);
}
可以看到每次读写key前,所有的Redis命令在执行之前都会调用expireIfNeeded函数:
int expireIfNeeded(redisDb *db, robj *key) {
mstime_t when = getExpire(db,key);
mstime_t now;
if (when < 0) return 0; /* No expire for this key */
now = server.lua_caller ? server.lua_time_start : mstime();
if (server.masterhost != NULL) return now > when;
/* Return when this key has not expired */
if (now <= when) return 0;
/* Delete the key */
server.stat_expiredkeys++;
propagateExpire(db,key);
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",key,db->id);
return dbDelete(db,key);
}
从代码可以看出,主从逻辑略有不同:
(1) 主库:过期则expireIfNeeded会删除过期键,删除成功返回1,否则返回0。
(2) 从库:expireIfNeeded不会删除key,而会返回一个逻辑删除的结果,过期返回1,不过期返回0。
但是从库过期键删除由主库的synthesized DEL operations控制。
2.定时删除策略:
单单靠惰性删除,肯定不能删除所有的过期key,考虑到Redis的单线程特性,Redis使用了定期删除策略,采用策略是从一定数量的数据库的过期库中取出一定数量的随机键进行检查,不为空则删除。不保证实时删除。有兴趣的同学可以看看activeExpireCycle中具体实现,还是挺有意思的,下图是个示意图:
(1)主库: 会定时删除过期键。
(2)从库: 不执行定期删除。
综上所述:
主库:
(1) 在执行所有操作之前调用expireIfNeeded惰性删除。
(2) 定期执行调用一次activeExpireCycle,每次随机删除部分键(定时删除)。
从库:过期键删除由主库的synthesized DEL operations控制。
三、过期读写问题
Redis过期删除策略带来的问题。我们只从用户操作的角度来讨论。
1、过期键读操作
下面是Redis 2.8~4.0过期键读操作的fix记录
(1) Redis2.8主从不一致
2.8中的读操作中都先调用lookupKeyRead函数:
robj *lookupKeyRead(redisDb *db, robs *key) {
robj *val;
expireIfNeeded(db,key);
val = lookupKey(db,key);
if (val == NULL)
server.stat_keyspace_misses++;
else
server.stat_keyspace_hits++;
return val;
}
- 对于主库,执行expireIfNeeded时,过期会删除key。lookupKey返回 NULL。
- 对于从库,执行expireIfNeeded时,过期不会删除key。lookupKey返回value。
所以对于过期键的读操作,主从返回就会存在不一致的情况,也就是开篇提到的问题。
(2) Redis 3.2主从除exists之外都一致
3.2-rc1读操作中同样先调用了lookupKeyRead,实际上调用的是lookupKeyReadWithFlags函数:
robj *lookupKeyReadWithFlags(redisDb *db, robj *key) {
robj *val;
if (expireIfNeeded(db,key) == 1) {
if (server.masterhost == NULL) return NULL;
if (server.current_client && //当前客户端存在
server.current_client != server.master && //当前客户端不是master请求建立的(用户请求的客户端)
server.current_client->cmd &&
server.current_client->cmd->flags & REDIS_CMD_READONLY) { //读命令
return NULL;
}
val = lookupKey(db,key,flags);
if (val == NULL)
server.stat_keyspace_misses++;
else
server.stat_keyspace_hits++;
return val;
}
可以看到,相对于2.8,增加了对expireIfNeeded返回结果的判断:
- 对于主库,执行expireIfNeeded时,过期会删除key,返回1。masterhost为空返回NULL。
- 对于从库,执行expireIfNeeded时,过期不会删除key,返回1。满足当前客户端不为 master且为读命令时返回NULL。
除非程序异常。正常情况下对于过期键的读操作,主从返回一致。
(2) Redis 4.0.11解决exists不一致的情况
3.2并未解决exists这个命令的问题,虽然它也是个读操作。之后的4.0.11中问题才得以解决.
2、过期键写操作
在具体说这个问题之前,我们先说一下可写从库的使用场景。
(1).主从分离场景中,利用从库可写执行耗时操作提升性能。
(2). 迁移数据时,需要先将从库设置为可写。
四.总结
1、针对过期键读操作
(1) Redis2.8主从不一致
(2) Redis3.2-rc1主从除exists之外都一致: https://github.com/antirez/redis/commit/06e76bc3e22dd72a30a8a614d367246b03ff1312
(3) Redis4.0.11主从一致:
https://github.com/antirez/redis/commit/32a7a2c88a8b8cca8119b849eee7976b8ada8936
2、针对过期键的写操作:
Redis2.8~4.0都只返回物理结果。
3、从库中对key执行expire操作,key不会过期。
Redis4.0 rc3解决从库中设置的过期键不过期问题 https://github.com/antirez/redis/commit/c65dfb436e9a5a28573ec9e763901b2684eadfc4