Java快取淺析

西召發表於2019-03-10

拿破崙說:勝利屬於堅持到最後的人。

而正巧,我們們今天就是要聊一個,關於怎麼讓系統在狂轟亂炸甚至泰山壓頂的情況下,都屹立不倒並堅持到最後的話題——快取。

拿破崙

Victory belongs to the most persevering. — Napoleon Bonaparte, French military and political leader

目錄體系

下面我們先簡單瀏覽一下這個分享的目錄體系。

今天我會分五個方面給大家介紹關於快取使用的問題,包括原理、實踐、技術選型和常見問題。

這個目錄體系就是一副人體骨骼,只有把各種內臟、器官和血肉都填充進去,快取之美才能躍然紙上。接下來,我就邀請大家跟我一起來做這件事情.

讓我們不止步於Hello World,一起來聊聊快取。

聊聊快取-目錄體系

關於快取

What

快取是什麼?

快取是實際工作中非常常用的一種提高效能的方法。

而在java中,所謂快取,就是將程式或系統經常要呼叫的物件存在記憶體中,再次呼叫時可以快速從記憶體中獲取物件,不必再去建立新的重複的例項。

這樣做可以減少系統開銷,提高系統效率。

目前快取的做法分為兩種模式:

  • 記憶體快取:快取資料存放在伺服器的記憶體空間中。

    優點:速度快。
    
    缺點:資源有限。
    複製程式碼
  • 檔案快取:快取資料存放在伺服器的硬碟空間中。

    優點:容量大。
    
    缺點:速度偏慢,尤其在快取數量巨大時。
    複製程式碼

Java快取淺析

why

為什麼要使用快取?

對於為什麼要使用快取,我見過的最精煉的回答是:來源一個夢想,那就是多快好省的構建社會主義社會。

但這是一種很矛盾的說法,就好像你不是高富帥還想迎娶白富美,好像是痴人說夢啊。

因為多就不可能快,好就不能省,怎麼做到多又快,好而且省呢?

答案就是用快取!

下面我們就聊聊怎麼用快取實現這個夢想。

首先我想先宣告一下,我什麼會想到做這樣一個分享。

其實,從第一次使用 Java整型的快取,到了解CDN的代理快取,從初次接觸 MySQL內建的查詢快取,到使用 Redis快取Session,我越來越發現使用快取的重要性和普遍性。

因此我覺得自己有必要把自己的所學所用梳理出來,用於工作,並造福大家,因此才有了這樣一個技術分享。

聊快取之前我們先聊聊資料庫。

在增刪改查中,資料庫查詢佔據了資料庫操作的80%以上, 非常頻繁的磁碟I/O讀取操作,會導致資料庫效能極度低下。

而資料庫的重要性就不言而喻了:

  • 資料庫通常是企業應用系統最核心的部分
  • 資料庫儲存的資料量通常非常龐大
  • 資料庫查詢操作通常很頻繁,有時還很複雜

我們知道,對於多數Web應用,整個系統的瓶頸在於資料庫。

原因很簡單,Web應用中的其他因素,例如網路頻寬、負載均衡節點、應用伺服器(包括CPU、記憶體、硬碟燈、連線數等)、快取,都很容易通過水平的擴充套件(俗稱加機器)來實現效能的提高。

而對於MySQL,由於資料一致性的要求,無法通過簡單的增加機器來分散向資料庫 寫資料 帶來的壓力。雖然可以通過前置快取(Redis等)、讀寫分離、分庫分表來減輕壓力,但是與系統其它元件的水平擴充套件相比,受到了太多的限制,而切會大大增加系統的複雜性。

因此資料庫的連線和讀寫要十分珍惜。

可能你會想到那就直接用快取唄,但大量的用、不分場景的用快取顯然是不科學的。我們不能手裡有了一把錘子,看什麼都是釘子。

但快取也不是萬能的,要慎用快取,想要用好快取並不容易。因此我花了點時間整理了一下關於快取的實現以及常見的一些問題。

Java快取淺析

when

首先簡單梳理一下Web請求的過程,以及不同節點快取的作用。

Java快取淺析

how

先不講程式碼,對於快取是如何工作的,簡單的快取資料請求流程就如下圖。

Java快取淺析

設計快取的時候需要考慮的最關鍵的兩個快取策略。

- TTL(Time To Live ) 存活期, 即從快取中建立時間點開始直到它到期的一個時間段(不管在這個時間段內有沒有訪問都將過期)

  • TTI(Time To Idle) 空閒期, 即一個資料多久沒被訪問將從快取中移除的時間

