背景
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
tonewkey
. It returns an error whenkey
does not exist. Ifnewkey
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
這個不起眼的命令也可能造成一樣的問題,使用時需要謹慎對待。