馬蜂窩推薦系統(tǒng)容災(zāi)緩存服務(wù)的設(shè)計(jì)與實(shí)現(xiàn)
數(shù)據(jù)庫(kù)突然斷開(kāi)連接、第三方接口遲遲不返回結(jié)果、高峰期網(wǎng)絡(luò)發(fā)生抖動(dòng)...... 當(dāng)程序突發(fā)異常時(shí),我們的應(yīng)用可以告訴調(diào)用方或者用戶「對(duì)不起,服務(wù)器出了點(diǎn)問(wèn)題」;或者找到更好的方式,達(dá)到提升用戶體驗(yàn)的目的。
背景
用戶在馬蜂窩 App 上「刷刷刷」時(shí),推薦系統(tǒng)需要持續(xù)給用戶推薦可能感興趣的內(nèi)容,主要分為根據(jù)用戶特性和業(yè)務(wù)場(chǎng)景,召回根據(jù)各種機(jī)器學(xué)習(xí)算法計(jì)算過(guò)的內(nèi)容,然后對(duì)這些內(nèi)容進(jìn)行排序后返回給前端這幾個(gè)步驟。
推薦的過(guò)程涉及到 MySQL 和 Redis 查詢、REST 服務(wù)調(diào)用、數(shù)據(jù)處理等一系列操作。對(duì)于推薦系統(tǒng)來(lái)說(shuō),對(duì)時(shí)延的要求比較高。馬蜂窩推薦系統(tǒng)對(duì)于請(qǐng)求的平均處理時(shí)延要求在 10ms 級(jí)別,時(shí)延的 99 線保持在 1s 以內(nèi)。
當(dāng)外部或者內(nèi)部系統(tǒng)出現(xiàn)異常時(shí),推薦系統(tǒng)就無(wú)法在限定時(shí)間內(nèi)返回?cái)?shù)據(jù)給到前端,導(dǎo)致用戶刷不出來(lái)新內(nèi)容,影響用戶體驗(yàn)。
所以我們希望通過(guò)設(shè)計(jì)一套容災(zāi)緩存服務(wù),實(shí)現(xiàn)在應(yīng)用本身或者依賴的服務(wù)發(fā)生超時(shí)等異常情況時(shí),可以返回緩存數(shù)據(jù)給到前端和用戶,來(lái)減少空結(jié)果數(shù)量,并且保證這些數(shù)據(jù)盡可能是用戶感興趣的。
設(shè)計(jì)與實(shí)現(xiàn)
設(shè)計(jì)思路和技術(shù)選型
不僅僅是推薦系統(tǒng),緩存技術(shù)在很多系統(tǒng)中已經(jīng)被廣泛應(yīng)用,小到 JVM 中的常用整型數(shù),大到網(wǎng)站用戶的 session 狀態(tài)。緩存的目的不盡相同,有些是為了提高效率,有些是為了備份;緩存的要求也高低不一,有些要求一致性,有些則沒(méi)有要求。我們需要根據(jù)業(yè)務(wù)場(chǎng)景選擇合適的緩存方案。
結(jié)合到我們上面提到的業(yè)務(wù)場(chǎng)景和需求,我們采用了基于 OHC 堆外緩存和 SpringBoot 的方案,實(shí)現(xiàn)在現(xiàn)有推薦系統(tǒng)中增加本地容災(zāi)緩存系統(tǒng)。主要是考慮到以下幾點(diǎn)因素:
1. 避免影響線上服務(wù),將業(yè)務(wù)邏輯和緩存邏輯隔離
為了不影響線上服務(wù),我們將緩存系統(tǒng)封裝為一個(gè) CacheService,配置在現(xiàn)有流程的末端,并提供讀、寫(xiě)的 API 給外部調(diào)用,將業(yè)務(wù)邏輯和緩存邏輯隔離。
2. 異步寫(xiě)入緩存,提高性能
讀、寫(xiě)緩存都會(huì)帶來(lái)時(shí)間消耗,特別是寫(xiě)入緩存。為了提高性能,我們考慮將寫(xiě)入緩存做成異步的方式。這部分使用的是 JDK 提供的線程池 ThreadPoolExecutor 來(lái)實(shí)現(xiàn),主線程只需要提交任務(wù)到線程池,由線程池里的 Worker 線程實(shí)現(xiàn)寫(xiě)入緩存。
3. 本地緩存,提高訪問(wèn)速度
在推薦系統(tǒng)中,給用戶推薦的內(nèi)容應(yīng)該是千人千面的,甚至同一位用戶每次刷新看到的內(nèi)容都可能不同,這就不要求緩存具有強(qiáng)一致性。因此,我們只需要進(jìn)行本地緩存,而不需要采用分布式的方式。這里使用到的是開(kāi)源緩存工具 OHC,緩存的數(shù)據(jù)來(lái)源于成功處理過(guò)的請(qǐng)求。
4. 備份緩存實(shí)例,保證可用性
為了保證緩存的可用性,我們不僅在內(nèi)存中進(jìn)行緩存,還定時(shí)備份到文件系統(tǒng)中,從而保證在可以應(yīng)用啟動(dòng)時(shí)從文件系統(tǒng)加載到內(nèi)存。具體可以使用 SpringBoot 提供的定時(shí)任務(wù)、ApplicationRunner 來(lái)實(shí)現(xiàn)。
整體架構(gòu)
我們保持了推薦系統(tǒng)的現(xiàn)有邏輯,并在現(xiàn)有流程的末端,配置了 CacheModule 和 CacheService,負(fù)責(zé)所有和緩存相關(guān)的邏輯。
其中,CacheService 是緩存的具體實(shí)現(xiàn),提供讀寫(xiě)接口;CacheModule 對(duì)本次請(qǐng)求的數(shù)據(jù)進(jìn)行處理,并決定是否需要調(diào)用 CacheService 對(duì)緩存進(jìn)行操作。
模塊解讀
1. CacheModule
在完成推薦系統(tǒng)的原有流程處理之后,CacheModule 會(huì)對(duì)得到的響應(yīng)報(bào)文進(jìn)行判斷,比如是否拋出了異常,響應(yīng)是否為空等,然后決定是否讀取緩存或者提交緩存任務(wù)。
CacheModule 的工作流程如圖所示,其中橘黃色部分代表對(duì) CacheService 的調(diào)用:
- 提交緩存任務(wù)。如果該次請(qǐng)求沒(méi)有拋出異常,并且響應(yīng)結(jié)果也不為空,則會(huì)提交一個(gè)緩存任務(wù)到 CacheService。任務(wù)的 key 值為對(duì)應(yīng)的業(yè)務(wù)場(chǎng)景,value 為本次響應(yīng)計(jì)算得到的內(nèi)容。提交的動(dòng)作是非阻塞的,對(duì)接口的耗時(shí)影響很小。
- 讀取緩存數(shù)據(jù)。當(dāng)應(yīng)用本身或者依賴應(yīng)用拋出異常時(shí),系統(tǒng)會(huì)根據(jù)業(yè)務(wù)場(chǎng)景的 key 值從 CacheService 中讀取緩存并返回給調(diào)用方。當(dāng)出現(xiàn)用戶本身已經(jīng)刷完所有可用數(shù)據(jù)的情況時(shí),就不需要讀取緩存,而是將請(qǐng)求的數(shù)據(jù)及時(shí)反饋給用戶。
2. CacheService
在緩存的具體實(shí)現(xiàn)上,CacheService 使用到了從 Apache Cassandra 項(xiàng)目中獨(dú)立出來(lái)的 OHC。另外因?yàn)槲覀冋麄€(gè)應(yīng)用是基于 SpringBoot 的,也用到了 SpringBoot 提供的各種功能。
上文說(shuō)到對(duì)緩存沒(méi)有強(qiáng)一致性的要求,所以我們采用的是本地緩存而非分布式緩存,并且抽象出一個(gè) CacheService 類負(fù)責(zé)對(duì)本地緩存進(jìn)行維護(hù)。
(1) 數(shù)據(jù)格式
推薦系統(tǒng)返回?cái)?shù)據(jù)時(shí),根據(jù)業(yè)務(wù)場(chǎng)景和用戶特征設(shè)定以「屏」為單位返回?cái)?shù)據(jù),每屏可以包含多個(gè)內(nèi)容項(xiàng),所以采取 key-set 的數(shù)據(jù)格式:key 值為業(yè)務(wù)場(chǎng)景,比如首頁(yè)的「視頻」頻道;緩存內(nèi)容則為「屏」的集合。
(2) 存儲(chǔ)位置
對(duì)于 Java 應(yīng)用,緩存可以存放在內(nèi)存中或者硬盤文件中。而內(nèi)存空間又分為 heap(堆內(nèi)存)和 off-heap(堆外內(nèi)存)。我們對(duì)這幾種方式進(jìn)行了對(duì)比:
為了保證較快的讀寫(xiě)速度,避免緩存 GC 影響線上服務(wù),所以選擇 off-heap 作為緩存空間。OHC 最早包含在 Apache Cassandra 項(xiàng)目中,之后獨(dú)立出來(lái),成為了基于 off-heap 的開(kāi)源緩存工具。它既可以維護(hù)大量的 off-heap 內(nèi)存空間,同時(shí)也使用于低開(kāi)銷的小型緩存實(shí)體。所以我們使用 OHC 作為 off-heap 的緩存實(shí)現(xiàn)。
(3) 文件備份
在應(yīng)用重啟時(shí),off-heap 中的緩存為空。為了盡快載入緩存,我們使用 SpringBoot 的 Scheduling Tasks 功能,定期將緩存從 off-heap 備份到文件系統(tǒng);通過(guò)繼承 SpringBoot 的 ApplicationRunner 監(jiān)聽(tīng)?wèi)?yīng)用啟動(dòng)的過(guò)程,啟動(dòng)完成后將硬盤中的備份文件加載到 off-heap,保證緩存數(shù)據(jù)的可用性。
CacheService 維護(hù)一個(gè)任務(wù)隊(duì)列,隊(duì)列中保存著 CacheModule 通過(guò)非阻塞的方式提交的緩存任務(wù),由 CacheService 決定是否要執(zhí)行這些緩存任務(wù)。
(4) 對(duì) CacheModule 提供的 API
讀取緩存時(shí),傳入 key 值,緩存模塊隨機(jī)從 set 中讀取數(shù)據(jù)返回。
寫(xiě)入緩存時(shí),將 key 和 value 封裝為一個(gè)任務(wù),提交到任務(wù)隊(duì)列,由任務(wù)隊(duì)列負(fù)責(zé)異步寫(xiě)入緩存。
(5) 任務(wù)隊(duì)列與異步寫(xiě)入
這里我們使用了 JDK 中的線程池來(lái)實(shí)現(xiàn)。在構(gòu)造線程池時(shí),使用 LinkedBlockingQueue 作為任務(wù)隊(duì)列,可以實(shí)現(xiàn)快速增刪元素;因?yàn)閼?yīng)用的 QPS 在 100 以內(nèi),所以工作線程數(shù)目固定為 1;隊(duì)列寫(xiě)滿之后,則執(zhí)行 DiscardPolicy,放棄插入隊(duì)列。
(6)緩存數(shù)量控制
如果緩存占用內(nèi)存空間過(guò)大,會(huì)影響線上應(yīng)用,我們可以采用為不同的業(yè)務(wù)場(chǎng)景配置最大緩存數(shù)量來(lái)控制緩存數(shù)量。沒(méi)有達(dá)到配置值時(shí),將成功處理過(guò)的數(shù)據(jù)寫(xiě)入緩存;達(dá)到配置值時(shí)可以隨機(jī)抽樣覆蓋原有緩存項(xiàng),來(lái)保證緩存的實(shí)時(shí)性。
綜合考慮以上各個(gè)方面,CacheService 的設(shè)計(jì)如下:
線上表現(xiàn)
為了驗(yàn)證容災(zāi)緩存的效果,我們?cè)诿芯彺鏁r(shí)進(jìn)行了埋點(diǎn),并通過(guò) Kibana 查看每小時(shí)緩存的命中數(shù)量。如圖所示,在 18:00 到 19:00 系統(tǒng)存在一定的超時(shí),而這段時(shí)間由于緩存服務(wù)發(fā)揮了作用,使系統(tǒng)的可用性得到提升。
我們還對(duì) OHC 的讀取和寫(xiě)入速度進(jìn)行了監(jiān)控。寫(xiě)入緩存的時(shí)延在毫秒級(jí)別,并且是異步寫(xiě)入;讀取緩存的時(shí)延在微秒級(jí)別?;緵](méi)有給系統(tǒng)增加額外的時(shí)間消耗。
踩過(guò)的坑
在將緩存寫(xiě)入 OHC 之前,需要進(jìn)行序列化,我們使用了開(kāi)源的 kryo 作為序列化工具。之前在使用 kyro 時(shí),發(fā)現(xiàn)對(duì)于沒(méi)有實(shí)現(xiàn) Serializable 的類,反序列化時(shí)可能失敗,比如使用 List#subList 方法返回的內(nèi)部類 java.util.ArrayList$SubList。這里可以手動(dòng)注冊(cè) Serializer 來(lái)解決這個(gè)問(wèn)題,在 Github 上開(kāi)源的 kryo-serializers 倉(cāng)庫(kù)提供了各種類型的 serializers。
另外一點(diǎn),需要注意根據(jù)具體使用場(chǎng)景,來(lái)配置 OHC 中的 capacity 和 maxEntrySize。如果配置的值太小的話,會(huì)導(dǎo)致寫(xiě)入緩存失敗??梢栽谏暇€之前測(cè)算緩存的空間占用,合理設(shè)置整個(gè)緩存空間的大小和每個(gè)緩存 entry 的大小。
優(yōu)化方向
基于 SpringBoot 和 OHC,我們?cè)诂F(xiàn)有的推薦系統(tǒng)中增加了一個(gè)本地容災(zāi)緩存系統(tǒng),當(dāng)依賴服務(wù)或者應(yīng)用本身突發(fā)異常時(shí)可以返回緩存的數(shù)據(jù)。
該緩存系統(tǒng)還存在一些不足,我們近期會(huì)針對(duì)以下幾點(diǎn)進(jìn)行重點(diǎn)優(yōu)化:
- 緩存數(shù)目寫(xiě)滿之后,目前應(yīng)用會(huì)隨機(jī)覆寫(xiě)已經(jīng)存在的緩存。未來(lái)可以進(jìn)行優(yōu)化,將最老的緩存項(xiàng)替換。
- 在某些場(chǎng)景下緩存的粒度不夠精細(xì),比如目的地頁(yè)推薦共用一個(gè)緩存的 key 值。未來(lái)可以根據(jù)目的地的 ID,為每個(gè)目的地配置一份緩存。
- 現(xiàn)在推薦系統(tǒng)還有部分配置依賴于 MySQL,未來(lái)會(huì)考慮將在本地進(jìn)行文件緩存。
[參考資料]
1. Java Caching Benchmarks 2016 - Part 1
https://cruftex.net/2016/03/16/Java-Caching-Benchmarks-2016-Part-1.html
2. On Heap vs Off Heap Memory Usage
https://dzone.com/articles/heap-vs-heap-memory-usage
3. OHC - An off-heap-cache
https://github.com/snazy/ohc
4. kryo-serializers
https://github.com/magro/kryo-serializers
5. scheduling-tasks
https://spring.io/guides/gs/scheduling-tasks/
本文作者:孫興斌,馬蜂窩推薦和搜索后端研發(fā)工程師。
【本文是51CTO專欄作者馬蜂窩技術(shù)的原創(chuàng)文章,作者微信公眾號(hào)馬蜂窩技術(shù)(ID:mfwtech)】