後面講到快取雪崩的時候,會講到,如果快取策略設定不當,將會造成如何的災難性後果,以及如何避免,這裡先按下不表。

Java快取淺析

自定義快取

如何實現

前面介紹了關於快取的一些概念,那麼實現快取,或者確切的說實現儲存的前置快取很難嗎?

答案是:不難。

JVM本身就是一個高速的快取儲存場所,同時Java為我們提供了執行緒安全的ConcurrentMap,可以非常方便的實現一個完全由你自定義的快取例項。

後面你會發現,Spring Cache的預設實現SimpleCacheManager,也是這樣設計自己的快取的。

這裡放上簡單的實現程式碼,不過36行,就實現了對快取的儲存、更新、讀取和刪除等基本操作。 再結合實際的業務程式碼,就能不依賴任何三方的實現,在JVM中輕鬆玩轉快取了。

Java快取淺析

但是,我想作為有追求的技術人,各位是絕對不會止步於此的。

那麼我們思考一下,我們自定義的快取實現,有哪些優缺點呢?

Java快取淺析

同與自定義的快取相比,就能更深刻的理解Spring Cache的原理,以及優點。

這裡先把Spring Cache的特性列舉出來,下面還會介紹它的原理和具體用法。

Java快取淺析

Java快取淺析

Spring Cache

Spring Cache是Spring提供的對快取功能的抽象:即允許繫結不同的快取解決方案(如Ehcache、Redis、Memcache、Map等等),但本身不直接提供快取功能的實現。

它支援註解方式使用快取,非常方便。

Spring Cache的實現本質上依賴了Spring AOP對切面的支援。

知道了Spring Cache的原理,你會對Spring Cache的註解的使用有更深入的認識。

Java快取淺析

Spring Cache主要用到的註解有4個。

@CacheEvict對於保證快取一致性非常重要,後面會專門講一下這個問題。

同時,Spring還支援自定義的快取Key以及SpringEL,這裡不詳細講了,感興趣的同學可以參考Spring Cache的文件。

Java快取淺析

快取三高音

正如寫得再好的樂譜,都需要歌唱家演唱出來才能美妙動聽一樣。

上面講到Spring Cache是對快取的抽象,那麼常用的快取的實現有哪些呢?

歌唱界有世界三大男高音,那麼快取界如果來評選一下話,三大高音會是誰呢?

Java快取淺析

Redis

Java快取淺析

redis是一個key-value儲存系統,這點和Memcached類似。

不同的是它支援儲存的value型別相對更多,包括string(字串)、list(連結串列)、set(集合)、zset(sorted set --有序集合)和hash(雜湊型別)。這些資料型別都支援push/pop、add/remove及取交集並集和差集。

和Memcached一樣,為了保證效率,資料都是快取在記憶體中。

區別的是redis會週期性的把更新的資料寫入磁碟或者把修改操作寫入追加的記錄檔案,並且在此基礎上實現了master-slave(主從)同步。 Redis支援主從同步。資料可以從主伺服器向任意數量的從伺服器上同步,從伺服器可以是關聯其他從伺服器的主伺服器。這使得Redis可執行單層樹複製。

存檔可以有意無意的對資料進行寫操作。由於完全實現了釋出/訂閱機制,使得從資料庫在任何地方同步樹時,可訂閱一個頻道並接收主伺服器完整的訊息釋出記錄。

同步對讀取操作的可擴充套件性和資料冗餘很有幫助。

Redis有哪些適合的場景?

  1. 會話快取(Session Cache):用Redis快取會話比其他儲存(如memcached)的優勢在於,redis提供持久化。
  2. 全頁快取(FPC):除基本的會話token之外,Redis還提供很簡便的FPC平臺。
  3. 佇列:Redis在記憶體儲存引擎領域的一大優點是提供list和set操作,這使得Redis能作為一個很好的訊息佇列平臺來使用。
  4. 排行榜/計數器:Redis在記憶體中對資料進行遞增遞減的操作實現的非常好。
  5. 訂閱/釋出

缺點:

  1. 持久化。Redis直接將資料儲存到記憶體中,要將資料儲存到磁碟上,Redis可以使用兩種方式實現持久化過程。

    定時快照(snapshot):每隔一段時間將整個資料庫寫到磁碟上,每次均是寫全部資料,代價非常高。 基於語句追加(aof):只追蹤變化的資料,但是追加的log可能過大,同時所有的操作均重新執行一遍,回覆速度慢。

  2. 耗記憶體,佔用記憶體過高。

Ehcache

Java快取淺析

Ehcache 是一個成熟的快取框架,你可以直接使用它來管理你的快取。

