高級架構師用10年曆練出的一份緩存使用總結

​作者介紹

張勇現任科大訊飛高級架構師。11年後端經驗,曾就職於同程藝龍、神州優車等公司。樂於分享、熱衷通過自己的實踐經驗平鋪對技術的理解

 

一位資深架構師曾經說過這樣一句話:

 

Nginx+業務邏輯層+數據庫+緩存層+消息隊列,這種模型幾乎能適配絕大部分的業務場景。

 

這麼多年過去了,這句話或深或淺地影響了我的技術選擇,以至於後來我花了很多時間去重點學習緩存相關的技術。

 

我在10年前開始使用緩存,從本地緩存、到分佈式緩存、再到多級緩存,踩過很多坑。下面我結合自己使用緩存的歷程,談談我對緩存的認識。

 

一、本地緩存

 

1. 頁面級緩存  

 

我使用緩存的時間很早,2010年左右使用過 OSCache,當時主要用在 JSP 頁面中用於實現頁面級緩存。僞代碼類似這樣:

 

 

<cache:cache key="foobar" scope="session"> 

      some jsp content 

</cache:cache>

 

中間的那段 JSP 代碼將會以 key="foobar" 緩存在 session 中,這樣其他頁面就能共享這段緩存內容。在使用 JSP 這種遠古技術的場景下,通過引入 OSCache 之後 ,頁面的加載速度確實提升很快。

 

但隨着前後端分離以及分佈式緩存的興起,服務端的頁面級緩存已經很少使用了。但是在前端領域,頁面級緩存仍然很流行。

 

2. 對象緩存  

 

2011年左右,開源中國的紅薯哥寫了很多篇關於緩存的文章。他提到:開源中國每天百萬的動態請求,只用 1 臺 4 Core 8G 的服務器就扛住了,得益於緩存框架 Ehcache。

 

這讓我非常神往,一個簡單的框架竟能將單機性能做到如此這般,讓我欲欲躍試。於是,我參考紅薯哥的示例代碼,在公司的餘額提現服務上第一次使用了 Ehcache。

 

邏輯也很簡單,就是將成功或者失敗狀態的訂單緩存起來,這樣下次查詢的時候,不用再查詢支付寶服務了。僞代碼類似這樣:

 

 

 

添加緩存之後,優化的效果很明顯 , 任務耗時從原來的40分鐘減少到了5~10分鐘。

 

上面這個示例就是典型的「對象緩存」,它是本地緩存最常見的應用場景。相比頁面緩存,它的粒度更細、更靈活,常用來緩存很少變化的數據,比如:全局配置、狀態已完結的訂單等,用於提升整體的查詢速度。

 

3. 刷新策略  

 

2018年,我和我的小夥伴自研了配置中心,爲了讓客戶端以最快的速度讀取配置, 本地緩存使用了 Guava,整體架構如下圖所示:

 

 

 

那本地緩存是如何更新的呢?有兩種機制:

 

  • 客戶端啓動定時任務,從配置中心拉取數據;

  • 當配置中心有數據變化時,主動推送給客戶端。這裏我並沒有使用websocket,而是使用了 RocketMQ Remoting 通訊框架。

 

後來我閱讀了 Soul 網關的源碼,它的本地緩存更新機制如下圖所示,共支持 3 種策略:

 

 

 

zookeeper watch機制

 

soul-admin 在啓動的時候,會將數據全量寫入 zookeeper,後續數據發生變更時,會增量更新 zookeeper 的節點。與此同時,soul-web 會監聽配置信息的節點,一旦有信息變更時,會更新本地緩存。

 

websocket 機制

 

websocket 和 zookeeper 機制有點類似,當網關與 admin 首次建立好 websocket 連接時,admin 會推送一次全量數據,後續如果配置數據發生變更,則將增量數據通過 websocket 主動推送給 soul-web。

 

http 長輪詢機制

 

http請求到達服務端後,並不是馬上響應,而是利用 Servlet 3.0 的異步機制響應數據。當配置發生變化時,服務端會挨個移除隊列中的長輪詢請求,告知是哪個 Group 的數據發生了變更,網關收到響應後,再次請求該 Group 的配置數據。

 

不知道大家發現了沒?

 

  • pull 模式必不可少;

  • 增量推送大同小異。

 

