大廠的影片推薦索引構建解決方案

公众号-JavaEdge發表於2024-03-07

關注我,緊跟本系列專欄文章,咱們下篇再續!

作者簡介:魔都技術專家兼架構,多家大廠後端一線研發經驗,各大技術社群頭部專家博主。具有豐富的引領團隊經驗,深厚業務架構和解決方案的積累。

負責:

  • 中央/分銷預訂系統效能最佳化
  • 活動&優惠券等營銷中臺建設
  • 交易平臺及資料中臺等架構和開發設計

目前主攻降低軟體複雜性設計、構建高可用系統方向。

參考:

  • 程式設計嚴選網

1 背景

在影片推薦場景:

  • 讓新啟用的影片儘可能快的觸達使用者,對新聞類內容尤為關鍵
  • 快速識別新物品的好壞,透過分發的流量,以及對應的後驗資料,來判斷新物品是否值得繼續分發流量

這兩點對索引先驗資料和後驗資料的延遲都高要求。下文介紹影片推薦的索引構建方案。

  • 先驗資料:影片建立時就帶有的資料如tag,作者賬號id
  • 後驗資料:使用者行為反饋的資料如曝光、點選、播放

2 影片推薦整體架構

資料鏈路角度,從下往上:

  • 影片內容由內容中心透過MQ給到我們,經過一定的處理入庫、建索引、生成正排/倒排資料,這時候在儲存層可召回的內容約1千萬條
  • 經召回層,透過使用者畫像、點選歷史等特徵召回出數千條影片,給到粗排層
  • 粗排將這數千條影片打分,取數百條給到精排層
  • 精排再一次打分,給到重排
  • 重排根據一定規則和策略進行打散和干預,最終取10+條給到使用者

影片在使用者側曝光後,從上到下,是另一條資料鏈路:使用者對影片的行為,如曝光、點選、播放、點贊、評論等經過上報至日誌服務,然後透過實時/離線處理產生特徵回到儲存層,由此形成迴圈。

基於此架構,需設計一套召回/倒排索引,以實時/近實時延遲來處理所有資料。

3 方案設計

舊方案的索引每半小時定時構建,無法滿足近實時要求。分析索引構建方案,發現挑戰:

  • 資料雖不要求強一致性,但需要保證最終一致性
  • 後驗資料寫入量極大,APP使用者行為每日百億+
  • 召回系統要求高併發、低延遲、高可用

3.1 業界主流方案調研

Redis方案靈活性較差,直接使用較難,需較多定製化開發,先排除。

可選方案主要在自研或開源成熟方案。研究發現:

  • 自研索引開發成本較高
  • 簡單自研方案可能無法滿足業務需求,完善的自研索引方案所需開發成本較高,需多人團隊開發維護

最終選擇基於ES的索引服務。不選Solr,主要因為ES有更成熟社群及雲廠商PaaS服務支援,使用更靈活方便。

3.2 資料鏈路圖

3.2.1 方案介紹

資料鏈路角度分兩塊:

  • 先驗資料鏈路,資料來源主要來自內容中心,透過解析服務寫入到CDB中。其中這個鏈路又分為全量鏈路和增量鏈路

    • 全量鏈路主要是在重建索引時才需要的,觸發次數少但也重要。它從DB這裡dump資料,寫入kafka,然後透過寫入服務寫入ES
    • 增量鏈路是確保其實時性的鏈路,透過監聽binlog,傳送訊息至kafka,寫入服務消費kafka然後寫入ES
  • 後驗資料鏈路。APP使用者行為流水每天有上百億,這個量級直接打入ES絕對扛不住。需對此進行聚合計算

用Flink做了1分鐘滾動視窗的聚合,然後把結果輸出到寫模組,得到1分鐘增量的後驗資料。在這裡,Redis儲存近7天的後驗資料,寫模組消費到增量資料後,需要讀出當天的資料,並於增量資料累加後寫回Redis,併傳送對應的rowkey和後驗資料訊息給到Kafka,再經由ES寫入服務消費、寫入ES索引。

3.2.2 一致性問題分析

該資料鏈路存在的一致性問題:

① Redis寫模組,需先讀資料,累加後再寫入

Redis寫模組,需先讀資料,累加後再寫入。先讀後寫,需要保證原子性,而這裡可能存在同時有其他執行緒在同一時間寫入,造成資料不一致。

解決方案1是透過redis加鎖來完成;解決方案2如下圖所示,在kafka佇列中,使用rowkey作為分割槽key,確保同一rowkey分配至同一分割槽,而同一只能由同一消費者消費,也就是同一rowkey由一個程序處理,再接著以rowkey作為分執行緒key,使用hash演算法分執行緒,這樣同一rowkey就在同一執行緒內處理,因此解決了此處的一致性問題。另外,透過這種方案,同一流內的一致性問題都可以解決。

② Redis寫模組,Redis寫入需先消費kafka的訊息

這就要求kafka訊息commit和redis寫入需要在一個事務內完成,即需保證原子性。

如果這裡先commit再進行redis寫入,那麼如果系統在commit完且寫入redis前當機了,那麼這條訊息將丟失掉;如果先寫入,在commit,那麼這裡就可能會重複消費。

如何解決?先寫入redis,且寫入的資訊裡帶上時間戳作版本號,再commit訊息;寫入前會比較訊息版本號和redis版本號,若小於,則直接丟棄。

③ 寫入ES有3個獨立程序

