為啥懶 Redis 是更好的 Redis
英文原文:Lazy Redis is better Redis
前言
大家都知道 Redis 是單執行緒的。真正的內行會告訴你,實際上 Redis 並不是完全單執行緒,因為在執行磁碟上的特定慢操作時會有多執行緒。目前為止多執行緒操作絕大部分集中在 I/O 上以至於在不同執行緒執行非同步任務的小型庫被稱為 bio.c: 也就是 Background I/O。
然而前陣子我提交了一個問題,在問題裡我承諾提供一個很多人(包括我自己)都想要的功能,叫做“免費懶載入”。原始的問題在這
問題的根本在於,Redis 的 DEL 操作通常是阻塞的。因此如果你傳送 Redis “DEL mykey” 命令,碰巧你的 key 有 5000萬個物件,那麼伺服器將會阻塞幾秒鐘,在此期間伺服器不會處理其他請求。歷史上這被當做 Redis 設計的副作用而被接受,但是在特定的用例下這是一個侷限。DEL 不是唯一的阻塞式命令,卻是特殊的一個命令,因為我們認為:Redis 非常快,只要你用複雜度為 O(1) 和 O(log_N) 的命令。你可以自由使用 O(N) 的命令,但是要知道這不是我們優化的用例,你需要做好延遲的準備。
這聽起來很合理,但是同時即便用快速操作建立的物件也需要被刪除。在這種情況下,Redis 會阻塞。
第一次嘗試
—
對於單執行緒伺服器,為了讓操作不阻塞,最簡單的方式就是用增量的方式一點點來,而不是一下子把整個世界都搞定。例如,如果要釋放一個百萬級的物件,可以每一個毫秒釋放1000個元素,而不是在一個 for() 迴圈裡一次性全做完。CPU 的耗時是差不多的,也許會稍微多一些,因為邏輯更多一些,但是從使用者來看延時更少一些。當然也許實際上並沒有每毫秒刪除1000個元素,這只是個例子。重點是如何避免秒級的阻塞。在 Redis 內部做了很多事情:最顯然易見的是 LRU 淘汰機制和 key 的過期,還有其他方面的,例如增量式的對 hash 表進行重排。
剛開始我們是這樣嘗試的:建立一個新的定時器函式,在裡面實現淘汰機制。物件只是被新增到一個連結串列裡,每次定時器呼叫的時候,會逐步的、增量式的去釋放。這需要一些小技巧,例如,那些用雜湊表實現的物件,會使用 Redis 的 SCAN 命令裡相同的機制去增量式的釋放:在字典裡設定一個遊標來遍歷和釋放元素。通過這種方式,在每次定時器呼叫的時候我們不需要釋放整個雜湊表。在重新進入定時器函式時,遊標可以告訴我們上次釋放到哪裡了。
適配是困難的
—
你知道這裡最困難的部分是哪裡嗎?這次我們是在增量式的做一件很特別的事情:釋放記憶體。如果記憶體的釋放是增量式的,伺服器的內容增長將會非常快,最後為了得到更少的延時,會消耗調無限的記憶體。這很糟,想象一下,有下面的操作:
WHILE 1
SADD myset element1 element2 … many many many elements
DEL myset
END
如果慢慢的在後臺去刪除myset,同時SADD呼叫又在不斷的新增大量的元素,記憶體使用量將會一直增長。
好在經過一段嘗試之後,我找到一種可以工作的很好的方式。定時器函式裡使用了兩個想法來適應記憶體的壓力:
1.檢測記憶體趨勢:增加還是減少?以決定釋放的力度。
2.同時適配定時器的頻率,避免在只有很少需要釋放的時候去浪費CPU,不用頻繁的去中斷事件迴圈。當確實需要的時候,定時器也可以達到大約300HZ的頻率。
這裡有一小段程式碼,不過這個想法現在已經不再實現了:
/計算記憶體趨勢,只要是上次和這次記憶體都在增加,就傾向於認為記憶體趨勢 是增加的 */
if (prev_mem < mem) mem_trend = 1;
mem_trend *= 0.9; /* 逐漸衰減 */
int mem_is_raising = mem_trend > .1;
/* 釋放一些元素 */
size_t workdone = lazyfreeStep(LAZYFREE_STEP_SLOW);
/* 根據現有狀態調整定時器頻率 */
if (workdone) {
if (timer_period == 1000) timer_period = 20;
if (mem_is_raising && timer_period > 3)
timer_period--; /* Raise call frequency. */
else if (!mem_is_raising && timer_period < 20)
timer_period++; /* Lower call frequency. */
} else {
timer_period = 1000; /* 1 HZ */
}
這是一個小技巧,工作的也很好。不過鬱悶的是我們還是不得不在單執行緒裡執行。要做好需要有很多的邏輯,而且當延遲釋放(lazy free)週期很繁忙的時候,每秒能完成的操作會降到平時的65%左右。
如果是在另一個執行緒去釋放物件,那就簡單多了:如果有一個執行緒只做釋放操作的話,釋放總是要比在資料集裡新增資料來的要快。
當然,主執行緒和延遲釋放執行緒直接對記憶體分配器的使用肯定會有競爭,不過 Redis 在記憶體分配上只用到一小部分時間,更多的時間用在I/O、命令分發、快取失敗等等。
不過,要實現執行緒化的延遲釋放有一個大問題,那就是 Redis 自身。內部實現完全是追求物件的共享,最終都是些引用計數。幹嘛不盡可能的共享呢?這樣可以節省記憶體和時間。例如:SUNIONSTORE 命令最後得到的是目標集合的共享物件。類似的,客戶端的輸出快取包含了作為返回結果傳送給socket的物件的列表,於是在類似 SMEMBERS 這樣的命令呼叫之後,集合的所有成員都有可能最終在輸出快取裡被共享。看上去物件共享是那麼有效、漂亮、精彩,還特別酷。
但是,嘿,還需要再多說一句的是,如果在 SUNIONSTORE 命令之後重新載入了資料庫,物件都取消了共享,記憶體也會突然回覆到最初的狀態。這可不太妙。接下來我們傳送應答請求給客戶端,會怎麼樣?當物件比較小時,我們實際上是把它們拼接成線性的快取,要不然進行多次 write() 呼叫效率是不高的!(友情提示,writev() 並沒有幫助)。於是我們大部分情況下是已經複製了資料。對於程式設計來說,沒有用的東西卻存在,通常意味著是有問題的。
事實上,訪問一個包含聚合型別資料的key,需要經過下面這些遍歷過程:
key -> value_obj -> hash table -> robj -> sds_string
如果去掉整個 tobj 結構體,把聚合型別轉換成 SDS 字串型別的雜湊表(或者跳錶)會怎麼樣?(SDS是Redis內部使用的字串型別)。
這樣做有個問題,假設有個命令:SADD myset myvalue,舉個例子來說,我們做不到通過client->argv[2] 來引用用來實現集合的雜湊表的某個元素。我們不得不很多次的把值複製出來,即使資料已經在客戶端命令解析後建立的引數 vector 裡,也沒辦法去複用。Redis的效能受控於快取失效,我們也許可以用稍微間接一些的辦法來彌補一下。
於是我在這個 lazyfree 的分支上開始了一項工作,並且在 Twitter 上聊了一下,但是沒有公佈上下文的細節,結果所有的人都覺得我像是絕望或者瘋狂了(甚至有人喊道 lazyfree 到底是什麼玩意)。那麼,我到底做了什麼呢?
把客戶端的輸出快取由 robj 結構體改成動態字串。在建立 reply 的時候總是複製值的內容。
把所有的 Redis 資料型別轉換成 SDS 字串,而不是使用共享物件結構。聽上去很簡單?實際上這花費了數週的時間,涉及到大約800行高風險的程式碼修改。不過現在全都測試通過了。
把 lazyfree 重寫成執行緒化的。
結果是 Redis 現在在記憶體使用上更加高效,因為在資料結構的實現上不再使用 robj 結構體(不過由於某些程式碼還涉及到大量的共享,所以 robj 依然存在,例如在命令分發和複製部分)。執行緒化的延遲釋放工作的很好,比增量的方式更能減少記憶體的使用,雖然增量方式在實現上與執行緒化的方式相似,並且也沒那麼糟糕。現在,你可以刪除一個巨大的 key,效能損失可以忽略不計,這非常有用。不過,最有趣的事情是,在我測過的一些操作上,Redis 現在都要更快一些。消除間接引用(Less indirection)最後勝出,即使在不相關的一些測試上也更快一些,還是因為客戶端的輸出快取現在更加簡單和高效。
最後我把增量式的延遲釋放實現從分支裡刪除,只保留了執行緒化的實現。
關於 API 的一點備註
不過 API 又怎麼樣了呢?DEL 命令仍然是阻塞的,預設還跟以前一樣,因為在 Redis 中 DEL 命令就意味著釋放記憶體,我並不打算改變這一點。所以現在你可以用新的命令 UNLINK,這個命令更清晰的表明了資料的狀態。
UNLINK 是一個聰明的命令:它會計算釋放物件的開銷,如果開銷很小,就會直接按 DEL 做的那樣立即釋放物件,否則物件會被放到後臺佇列裡進行處理。除此之外,這兩個命令在語義上是相同的。
我們也實現了 FLUSHALL/FLUSHDB 的非阻塞版本,不過沒有新增的 API,而是增加了一個 LAZY 選項,說明是否更改命令的行為。
不只是延遲釋放
—
現在聚合資料型別的值都不再共享了,客戶端的輸出快取也不再包含共享物件了,這一點有很多文章可做。例如,現在終於可以在 Redis 裡實現執行緒化的 I/O,從而不同的客戶端可以由不同的執行緒去服務。也就是說,只有訪問資料庫才需要全域性的鎖,客戶端的讀寫系統呼叫,甚至是客戶端傳送的命令的解析,都可以線上程中去處理。這跟 memcached 的設計理念類似,我比較期待能夠被實現和測試。
還有,現在也可以在其他執行緒實現針對聚合資料型別的特定的慢操作,可以讓某些 key 被“阻塞”,但是所有其他的客戶端不會被阻塞。這個可以用很類似現在的阻塞操作的方式去完成(參考blocking.c),只是增加一個雜湊表儲存那些正在處理的 key 和對應的客戶端。於是一個客戶端請求類似 SMEMBERS 這樣的命令,可能只是僅僅阻塞住這一個 key,然後會建立輸出快取處理資料,之後在釋放這個 key。只有那些嘗試訪問相同的 key 的客戶端,才會在這個 key 被阻塞的時候被阻塞住。
所有這些需求起了更激烈的內部變化,但這裡的底線我們已很少顧忌。我們可以補償物件複製時間來減少快取記憶體的缺失,以更小的記憶體佔用聚合資料型別,所以我們現在可依照執行緒化的 Redis 來進行無共享化設計,這一設計,可以很容易超越我們的單執行緒。在過去,一個執行緒化的 Redis 看起來總像是一個壞主意,因為為了實現併發訪問資料結構和物件其必定是一組互斥鎖,但幸運的是還有別的選擇獲得這兩個環境的優勢。如果我們想要,我們依然可以選擇快速操作服務,就像我們過去在主執行緒所做的那樣。這包含在複雜的代價之上,獲取執行智慧(performance-wise)。
計劃表
—
我在內部增加了很多東西,明天就上線看上去是不現實的。我的計劃是先讓3.2版(已經是unstable狀態)成為候選版本(RC)狀態,然後把我們的分支合併到進入unstable的3.4版本。
不過在合併之前,需要對速度做細緻的迴歸測試,這有不少工作要做。
如果你現在就想嘗試的話,可以從Github上下載lazyfree分支。不過要注意的是,當前我並不是很頻繁的更新這個分支,所以有些地方可能會不能工作。