本文由雲+社群發表
前言
業務已基於Redis實現了一個高可用的排行榜服務,長期以來相安無事。有一天,產品說:我要一個按周排名的排行榜,以反映本週內使用者的活躍情況。於是周榜(按周重置更新的榜單)誕生了。為了滿足產品多變的需求,我們一併實現了小時榜、日榜、周榜、月榜幾種週期榜。本以為可長治久安了,又有一天,產品體驗業務後說:我想要一個最近7天榜,反映最近一段時間的使用者活躍情況,不想讓歷史的高分使用者長期佔據榜首,可否?於是,滾動榜(最近N期榜)的需求誕生了。
週期榜
週期榜實現還是很容易的,給每個週期算出一個序號,作為榜單名字尾,進入新的週期自然切換讀寫新榜單,平滑過度。以日榜為例,根據時間戳ts計算每日序號s=ts/86400,以日序號s作為字尾即可實現零點後自動讀寫新日榜。小時榜與此雷同,不再贅述。
對於周榜,可以選定某一個週一(或週日,看需求)的時間戳為基準,計算基準到當前經過的週數為周序號,以此作為榜單字尾。
對於月榜,稍有不同,因為月份天數不固定,所以不能按照上述方法計算。但我們可以根據時間戳取得年、月資訊,以年月做標誌(如201810)字尾,即可實現月榜。
滾動榜
方案探討
滾動榜需要考慮多個週期榜資料的聚合與自動迭代更新,實現起來就沒那麼容易了。下面分析幾個方案。
方案1:每日一個滾動榜,當日離線補齊資料
還以日榜為例,最近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天榜,顯然不可接受。
方案2:全域性一個滾動榜,當日離線補齊資料
基於方案1,如果業務無需查詢歷史的S,可以只使用全域性一個S,無需每日建立一個Si。加分操作還是同時加當日的Ri和全域性唯一的S,但每日零點的離線作業改為從S中減去Ri−(N−1)的資料(即將最早一天的資料淘汰,從而實現S的計數滾動)。
此方案減少了記憶體使用,同時離線任務每次只需讀取一個日榜做減法,時間複雜度為O(1);但仍需要離線作業完成才能保證資料正確性,還是無法做到平滑過渡。
方案3:每日一個滾動榜,實時更新
要做到每日零點後榜單實時生效,而不需要等待離線作業的完成,一種方案是預寫未來的榜單。不難得出,當日分數會計入往後N-1天的滾動榜中。因此,可以寫當天的滾動榜Si的同時,寫往後N-1天的榜單Si+1到Si+N−1。
該方案不僅能脫離離線作業做到實時更新,且可以省略每天的日榜。但缺點也不難看出,對於7天滾動榜,每次寫操作需要更新7個榜單,寫入量小時還勉強能接受,如果寫操作量大或者需要的是30天、60天滾動榜,此方案可行性幾乎為零。
方案4:實時更新,常數次寫操作
有不有辦法做到既能實時更新,寫榜數量也不隨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)這部分資料呢?可以如下處理:
- 對一個元素加分時,加當日週期榜Ri、滾動榜Si;還需根據其在今日滾動榜中的分數s、及n-1天日榜中的分數r,計算出其在明日滾動榜中的初始分數
s-r
寫入明日滾動榜中;即3個寫操作; - 如果一個元素在當日沒有任何加分操作,那麼不會觸發寫入初始分數操作,所以還需要一個離線工具補齊。與方案1、2不同的是,該離線工具可提前一天執行,即當日執行離線工具補齊次日的滾動榜資料即可。
簡而言之:第一步是執行離線工具生成次日的滾動榜;第二步是在寫操作時同時更新次日的滾動榜。
該方案也是每日一個滾動榜。相對方案3而言,是空間換時間。如果空間不足且無保留歷史的需求,可在離線工具中清理歷史資料。
+--------------+
| |
| AddScore |
| |
+-+----+-----+-+
| | |
v | |
+--------+ +--------+ +-------++ | |
| | | | | | | |
| R(i-2) | | R(i-1) | | R(i) | | |
| | | | | | | |
+--------+ +--------+ +--------+ | |
| v
+--------+ | ++-------+
| | | | |
| S(i) +<--+ | S(i+1) |
| | | |
+--------+ +----+---+
^
|
|
+------+-----+
| |
| Tool |
| |
+------------+
複製程式碼
方案4的實現
以下是實現參考。此處僅列出核心的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)次數的寫操作,有較好的效能和可擴充套件性。且通過離線+線上的雙預生成機制,確保了榜單實時生效,可用性較強。
此文已由作者授權騰訊雲+社群釋出