Java緩存淺析

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

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

拿破崙

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

目錄體系

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

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

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

讓我們不止步於Hello World,一起來聊聊緩存。

聊聊緩存-目錄體系

關於緩存

What

緩存是什麼?

緩存是實際工作中非常常用的一種提高性能的方法。

而在java中,所謂緩存,就是將程序或系統經常要調用的對象存在內存中,再次調用時可以快速從內存中獲取對象,不必再去創建新的重複的實例。

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

目前緩存的做法分爲兩種模式:

  • 內存緩存:緩存數據存放在服務器的內存空間中。

    優點:速度快。
    
    缺點:資源有限。
    
  • 文件緩存:緩存數據存放在服務器的硬盤空間中。

    優點:容量大。
    
    缺點:速度偏慢,尤其在緩存數量巨大時。
    

why

爲什麼要使用緩存?

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

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

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

答案就是用緩存!

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

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

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

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

聊緩存之前我們先聊聊數據庫。

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

而數據庫的重要性就不言而喻了:

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

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

原因很簡單,Web應用中的其他因素,例如網絡帶寬、負載均衡節點、應用服務器(包括CPU、內存、硬盤燈、連接數等)、緩存,都很容易通過水平的擴展(俗稱加機器)來實現性能的提高。

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

因此數據庫的連接和讀寫要十分珍惜。

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

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

when

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

how

先不講代碼,對於緩存是如何工作的,簡單的緩存數據請求流程就如下圖。

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

  • TTL(Time To Live ) 存活期,
    即從緩存中創建時間點開始直到它到期的一個時間段(不管在這個時間段內有沒有訪問都將過期)
  • TTI(Time To Idle) 空閒期,
    即一個數據多久沒被訪問將從緩存中移除的時間

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

自定義緩存

如何實現

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

答案是:不難。

JVM本身就是一個高速的緩存存儲場所,同時Java爲我們提供了線程安全的ConcurrentMap,可以非常方便的實現一個完全由你自定義的緩存實例。

後面你會發現,Spring Cache的缺省實現SimpleCacheManager,也是這樣設計自己的緩存的。

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

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

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

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

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

Spring Cache

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

它支持註解方式使用緩存,非常方便。

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

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

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

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

同時,Spring還支持自定義的緩存Key以及SpringEL,這裏不詳細講了,感興趣的同學可以參考Spring Cache的文檔。

緩存三高音

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

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

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

Redis

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

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

Java緩存框架 EhCache EhCache 是一個純Java的進程內緩存框架,具有快速、精幹等特點,是Hibernate中默認的CacheProvider。

特性:可以配置內存不足時,啓用磁盤緩存(maxEntriesLoverflowToDiskocalDisk配置當內存中對象數量達到maxElementsInMemory時,Ehcache將會對象寫到磁盤中)。

Memcached

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已經支持了分佈式存儲功能。

緩存三高音比較

緩存進階

緩存由於其高併發和高性能的特性,已經在項目中被廣泛使用。尤其是在高併發、分佈式和微服務的業務場景和架構下。

無論是高併發、分佈式還是微服務都依賴於高性能的服務器。而談到高性能服務器,就必談緩存。

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

數據處理及時就是個“空間換時間”的問題,利用分佈式內存或者閃存等可以快速存取的設備,來替代部署在一般服務器上的數據庫,機械硬盤上存儲的文件,這是緩存提升服務器性能的本質。

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

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

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

緩存一致性問題

緩存一致性是如何發生的:先寫數據庫,再淘汰緩存:

第一步寫數據庫成功,第二步淘汰緩存失敗,則會引發一次嚴重的緩存不一致問題。

如何避免緩存不一致的問題:先淘汰緩存,再寫數據庫:

第一步淘汰緩存成功,第二步寫數據庫失敗,則只會引發一次Cache miss。

分佈式緩存一致性

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

一致性Hash算法通過一個叫做一致性Hash環的數據結構,實現KEY到緩存服務器的Hash映射。

緩存雪崩

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

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

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

解決方案

  1. 加鎖/隊列 保證緩存單線程的寫

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

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

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

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

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

  1. 避免緩存同時失效

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

  1. 緩存降級

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

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

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

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

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

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

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

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

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

緩存擊穿/緩存穿透

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

緩存穿透-解決方案1

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

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

緩存穿透-解決方案2

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

例如,商城有100萬用戶數據,將所有用戶id刷入一個Map。

當請求過來以後,先判斷Map中是否包含該用戶id,不包含直接返回,包含的話先去緩存中查是否有這條數據,有的話返回,沒有的話再去查數據庫。

這樣不僅減輕了數據庫的壓力,緩存系統的壓力也將大大降低。

寄語

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

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

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

參考鏈接

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章