背景
rename
是redis中给key重命名命令,rename key newkey
的意思就是将key重命名为newkey。
rename
的时候只将它描述成一个时间复杂度为O(1)的命令,却忘了说明它可能导致的性能问题(涉及覆盖旧值的时候 时间复杂度应该是O(1)+O(M))。 我们先做个试验看看rename
的问题。
现象
先搭建一个redis服务器,版本号为3.2,看看它的内存信息
127.0.0.1:8401> info memory# Memoryused_memory:842416used_memory_human:822.67K
接着用lua给redis创建一个名为 test的大key,test有500w个field,每个field的值都是1
127.0.0.1:8401> eval "for i=1,5000000,1 do redis.call('hset','test', i,1) end" 0(nil)(11.61s)127.0.0.1:8401> hlen test(integer) 5000000
这时候我们看看redis的内存占用情况
127.0.0.1:8401> info memory# Memoryused_memory:381185592used_memory_human:363.53M
由于大key test的创建,redis内存占用多了300多兆。
接下来我们创建一个临时key,并用它来rename
掉大key test 127.0.0.1:8401> set tmp 1OK127.0.0.1:8401> rename tmp testOK(2.36s)
这时就能看到执行时间的异常了,rename
执行时间长达2.36秒,这是为什么呢?我们再看看redis内存占用情况:
127.0.0.1:8401> info memory# Memoryused_memory:821528used_memory_human:802.27K
通过info
返回的信息我们可以发现在执行rename
之后redis将大key test大小为300多兆的值对象直接删除并回收掉了,而redis删除一个key的时间复杂度是O(M),在这里M是被删除的成员数量---500w。应该就是这个"隐式"删除操作导致了高延迟的产生。
文档
我们看看官方文档是怎么描述rename
这一行为的:
RENAME key newkey
Renames
key
tonewkey
. It returns an error whenkey
does not exist. Ifnewkey
already exists it is overwritten, when this happens executes an implicit operation, so if the deleted key contains a very big value it may cause high latency even if itself is usually a constant-time operation.
newkey如果本就存在,redis会用key的值覆盖掉newkey的值,而newkey原本的值会被redis隐式地删除。我们知道大key的删除伴随着高延迟(redis是单进程服务,服务器会在删除大key期间block住接下来其他命令的执行),这就导致时间复杂度本为O(1)的rename
也有可能卡住redis。
这句官方文档的原话我没在其他文档里找到类似的翻译,看这些文档的开发者可能会误以为这是个特别安全的O(1)命令。
既然文档里已经说明了这种行为的存在,我就顺便看看源码这块逻辑是怎么走的:
源码分析
db.cvoid renameCommand(client *c) { renameGenericCommand(c,0);}void renameGenericCommand(client *c, int nx) { robj *o; ... if ((o = lookupKeyWriteOrReply(c,c->argv[1],shared.nokeyerr)) == NULL) //旧key的值对象地址复制给o return; ... incrRefCount(o); //旧key的值对象引用计数+1(被o引用) if (lookupKeyWrite(c->db,c->argv[2]) != NULL) { //如果新key已经有值对象了 ... dbDelete(c->db,c->argv[2]); //新key从db中移除、并将新key的值对象引用计数-1(变为0),并释放内存 } dbAdd(c->db,c->argv[2],o); //将新key => 旧key的值对象的组合放入db中 ... dbDelete(c->db,c->argv[1]); //旧key从db中移除、并将旧key的值对象引用计数-1(不会变为0),不释放内存 ...}
正常O(1)重命名的逻辑不用多说,涉及到覆盖的过程可以简化成如下图:
在改变指针的指向之前,redis会先用if (lookupKeyWrite(c->db,c->argv[2]) != NULL)
判断newkey是否有对应的值,若有 则调用dbDelete(c->db,c->argv[2]);
将newkey的值v2删掉。
结论
用redis的时候,keys
、 hgetall
、 del
这些命令我们会多加小心,因为不合理地调用它们可能会长时间block住redis的其他请求 甚至导致CPU使用率居高不下从而卡住整个服务器。但其实rename
这个不起眼的命令也可能造成一样的问题,使用时需要谨慎对待。