Helios多級緩存實踐
1.緩存定位
在講述緩存之前,我們首先需要確認系統是否真的需要緩存。而引入緩存的理由,總結起來無外乎以下兩種:
為緩解 CPU 壓力而做緩存:譬如把方法運行結果存儲起來、把原本要實時計算的內容提前算好、把一些公用的數據進行復用,這可以節省 CPU 算力,順帶提升響應性能。
- ?為緩解 I/O 壓力而做緩存:譬如把原本對網絡、磁盤等較慢介質的讀寫訪問變為對內存等較快介質的訪問,將原本對單點組件(如數據庫)的讀寫訪問變為到可擴縮部件(如緩存中間件)的訪問,順帶提升響應性能。
- 緩存是典型以空間換時間來提升性能的手段,一般引入緩存的出發點都是緩解 CPU 和 I/O 資源在峰值流量下的壓力,進而提升系統的響應性能。
2.緩存屬性
目前 ,“緩存”其實已經被看作一項技術基礎設施,針對該種基礎設施,除了緩存基本的存儲與讀取能力,通用、高效、可統計、可管理等方面的需求也被重視。通常,我們設計或者選擇緩存至少會考慮以下四個維度的屬性:
- 吞吐量:緩存的吞吐量使用 OPS 值(每秒操作數,Operations per Second,ops/s)來衡量,反映了對緩存進行并發讀、寫操作的效率,即緩存本身的工作效率高低。
- 命中率:緩存的命中率即成功從緩存中返回結果次數與總請求次數的比值,反映了引入緩存的價值高低,命中率越低,引入緩存的收益越小,價值越低。
- 擴展功能:緩存除了基本讀寫功能外,還提供哪些額外的管理功能,譬如最大容量、失效時間、失效事件、命中率統計,等等。
- 分布式支持:緩存可分為“進程內緩存”和“分布式緩存”兩大類,前者只為節點本身提供服務,無網絡訪問操作,速度快但緩存的數據不能在各個服務節點中共享,后者則相反。
3.本地緩存
下面圍繞幾個主流的本地緩存,HashMap, Guava, Ehcache, Caffeine對上述屬性進行簡單介紹。
吞吐量:因為涉及到并發讀寫,所以對于吞吐量影響最大的即是并發訪問方式。最原始的 HashMap緩存,因為沒有進行并發訪問控制,其吞吐量最高, 但也決定了其無法在多線程并發下正確地工作;后續線程安全版本ConcurrentHashMap 采用分段加鎖的方式進行了訪問控制;ConcurrentHashMap的并發訪問用于解決并發讀寫時的數據丟失,而在其他幾種本地緩存的設計中,因為涉及到數據淘汰與驅逐能力,其主要的數據競爭源于讀取數據的同時,也會伴隨著對數據狀態的寫入操作,寫入數據的同時,也會伴隨著數據狀態的讀取操作。針對這種一種是以 Guava Cache 為代表的同步處理機制,即在訪問數據時一并完成緩存淘汰、統計、失效等狀態變更操作,通過分段加鎖等優化手段來盡量減少競爭。另一種是以 Caffeine 為代表的異步日志提交機制,將對數據的讀、寫過程看作是日志(即對數據的操作指令)的提交過程,然后通過異步批量處理的方式降低鎖的并發訪問。下圖是Caffeine官方文檔中壓測得到的吞吐量數據。
命中率:主要用于最大化有限物理內存的使用價值。優秀的緩存需要能夠自動地實現淘汰低價值數據,而該能力則會涉及到不同的淘汰策略。目前,最基礎的淘汰策略實現方案有以下三種:
- FIFO(First In First Out):優先淘汰最早進入被緩存的數據。
- LRU(Least Recent Used):優先淘汰最久未被使用訪問過的數據。對大多數的緩存場景來說,LRU 都明顯要比 FIFO 策略合理,尤其適合用來處理短時間內頻繁訪問的熱點對象。但相反,它的問題是如果一些熱點數據在系統中經常被頻繁訪問,但最近一段時間因為某種原因未被訪問過,此時這些熱點數據依然要面臨淘汰的命運,LRU 依然可能錯誤淘汰價值更高的數據。
- LFU(Least Frequently Used):優先淘汰最不經常使用的數據。LFU 可以解決上面 LRU 中熱點數據間隔一段時間不訪問就被淘汰的問題,但同時它又引入了兩個新的問題,首先是需要對每個緩存的數據專門去維護一個計數器,每次訪問都要更新,存在高昂的維護開銷;另一個問題是無法有效反應隨時間變化的熱度變化信息,譬如某個曾經頻繁訪問的數據現在不需要了,它也很難自動被清理出緩存
在此之上,針對LFU策略最近又衍生出了以下兩種變形:
- TinyLFU(Tiny Least Frequently Used):TinyLFU 是 LFU 的改進版本。為了緩解 LFU 每次訪問都要修改計數器所帶來的性能負擔,TinyLFU 會首先采用 Sketch 對訪問數據進行分析,關于sketch的詳細原理讀者可自行參考Count–min sketch
- W-TinyLFU(Windows-TinyLFU):W-TinyLFU 又是 TinyLFU 的改進版本。TinyLFU 在實現減少計數器維護頻率的同時,也帶來了無法很好地應對稀疏突發訪問的問題,所謂稀疏突發訪問是指有一些絕對頻率較小,但突發訪問頻率很高的數據,譬如某些運維性質的任務,也許一天、一周只會在特定時間運行一次,其余時間都不會用到,此時 TinyLFU 就很難讓這類元素通過 Sketch 的過濾,因為它們無法在運行期間積累到足夠高的頻率。應對短時間的突發訪問是 LRU 的強項,W-TinyLFU 就結合了 LRU 和 LFU 兩者的優點,從整體上看是它是 LFU 策略,從局部實現上看又是 LRU 策略。具體做法是將新記錄暫時放入一個名為 Window Cache 的前端 LRU 緩存里面,讓這些對象可以在 Window Cache 中累積熱度,如果能通過 TinyLFU 的過濾器,再進入名為 Main Cache 的主緩存中存儲,主緩存根據數據的訪問頻繁程度分為不同的段(LFU 策略,實際上 W-TinyLFU 只分了兩段),但單獨某一段局部來看又是基于 LRU 策略去實現的(稱為 Segmented LRU)。每當前一段緩存滿了之后,會將低價值數據淘汰到后一段中去存儲,直至最后一段也滿了之后,該數據就徹底清理出緩存。下圖是W-TinyLFU驅逐算法的原理圖,詳細的介紹以及在Caffeine中的應用可以參閱caffeine的官方設計文檔。
擴展功能:是基礎數據讀寫功能之外的額外功能。主要側重于監控統計能力,過期控制,容量控制,引用方式等。
分布式支持:Caffeine只作為本地進程內緩存,而Ehcache則演變為同時能夠支持分布式部署的模式。另外,Ehcache在3.x也支持了堆外緩存的能力,而該能力在本地緩存在GB以上,且對RT敏感的場景就有了用武之地。反觀Caffeine,則更聚焦于單實例本地進程堆內緩存。
4.分布式緩存
在微服務的背景下,Ehcache、Infinispan 等也演進為能夠同時支持分布式部署和進程內嵌部署的緩存方案。Ehcache類的緩存共享方案是通過RMI或者Jgroup多播方式進行廣播緩存通知更新,緩存共享復雜,維護不方便;簡單的共享可以,但是涉及到緩存恢復,大數據緩存,則不合適。
對分布式緩存來說,處理與網絡相關的操作是對吞吐量影響更大的因素,目前Redis已經成為分布式緩存技術的首選,我們暫不對Redis分布式緩存技術做過多的探討,有興趣的讀者可以參閱相關官網和技術書籍。
5.多級緩存
分布式緩存與進程內的本地緩存各有所長,也有各有局限,它們是互補而非競爭的關系,如有需要,完全可以同時把進程內緩存和分布式緩存互相搭配,構成透明多級緩存(Transparent Multilevel Cache,TMC)。
典型的多級緩存結構如下圖所示,使用進程內緩存作為一級緩存,分布式緩存作為二級緩存,DB等其他數據源作為三級緩存。應用進程首先讀取一級緩存,未命中的情況下讀取二級緩存并回填數據到一級緩存。如果二級緩存也查詢不到,就發起對最終源的查詢,將結果回填到一、二級緩存中去。各級緩存數據的讀取命中率依次是: 進程內緩存 > 分布式緩存 > 數據源。
對應于上述抽象的多級緩存結構,Helios多級緩存的架構設計圖如下所示:
在Helios多級緩存的設計中,緩存邊界被定義為:
用戶觸發的數據讀取99.9%以上只走本地緩存,極少數miss到分布式緩存或DB 。
DB只會被分布式調度任務訪問,用以將最新的數據刷新到分布式緩存。
分布式緩存絕大部分情況只會被本地緩存和reload任務訪問,用以中轉最新的數據。
同時采用了如下的分層刷新機制:
- 分布式調度從數據庫reload最新的數據覆蓋分布式緩存;有條件進行增量更新的緩存數據基于變更事件觸發,分布式調度全量reload兜底。
- 本地調度定期從分布式緩存同步數據覆蓋本地緩存。
下文將聚焦于在Helios多級緩存建設中的一些實踐。
5.1 緩存一致性
緩存意味著副本,就必然存在著各數據副本之間的一致性問題。而從多級緩存中,存在著本地進程內緩存與分布式緩存的一致性問題,分布式緩存與DB等外部數據源的一致性問題。
關于本地進程內緩存與分布式緩存的一致性問題,因為本地進程內的緩存一般是分布式多實例的結點,所以一般做法是數據發生變動時,在集群內發送推送通知(簡單點的話可采用 Redis 的 PUB/SUB,或者MQ的廣播消息機制,嚴謹的話引入 ZooKeeper 或 Etcd 來處理),讓各個節點的一級緩存自動失效或者刷新。
關于分布式緩存和DB等外部數據源之間的一致性問題,二者皆有成熟的數據訪問接口,無需考慮分布式多實例之間的數據復制問題。問題的復雜性在于如何保證并發讀寫情況下的一致性。更新緩存的的Design Pattern有基礎的有四種:cache aside, Read through, Write through, Write behind caching。其中最經常使用,成本最低的 Cache Aside 模式邏輯如下:
- 失效:應用程序先從cache取數據,沒有得到,則從數據庫中取數據,成功后,放到緩存中。
- 命中:應用程序從cache中取數據,取到后返回。
- 更新:先把數據存到數據庫中,成功后,再讓緩存失效。
關于緩存更新過程中,使用失效而非刷新主要因為避免多個更新請求并發操作導致的臟數據問題。
當然Cache Aside 模式在極端情況下也會存在臟數據問題,針對該一致性問題,要么通過2PC或是Paxos協議保證強一致性,要么就是拼命的降低并發時臟數據的概率。而Facebook在論文使用了這個降低概率的玩法,因為2PC太慢,而Paxos太復雜。CacheAside更新模式是最簡單也是最常用的更新模式,但在實際應用場景下,還需要考慮到業務側對緩存Miss的容忍度,分布式緩存更新請求失敗的兜底和補償策略等等。
Helios的緩存更新策略基于實際場景稍有不同, 具體更新模式如下:
- 失效:應用程序先從cache取數據,沒有取到數據, 則直接返回未命中(數據不存在)
- 命中:應用程序從cache中取數據,取到后返回。
- 更新:先把數據更新存儲到數據庫中,成功后,再主動刷新分布式緩存,之后通過MQ觸發本地進程內緩存的刷新。
Helios多級緩存在更新策略放棄了可能出現的臟數據,而選擇避免緩存穿透,相當于AP模型,而cacheAside模式則屬于CP模型。在實際應用場景下,一般情況下出現臟數據的概率會非常低,但是高并發和高頻更新的數據,將放大出現臟的概率。在實際的使用場景中,為了兜底可能的失敗或者遺漏的更新請求,而增加的全量兜底功能:通過定時任務將DB等數據源的數據全量刷新到分布式緩存。而該兜底方案卻帶來了另外一個可能并發更新分布式緩存的場景。針對可能出現的臟數據以及其他管控訴求,Helios提供了KEY級別的手動驅逐與緩存刷新能力。同時為了降低全量兜底策略對數據不一致的影響,全量兜底策略也被設計成為支持流式更新,業務側可自主選擇更新。
緩存一致性的是無法避免的問題,也沒有絕對合適的一致性方案。未來Helios多級緩存架構會提供多種更新模式,供業務在不同的業務場景下選擇。
5.2 緩存監控
對于進程內緩存,例如Ehcache, Caffeine等都雖然都提供了成熟的訪問統計指標監控能力。但該統計指標均為從運行時刻起的累計指標,無法有效反應隨時間變化的監控信息。同時對于業務側關心的大KEY和熱KEY 指標均不支持。
時間敏感的統計指標監控能力需要使用滑動時間窗口的方式進行分窗口統計上報, Helios內部使用Disruptor異步消費監控事件,以避免對用戶側請求的影響。同時在訪問頻率統計上,又借鑒了Sketch對低頻訪問數據進行過濾,避免大量統計數據帶來的穩定性風險。
需要注意的是,異步設計往往帶來的副作用就是指標延遲,且大流量下固定緩沖隊列往往會犧牲一部分的數據準確性,這點在Caffeine的異步設計中也有所體現。
5.3 緩存熱點
熱點緩存經常是業務需要引入多級緩存的一個重要原因,針對頻繁訪問的熱點數據,如果每次都要從緩存服務器獲取,可能導致緩存服務?負載過高、或者帶寬過?。而針對熱點數據最直接的想法就是將數據放到使用最近的地方,也就是本地內存。
針對緩存熱點最簡單有效的方式就是手動指定,提前預熱到本地緩存,比如在大促活動前手動將秒殺的商品信息提前緩存到本地進程內緩存。但是手動預熱的方式無法解決突發或者異常熱點流量。這就需要多級緩存框架能夠透明的支持熱點發現與同步機制。上文中提到過Caffeine這種本地進程內緩存通過優秀的淘汰策略W-TinyLFU解決了熱點統計,衰減和稀疏突發訪問的問題,但是Caffeine本身熱點統計周期,熱度衰減策略可能無法match業務的統計周期,而且當業務變更時,存在一定的時間成本優化容量參數以平衡內存使用和緩存命中率。
熱點發現與緩存機制整個流程必然: 熱度統計,熱KEY認定,熱KEY同步與更新這3個流程。目前業界成熟的熱點方案主要有有贊的TMC方案以及京東的HotKey方案。二者均定位于實現全局熱點方案,且整體架構和流程類似。以有贊的TMC解決方案為例:
- 本地實例對KEY的訪問進行攔截,監控統計,并進行秒級別的數據上報
- 中央決策結點使用滑動窗口的方式聚合計算窗口內部的熱度計算
- 通過熱點閾值判斷出熱點KEY,然后通過etcd等方式通知各應用結點
- 應用結點獲知熱KEY變化后,變更本地熱KEY列表
全局熱點探測模式相較本地熱點探測會更加精確:特別是在微服務背景下,服務實例較多的情況下,當單個實例探測到熱KEY時可以快速通知其他結點;而且能夠應對流量不均情況下的異常熱點KEY的發現,而代價則是較高的通信成本和相對較高的架構復雜性。當前Helios熱點探測放棄了全局探測的模式,轉而專注于優先探索本地熱點方案。因為針對目前嚴選應用環境 流量幾乎均分到每個實例,可以使用較小的準入閾值來提升本地熱點探測的靈敏度;另外一方面,嚴選環境的當前痛點是希望通過本地熱點自管理的方式去緩解本地緩存過熱,以及緩存參數調優的復雜性。Helios本地熱點探測流程如下如所示:
- 本地訪問請求在Miss的情況下,會觸發熱點KEY統計,這里借鑒sketch的模式進行頻率統計,避免頻率統計導致的內存占用風險
- 熱點準入模塊會根據t-1和當前周期t的KEY訪問頻率進行
- 當統計周期輪轉時,對內存非熱KEY進行驅逐,降低內存使用水位
自動化的本地熱點探測,熱點管理非熱點驅逐能力降低了本地內存的水位占用,避免出現本地緩存過熱的情況,同時熱點統計模塊的熱點KEY調用分析統計也可以用于指導緩存實例的參數調整和性能優化。
6.總結與展望
緩存分為本地進程內緩存和分布式緩存,因為定位和使用場景的不同,二者在技術選型時候選對象及考察點存在很大不同 ,而且目前二者也已呈現不同的演進方向。透明多級緩存結構則是希望結合二者的長處,通過封裝了本地進程內緩存和分布式緩存之間常用的使用模式,降低了多級緩存的接入與開發成本。而副作用是,本地緩存與分布式緩存Redis類接口命令協議上的差異性導致了多級緩存存在著諸多的限制,同時也帶來緩存一致性等問題。要實現多級緩存架構的透明性仍然存在很大挑戰,未來也將繼續在易用性,一致性,緩存治理等方面與業務側深入探討繼續前行。