vivo 短視頻推薦去重服務(wù)的設(shè)計(jì)實(shí)踐
一、概述
1.1 業(yè)務(wù)背景
vivo短視頻在視頻推薦時(shí)需要對(duì)用戶已經(jīng)看過的視頻進(jìn)行過濾去重,避免給用戶重復(fù)推薦同一個(gè)視頻影響體驗(yàn)。在一次推薦請(qǐng)求處理流程中,會(huì)基于用戶興趣進(jìn)行視頻召回,大約召回2000~10000條不等的視頻,然后進(jìn)行視頻去重,過濾用戶已經(jīng)看過的視頻,僅保留用戶未觀看過的視頻進(jìn)行排序,選取得分高的視頻下發(fā)給用戶。
1.2 當(dāng)前現(xiàn)狀
當(dāng)前推薦去重基于Redis Zset實(shí)現(xiàn),服務(wù)端將播放埋點(diǎn)上報(bào)的視頻和下發(fā)給客戶端的視頻分別以不同的Key寫入Redis ZSet,推薦算法在視頻召回后直接讀取Redis里對(duì)應(yīng)用戶的播放和下發(fā)記錄(整個(gè)ZSet),基于內(nèi)存中的Set結(jié)構(gòu)實(shí)現(xiàn)去重,即判斷當(dāng)前召回視頻是否已存在下發(fā)或播放視頻Set中,大致的流程如圖1所示。
(圖1:短視頻去重當(dāng)前現(xiàn)狀)
視頻去重本身是基于用戶實(shí)際觀看過的視頻進(jìn)行過濾,但考慮到實(shí)際觀看的視頻是通過客戶端埋點(diǎn)上報(bào),存在一定的時(shí)延,因此服務(wù)端會(huì)保存用戶最近100條下發(fā)記錄用于去重,這樣就保證了即使客戶端埋點(diǎn)還未上報(bào)上來,也不會(huì)給用戶推薦了已經(jīng)看過的視頻(即重復(fù)推薦)。而下發(fā)給用戶的視頻并不一定會(huì)被曝光,因此僅保存100條,使得未被用戶觀看的視頻在100條下發(fā)記錄之后仍然可以繼續(xù)推薦。
當(dāng)前方案主要問題是占用Redis內(nèi)存非常大,因?yàn)橐曨lID是以原始字符串形式存在Redis Zset中,為了控制內(nèi)存占用并且保證讀寫性能,我們對(duì)每個(gè)用戶的播放記錄最大長度進(jìn)行了限制,當(dāng)前限制單用戶最大存儲(chǔ)長度為10000,但這會(huì)影響重度用戶產(chǎn)品體驗(yàn)。
二、方案調(diào)研
2.1 主流方案
第一,存儲(chǔ)形式。視頻去重場景是典型的只需要判斷是否存在即可,因此并不需要把原始的視頻ID存儲(chǔ)下來,目前比較常用的方案是使用布隆過濾器存儲(chǔ)視頻的多個(gè)Hash值,可降低存儲(chǔ)空間數(shù)倍甚至十幾倍。
第二,存儲(chǔ)介質(zhì)。如果要支持存儲(chǔ)90天(三個(gè)月)播放記錄,而不是當(dāng)前粗暴地限制最大存儲(chǔ)10000條,那么需要的Redis存儲(chǔ)容量非常大。比如,按照5000萬用戶,平均單用戶90天播放10000條視頻,每個(gè)視頻ID占內(nèi)存25B,共計(jì)需要12.5TB。視頻去重最終會(huì)讀取到內(nèi)存中完成,可以考慮犧牲一些讀取性能換取更大的存儲(chǔ)空間。而且,當(dāng)前使用的Redis未進(jìn)行持久化,如果出現(xiàn)Redis故障會(huì)造成數(shù)據(jù)丟失,且很難恢復(fù)(因數(shù)據(jù)量大,恢復(fù)時(shí)間會(huì)很長)。
目前業(yè)界比較常用的方案是使用磁盤KV(一般底層基于RocksDB實(shí)現(xiàn)持久化存儲(chǔ),硬盤使用SSD),讀寫性能相比Redis稍遜色,但是相比內(nèi)存而言,磁盤在容量上的優(yōu)勢非常明顯。
2.2 技術(shù)選型
第一,播放記錄。因需要支持至少三個(gè)月的播放歷史記錄,因此選用布隆過濾器存儲(chǔ)用戶觀看過的視頻記錄,這樣相比存儲(chǔ)原始視頻ID,空間占用上會(huì)極大壓縮。我們按照5000萬用戶來設(shè)計(jì),如果使用Redis來存儲(chǔ)布隆過濾器形式的播放記錄,也將是TB級(jí)別以上的數(shù)據(jù),考慮到我們最終在主機(jī)本地內(nèi)存中執(zhí)行過濾操作,因此可以接受稍微低一點(diǎn)的讀取性能,選用磁盤KV持久化存儲(chǔ)布隆過濾器形式的播放記錄。
第二,下發(fā)記錄。因只需存儲(chǔ)100條下發(fā)視頻記錄,整體的數(shù)據(jù)量不大,而且考慮到要對(duì)100條之前的數(shù)據(jù)淘汰,仍然使用Redis存儲(chǔ)最近100條的下發(fā)記錄。
三、方案設(shè)計(jì)
基于如上的技術(shù)選型,我們計(jì)劃新增統(tǒng)一去重服務(wù)來支持寫入下發(fā)和播放記錄、根據(jù)下發(fā)和播放記錄實(shí)現(xiàn)視頻去重等功能。其中,重點(diǎn)要考慮的就是接收到播放埋點(diǎn)以后將其存入布隆過濾器。在收到播放埋點(diǎn)以后,以布隆過濾器形式寫入磁盤KV需要經(jīng)過三步,如圖2所示:第一,讀取并反序列化布隆過濾器,如布隆過濾器不存在則需創(chuàng)建布隆過濾器;第二,將播放視頻ID更新到布隆過濾器中;第三,將更新后的布隆過濾器序列化并回寫到磁盤KV中。
(圖2:統(tǒng)一去重服務(wù)主要步驟)
整個(gè)過程很清晰,但是考慮到需要支持千萬級(jí)用戶量,假設(shè)按照5000萬用戶目標(biāo)設(shè)計(jì),我們還需要考慮四個(gè)問題:
- 第一,視頻按刷次下發(fā)(一刷5~10條視頻),而播放埋點(diǎn)按照視頻粒度上報(bào),那么就視頻推薦消重而言,數(shù)據(jù)的寫入QPS比讀取更高,然而,相比Redis磁盤KV的性能要遜色,磁盤KV本身的寫性能比讀性能低,要支持5000萬用戶量級(jí),那么如何實(shí)現(xiàn)布隆過濾器寫入磁盤KV是一個(gè)要考慮的重要問題。
- 第二,由于布隆過濾器不支持刪除,超過一定時(shí)間的數(shù)據(jù)需要過期淘汰,否則不再使用的數(shù)據(jù)將會(huì)一直占用存儲(chǔ)資源,那么如何實(shí)現(xiàn)布隆過濾器過期淘汰也是一個(gè)要考慮的重要問題。
- 第三,服務(wù)端和算法當(dāng)前直接通過Redis交互,我們希望構(gòu)建統(tǒng)一去重服務(wù),算法調(diào)用該服務(wù)來實(shí)現(xiàn)過濾已看視頻,而服務(wù)端基于Java技術(shù)棧,算法基于C++技術(shù)棧,那么需要在Java技術(shù)棧中提供服務(wù)給C++技術(shù)棧調(diào)用。我們最終采用gRPC提供接口給算法調(diào)用,注冊(cè)中心采用了Consul,該部分非重點(diǎn),就不詳細(xì)展開闡述。
- 第四,切換到新方案后我們希望將之前存儲(chǔ)在Redis ZSet中的播放記錄遷移到布隆過濾器,做到平滑升級(jí)以保證用戶體驗(yàn),那么設(shè)計(jì)遷移方案也是要考慮的重要問題。
3.1 整體流程
統(tǒng)一去重服務(wù)的整體流程及其與上下游之間的交互如圖3所示。服務(wù)端在下發(fā)視頻的時(shí)候,將當(dāng)次下發(fā)記錄通過統(tǒng)一去重服務(wù)的Dubbo接口保存到Redis下發(fā)記錄對(duì)應(yīng)的Key下,使用Dubbo接口可以確保立即將下發(fā)記錄寫入。同時(shí),監(jiān)聽視頻播放埋點(diǎn)并將其以布隆過濾器形式存放到磁盤KV中,考慮到性能我們采用了批量寫入方案,具體下文詳述。統(tǒng)一去重服務(wù)提供RPC接口供推薦算法調(diào)用,實(shí)現(xiàn)對(duì)召回視頻過濾掉用戶已觀看的視頻。
(圖3:統(tǒng)一去重服務(wù)整體流程)
磁盤KV寫性能相比讀性能差很多,尤其是在Value比較大的情況下寫QPS會(huì)更差,考慮日活千萬級(jí)情況下磁盤KV寫性能沒法滿足直接寫入要求,因此需要設(shè)計(jì)寫流量匯聚方案,即將一段時(shí)間以內(nèi)同一個(gè)用戶的播放記錄匯聚起來一次寫入,這樣就大大降低寫入頻率,降低對(duì)磁盤KV的寫壓力。
3.2 流量匯聚
為了實(shí)現(xiàn)寫流量匯聚,我們需要將播放視頻先暫存在Redis匯聚起來,然后隔一段時(shí)間將暫存的視頻生成布隆過濾器寫入磁盤KV中保存,具體而言我們考慮過N分鐘僅寫入一次和定時(shí)任務(wù)批量寫入兩種方式。接下來詳細(xì)闡述我們?cè)诹髁繀R聚和布隆過濾器寫入方面的設(shè)計(jì)和考慮。
3.2.1 近實(shí)時(shí)寫入
監(jiān)聽到客戶端上報(bào)的播放埋點(diǎn)后,原本應(yīng)該直接將其更新到布隆過濾器并保存到磁盤KV,但是考慮到降低寫頻率,我們只能將播放的視頻ID先保存到Redis中,N分鐘內(nèi)僅統(tǒng)一寫一次磁盤KV,這種方案姑且稱之為近實(shí)時(shí)寫入方案吧。
最樸素的想法是每次寫的時(shí)候,在Redis中保存一個(gè)Value,N分鐘以后失效,每次監(jiān)聽到播放埋點(diǎn)以后判斷這個(gè)Value是否存在,如果存在則表示N分鐘內(nèi)已經(jīng)寫過一次磁盤KV本次不寫,否則執(zhí)行寫磁盤KV操作。這樣的考慮主要是在數(shù)據(jù)產(chǎn)生時(shí),先不要立即寫入,等N分鐘匯聚一小批流量之后再寫入。這個(gè)Value就像一把“鎖”,保護(hù)磁盤KV每隔N分鐘僅被寫入一次,如圖4所示,如果當(dāng)前為已加鎖狀態(tài),再進(jìn)行加鎖會(huì)失敗,可保護(hù)在加鎖期間磁盤KV不被寫入。從埋點(diǎn)數(shù)據(jù)流來看,原本連續(xù)不斷的數(shù)據(jù)流,經(jīng)過這把“鎖”就變成了每隔N分鐘一批的微批量數(shù)據(jù),從而實(shí)現(xiàn)流量匯聚,并降低磁盤KV的寫壓力。
(圖4:近實(shí)時(shí)寫入方案)
近實(shí)時(shí)寫入的出發(fā)點(diǎn)很單純,優(yōu)勢也很明顯,可以近實(shí)時(shí)地將播放埋點(diǎn)中的視頻ID寫入到布隆過濾器中,而且時(shí)間比較短(N分鐘),可以避免Redis Zset中暫存的數(shù)據(jù)過長。但是,仔細(xì)分析還需要考慮很多特殊的場景,主要如下:
第一,Redis中保存一個(gè)Value其實(shí)相當(dāng)于一個(gè)分布式鎖,實(shí)際上很難保證這把“鎖”是絕對(duì)安全的,因此可能會(huì)存在兩次收到播放埋點(diǎn)均認(rèn)為可以進(jìn)行磁盤KV寫操作,但這兩次讀到的暫存數(shù)據(jù)不一定一樣,由于磁盤KV不支持布隆過濾器結(jié)構(gòu),寫入操作需要先從磁盤KV中讀出當(dāng)前的布隆過濾器,然后將需要寫入的視頻ID更新到該布隆過濾器,最后再寫回到磁盤KV,這樣的話,寫入磁盤KV后就有可能存在數(shù)據(jù)丟失。
第二,最后一個(gè)N分鐘的數(shù)據(jù)需要等到用戶下次再使用的時(shí)候才能通過播放埋點(diǎn)觸發(fā)寫入磁盤KV,如果有大量不活躍的用戶,那么就會(huì)存在大量暫存數(shù)據(jù)遺留在Redis中占用空間。此時(shí),如果再采用定時(shí)任務(wù)來將這部分?jǐn)?shù)據(jù)寫入到磁盤KV,那么也會(huì)很容易出現(xiàn)第一種場景中的并發(fā)寫數(shù)據(jù)丟失問題。
如此看來,近實(shí)時(shí)寫入方案雖然出發(fā)點(diǎn)很直接,但是仔細(xì)想來,越來越復(fù)雜,只能另尋其他方案。
3.2.2 批量寫入
既然近實(shí)時(shí)寫入方案復(fù)雜,那不妨考慮簡單的方案,通過定時(shí)任務(wù)批量將暫存的數(shù)據(jù)寫入到磁盤KV中。我們將待寫的數(shù)據(jù)標(biāo)記出來,假設(shè)我們每小時(shí)寫入一次,那么我們就可以把暫存數(shù)據(jù)以小時(shí)值標(biāo)記。但是,考慮到定時(shí)任務(wù)難免可能會(huì)執(zhí)行失敗,我們需要有補(bǔ)償措施,常見的方案是每次執(zhí)行任務(wù)的時(shí)候,都在往前多1~2個(gè)小時(shí)的數(shù)據(jù)上執(zhí)行任務(wù),以作補(bǔ)償。但是,明顯這樣的方案并不夠優(yōu)雅,我們從時(shí)間輪得到啟發(fā),并基于此設(shè)計(jì)了布隆過濾器批量寫入的方案。
我們將小時(shí)值首尾相連,從而得到一個(gè)環(huán),并且將對(duì)應(yīng)的數(shù)據(jù)存在該小時(shí)值標(biāo)識(shí)的地方,那么同一小時(shí)值(比如每天11點(diǎn))的數(shù)據(jù)是存在一起的,如果今天的數(shù)據(jù)因任務(wù)未執(zhí)行或執(zhí)行失敗未同步到磁盤KV,那么在第二天將會(huì)得到一次補(bǔ)償。
順著這個(gè)思路,我們可以將小時(shí)值對(duì)某個(gè)值取模以進(jìn)一步縮短兩次補(bǔ)償?shù)臅r(shí)間間隔,比如圖5所示對(duì)8取模,可見1:00~2:00和9:00~10:00的數(shù)據(jù)都會(huì)落在圖中時(shí)間環(huán)上的點(diǎn)1標(biāo)識(shí)的待寫入數(shù)據(jù),過8個(gè)小時(shí)將會(huì)得到一次補(bǔ)償?shù)臋C(jī)會(huì),也就是說這個(gè)取模的值就是補(bǔ)償?shù)臅r(shí)間間隔。
(圖5:批量寫入方案)
那么,我們應(yīng)該將補(bǔ)償時(shí)間間隔設(shè)置為多少呢?這是一個(gè)值得思考的問題,這個(gè)值的選取會(huì)影響到待寫入數(shù)據(jù)在環(huán)上的分布。我們的業(yè)務(wù)一般都會(huì)有忙時(shí)、閑時(shí),忙時(shí)的數(shù)據(jù)量會(huì)更大,根據(jù)短視頻忙閑時(shí)特點(diǎn),最終我們將補(bǔ)償間隔設(shè)置為6,這樣業(yè)務(wù)忙時(shí)比較均勻地落在環(huán)上的各個(gè)點(diǎn)。
確定了補(bǔ)償時(shí)間間隔以后,我們覺得6個(gè)小時(shí)補(bǔ)償還是太長了,因?yàn)橛脩粼?個(gè)小時(shí)內(nèi)有可能會(huì)看過大量的視頻,如果不及時(shí)將數(shù)據(jù)同步到磁盤KV,會(huì)占用大量Redis內(nèi)存,而且我們使用Redis ZSet暫存用戶播放記錄,過長的話會(huì)嚴(yán)重影響性能。于是,我們?cè)O(shè)計(jì)每個(gè)小時(shí)增加一次定時(shí)任務(wù),第二次任務(wù)對(duì)第一次任務(wù)補(bǔ)償,如果第二次任務(wù)仍然沒有補(bǔ)償成功,那么經(jīng)過一圈以后,還可以得到再次補(bǔ)償(兜底)。
細(xì)心一點(diǎn)應(yīng)該會(huì)發(fā)現(xiàn)在圖5中的“待寫入數(shù)據(jù)”和定時(shí)任務(wù)并不是分布在環(huán)上的同一個(gè)點(diǎn)的,我們這樣設(shè)計(jì)的考慮是希望方案更簡單,定時(shí)任務(wù)只會(huì)去操作已經(jīng)不再變化的數(shù)據(jù),這樣就能避免并發(fā)操作問題。就像Java虛擬機(jī)中垃圾回收一樣,我們不能一邊回收垃圾,一邊卻還在同一間屋子里扔著垃圾。所以,設(shè)計(jì)成環(huán)上節(jié)點(diǎn)對(duì)應(yīng)定時(shí)任務(wù)只去處理前一個(gè)節(jié)點(diǎn)上的數(shù)據(jù),以確保不會(huì)產(chǎn)生并發(fā)沖突,使方案保持簡單。
批量寫入方案簡單且不存在并發(fā)問題,但是在Redis Zset需要保存一個(gè)小時(shí)的數(shù)據(jù),可能會(huì)超過最大長度,但是考慮到現(xiàn)實(shí)中一般用戶一小時(shí)內(nèi)不會(huì)播放非常大量的視頻,這一點(diǎn)是可以接受的。最終,我們選擇了批量寫入方案,其簡單、優(yōu)雅、高效,在此基礎(chǔ)上,我們需要繼續(xù)設(shè)計(jì)暫存大量用戶的播放視頻ID方案。
3.3 數(shù)據(jù)分片
為了支持5000萬日活量級(jí),我們需要為定時(shí)批量寫入方案設(shè)計(jì)對(duì)應(yīng)的數(shù)據(jù)存儲(chǔ)分片方式。首先,我們依然需要將播放視頻列表存放在Redis Zset,因?yàn)樵跊]寫入布隆過濾器之前,我們需要用這份數(shù)據(jù)過濾用戶已觀看過的視頻。正如前文提到過,我們會(huì)暫存一個(gè)小時(shí)的數(shù)據(jù),正常一個(gè)用戶一個(gè)小時(shí)內(nèi)不會(huì)播放超過一萬條數(shù)據(jù)的,所以一般來說是沒有問題的。除了視頻ID本身以外,我們還需要保存這個(gè)小時(shí)到底有哪些用戶產(chǎn)生過播放數(shù)據(jù),否則定時(shí)任務(wù)不知道要將哪些用戶的播放記錄寫入布隆過濾器,存儲(chǔ)5000萬用戶的話就需要進(jìn)行數(shù)據(jù)分片。
結(jié)合批量同步部分介紹的時(shí)間環(huán),我們?cè)O(shè)計(jì)了如圖6所示的數(shù)據(jù)分片方案,將5000萬的用戶Hash到5000個(gè)Set中,這樣每個(gè)Set最多保存1萬個(gè)用戶ID,不至于影響Set的性能。同時(shí),時(shí)間環(huán)上的每個(gè)節(jié)點(diǎn)都按照這個(gè)的分片方式保存數(shù)據(jù),將其展開就如同圖6下半部分所示,以played:user:${時(shí)間節(jié)點(diǎn)編號(hào)}:${用戶Hash值}為Key保存某個(gè)時(shí)間節(jié)點(diǎn)某個(gè)分片下所有產(chǎn)生了播放數(shù)據(jù)的用戶ID。
(圖6:數(shù)據(jù)分片方案)
對(duì)應(yīng)地,我們的定時(shí)任務(wù)也要進(jìn)行分片,每個(gè)任務(wù)分片負(fù)責(zé)處理一定數(shù)目的數(shù)據(jù)分片。否則,如果兩者一一對(duì)應(yīng)的話,將分布式定時(shí)任務(wù)分成5000個(gè)分片,雖然對(duì)于失敗重試是更好的,但是對(duì)于任務(wù)調(diào)度來說會(huì)存在壓力,實(shí)際上公司的定時(shí)任務(wù)也不支持5000分分片。我們將定時(shí)任務(wù)分為了50個(gè)分片,任務(wù)分片0負(fù)責(zé)處理數(shù)據(jù)分片0~100,任務(wù)分片1負(fù)責(zé)處理數(shù)據(jù)分片100~199,以此類推。
3.4 數(shù)據(jù)淘汰
對(duì)于短視頻推薦去重業(yè)務(wù)場景,我們一般保證讓用戶在看過某條視頻后三個(gè)月內(nèi)不會(huì)再向該用戶推薦這條視頻,因此就涉及到過期數(shù)據(jù)淘汰問題。布隆過濾器不支持刪除操作,因此我們將用戶的播放歷史記錄添加到布隆過濾器以后,按月存儲(chǔ)并設(shè)置相應(yīng)的過期時(shí)間,如圖7所示,目前過期時(shí)間設(shè)置為6個(gè)月。在數(shù)據(jù)讀取的時(shí)候,根據(jù)當(dāng)前時(shí)間選擇讀取最近4個(gè)月數(shù)據(jù)用于去重。之所以需要讀取4個(gè)月的數(shù)據(jù),是因?yàn)楫?dāng)月數(shù)據(jù)未滿一個(gè)月,為了保證三個(gè)月內(nèi)不會(huì)再向用戶重復(fù)推薦,需要讀取三個(gè)完整月和當(dāng)月數(shù)據(jù)。
(圖7:數(shù)據(jù)淘汰方案)
對(duì)于數(shù)據(jù)過期時(shí)間的設(shè)置我們也進(jìn)行了精心考慮,數(shù)據(jù)按月存儲(chǔ),因此新數(shù)據(jù)產(chǎn)生時(shí)間一般在月初,如果僅將過期時(shí)間設(shè)置為6個(gè)月以后,那么會(huì)造成月初不僅產(chǎn)生大量新數(shù)據(jù),也需要淘汰大量老數(shù)據(jù),對(duì)數(shù)據(jù)庫系統(tǒng)造成壓力。所以,我們將過期時(shí)間進(jìn)行了打散,首先隨機(jī)到6個(gè)月后的那個(gè)月任意一天,其次我們將過期時(shí)間設(shè)置在業(yè)務(wù)閑時(shí),比如:00:00~05:00,以此來降低數(shù)據(jù)庫清理時(shí)對(duì)系統(tǒng)的壓力。
3.5 方案小結(jié)
通過綜合上述流量匯聚、數(shù)據(jù)分片和數(shù)據(jù)淘汰三部分設(shè)計(jì)方案,整體的設(shè)計(jì)方案如圖8所示,從左至右播放埋點(diǎn)數(shù)據(jù)依次從數(shù)據(jù)源Kafka流向Redis暫存,最終流向磁盤KV持久化。
(圖8:整體方案流程)
首先,從Kafka播放埋點(diǎn)監(jiān)聽到數(shù)據(jù)以后,我們根據(jù)用戶ID將該條視頻追加到用戶對(duì)應(yīng)的播放歷史中暫存,同時(shí)根據(jù)當(dāng)前時(shí)間和用戶ID的Hash值確定對(duì)應(yīng)時(shí)間環(huán),并將用戶ID保存到該時(shí)間環(huán)對(duì)應(yīng)的用戶列表中。然后,每個(gè)分布式定時(shí)任務(wù)分片去獲取上一個(gè)時(shí)間環(huán)的播放用戶數(shù)據(jù)分片,再獲取用戶的播放記錄更新到讀出的布隆過濾器,最后將布隆顧慮其序列化后寫入磁盤KV中。
四、數(shù)據(jù)遷移
為了實(shí)現(xiàn)從當(dāng)前基于Redis ZSet去重平滑遷移到基于布隆過濾器去重,我們需要將統(tǒng)一去重服務(wù)上線前用戶產(chǎn)生的播放記錄遷移過來,以保證用戶體驗(yàn)不受影響,我們?cè)O(shè)計(jì)和嘗試了兩種方案,經(jīng)過對(duì)比和改進(jìn)形成了最終方案。
我們已經(jīng)實(shí)現(xiàn)了批量將播放記錄原始數(shù)據(jù)生成布隆過濾器存儲(chǔ)到磁盤KV中,因此,遷移方案只需要考慮將存儲(chǔ)在原來Redis中的歷史數(shù)據(jù)(去重服務(wù)上線前產(chǎn)生)遷移到新的Redis中即可,接下來就交由定時(shí)任務(wù)完成即可,方案如圖9所示。用戶在統(tǒng)一去重服務(wù)上線后新產(chǎn)生的增量數(shù)據(jù)通過監(jiān)聽播放埋點(diǎn)寫入,新老數(shù)據(jù)雙寫,以便需要時(shí)可以降級(jí)。
(圖9:遷移方案一)
但是,我們忽略了兩個(gè)問題:第一,新的Redis僅用作暫存,因此比老的Redis容量小很多,沒法一次性將數(shù)據(jù)遷移過去,需要分多批遷移;第二,遷移到新的Redis后的存儲(chǔ)格式和老的Redis不一樣,除了播放視頻列表,還需要播放用戶列表,咨詢DBA得知這樣遷移比較難實(shí)現(xiàn)。
既然遷移數(shù)據(jù)比較麻煩,我們就考慮能不能不遷移數(shù)據(jù)呢,在去重的時(shí)候判斷該用戶是否已遷移,如未遷移則同時(shí)讀取一份老數(shù)據(jù)一起用于去重過濾,并觸發(fā)將該用戶的老數(shù)據(jù)遷移到新Redis(含寫入播放用戶列表),三個(gè)月以后,老數(shù)據(jù)已可過期淘汰,此時(shí)就完成了數(shù)據(jù)遷移,如圖10所示。這個(gè)遷移方案解決了新老Redis數(shù)據(jù)格式不一致遷移難的問題,而且是用戶請(qǐng)求時(shí)觸發(fā)遷移,也避免了一次性遷移數(shù)據(jù)對(duì)新Redis容量要求,同時(shí)還可以做到精確遷移,僅遷移了三個(gè)月內(nèi)需要遷移數(shù)據(jù)的用戶。
(圖10:遷移方案二)
于是,我們按照方案二進(jìn)行了數(shù)據(jù)遷移,在上線測試的時(shí)候,發(fā)現(xiàn)由于用戶首次請(qǐng)求的時(shí)候需要去遷移老的數(shù)據(jù),造成去重接口耗時(shí)不穩(wěn)定,而視頻去重作為視頻推薦重要環(huán)節(jié),對(duì)于耗時(shí)比較敏感,所以就不得不繼續(xù)思考新的遷移方案。我們注意到,在定時(shí)批量生成布隆過濾器的時(shí)候,讀取到時(shí)間環(huán)對(duì)應(yīng)的播放用戶列表后,根據(jù)用戶ID獲取播放視頻列表,然后生成布隆過濾器保存到磁盤KV,此時(shí),我們只需要增加一個(gè)從老Redis讀取用戶的歷史播放記錄即可把歷史數(shù)據(jù)遷移過來。為了觸發(fā)將某個(gè)用戶的播放記錄生成布隆過濾器的過程,我們需要將用戶ID保存到時(shí)間環(huán)上對(duì)應(yīng)的播放用戶列表,最終方案如圖11所示。
(圖11:最終遷移方案)
首先,DBA幫助我們把老Redis中播放記錄的Key(含有用戶ID)都掃描出來,通過文件導(dǎo)出;然后,我們通過大數(shù)據(jù)平臺(tái)將導(dǎo)出的文件導(dǎo)入到Kafka,啟用消費(fèi)者監(jiān)聽并消費(fèi)文件中的數(shù)據(jù),解析后將其寫入到當(dāng)前時(shí)間環(huán)對(duì)應(yīng)的播放用戶列表。接下來,分布式批量任務(wù)在讀取到播放用戶列表中的某個(gè)用戶后,如果該用戶未遷移數(shù)據(jù),則從老Redis讀取歷史播放記錄,并和新的播放記錄一起更新到布隆過濾器并存入磁盤KV。
五、小結(jié)
本文主要介紹短視頻基于布隆過濾器構(gòu)建推薦去重服務(wù)的設(shè)計(jì)與思考,從問題出發(fā)逐步設(shè)計(jì)和優(yōu)化方案,力求簡單、完美、優(yōu)雅,希望能對(duì)讀者有參考和借鑒價(jià)值。由于文章篇幅有限,有些方面未涉及,也有很多技術(shù)細(xì)節(jié)未詳細(xì)闡述,如有疑問歡迎繼續(xù)交流。