最近咱們在Redis集羣中發現了一個有趣的問題。在花費大量時間進行調試和測試後,經過更改key過時,咱們能夠將某些集羣中的Redis內存使用量減小25%。html
Twitter內部運行着多個緩存服務。其中一個是由Redis實現的。咱們的Redis集羣中存儲了一些Twitter重要的用例數據,例如展現和參與度數據、廣告支出計數和直接消息。redis
早在2016年初,Twitter的Cache團隊就對Redis集羣的架構進行了大量更新。Redis發生了一些變化,其中包括從Redis 2.4版到3.2版的更新。在此更新後,出現了幾個問題,例如用戶開始看到內存使用與他們的預期或準備使用的內存不一致、延遲增長和key清除問題。key的清除是一個很大的問題,這可能致使本應持久化的數據可能被刪除了,或者請求發送到數據原始存儲。數據庫
受影響的團隊和緩存團隊開始進行初步的調查。咱們發現延遲增長與如今正在發生的key清除有關。當Redis收到寫入請求但沒有內存來保存寫入時,它將中止正在執行的操做,清除key而後保存新key。可是,咱們仍然須要找出致使這些新清除的內存使用量增長的緣由。後端
咱們懷疑內存中充滿了過時但還沒有刪除的key。有人建議使用掃描,掃描的方法會讀取全部的key,而且讓過時的key被刪除。緩存
在Redis中,key有兩種過時方式,主動過時和被動過時。掃描將觸發key的被動過時,當讀取key時, TTL將會被檢查,若是TTL已過時,TTL會被刪除而且不返回任何內容。Redis文檔中描述了版本3.2中的key的主動過時。key的主動過時以一個名爲activeExpireCycle的函數開始。它以每秒運行幾回的頻率,運行在一個稱爲cron的內部計時器上。activeExpireCycle函數的做用是遍歷每一個密鑰空間,檢查具備TTL集的隨機kry,若是知足過時kry的百分比閾值,則重複此過程直到知足時間限制。bash
這種掃描全部kry的方法是有效的,當掃描完成時,內存使用量也降低了。彷佛Redis再也不有效地使key過時了。可是,當時的解決方案是增長集羣的大小和更多的硬件,這樣key就會分佈得更多,就會有更多的可用內存。這是使人失望的,由於前面提到的升級Redis的項目經過提升集羣的效率下降了運行這些集羣的規模和成本。架構
Redis版本2.4和3.2之間,activeExpireCycle的實現發生了變化。在Redis 2.4中,每次運行時都會檢查每一個數據庫,在Redis3.2中,能夠檢查的數據庫數量達到了最大值。版本3.2還引入了檢查數據庫的快速選項。「Slow」在計時器上運行,「fast」 運行在檢查事件循環上的事件以前。快速到期週期將在某些條件下提早返回,而且它還具備較低的超時和退出功能閾值。時間限制也會被更頻繁地檢查。總共有100行代碼被添加到此函數中。函數
最近咱們有時間回過頭來從新審視這個內存使用問題。咱們想探索爲何會出現regression,而後看看咱們如何才能更好地實現key expiration。咱們的第一個想法是,在Redis中有不少的key,只採樣20是遠遠不夠的。咱們想研究的另外一件事是Redi 3.2中引入數據庫限制的影響。性能
縮放和處理shard的方式使得在Twitter上運行Redis是獨一無二的。咱們有包含數百萬個key的key空間。這對於Redis用戶來講並不常見。shard由key空間表示,所以Redis的每一個實例均可以有多個shard。咱們Redis的實例有不少key空間。Sharding與Twitter的規模相結合,建立了具備大量key和數據庫的密集後端。測試
每一個循環上採樣的數字由變量
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP
該測試有一個控件和三個測試實例,能夠對更多key進行採樣。500和200是任意的。值300是基於統計樣本大小的計算器的輸出,其中總key數是整體大小。在上面的圖表中,即便只看測試實例的初始數量,也能夠清楚地看出它們的性能更好。這個與運行掃描的百分比的差別代表,過時key的開銷約爲25%。
雖然對更多key進行採樣有助於咱們找到更多過時key,但負延遲效應超出了咱們的承受能力。
上圖顯示了99.9%的延遲(以毫秒爲單位)。這代表延遲與採樣的key的增長相關。橙色表明值500,綠色表明300,藍色表明200,控制爲黃色。這些線條與上表中的顏色相匹配。
在看到延遲受到樣本大小影響後,我想知道是否能夠根據有多少key過時來自動調整樣本大小。當有更多的key過時時,延遲會受到影響,可是當沒有更多的工做要作時,咱們會掃描更少的key並更快地執行。
這個想法基本上是可行的,咱們能夠看到內存使用更低,延遲沒有受到影響,一個度量跟蹤樣本量顯示它隨着時間的推移在增長和減小。可是,咱們沒有采用這種解決方案。這種解決方案引入了一些在咱們的控件實例中沒有出現的延遲峯值。代碼也有點複雜,難以解釋,也不直觀。咱們還必須針對每一個不理想的羣集進行調整,由於咱們但願避免增長操做複雜性。
咱們還想調查Redis版本之間的變化。Redis新版本引入了一個名爲CRON_DBS_PER_CALL的變量。這個變量設置了每次運行此cron時要檢查的最大數據庫數量。爲了測試這種變量的影響,咱們簡單地註釋掉了這些行。
//if (dbs_per_call > server.dbnum || timelimit_exit)dbs_per_call = server.dbnum;複製代碼
這會比較每次運行時具備限制的,和沒有限制的檢查全部數據庫兩個方法之間的效果。咱們的基準測試結果十分使人興奮。可是,咱們的測試實例只有一個數據庫,從邏輯上講,這行代碼在修改版本和未修改版本之間沒有什麼區別。變量始終都會被設置。
99.9%的以微秒爲單位。未修改的Redis在上面,修改的Redis在下面。
咱們開始研究爲何註釋掉這一行會產生如此巨大的差別。因爲這是一個if語句,咱們首先懷疑的是分支預測。咱們利用
gcc’s__builtin_expect
接下來,咱們查看生成的程序集,以瞭解究竟發生了什麼。
咱們將if語句編譯成三個重要指令mov、cmp和jg。Mov將加載一些內存到寄存器中,cmp將比較兩個寄存器並根據結果設置另外一個寄存器,jg將根據另外一個寄存器的值執行條件跳轉。跳轉到的代碼將是if塊或else塊中的代碼。我取出if語句並將編譯後的程序集放入Redis中。而後我經過註釋不一樣的行來測試每條指令的效果。我測試了mov指令,看看是否存在加載內存或cpu緩存方面的性能問題,但沒有發現區別。我測試了cmp指令也沒有發現區別。當我使用包含的jg指令運行測試時,延遲會回升到未修改的級別。在找到這個以後,我測試了它是否只是一個跳轉,或者是一個特定的jg指令。我添加了非條件跳轉指令jmp,跳轉而後跳回到代碼運行,期間沒有出現性能損失。
咱們花了一些時間查看不一樣的性能指標,並嘗試了cpu手冊中列出的一些自定義指標。關於爲何一條指令會致使這樣的性能問題,咱們沒有任何結論。當執行跳轉時,咱們有一些與指令緩存緩衝區和cpu行爲相關的想法,可是時間不夠了,可能的話,咱們會在未來再回到這一點。
既然咱們已經很好地理解了問題的緣由,那麼咱們須要選擇一個解決這個問題的方法。咱們的決定是進行簡單的修改,以便可以在啓動選項中配置穩定的樣本量。這樣,咱們就可以在延遲和內存使用之間找到一個很好的平衡點。即便刪除if語句引發瞭如此大幅度的改進,若是咱們不能解釋清楚其緣由,咱們也很難作出改變。
此圖是部署到的第一個集羣的內存使用狀況。頂線(粉紅色)隱藏在橙色後面,是集羣內存使用的中值。橙色的頂行是一個控件實例。圖表的中間部分是新變化的趨勢。第三部分顯示了一個正在從新啓動的控件實例,與淡黃色進行比較。從新啓動後,控件的內存使用量迅速增長。
這是一個包括工程師和多個團隊的至關大的調查,減小25%的集羣大小是一個很是好的結果,從中咱們學到了不少!咱們想再看一看這段代碼,看看在關注性能和調優的其餘團隊的幫助下,咱們能夠進行哪些優化。
其餘對這項研究作出重大貢獻的工程師還有Mike Barry,Rashmi Ramesh和Bart Robinson。
- end -