大家都知道 redis 是單線程的。真正的内行會告訴你,實際上 redis 并不是完全單線程,因為在執行磁盤上的特定慢操作時會有多線程。目前為止多線程操作絕大部分集中在 i/o 上以至于在不同線程執行異步任務的小型庫被稱為 bio.c: 也就是 background i/o。
然而前陣子我送出了一個問題,在問題裡我承諾提供一個很多人(包括我自己)都想要的功能,叫做“免費懶加載”。原始的問題在這
<a href="https://github.com/antirez/redis/issues/1748">https://github.com/antirez/redis/issues/1748</a>
問題的根本在于,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 指令裡相同的機制去增量式的釋放:在字典裡設定一個遊标來周遊和釋放元素。通過這種方式,在每次定時器調用的時候我們不需要釋放整個哈希表。在重新進入定時器函數時,遊标可以告訴我們上次釋放到哪裡了。
适配是困難的
你知道這裡最困難的部分是哪裡嗎?這次我們是在增量式的做一件很特别的事情:釋放記憶體。如果記憶體的釋放是增量式的,伺服器的内容增長将會非常快,最後為了得到更少的延時,會消耗調無限的記憶體。這很糟,想象一下,有下面的操作:
如果慢慢的在背景去删除myset,同時sadd調用又在不斷的添加大量的元素,記憶體使用量将會一直增長。
好在經過一段嘗試之後,我找到一種可以工作的很好的方式。定時器函數裡使用了兩個想法來适應記憶體的壓力:
這裡有一小段代碼,不過這個想法現在已經不再實作了:
這是一個小技巧,工作的也很好。不過郁悶的是我們還是不得不在單線程裡執行。要做好需要有很多的邏輯,而且當延遲釋放(lazy free)周期很繁忙的時候,每秒能完成的操作會降到平時的65%左右。
如果是在另一個線程去釋放對象,那就簡單多了:如果有一個線程隻做釋放操作的話,釋放總是要比在資料集裡添加資料來的要快。
當然,主線程和延遲釋放線程直接對記憶體配置設定器的使用肯定會有競争,不過 redis 在記憶體配置設定上隻用到一小部分時間,更多的時間用在i/o、指令分發、緩存失敗等等。
不過,要實作線程化的延遲釋放有一個大問題,那就是 redis 自身。内部實作完全是追求對象的共享,最終都是些引用計數。幹嘛不盡可能的共享呢?這樣可以節省記憶體和時間。例如:sunionstore 指令最後得到的是目标集合的共享對象。類似的,用戶端的輸出緩存包含了作為傳回結果發送給socket的對象的清單,于是在類似 smembers 這樣的指令調用之後,集合的所有成員都有可能最終在輸出緩存裡被共享。看上去對象共享是那麼有效、漂亮、精彩,還特别酷。
但是,嘿,還需要再多說一句的是,如果在 sunionstore 指令之後重新加載了資料庫,對象都取消了共享,記憶體也會突然回複到最初的狀态。這可不太妙。接下來我們發送應答請求給用戶端,會怎麼樣?當對象比較小時,我們實際上是把它們拼接成線性的緩存,要不然進行多次 write() 調用效率是不高的!(友情提示,writev() 并沒有幫助)。于是我們大部分情況下是已經複制了資料。對于程式設計來說,沒有用的東西卻存在,通常意味着是有問題的。
事實上,通路一個包含聚合類型資料的key,需要經過下面這些周遊過程:
如果去掉整個 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分支。不過要注意的是,目前我并不是很頻繁的更新這個分支,是以有些地方可能會不能工作。