redis在排行榜中的使用總結

wisdom123發表於2017-03-24

前言

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)結構(圖片來自維基百科):
screenshot.png

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中排行榜資料記憶體佔用情況進行評估

參考資料


相關文章