共计 2299 个字符,预计需要花费 6 分钟才能阅读完成。
背景
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
# Memory
used_memory:842416
used_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
# Memory
used_memory:381185592
used_memory_human:363.53M
由于大 key test 的创建,redis 内存占用多了 300 多兆。接下来我们创建一个临时 key,并用它来 rename 掉大 key test
127.0.0.1:8401> set tmp 1
OK
127.0.0.1:8401> rename tmp test
OK
(2.36s)
这时就能看到执行时间的异常了,rename 执行时间长达 2.36 秒,这是为什么呢?我们再看看 redis 内存占用情况:
127.0.0.1:8401> info memory
# Memory
used_memory:821528
used_memory_human:802.27K
通过 info 返回的信息我们可以发现在执行 rename 之后 redis 将大 key test 大小为 300 多兆的值对象直接删除并回收掉了,而 redis 删除一个 key 的时间复杂度是 O(M),在这里 M 是被删除的成员数量 —500w。应该就是这个 ” 隐式 ” 删除操作导致了高延迟的产生。
文档
我们看看官方文档是怎么描述 rename 这一行为的:
RENAME key newkey
Renames key to newkey. It returns an error when key does not exist. If newkey already exists it is overwritten, when this happens RENAMEexecutes an implicit DEL operation, so if the deleted key contains a very big value it may cause high latency even if RENAME itself is usually a constant-time operation.
newkey 如果本就存在,redis 会用 key 的值覆盖掉 newkey 的值,而 newkey 原本的值会被 redis 隐式地删除。我们知道大 key 的删除伴随着高延迟(redis 是单进程服务,服务器会在删除大 key 期间 block 住接下来其他命令的执行),这就导致时间复杂度本为 O(1) 的 rename 也有可能卡住 redis。
这句官方文档的原话我没在其他文档里找到类似的翻译,看这些文档的开发者可能会误以为这是个特别安全的 O(1) 命令。
既然文档里已经说明了这种行为的存在,我就顺便看看源码这块逻辑是怎么走的:
源码分析
db.c
void 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 这个不起眼的命令也可能造成一样的问题,使用时需要谨慎对待。
参考资料
RENAME – Redis