最近我們在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 -