Java快取框架 EhCache EhCache 是一個純Java的程式內快取框架,具有快速、精幹等特點,是Hibernate中預設的CacheProvider。

特性:可以配置記憶體不足時,啟用磁碟快取(maxEntriesLoverflowToDiskocalDisk配置當記憶體中物件數量達到maxElementsInMemory時,Ehcache將會物件寫到磁碟中)。

Memcached

Java快取淺析

Memcached 是一個高效能的分散式記憶體物件快取系統,用於動態Web應用以減輕資料庫負載。它基於一個儲存鍵/值對的hashmap。

其守護程式(daemon )是用C寫的,但是客戶端可以用任何語言來編寫,並通過memcached協議與守護程式通訊。

Memcached通過在記憶體中快取資料和物件來減少讀取資料庫的次數,從而提高動態、資料庫驅動網站的速度。

同屬於個key-value儲存系統,Memcached與Redis常常一起比:

  1. Memcached的資料結構和操作較為簡單,不如Redis支援的結構豐富。
  2. 使用簡單的key-value儲存的話,Memcached的記憶體利用率更高, 而如果Redis採用hash結構來做key-value儲存,由於其組合式的壓縮,其記憶體利用率會高於Memcached。
  3. 由於Redis只使用單核,而Memcached可以使用多核,所以平均每一個核上Redis在儲存小資料時比Memcached效能更高。 而在100k以上的資料中,Memcached效能要高於Redis,雖然Redis最近也在儲存大資料的效能上進行優化,但是比起Memcached,還是稍有遜色。
  4. Redis雖然是基於記憶體的儲存系統,但是它本身是支援記憶體資料的持久化的,而且提供兩種主要的持久化策略:RDB快照和AOF日誌。而memcached是不支援資料持久化操作的。 Memcached是全記憶體的資料緩衝系統,Redis雖然支援資料的持久化,但是全記憶體畢竟才是其高效能的本質。
  5. 作為基於記憶體的儲存系統來說,機器實體記憶體的大小就是系統能夠容納的最大資料量。如果需要處理的資料量超過了單臺機器的實體記憶體大小,就需要構建分散式叢集來擴充套件儲存能力。

Memcached本身並不支援分散式,因此只能在客戶端通過像一致性雜湊這樣的分散式演算法來實現Memcached的分散式儲存。

相較於Memcached只能採用客戶端實現分散式儲存,Redis更偏向於在伺服器端構建分散式儲存。最新版本的Redis已經支援了分散式儲存功能。

快取三高音比較
快取三高音比較

快取進階

Java快取淺析

快取由於其高併發和高效能的特性,已經在專案中被廣泛使用。尤其是在高併發、分散式和微服務的業務場景和架構下。

無論是高併發、分散式還是微服務都依賴於高效能的伺服器。而談到高效能伺服器,就必談快取。

所謂高效能主要體現在高可用情況下,業務處理時間短,資料正確。

資料處理及時就是個“空間換時間”的問題,利用分散式記憶體或者快閃記憶體等可以快速存取的裝置,來替代部署在一般伺服器上的資料庫,機械硬碟上儲存的檔案,這是快取提升伺服器效能的本質。

高併發(High Concurrency): 是網際網路分散式系統架構設計中必須考慮的因素之一,它通常是指,通過設計保證系統能夠同時並行處理很多請求。

分散式: 是以縮短單個任務的執行時間來提升效率的。 比如一個任務由10個子任務組成,每個子任務單獨執行需1小時,則在一臺伺服器上執行改任務需10小時。 採用分散式方案,提供10臺伺服器,每臺伺服器只負責處理一個子任務,不考慮子任務間的依賴關係,執行完這個任務只需一個小時。

微服務: 架構強調的第一個重點就是業務系統需要徹底的元件化和服務化,原有的單個業務系統會拆分為多個可以獨立開發,設計,執行和運維的小應用。這些小應用之間通過服務完成互動和整合。

快取一致性問題

快取一致性是如何發生的:先寫資料庫,再淘汰快取:

第一步寫資料庫成功,第二步淘汰快取失敗,則會引發一次嚴重的快取不一致問題。
複製程式碼

如何避免快取不一致的問題:先淘汰快取,再寫資料庫:

第一步淘汰快取成功,第二步寫資料庫失敗,則只會引發一次Cache miss。
複製程式碼

Java快取淺析

分散式快取一致性

我們使用zookeeper來協調各個快取例項節點,zookeeper是一個分散式協調服務,包含一個原語集,可以通知所有watch節點的client端,並保證事件發生順序和client收到訊息的順序一致;使用zookeeper叢集可非常容易的實現這場景。

