本文由雲+社區發表
業務已基於Redis實現了一個高可用的排行榜服務,長期以來相安無事。有一天,產品說:我要一個按周排名的排行榜,以反映本週內用戶的活躍狀況。因而周榜(按周重置更新的榜單)誕生了。爲了知足產品多變的需求,咱們一併實現了小時榜、日榜、周榜、月榜幾種週期榜。本覺得可長治久安了,又有一天,產品體驗業務後說:我想要一個最近7天榜,反映最近一段時間的用戶活躍狀況,不想讓歷史的高分用戶長期佔據榜首,能否?因而,滾動榜(最近N期榜)的需求誕生了。redis
週期榜實現仍是很容易的,給每一個週期算出一個序號,做爲榜單名後綴,進入新的週期天然切換讀寫新榜單,平滑過分。以日榜爲例,根據時間戳ts計算每日序號s=ts/86400,以日序號s做爲後綴便可實現零點後自動讀寫新日榜。小時榜與此雷同,再也不贅述。 數據庫
對於周榜,能夠選定某一個週一(或週日,看需求)的時間戳爲基準,計算基準到當前通過的週數爲周序號,以此做爲榜單後綴。 服務器
對於月榜,稍有不一樣,由於月份天數不固定,因此不能按照上述方法計算。但咱們能夠根據時間戳取得年、月信息,以年月作標誌(如201810)後綴,便可實現月榜。併發
滾動榜須要考慮多個週期榜數據的聚合與自動迭代更新,實現起來就沒那麼容易了。下面分析幾個方案。高併發
還以日榜爲例,最近N天榜就是把前N-1天到當天的每個日榜榜單累加便可,好比最近7天榜,就是前6天到當天的每個日榜中相同元素數據累加。所以,最直觀的一個方案是:首先記錄天天的排行榜R,那麼第i天的最近N天榜Si=∑N−1n=0Ri−n,其中,Ri−x表示第i天的前x天的日榜。實現上,能夠每日生成一個滾動榜S和當天日榜R,加分時同時寫入S和R,每日零點後跑工具將前N-1天數據累加寫入當日滾動榜S。 工具
這個方案的優勢是直觀,實現簡單。但缺點也很明顯,一是每日一個滾動榜,消耗內存較多;二是數據更新不實時,須要等待離線做業完成累加後S中的數據才徹底正確;三是時間複雜度高,7天榜還好,只須要讀過去6天數據,若是是100天榜,該方案須要讀過去99天榜,顯然不可接受。性能
基於方案1,若是業務無需查詢歷史的S,能夠只使用全局一個S,無需每日建立一個Si。加分操做仍是同時加當日的Ri和全局惟一的S,但每日零點的離線做業改成從S中減去Ri−(N−1)的數據(即將最先一天的數據淘汰,從而實現S的計數滾動)。 lua
此方案減小了內存使用,同時離線任務每次只需讀取一個日榜作減法,時間複雜度爲O(1);但仍須要離線做業完成才能保證數據正確性,仍是沒法作到平滑過渡。code
要作到每日零點後榜單實時生效,而不須要等待離線做業的完成,一種方案是預寫將來的榜單。不可貴出,當日分數會計入日後N-1天的滾動榜中。所以,能夠寫當天的滾動榜Si的同時,寫日後N-1天的榜單Si+1到Si+N−1。 ip
該方案不只能脫離離線做業作到實時更新,且能夠省略天天的日榜。但缺點也不難看出,對於7天滾動榜,每次寫操做須要更新7個榜單,寫入量小時還勉強能接受,若是寫操做量大或者須要的是30天、60天滾動榜,此方案可行性幾乎爲零。
有不有辦法作到既能實時更新,寫榜數量也不隨N的增長而增長呢?不難看出,第i天滾動榜Si=∑N−1n=0Ri−n,而第i+1天的滾動榜Si+1=∑N−1n=0R(i+1)−n=∑N−2n=0Ri−n+Ri+1。顯然,Si+1=Si−Ri−(N−1)+Ri+1。因爲Ri+1在剛達到零點時必然爲空且能夠在第二天實時加到Si+1上,所以若是咱們能提早準備好Si−Ri−(N−1)這部分數據,那麼在零點進入i+1天后,Ri+1天然就是可用狀態了。
以3天滾動榜爲例,第二天滾動榜初始態爲當日滾動榜減去n-2天的日榜數據。 +-------------------------------------------+ | | +----+---+ +--------+ +--------+ | | | | | | | | | R(i-2) | | R(i-1) | | R(i) | | | | | | | | | +----+---+ +----+---+ +---+----+ | | | | | | | | | | | | | | | v+ v- | | | | + +--------+ +--------+ | +-----> | | + | | | + | S(i) | +---+> | S(i+1) | +-----------------+> | | | | +--------+ +--------+
那麼,如何提早準備好Si−Ri−(N−1)這部分數據呢?能夠以下處理:
s-r
寫入明日滾動榜中;即3個寫操做;簡而言之:第一步是運行離線工具生成第二天的滾動榜;第二步是在寫操做時同時更新第二天的滾動榜。
該方案也是每日一個滾動榜。相對方案3而言,是空間換時間。若是空間不足且無保留歷史的需求,可在離線工具中清理歷史數據。
+--------------+ | | | AddScore | | | +-+----+-----+-+ | | | v | | +--------+ +--------+ +-------++ | | | | | | | | | | | R(i-2) | | R(i-1) | | R(i) | | | | | | | | | | | +--------+ +--------+ +--------+ | | | v +--------+ | ++-------+ | | | | | | S(i) +<--+ | S(i+1) | | | | | +--------+ +----+---+ ^ | | +------+-----+ | | | Tool | | | +------------+
如下是實現參考。此處僅列出核心的lua腳本。Redis命令調用腳本的參數定義爲:
eval script 4 當日日榜key 當日滾動榜key 即將淘汰的日榜key 明日滾動榜key 榜單元素名 加分數
lua腳本script以下:
--加今日日榜分數 redis.call('ZINCRBY', KEYS[1], ARGV[2], ARGV[1]) --加今日滾動榜分數 local rs = redis.call('ZINCRBY', KEYS[2], ARGV[2], ARGV[1]) local curRoundScore = 0 if (rs) then curRoundScore = tonumber(rs) end --取即將淘汰的日榜分數 rs = redis.call('ZSCORE', KEYS[3], ARGV[1]) local oldCycleScore = 0 if (rs) then oldCycleScore = tonumber(rs) end --計算第二天滾動榜初始分數 local nextRoundScore = curRoundScore - oldCycleScore if nextRoundScore < 0 then nextRoundScore = 0 end --設置第二天滾動榜分數 redis.call('ZADD', KEYS[4], nextRoundScore, ARGV[1]) --返回今日分數 rs = redis.call('ZREVRANK', KEYS[2], ARGV[1]) return {curRoundScore, rs}
關於榜單key計算準確度的探討 咱們的業務是在排行榜接入層邏輯中計算榜單後綴的,這種方案對邏輯層多臺機器的時間一致性要求較高,若是邏輯層服務器時鐘不一致,可能在時間切換點上出現不一樣機器讀寫不一樣榜單的問題。若是業務對時間精確度要求嚴格,能夠考慮經過lua腳步在redis端計算後綴。
.
關於內存容量限制的探討 基於ZSet實現的排行榜,每一個元素約須要100字節內存。若是榜單長度爲1000萬,則每一個榜單約須要1G內存。滾動榜的計算須要每日保留一個日榜,若是滾動週期較長,則可能單機內存容量不足以容納全部須要的榜單。 考慮到歷史日榜數據是不會變動的,所以不在lua腳本中讀取歷史日榜數據也無一致性問題。故能夠將榜單打散到多個Redis實例,在接入層作邏輯讀取歷史日榜的分數,再以參數形式傳入給lua腳本處理。
在榜單長度不大且併發量不高的場景下,使用關係數據庫+Cache的方案實現排行榜有更高的靈活性。而在海量數據與高併發的場景下,Redis是一個更好的選擇。本文基於Redis實現的滾動榜,不論滾動週期多長,都只須要常數(3)次數的寫操做,有較好的性能和可擴展性。且經過離線+在線的雙預生成機制,確保了榜單實時生效,可用性較強。
此文已由做者受權騰訊雲+社區發佈