1. 程式人生 > >一個架構師的快取修煉之路

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

一位七牛的資深架構師曾經說過這樣一句話: > Nginx+業務邏輯層+資料庫+快取層+訊息佇列,這種模型幾乎能適配絕大部分的業務場景。 這麼多年過去了,這句話或深或淺地影響了我的技術選擇,以至於後來我花了很多時間去重點學習快取相關的技術。 我在10年前開始使用快取,從本地快取、到分散式快取、再到多級快取,踩過很多坑。下面我結合自己使用快取的歷程,談談我對快取的認識。
# 01 本地快取 **1\. 頁面級快取** 我使用快取的時間很早,2010年左右使用過 OSCache,當時主要用在 JSP 頁面中用於實現頁面級快取。虛擬碼類似這樣: ``` ` ``` 中間的那段 JSP 程式碼將會以 key="foobar" 快取在 session 中,這樣其他頁面就能共享這段快取內容。 在使用 JSP 這種遠古技術的場景下,通過引入 OSCache 之後 ,頁面的載入速度確實提升很快。 但隨著前後端分離以及分散式快取的興起,服務端的頁面級快取已經很少使用了。但是在前端領域,頁面級快取仍然很流行。
**2\. 物件快取** 2011年左右,開源中國的紅薯哥寫了很多篇關於快取的文章。他提到:開源中國每天百萬的動態請求,只用 1 臺 4 Core 8G 的伺服器就扛住了,得益於快取框架 Ehcache。 這讓我非常神往,一個簡單的框架竟能將單機效能做到如此這般,讓我欲欲躍試。於是,我參考紅薯哥的示例程式碼,在公司的餘額提現服務上第一次使用了 Ehcache。 邏輯也很簡單,就是將成功或者失敗狀態的訂單快取起來,這樣下次查詢的時候,不用再查詢支付寶服務了。虛擬碼類似這樣: ![](https://oscimg.oschina.net/oscnet/976ceb93-8267-40a7-bc9b-a32fe7081cbc.png) 新增快取之後,優化的效果很明顯 , 任務耗時從原來的40分鐘減少到了5~10分鐘。 上面這個示例就是典型的「物件快取」,它是本地快取最常見的應用場景。相比頁面快取,它的粒度更細、更靈活,常用來快取很少變化的資料,比如:全域性配置、狀態已完結的訂單等,用於提升整體的查詢速度。
**3\. 重新整理策略** 2018年,我和我的小夥伴自研了配置中心,為了讓客戶端以最快的速度讀取配置, 本地快取使用了 Guava,整體架構如下圖所示: ![](https://oscimg.oschina.net/oscnet/04f25e3f-99a8-4ba2-ab4e-4194e032df42.png) 那本地快取是如何更新的呢?有兩種機制: - 客戶端啟動定時任務,從配置中心拉取資料。 - 當配置中心有資料變化時,主動推送給客戶端。這裡我並沒有使用websocket,而是使用了 RocketMQ Remoting 通訊框架。 後來我閱讀了 Soul 閘道器的原始碼,它的本地快取更新機制如下圖所示,共支援 3 種策略: ![](https://oscimg.oschina.net/oscnet/b3848b94-adbf-483e-9c60-c905b3f20d61.png) **▍ 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 的惰性載入機制,整體架構如下圖所示: ![](https://oscimg.oschina.net/oscnet/510c833e-95e2-4222-9d54-d8f97abc2888.png) 快取讀取流程如下: 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人的職場進階** ![](https://img-blog.csdnimg.cn/20201107215432