一個架構師的快取修煉之路

IT人的職場進階發表於2020-12-07

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

Nginx+業務邏輯層+資料庫+快取層+訊息佇列,這種模型幾乎能適配絕大部分的業務場景。

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

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


01 本地快取

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 的消費者模型也同樣被使用,接近準實時,並且可以減少服務端的壓力。


02 分散式快取

關於分散式快取, memcached 和 Redis 應該是最常用的技術選型。相信程式設計師朋友都非常熟悉了,我這裡分享兩個案例。

1. 合理控制物件大小及讀取策略

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

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

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

1、資料格式非常精簡,只返回給前端必要的資料,部分資料通過陣列的方式返回

2、使用 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 列表,然後通過如上的方式,快速獲取部落格列表。


03 多級快取

首先要明確為什麼要使用多級快取?

本地快取速度極快,但是容量有限,而且無法共享記憶體。分散式快取容量可擴充套件,但在高併發場景下,如果所有資料都必須從遠端快取種獲取,很容易導致頻寬跑滿,吞吐量下降。

有句話說得好,快取離使用者越近越高效!

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

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

快取讀取流程如下:

1、業務閘道器剛啟動時,本地快取沒有資料,讀取 Redis 快取,如果 Redis 快取也沒資料,則通過 RPC 呼叫導購服務讀取資料,然後再將資料寫入本地快取和 Redis 中;若 Redis 快取不為空,則將快取資料寫入本地快取中。

2、由於步驟1已經對本地快取預熱,後續請求直接讀取本地快取,返回給使用者端。

3、Guava 配置了 refresh 機制,每隔一段時間會呼叫自定義 LoadingCache 執行緒池(5個最大執行緒,5個核心執行緒)去導購服務同步資料到本地快取和 Redis 中。

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

也就是說: 雖然 LoadingCache 執行緒一直在呼叫介面更新快取資訊,但是各個 伺服器本地快取中的資料並非完成一致。 說明了兩個很重要的點:

1、惰性載入仍然可能造成多臺機器的資料不一致

2、 LoadingCache 執行緒池數量配置的不太合理, 導致了執行緒堆積

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

1、惰性載入結合訊息機制來更新快取資料,也就是:當導購服務的配置發生變化時,通知業務閘道器重新拉取資料,更新快取。

2、適當調大 LoadigCache 的執行緒池引數,並線上程池埋點,監控執行緒池的使用情況,當執行緒繁忙時能發出告警,然後動態修改執行緒池引數。


寫在最後

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

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

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

  • 選擇該技術的經典書籍,理解基礎概念
  • 建立該技術的知識脈絡
  • 知行合一,在生產環境中實踐或者自己造輪子
  • 不斷覆盤,思考是否有更優的方案

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

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


作者簡介:985碩士,前亞馬遜工程師,現58轉轉技術總監

歡迎掃描下方的二維碼,關注我的個人公眾號:IT人的職場進階

相關文章