前言
之前 白馨(陌陌-技术保障部存储工程师 )在Redis技术交流群里,总结了一下Redis从2.8~4.0关于过期键相关的fix记录,非常有帮助,但有些东西未尽详细,本文将进行详细说明。
先从一个问题来看,运行环境如下:
Redis: 2.8.19
db0:keys=10000000,expires=10000000
主从结构
从下图中可以看到,在从节点get hello非空,在主节点get hello为空,之后从节点get hello为空,经排查主从同步offset基本正常,但出现了主从不一致。
原因先不说,本文来探讨下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中具体实现,还是挺有意思的,下图是个示意图
if (server->masterhost == NULL) 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之外都一致
https://github.com/antirez/redis/commit/06e76bc3e22dd72a30a8a614d367246b03ff1312
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不一致的情况
https://github.com/antirez/redis/commit/32a7a2c88a8b8cca8119b849eee7976b8ada8936
3.2并未解决exists这个命令的问题,虽然它也是个读操作。之后的4.0.11中问题才得以解决.
2、过期键写操作
在具体说这个问题之前,我们先说一下可写从库的使用场景。
(1).主从分离场景中,利用从库可写执行耗时操作提升性能。
作者在https://redis.io/topics/replication 中提到过:
For example computing slow Set or Sorted set operations and storing them into local keys is an use case for writable slaves that was observed multiple times.
在 https://github.com/antirez/redis/commit/c65dfb436e9a5a28573ec9e763901b2684eadfc4 举了一个更具体的例子:
For instance imagine having slaves replicating certain Sets keys from the master. When accessing the data on the slave, we want to peform intersections between
such Sets values. However we don"t want to intersect each time: to cache the intersection for some time often is a good idea.
也就是说在读写分离的场景中,可以使用过期键的机制将从库作为一个缓存,去缓存从库上耗时操作的结果,提升整体性能。
(2). 迁移数据时,需要先将从库设置为可写。
比如下列场景:线上Redis服务正常,但可能遇到一些硬件的情况,需要对该机器上的Redis主从集群迁移。迁数据的方式就是搭建一个新的主从集群,让新主成为旧主的从。
进行如下操作:
•(1)主(旧主)从(新主)同步,rdb传输完毕90s之后,设置从库(新主)可写。
•(2)在主库(旧主)完全没有业务连接后,从库(新主)执行slaveof no one。
这种场景下,为了保证数据完全同步,并且尽量减少对业务的影响,就会先设置从库可写。
接着我们来做一个测试:
3.2版本主库执行的操作,主库的过期键正常过期。
3.2版本可写从库执行以下操作,从库的过期键并不会过期。
4.0rc3版本可写从库执行以下操作,从库的过期键却能够过期。
其实可写从库过期键问题包含两个问题:
•(1)从库中的过期键由主库同步过来的,过期操作由主库执行(未变更过)。
•(2)从库中的过期键的设置是从库上操作的。
redis4.0rc3之前,存在过期键泄露的问题。当expire直接在从库上操作,这个key是不会过期的。作者也在https://redis.io/topics/replication 提到过:
However note that writable slaves before version 4.0 were incapable of expiring keys with a time to live set. This means that if you use EXPIRE or other commands that set a maximum TTL for a key, the key will leak, and while you may no longer see it while accessing it with read commands, you will see it in the count of keys and it will still use memory. So in general mixing writable slaves (previous version 4.0) and keys with TTL is going to create issues.
过期键泄露问题在https://github.com/antirez/redis/commit/c65dfb436e9a5a28573ec9e763901b2684eadfc4中得到了解决。
四.总结
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
4、如果slave非读写分离、上述迁移使用,基本本文问题不会出现。还有就是Redis 4非常靠谱,后面也会有文章介绍相关内容。(付磊)
总结
以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对好代码网的支持。