一致性Hash演算法通過一個叫做一致性Hash環的資料結構,實現KEY到快取伺服器的Hash對映。

Java快取淺析

快取雪崩

產生原因1. a. 由於Cache層承載著大量請求,有效的保護了Storage層(通常認為此層抗壓能力稍弱),所以Storage的呼叫量實際很低,所以它很爽。 b. 但是,如果Cache層由於某些原因(當機、cache服務掛了或者不響應了)整體crash掉了,也就意味著所有的請求都會達到Storage層,所有Storage的呼叫量會暴增,所以它有點扛不住了,甚至也會掛掉

產生原因2. 我們設定快取時採用了相同的過期時間,導致快取在某一時刻同時失效,請求全部轉發到DB,DB瞬時壓力過重雪崩。

雪崩問題在國外叫做:stampeding herd(奔逃的野牛),指的的cache crash後,流量會像奔逃的野牛一樣,打向後端。

Java快取淺析

解決方案

  1. 加鎖/佇列 保證快取單執行緒的寫

失效時的雪崩效應對底層系統的衝擊非常可怕。

大多數系統設計者考慮用加鎖或者佇列的方式保證快取的單線 程(程式)寫,從而避免失效時大量的併發請求落到底層儲存系統上。

加鎖排隊只是為了減輕資料庫的壓力,並沒有提高系統吞吐量。

假設在高併發下,快取重建期間key是鎖著的,這是過來1000個請求999個都在阻塞的。同樣會導致使用者等待超時,這是個治標不治本的方法!

加鎖排隊的解決方式分散式環境的併發問題,有可能還要解決分散式鎖的問題;執行緒還會被阻塞,使用者體驗很差!因此,在真正的高併發場景下很少使用!

  1. 避免快取同時失效

將快取失效時間分散開,比如我們可以在原有的失效時間基礎上,末尾增加一個隨機值。

  1. 快取降級

當訪問量劇增、服務出現問題(如響應時間慢或不響應)或非核心服務影響到核心流程的效能時,仍然需要保證服務還是可用的,即使是有損服務。

系統可以根據一些關鍵資料進行自動降級,也可以配置開關實現人工降級。

降級的最終目的是保證核心服務可用,即使是有損的。而且有些服務是無法降級的(如加入購物車、結算)。

在進行降級之前要對系統進行梳理,看看系統是不是可以丟卒保帥;從而梳理出哪些必須誓死保護,哪些可降級。

比如可以參考日誌級別設定預案:

(1)一般:比如有些服務偶爾因為網路抖動或者服務正在上線而超時,可以自動降級;

(2)警告:有些服務在一段時間內成功率有波動(如在95~100%之間),可以自動降級或人工降級,併傳送告警;

(3)錯誤:比如可用率低於90%,或者資料庫連線池被打爆了,或者訪問量突然猛增到系統能承受的最大閥值,此時可以根據情況自動降級或者人工降級;

(4)嚴重錯誤:比如因為特殊原因資料錯誤了,此時需要緊急人工降級。

Java快取淺析

快取擊穿/快取穿透

快取穿透是指查詢一個一定不存在的資料,由於快取是不命中時被動寫的,並且出於容錯考慮,如果從儲存層查不到資料則不寫入快取,這將導致這個不存在的資料每次請求都要到儲存層去查詢,失去了快取的意義。在流量大時,可能DB就掛掉了,要是有人利用不存在的key頻繁攻擊我們的應用,這就是漏洞。

Java快取淺析

快取穿透-解決方案1

一個簡單粗暴的方法,如果一個查詢返回的資料為空(不管是數 據不存在,還是系統故障),我們仍然把這個空結果進行快取,

但它的過期時間會很短,最長不超過五分鐘。

Java快取淺析

快取穿透-解決方案2

最常見的則是採用布隆過濾器,將所有可能存在的資料雜湊到一個足夠大的bitmap中,一個一定不存在的資料會被 這個bitmap攔截掉,從而避免了對底層儲存系統的查詢壓力。

例如,商城有100萬使用者資料,將所有使用者id刷入一個Map。

當請求過來以後,先判斷Map中是否包含該使用者id,不包含直接返回,包含的話先去快取中查是否有這條資料,有的話返回,沒有的話再去查資料庫。

這樣不僅減輕了資料庫的壓力,快取系統的壓力也將大大降低。

Java快取淺析

寄語

古人云:紙上得來終覺淺,絕知此事要躬行。

別人的經驗和智慧,需要經過你親自驗證才知道是不是真理,要經過親手實踐才能為我所用。

別人的知識只是一些樹枝,需要你把它們編織成一架梯子,才能助你高升。

Java快取淺析

參考連結

相關文章