寫入ES有3個獨立程序寫入,不同流寫入同一索引也會引入一致性問題。這裡我們可以分析出,主要是先驗資料的寫入可能會存在一致性問題,因為後驗資料寫入的是不同欄位,而且只有update操作,不會刪除或者插入。

若上游的MySQL這裡刪除一條資料,全量鏈路和增量鏈路同時執行,而剛好全量Dump時剛好取到這條資料,隨後binlog寫入delete記錄,那麼ES寫入模組分別會消費到插入和寫入兩條訊息,而他自己無法區分先後順序,最終可能導致先刪除後插入,而DB裡這條訊息是已刪除的,這就造成了不一致。

那麼這裡如何解決該問題呢?其實分析到問題之後就比較好辦,常用的辦法就是利用Kfaka的回溯能力:在Dump全量資料前記錄下當前時間戳t1,Dump完成之後,將增量鏈路回溯至t1即可。而這段可能不一致的時間視窗1min,業務完全可接受。

線上0停機高可用線上索引升級流程:

3.2.3 寫入平滑

由於Flink聚合後的資料有很大的毛刺,匯入寫入ES時服務不穩定,cpu和rt都有較大毛刺,寫入情況如圖:

此處監控間隔是10秒,可以看到,由於聚合視窗是1min,每分鐘前10秒寫入達到峰值,後面逐漸減少,然後新的一分鐘開始時又週期性重複這種情況。

對此我們需要研究出合適的平滑寫入方案,這裡直接使用固定閾值來平滑寫入不合適,因為業務不同時間寫入量不同,無法給出固定閾值。

最終我們使用以下方案來平滑寫入:

使用自適應限流器來平滑寫,透過統計前1min接收的訊息總量,來計算當前每秒可傳送的訊息總量。具體實現如圖,將該模組拆分為讀執行緒和寫執行緒,讀執行緒統計接收訊息數,並把訊息存入佇列;令牌桶資料每秒更新;寫執行緒獲取令牌桶,獲取不到則等待,獲取到了就寫入。最終平滑寫入後效果:




不同時間段,均達到平滑效果。

4 召回效能調優

4.1 高併發場景最佳化

由於存在多路召回,所以召回系統有讀放大的問題,我們ES相關的召回,總qps是50W。這麼大的請求量如果直接打入ES,一定是扛不住的,那麼如何來進行最佳化呢?

由於大量請求的引數是相同的,並且存在大量的熱門key,因此我們引入了多級快取來提高召回的吞吐量和延遲時間。

多級快取方案:

方案架構清晰,簡單明瞭,整個鏈路:本地快取(BigCache)<->分散式快取(Redis)<->ES。

經計算,整體快取命中率為95+%,其中本地快取命中率75+%,分散式快取命中率20%,打入ES的請求量大約為5%。這大大提高召回的吞吐量並降低RT。

該方案還考慮快取穿透和雪崩問題,上線後不久就發生一次雪崩,ES全部請求失敗,且快取全部未命中。起初還分析究竟快取失效導致ES失敗orES失敗導致設定請求失效,實際就是經典快取雪崩問題。

該方案解決了:

  • 本地快取定時dump到磁碟中,服務重啟時將磁碟中的快取檔案載入至本地快取。
  • 巧妙設計快取Value,包含請求結果和過期時間,由業務自行判斷是否過期;當下遊請求失敗時,直接延長過期時間,並將老結果返回上游。
  • 熱點key失效後,請求下游資源前進行加鎖,限制單key併發請求量,保護下游不會被瞬間流量打崩。
  • 最後使用限流器兜底,如果系統整體超時或者失敗率增加,會觸發限流器限制總請求量。

4.2 ES效能調優

4.2.1 設定合理的primary_shards

primary_shards即主分片數,是ES索引拆分的分片數,對應底層Lucene的索引數。這個值越大,單請求的併發度就越高,但給到上層MergeResult的數量也會增加,因此這個數字不是越大越好。

根據我們的經驗結合官方建議,通常單個shard為150G比較合理,由於整個索引大小10G,我們計算出合理取值範圍為110個,接下里我們透過壓測來取最合適業務的值。壓測結果:


根據壓測資料,我們選擇6作為主分片數,此時es的平均rt13ms,99分位的rt為39ms。

4.2.2 請求結果過濾不需要的欄位

ES返回結果都是json,而且預設會帶上source和_id,_version等欄位,我們把不必要的正排欄位過濾掉,再使用filter_path把其他不需要的欄位過濾掉,這樣總共能減少80%的包大小,過濾結果:

包大小由26k減小到5k,帶來的收益是提升了30%的吞吐效能和降低3ms左右的rt。

4.2.3 設定合理routing欄位

ES支援使用routing欄位來對索引進行路由,即在建立索引時,可以將制定欄位作為路由依據,透過雜湊演算法直接算出其對應的分片位置。

這樣查詢時也可根據指定欄位路由,到指定分片查詢,無需到所有分片查詢。根據業務特點,將作者賬號id puin 作為路由欄位,路由過程:

這樣對帶有作者賬號id的召回的查詢吞吐量可以提高6倍,整體來看,給ES帶來了30%的吞吐效能提升。

4 關閉不需要索引或排序的欄位

透過索引模板,我們將可以將不需要索引的欄位指定為"index":false,將不需要排序的欄位指定為"doc_values":false。這裡經測試,給ES整體帶來了10%左右的吞吐效能提升。

本文由部落格一文多發平臺 OpenWrite 釋出!

相關文章