redis在排行榜中的使用總結
前言
Redis 是一個開源(BSD許可)的,記憶體中的資料結構儲存系統,它可以用作資料庫、快取和訊息中介軟體。它支援多種型別的資料結構,如 字串(strings), 雜湊(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 與範圍查詢, bitmaps, hyperloglogs 和 地理空間(geospatial) 索引半徑查詢。 Redis 內建了 複製(replication),LUA指令碼(Lua scripting), LRU驅動事件(LRU eviction),事務(transactions) 和不同級別的 磁碟持久化(persistence), 並通過 Redis哨兵(Sentinel)和自動 分割槽(Cluster)提供高可用性(high availability)
正如上面的介紹,redis支援很多種型別的資料結構,適用於多種場景。目前應用最為廣泛的場景主要分為以下幾類:
- 會話快取(session cache)
- 佇列
- 排行榜/列表
- 計數器
- 釋出/訂閱
本篇,我主要是從排行榜的業務場景下,進行的一些個人總結。
一、排行榜業務的分類
排行榜業務變化多樣,從不同的角度思考,是不同的排行榜需求,但總結起來,主要分為以下幾類:
實效性
從排行榜的實效性上劃分,主要分為:
- 實時榜:基於當前一段時間內資料的實時更新,進行排行。例如:當前一小時內遊戲熱度實時榜,當前一小時內明星送花實時榜等
- 歷史榜:基於歷史一段週期內的資料,進行排行。例如:日榜(今天看昨天的),周榜(上一週的),月榜(上個月的),年榜(上一年的)
業務資料型別
從需要排行的資料型別上劃分,主要分為:
- 單型別資料排行榜:是指需要排行的主體不需要區分型別,例如,所有使用者積分排行,所有公貢獻值排行,所有遊戲熱度排行等
- 多型別(複合型別)資料排行榜:是指需要排行的主體在排行中要求有型別上的區分,例如:競技類遊戲熱度排行、體育類遊戲熱度排行、MOBA類遊戲操作性排行、角色/回合/卡牌三類遊戲熱度排行等
展示唯度
從榜單的最終展示唯度上劃分,主要分為:
- 單唯度:是指選擇展示的排行榜就是基於一個唯度下的排行,例如前面提到的MOBA類遊戲操作性排行榜,就僅展示所有MOBA類遊戲按操作性的評分排行
- 多唯度:是指選擇展示的排行榜還有多種唯度供使用者選擇,仍然以前面的MOBA類遊戲為例,唯度除了操作性,還有音效評分排行,難易度評分排行,畫面評分排行等。
展示資料量
從需要展示的資料量上劃分,主要分為:
- topN資料:只要求展示topN條排行紀錄,例如:最火MOBA遊戲top20
- 全量資料:要求展示所有資料的排行,例如:所有使用者的積分排行
在以上幾種榜單中,往往還會有需要加入,當前使用者自身的一些資料在排行榜中的位置。
二、排行榜redis資料結構的選擇
選擇合適的redis資料結構,可以快速的實現排行榜要求。
首選Sorted Set資料結構:
Redis Sorted sets —
Sorted sets are a data type which is similar to a mix between a Set and a Hash. Like sets, sorted sets are composed of unique, non-repeating string elements, so in some sense a sorted set is a set as well.
However while elements inside sets are not ordered, every element in a sorted set is associated with a floating point value, called the score (this is why the type is also similar to a hash, since every element is mapped to a value).
Moreover, elements in a sorted sets are taken in order (so they are not ordered on request, order is a peculiarity of the data structure used to represent sorted sets). They are ordered according to the following rule:
If A and B are two elements with a different score, then A > B if A.score is > B.score.
If A and B have exactly the same score, then A > B if the A string is lexicographically greater than the B string. A and B strings can’t be equal since sorted sets only have unique elements.
在Sorted-Set中新增、刪除或更新一個成員都是非常快速的操作,其時間複雜度為集合中成員數量的對數。由於Sorted-Sets中的成員在集合中的位置是有序的,因此,即便是訪問位於集合中部的成員也仍然是非常高效的。
Sorted-Set底層的實現是跳錶(skiplist),插入和刪除的效率都很高.
關於跳錶(skiplist)結構(圖片來自維基百科):
wiki上的詳情介紹
在redis的原始碼中,找到zset的定義如下(server.h):
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
插入節點對應的gif演示來自wiki
看了上面的插入節點動畫演示,再來看插入節點對應的方法原始碼(t_zset.c),理解起來就容易多了:
/* Insert a new node in the skiplist. Assumes the element does not already
* exist (up to the caller to enforce that). The skiplist takes ownership
* of the passed SDS string `ele`. */
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
unsigned int rank[ZSKIPLIST_MAXLEVEL];
int i, level;
serverAssert(!isnan(score));
x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
/* store rank that is crossed to reach the insert position */
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele,ele) < 0)))
{
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
update[i] = x;
}
/* we assume the element is not already inside, since we allow duplicated
* scores, reinserting the same element should never happen since the
* caller of zslInsert() should test in the hash table if the element is
* already inside or not. */
level = zslRandomLevel();
if (level > zsl->level) {
for (i = zsl->level; i < level; i++) {
rank[i] = 0;
update[i] = zsl->header;
update[i]->level[i].span = zsl->length;
}
zsl->level = level;
}
x = zslCreateNode(level,score,ele);
for (i = 0; i < level; i++) {
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;
/* update span covered by update[i] as x is inserted here */
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
/* increment span for untouched levels */
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}
x->backward = (update[0] == zsl->header) ? NULL : update[0];
if (x->level[0].forward)
x->level[0].forward->backward = x;
else
zsl->tail = x;
zsl->length++;
return x;
}
(p.s:skip list 看似一個簡單的連結串列分層設計,卻可以取得很好的效果,大道至簡!)
三、快取的設計
瞭解redis中的sorted set為何可以做到快速的排序之後,接下來就是我們在實際的業務中,如何來利用它的這個特性了。
在我們的業務排行榜中,在決定使用redis來做排序之前,我們往往還有其他方案可供選擇,比如:採用資料庫儲存資料,構建好相應的索引,通過sql查詢來直接排行,也是可以做到的。個人認為,如果資料量不大,而且排行榜型別也不多,完全可以用sql來解決,畢竟引入redis,也還需要考慮很多運維場景。
如果我們選擇使用redis,那如何定義好業務的key,member,和scores,就很重要了。
根據前面所總結的排行榜業務的分類不同,設計時思考的角度也都不同,但基本上我會按照以下步驟:
- 規範key的命名:一般我們採用這種格式,服務代號:業務代號:排行主體:榜單分類:%s_版本號。在這裡的變數%s,也會根據不同的排行榜進行定義,往往是排序物件的父級,例如:所有遊戲使用者日積分排行榜,排行展示的物件是使用者列表,在該榜單中使用者歸屬至某一天,那對應的%s就可以定義為yyyyMMdd格式的具體日期,這樣就可以很方便我們進行key的組裝和資料查詢;類似,某款遊戲下的使用者累積充值排行榜,排行展示的物件是使用者列表,在該榜單中的使用者歸屬於遊戲,那對應的%s可以是遊戲id,再比如,競技類遊戲熱度排行榜,排行展示的是遊戲列表,遊戲都歸屬在這個競技類下面,那這裡的%s可以是該遊戲類別。
- 確定memeber:將需要排序的物件,也就是最終要展示在榜單列表中的物件定義為member。就像上面提到所有遊戲使用者日積分排行榜中對應的member是使用者(uid)、競技類遊戲熱度排行榜中對應的member是遊戲(gameId)
- 確定scores:大部份的情況下,scores的值就是最終用來比較的值,例如上面提到的積分值、充值值、熱度值,還有的可能是時間值,也可能是一個經過複合計算的權重值(小技巧),目的就是為了提供給sorted set進行比較排序
- 有效期:根據上面第一節中榜單的時效性分類,對應不同的快取時間
案例分析
明星收花自然周實時榜(遊戲使用者給喜歡的遊戲代言明星送花,使用者即可檢視當前所有明星的本週排行榜,一週有效,獲得花越多明星的排名越前,分頁展示),也可以檢視具體一個明星下,送花的使用者的排行榜,當天有效。根據前面的分類,明星本週排行,需要最終展示的列表是明星,那member必定是明星(starId),而歸屬的實效性上是自然周,所以我們key值用自然周來進行組合,於是我們做下面這種設計:
key---star:ss:flower:week:real:%s_1.0 (%s 可以是周的第一天日期yyyyMMdd)
member---starId
score---flowerNumber
每當一個使用者有給明星送花時,通過zadd、incrBy(key,flowerNumber,starId)插入資料
需要排行時,通過zrangeByScore可能方便的分頁獲取排行榜,倒序可以zrevrangebyscore
需要獲取排行榜中的總數量,通過zcount可以獲得
需要獲取當前使用者送過花的某個明星的所處的排行,通過zrank,即可得到相應的序號
需要獲取當前使用者送過花的某個明星的具體收到的花的數量,通過zscore,即可得到相應的數量
具體明星下的送花使用者排行榜(使用者送的越多,排名越前,分頁展示),這種場景,這時要求展示的列表是普通使用者,那member我們選擇普通使用者(uid),而其歸屬是在一個具體的明星下,所以我們key值用starId來進行組合,於是我們也可以做這種設計:
key---star:ss:flower:week:real:%s_1.0 (%s 就可以用starId)
member---uid
score---flowerNumber
使用者送花自然周實時榜、使用者送花自然月實時榜也類似。
是不是覺其實挺簡單,只要我們能清楚的將需要展示的排行榜,確定member,界定key,score值的量化,那這種排行榜實現起來是不是比資料庫來做就容易多了。
上面舉的列子中,score值都是屬於比較確定的,在業務動作發生時,通過不斷的累積,能有一個具體的值。還有一些業務場景,這種score值並不是由簡單的一個確定了的數字,這個時候往往需要我們自己定義一種計算公式,通過一些簡單的運算得到一個“權值”;其次,上面的member也都是可以直接確定的物件id,有的些業務排行榜中,我們可能member也有可能遇到組合的方式。
不管member是唯一的id,還是組合而成,我們都需要注意以下兩點:
- member在sorted set資料結構中,必須是唯一的
- 相同score的情況下,是以member的字典順序排序的
member組合的場景,例如:如果我們需要一種實時排行榜,總是排行當前3小時內的某個明星下的所有送花使用者排行列表(如,從14:01–17:01,從14:02–17:02………..),那我們可以這樣設計:
key---star:ss:flower:hour:real:%s_2.0 (%s 用starId)
member---uid_now()_flowerNumber (誰,什麼時間,本次送了幾朵)
score---now()
說明:
明星每收到一朵花,我們同樣可以通過zadd的方式加入至快取中,用當前時間和本次送花數量,組合member值(唯一),以當前時間為score值(方便比較)
在我們需要查詢該排行榜時,我們首先會zremrangebyscore 刪除掉當前時間3個小時以前的資料,然後取出member、拆分、zadd(zincrby)到另一個結果快取中(1分鐘有效),1分鐘內的請求從結果快取中直接獲取,這樣也就可以得到相應的3小時滾動時間內的排行榜了。
當然也許你還有更好的方案。
還有一些排行榜中,我們有時候甚至需要將多個key進行重新組合再排序,這種排行有一個顯著的特殊,那就是排行榜中展示的主體物件是有較多型別的,也是說對應我們上面排行榜分類中所提到的,從業務資料型別上劃分中的多型別那一類。舉個例子:遊戲分為競技類遊戲、動作類遊戲、賽車類遊戲,如果要求每一種型別的遊戲都有熱度排行榜,我們可以很輕鬆的設計出key:
key:ng:game:hot:rank:%s_1.0 (%s 用遊戲分類id)
member:gameId
score:hotVal
如果後面又想將這三種型別的遊戲組合到一起進行排序,那又該怎麼辦?
這就需要通過zunionstore來實現了,通過zunionstore來得到一個並集,並最終排序。但這裡需要注意的是需要組成並集的各個集合的key必須是對應到redis叢集中的同一個slot上,否則將會出現一個異常:CROSSSLOT Keys in request don`t hash to the same slot。所以redis提供了一種特定的標籤{},這個{}內的字串才參與計算hash slot.列如:{user}:aaa與{user}:bbb 這兩個集合可以確保在同一個slot上,可以使用zunionstore求它們的並集。所以,在處理這種需要合併redis歷史資料時,如果是在redis叢集環境下,需要特別注意。
類似的,我們有時候可以通過日榜組合三日榜,七日榜,組合月榜,組合年榜等等
是不是看起來排行榜也挺容易實現的,趕緊試試吧
其他考慮
在基於redis的整個排行榜的設計過程中,我們還需要考慮的
- 排行榜key的數量:確保key的增長數量是可控的,可設定過期時間的,就設定明顯的過期時間
- 佔用空間評估:redis中排行榜資料記憶體佔用情況進行評估
參考資料
相關文章
- Redis在專案中合理使用經驗總結Redis
- PHP 在redis-sentinel模式下的使用總結PHPRedis模式
- 最近使用redis的總結Redis
- TypeScript在React專案中的使用總結TypeScriptReact
- Redis在.net中的使用(2).net專案中的Redis使用Redis
- Redis在.net中的使用(5)Redis持久化Redis持久化
- Redis中 HyperLogLog資料型別使用總結Redis資料型別
- Redis在.net中的使用(6)Redis併發鎖Redis
- Redis在遊戲業務中的使用Redis遊戲
- Redis在.net中的使用(4)常見的集中資料結構Redis資料結構
- Redis在.net中的使用(1)下載安裝RedisRedis
- Redis總結Redis
- 聊聊jesque在redis中的資料結構Redis資料結構
- Redis的安裝及在Java中的使用RedisJava
- JavaScript 中 this 的使用技巧總結JavaScript
- Redis在.net中的使用(7)redis部署為Windows服務RedisWindows
- Redis 哨兵使用以及在 Laravel 中的配置RedisLaravel
- redis命令總結Redis
- Redis總結(上)Redis
- 在 Android 中使用 JNI 的總結Android
- redis的安裝並在java中初步使用(spring配置redis)RedisJavaSpring
- PHP中Trait的使用總結PHPAI
- loadrunner中log的使用總結
- redis面試題 redis總結 redis簡述Redis面試題
- Java中Equals使用總結Java
- 在React專案中安裝並使用Less(用法總結)React
- redis redis中的hash結構【八】Redis
- 使用Redis的有序集合實現排行榜功能Redis
- Redux在React中的使用小結ReduxReact
- Redis 中ZSET資料型別命令使用及對應場景總結Redis資料型別
- Redis中的事務處理機制分析與總結Redis
- Redis知識總結Redis
- redis 系列:總結篇Redis
- redis學習總結Redis
- MybatisPlus 中的API 使用總結(CRUD)MyBatisAPI
- Redis使用ZSET實現訊息佇列使用總結二Redis佇列
- Redis使用ZSET實現訊息佇列使用總結一Redis佇列
- canvas在H5中的繪圖總結CanvasH5繪圖