長輪詢是一個有意思的話題 , 這種模式在 RocketMQ 的消費者模型也同樣被使用,接近準實時,並且可以減少服務端的壓力。

 

二、分佈式緩存

 

關於分佈式緩存, memcached 和 Redis 應該是最常用的技術選型。相信程序員朋友都非常熟悉了,我這裏分享兩個案例。

 

1.  合理控制對象大小及讀取策略  

 

2013年,我服務一家彩票公司,我們的比分直播模塊也用到了分佈式緩存。當時,遇到了一個 Young GC 頻繁的線上問題,通過 jstat 工具排查後,發現新生代每隔兩秒就被佔滿了。

 

進一步定位分析,原來是某些 key 緩存的 value 太大了,平均在 300K左右,最大的達到了500K。這樣在高併發下,就很容易導致 GC 頻繁。

 

找到了根本原因後,具體怎麼改呢?我當時也沒有清晰的思路。於是,我去同行的網站上研究他們是怎麼實現相同功能的,包括:360彩票,澳客網。我發現了兩點:

 

  • 數據格式非常精簡,只返回給前端必要的數據,部分數據通過數組的方式返回;

  • 使用 websocket,進入頁面後推送全量數據,數據發生變化推送增量數據。

 

再回到我的問題上,最終是用什麼方案解決的呢?當時,我們的比分直播模塊緩存格式是 JSON 數組,每個數組元素包含 20 多個鍵值對, 下面的 JSON 示例我僅僅列了其中 4 個屬性。

 

 

[{

     "playId":"2399",

     "guestTeamName":"小牛",

     "hostTeamName":"湖人",

     "europe":"123"

 }]

 

這種數據結構,一般情況下沒有什麼問題。但是當字段數多達 20 多個,而且每天的比賽場次非常多時,在高併發的請求下其實很容易引發問題。

 

基於工期以及風險考慮,最終我們採用了比較保守的優化方案:

 

1)修改新生代大小,從原來的 2G 修改成 4G

 

2)將緩存數據的格式由 JSON 改成數組,如下所示:

 

 

[["2399","小牛","湖人","123"]]

 

修改完成之後, 緩存的大小從平均 300k 左右降爲 80k 左右,YGC 頻率下降很明顯,同時頁面響應也變快了很多。

 

但過了一會,cpu load 會在瞬間波動得比較高。可見,雖然我們減少了緩存大小,但是讀取大對象依然對系統資源是極大的損耗,導致 Full GC 的頻率也不低。 

 

3)爲了徹底解決這個問題,我們使用了更精細化的緩存讀取策略。

 

我們把緩存拆成兩個部分,第一部分是全量數據,第二部分是增量數據(數據量很小)。頁面第一次請求拉取全量數據,當比分有變化的時候,通過 websocket 推送增量數據。

 

第 3 步完成後,頁面的訪問速度極快,服務器的資源使用也很少,優化的效果非常優異。

 

經過這次優化,我理解到:  緩存雖然可以提升整體速度,但是在高併發場景下,緩存對象大小依然是需要關注的點,稍不留神就會產生事故。另外我們也需要合理地控制讀取策略,最大程度減少 GC 的頻率 , 從而提升整體性能。

 

2.  分頁列表查詢  

 

列表如何緩存是我非常渴望和大家分享的技能點。這個知識點也是我 2012 年從開源中國上學到的,下面我以「查詢博客列表」的場景爲例。

 

我們先說第 1 種方案:對分頁內容進行整體緩存。這種方案會按照頁碼和每頁大小組合成一個緩存key,緩存值就是博客信息列表。假如某一個博客內容發生修改, 我們要重新加載緩存,或者刪除整頁的緩存。

 

這種方案,緩存的顆粒度比較大,如果博客更新較爲頻繁,則緩存很容易失效。下面我介紹下第 2 種方案:僅對博客進行緩存。流程大致如下:

 

1)先從數據庫查詢當前頁的博客id列表,sql類似:

 

 

select id from blogs limit 0,10 

 

2)批量從緩存中獲取博客id列表對應的緩存數據 ,並記錄沒有命中的博客id,若沒有命中的id列表大於0,再次從數據庫中查詢一次,並放入緩存,sql類似:

 

 

select id from blogs where id in (noHitId1, noHitId2)

 

