最近我們在Redis集群中發現了一個有趣的問題。在花費大量時間進行調試和測試后,通過更改key過期,我們可以將某些集群中的Redis內存使用量減少25%。
Twitter內部運行着多個緩存服務。其中一個是由Redis實現的。我們的Redis集群中存儲了一些Twitter重要的用例數據,例如展示和參與度數據、廣告支出計數和直接消息。
問題背景
早在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的百分比閾值,則重復此過程直到滿足時間限制。
這種掃描所有kry的方法是有效的,當掃描完成時,內存使用量也下降了。似乎Redis不再有效地使key過期了。但是,當時的解決方案是增加集群的大小和更多的硬件,這樣key就會分布得更多,就會有更多的可用內存。這是令人失望的,因為前面提到的升級Redis的項目通過提高集群的效率降低了運行這些集群的規模和成本。
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 -
轉載於:https://juejin.im/post/5cc18fc0e51d456e7349dc49