數據庫忽然斷開鏈接、第三方接口遲遲不返回結果、高峯期網絡發生抖動...... 當程序突發異常時,咱們的應用能夠告訴調用方或者用戶「對不起,服務器出了點問題」;或者找到更好的方式,達到提高用戶體驗的目的。html
用戶在馬蜂窩 App 上「刷刷刷」時,推薦系統須要持續給用戶推薦可能感興趣的內容,主要分爲根據用戶特性和業務場景,召回根據各類機器學習算法計算過的內容,而後對這些內容進行排序後返回給前端這幾個步驟。前端
推薦的過程涉及到 MySQL 和 Redis 查詢、REST 服務調用、數據處理等一系列操做。對於推薦系統來講,對時延的要求比較高。馬蜂窩推薦系統對於請求的平均處理時延要求在 10ms 級別,時延的 99 線保持在 1s 之內。java
當外部或者內部系統出現異常時,推薦系統就沒法在限定時間內返回數據給到前端,致使用戶刷不出來新內容,影響用戶體驗。git
因此咱們但願經過設計一套容災緩存服務,實如今應用自己或者依賴的服務發生超時等異常狀況時,能夠返回緩存數據給到前端和用戶,來減小空結果數量,而且保證這些數據儘量是用戶感興趣的。github
不只僅是推薦系統,緩存技術在不少系統中已經被普遍應用,小到 JVM 中的經常使用整型數,大到網站用戶的 session 狀態。緩存的目的不盡相同,有些是爲了提升效率,有些是爲了備份;緩存的要求也高低不一,有些要求一致性,有些則沒有要求。咱們須要根據業務場景選擇合適的緩存方案。算法
結合到咱們上面提到的業務場景和需求,咱們採用了基於 OHC 堆外緩存和 SpringBoot 的方案,實如今現有推薦系統中增長本地容災緩存系統。主要是考慮到如下幾點因素:spring
1. 避免影響線上服務,將業務邏輯和緩存邏輯隔離數據庫
爲了避免影響線上服務,咱們將緩存系統封裝爲一個 CacheService,配置在現有流程的末端,並提供讀、寫的 API 給外部調用,將業務邏輯和緩存邏輯隔離。後端
2. 異步寫入緩存,提升性能緩存
讀、寫緩存都會帶來時間消耗,特別是寫入緩存。爲了提升性能,咱們考慮將寫入緩存作成異步的方式。這部分使用的是 JDK 提供的線程池 ThreadPoolExecutor 來實現,主線程只須要提交任務到線程池,由線程池裏的 Worker 線程實現寫入緩存。
3. 本地緩存,提升訪問速度
在推薦系統中,給用戶推薦的內容應該是千人千面的,甚至同一位用戶每次刷新看到的內容均可能不一樣,這就不要求緩存具備強一致性。所以,咱們只須要進行本地緩存,而不須要採用分佈式的方式。這裏使用到的是開源緩存工具 OHC,緩存的數據來源於成功處理過的請求。
4. 備份緩存實例,保證可用性
爲了保證緩存的可用性,咱們不只在內存中進行緩存,還定時備份到文件系統中,從而保證在能夠應用啓動時從文件系統加載到內存。具體可使用 SpringBoot 提供的定時任務、ApplicationRunner 來實現。
咱們保持了推薦系統的現有邏輯,並在現有流程的末端,配置了 CacheModule 和 CacheService,負責全部和緩存相關的邏輯。
其中,CacheService 是緩存的具體實現,提供讀寫接口;CacheModule 對本次請求的數據進行處理,並決定是否須要調用 CacheService 對緩存進行操做。
1. CacheModule
在完成推薦系統的原有流程處理以後,CacheModule 會對獲得的響應報文進行判斷,好比是否拋出了異常,響應是否爲空等,而後決定是否讀取緩存或者提交緩存任務。
CacheModule 的工做流程如圖所示,其中橘黃色部分表明對 CacheService 的調用:
提交緩存任務。若是該次請求沒有拋出異常,而且響應結果也不爲空,則會提交一個緩存任務到 CacheService。任務的 key 值爲對應的業務場景,value 爲本次響應計算獲得的內容。提交的動做是非阻塞的,對接口的耗時影響很小。
讀取緩存數據。當應用自己或者依賴應用拋出異常時,系統會根據業務場景的 key 值從 CacheService 中讀取緩存並返回給調用方。當出現用戶自己已經刷完全部可用數據的狀況時,就不須要讀取緩存,而是將請求的數據及時反饋給用戶。
2. CacheService
在緩存的具體實現上,CacheService 使用到了從 Apache Cassandra 項目中獨立出來的 OHC。另外由於咱們整個應用是基於 SpringBoot 的,也用到了 SpringBoot 提供的各類功能。
上文說到對緩存沒有強一致性的要求,因此咱們採用的是本地緩存而非分佈式緩存,而且抽象出一個 CacheService 類負責對本地緩存進行維護。
(1) 數據格式
推薦系統返回數據時,根據業務場景和用戶特徵設定以「屏」爲單位返回數據,每屏能夠包含多個內容項,因此採起 key-set 的數據格式:key 值爲業務場景,好比首頁的「視頻」頻道;緩存內容則爲「屏」的集合。
(2) 存儲位置
對於 Java 應用,緩存能夠存放在內存中或者硬盤文件中。而內存空間又分爲 heap(堆內存)和 off-heap(堆外內存)。咱們對這幾種方式進行了對比:
爲了保證較快的讀寫速度,避免緩存 GC 影響線上服務,因此選擇 off-heap 做爲緩存空間。OHC 最先包含在 Apache Cassandra 項目中,以後獨立出來,成爲了基於 off-heap 的開源緩存工具。它既能夠維護大量的 off-heap 內存空間,同時也使用於低開銷的小型緩存實體。因此咱們使用 OHC 做爲 off-heap 的緩存實現。
(3) 文件備份
在應用重啓時,off-heap 中的緩存爲空。爲了儘快載入緩存,咱們使用 SpringBoot 的 Scheduling Tasks 功能,按期將緩存從 off-heap 備份到文件系統;經過繼承 SpringBoot 的 ApplicationRunner 監聽應用啓動的過程,啓動完成後將硬盤中的備份文件加載到 off-heap,保證緩存數據的可用性。
CacheService 維護一個任務隊列,隊列中保存着 CacheModule 經過非阻塞的方式提交的緩存任務,由 CacheService 決定是否要執行這些緩存任務。
(4) 對 CacheModule 提供的 API
讀取緩存時,傳入 key 值,緩存模塊隨機從 set 中讀取數據返回。
寫入緩存時,將 key 和 value 封裝爲一個任務,提交到任務隊列,由任務隊列負責異步寫入緩存。
(5) 任務隊列與異步寫入
這裏咱們使用了 JDK 中的線程池來實現。在構造線程池時,使用 LinkedBlockingQueue 做爲任務隊列,能夠實現快速增刪元素;由於應用的 QPS 在 100 之內,因此工做線程數目固定爲 1;隊列寫滿以後,則執行 DiscardPolicy,放棄插入隊列。
(6) 緩存數量控制
若是緩存佔用內存空間過大,會影響線上應用,咱們能夠採用爲不一樣的業務場景配置最大緩存數量來控制緩存數量。沒有達到配置值時,將成功處理過的數據寫入緩存;達到配置值時能夠隨機抽樣覆蓋原有緩存項,來保證緩存的實時性。
綜合考慮以上各個方面,CacheService 的設計以下:
爲了驗證容災緩存的效果,咱們在命中緩存時進行了埋點,並經過 Kibana 查看每小時緩存的命中數量。如圖所示,在 18:00 到 19:00 系統存在必定的超時,而這段時間因爲緩存服務發揮了做用,使系統的可用性獲得提高。
咱們還對 OHC 的讀取和寫入速度進行了監控。寫入緩存的時延在毫秒級別,而且是異步寫入;讀取緩存的時延在微秒級別。基本沒有給系統增長額外的時間消耗。
在將緩存寫入 OHC 以前,須要進行序列化,咱們使用了開源的 kryo 做爲序列化工具。以前在使用 kyro 時,發現對於沒有實現 Serializable 的類,反序列化時可能失敗,好比使用 List#subList 方法返回的內部類 java.util.ArrayList$SubList。這裏能夠手動註冊 Serializer 來解決這個問題,在 Github 上開源的 kryo-serializers 倉庫提供了各類類型的 serializers。
另一點,須要注意根據具體使用場景,來配置 OHC 中的 capacity 和 maxEntrySize。若是配置的值過小的話,會致使寫入緩存失敗。能夠在上線以前測算緩存的空間佔用,合理設置整個緩存空間的大小和每一個緩存 entry 的大小。
基於 SpringBoot 和 OHC,咱們在現有的推薦系統中增長了一個本地容災緩存系統,當依賴服務或者應用自己突發異常時能夠返回緩存的數據。
該緩存系統還存在一些不足,咱們近期會針對如下幾點進行重點優化:
緩存數目寫滿以後,目前應用會隨機覆寫已經存在的緩存。將來能夠進行優化,將最老的緩存項替換。
在某些場景下緩存的粒度不夠精細,好比目的地頁推薦共用一個緩存的 key 值。將來能夠根據目的地的 ID,爲每一個目的地配置一份緩存。
如今推薦系統還有部分配置依賴於 MySQL,將來會考慮將在本地進行文件緩存。
[參考資料]
1. Java Caching Benchmarks 2016 - Part 1
2. On Heap vs Off Heap Memory Usage
本文做者:孫興斌,馬蜂窩推薦和搜索後端研發工程師。
(馬蜂窩技術原創內容,轉載務必註明出處保存文末二維碼圖片,謝謝配合。)
關注馬蜂窩技術公衆號,找到更多你須要的內容