3)將沒有緩存的博客對象存入緩存中

 

4)返回博客對象列表

 

理論上,要是緩存都預熱的情況下,一次簡單的數據庫查詢,一次緩存批量獲取,即可返回所有的數據。另外,關於緩存批量獲取,如何實現?

 

  • 本地緩存:性能極高,for 循環即可;

  • memcached:使用 mget 命令;

  • Redis:若緩存對象結構簡單,使用 mget 、hmget命令;若結構複雜,可以考慮使用 pipleline,lua腳本模式。

 

第 1 種方案適用於數據極少發生變化的場景,比如排行榜,首頁新聞資訊等。

 

第 2 種方案適用於大部分的分頁場景,而且能和其他資源整合在一起。舉例:在搜索系統裏,我們可以通過篩選條件查詢出博客 id 列表,然後通過如上的方式,快速獲取博客列表。

 

三、多級緩存

 

首先要明確爲什麼要使用多級緩存?

 

本地緩存速度極快,但是容量有限,而且無法共享內存。分佈式緩存容量可擴展,但在高併發場景下,如果所有數據都必須從遠程緩存種獲取,很容易導致帶寬跑滿,吞吐量下降。

 

有句話說得好,緩存離用戶越近越高效!

 

使用多級緩存的好處在於:高併發場景下, 能提升整個系統的吞吐量,減少分佈式緩存的壓力。

 

2018年,我服務的一家電商公司需要進行 app 首頁接口的性能優化。我花了大概兩天的時間完成了整個方案,採取的是兩級緩存模式,同時利用了 guava 的惰性加載機制,整體架構如下圖所示:

 

 

緩存讀取流程如下:

 

  • 業務網關剛啓動時,本地緩存沒有數據,讀取 Redis 緩存,如果 Redis 緩存也沒數據,則通過 RPC 調用導購服務讀取數據,然後再將數據寫入本地緩存和 Redis 中;若 Redis 緩存不爲空,則將緩存數據寫入本地緩存中。

     

  • 由於步驟1已經對本地緩存預熱,後續請求直接讀取本地緩存,返回給用戶端。

     

  • Guava 配置了 refresh 機制,每隔一段時間會調用自定義 LoadingCache 線程池(5個最大線程,5個核心線程)去導購服務同步數據到本地緩存和 Redis 中。

 

優化後,性能表現很好,平均耗時在 5ms 左右。最開始我以爲出現問題的機率很小,可是有一天晚上,突然發現 app 端首頁顯示的數據時而相同,時而不同。

 

也就是說:雖然 LoadingCache 線程一直在調用接口更新緩存信息,但是各個服務器本地緩存中的數據並非完成一致。說明了兩個很重要的點: 

 

  • 惰性加載仍然可能造成多臺機器的數據不一致;

  • LoadingCache 線程池數量配置的不太合理,  導致了線程堆積。

 

最終,我們的解決方案是:

 

  • 惰性加載結合消息機制來更新緩存數據,也就是:當導購服務的配置發生變化時,通知業務網關重新拉取數據,更新緩存;

  • 適當調大 LoadigCache 的線程池參數,並在線程池埋點,監控線程池的使用情況,當線程繁忙時能發出告警,然後動態修改線程池參數。

 

寫在最後

 

緩存是非常重要的一個技術手段。如果能從原理到實踐,不斷深入地去掌握它,這應該是技術人員最享受的事情。

 

這篇文章屬於緩存系列的開篇,更多是把我 10 多年工作中遇到的典型問題娓娓道來,並沒有非常深入地去探討原理性的知識。

 

我想我更應該和朋友交流的是:如何體系化的學習一門新技術。

 

  • 選擇該技術的經典書籍,理解基礎概念 ;

  • 建立該技術的知識脈絡 ;

  • 知行合一,在生產環境中實踐或者自己造輪子;

  • 不斷覆盤,思考是否有更優的方案。

 

後續我會連載一些緩存相關的內容:包括緩存的高可用機制、codis 的原理等,歡迎大家繼續關注。

 

關於緩存,如果你有自己的心得體會或者想深入瞭解的內容,歡迎評論區留言。

 

作者丨 張勇 來源丨公衆號:IT人的職場進階(ID:BestITer) dbaplus社羣歡迎廣大技術人員投稿,投稿郵箱: [email protected]
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章