游戲中百萬用戶排行設計與實現


 

排行榜在游戲中非常常見的功能之一,在游戲中有各種排行榜,如工會活躍度,玩家的英雄戰斗力排行等。當數據上億時,如果使用數據庫直排是致命的慢,遠遠超出用戶接受的響應時間。也對數據庫造成非常大的壓力。本文將會講述千萬用戶級別的用戶排行系統的一些設計理念並講述數據庫直排以及使用桶排和內存數據優化排行榜。

在講述設計前,有必要先了解一些基礎理論,文章將會先講述什么排行榜的類別,排行規則和排名分布,然后進一步結合以往寫的一個簡單的排行系統Nagi,講述數據庫直排和使用桶排技術,以及內存緩存技術等。

排行榜的類別

刷新頻率

如果以排行榜的刷新頻率來分類可分為及時排行榜,和周期排行榜。

及時排行榜

排行榜的排名能及時反映用戶的排變名化,但不意味着是非常准備的排名。

周期性排行榜

排行榜將會在一定周期內刷新排名,如日排行,周排行,月排行等

准確性分類

精准排名

能夠准確的反應當前玩家的某段時間,或者當前的排名。

近似排名

近似排名能夠反映用戶的排名變化和接近真實排名也許會稍稍低於真實排名,或者高於真實排名。總之可能與真實的排名有一定差別。

排行規則

排名規則,這里並不是如競技場,使用交換排名的方式,一個新用戶進入競技場時只要簡單的統計下當前競技場用戶數量就可以初始化其排名,隨着玩家挑戰高名次的玩家,如果勝利就交換名次這類規則。而是諸如工會活躍度可能是當前工會所有工會成員的活躍度總和作為工會活躍度、或工會所有玩家戰斗力總和作為工會戰斗力。這類因為最后由唯一屬性(如工會活躍度,工會戰斗力)決定排名的歸為簡單排名(唯一屬性排名)。

你可能會為不放心如何計算工會的戰斗力。那么考慮一個簡單的游戲功能如簽到排名,規則是用戶每天簽到將會記錄用戶最近連續簽到的天數,如果某天用戶忘記簽到,那么用戶簽到天數將會從零開始重新計算,除非用戶補簽。如果用戶簽到天數越多,那么用戶排名越高這類就是簡單的排名,僅有單一屬性決定玩家的排名。但是由於這個排名可能因為大多數用戶都在游戲開始就持續的簽名,這樣就會有很多玩家排名一致,但為了保證每個用戶都有不同的排名,於是將由用戶id來區分排名,id越小排名越靠前,這類排名簽到天數結合用戶id就有多個屬性決定排名就是復合屬性排名。

用戶排名的分布

在設計排名系統時一定要注意到用戶排名的分布,正如上面講到簽到系統,是非常符合‘二八法則’的,大多數用戶的排名將會非常接近或者相同。這類分布也可能會相近於正太分布。兩端的用戶越來越少,中間用戶越來多。這樣造成大量用戶的排名相同。所以如果有可能應該制定比較好的游戲規則,使用戶的排行分散均勻。

算法設計

算法設計將結合個人一個項目Nagi來講述具體設計。 Nagi是一個抽象的排行榜系統,在系統中把所有需要排行的數據抽象成一個具有一個積分的實體對象。並且可以排行多個排行榜,數據庫使用的是MySQL。

基礎表設計

用戶積分表(實體表)

    CREATE TABLE entries (
      eid INT(11) unsigned NOT NULL COMMENT 'The unique identifier for a entry in a leaderboards.',
      lid MEDIUMINT(8) unsigned NOT NULL,
      score INT(11) unsigned NOT NULL,
      data VARCHAR(1024) DEFAULT NULL COMMENT 'The custom entry data',
      created DATETIME NOT NULL DEFAULT NOW() COMMENT 'The DATETIME when the entry was created.',

      PRIMARY KEY (lid, eid),
      KEY user_entry (lid, score)
    ) ENGINE=InnoDB CHARSET=utf8;
eid: 實體唯一標識符(在簽到系統相當於用戶id)
score: 排名積分(在簽到系統相當於簽到天數)
data: 存放實體的一些自定義數據,json序列化數據
created: 創建時間
lid: 排行榜唯一標識,參考leaderboards表
  • 排行榜表

    CREATE TABLE leaderboards (
      lid MEDIUMINT(8) unsigned NOT NULL AUTO_INCREMENT,
      name VARCHAR(124) NOT NULL,
      adapter VARCHAR(16),

      PRIMARY KEY (lid),
      UNIQUE KEY name (name)
    ) ENGINE=InnoDB CHARSET=utf8;

 

lid: 排行榜唯一標識
name: 可讀的排行榜名
adapter: 這個用來決定使用什么什么算法做排行榜
  • API

這里主要講述兩個api, rank和rank_for_user

  • rank(limit, offset, dense=False)

接口來可以做排行榜分頁

rank(1000, 0) 將會獲取到排名前1000的用戶。

  • rank_for_user(eid, dense=False)

將通過eid(對於簽到系統里面是uid)來獲取該玩家的排名。

Note

接口中的dense為True將會使用簽到天數和用戶id復合屬性排名保證用戶排名的唯一性。

  • 使用數據庫直排

數據庫直排,算法比較低效,但數據少量時,依舊是最高效最簡單的算法。

  • rank_for_user

獲取某個用戶排名核心sql如下

    RANK_SQL = """SELECT  eo.*,
            (
            SELECT  COUNT(%sei.score) %s
            FROM    entries ei
            WHERE  eo.lid=ei.lid AND %s
            ) AS rank
    FROM   entries eo"""


    def rank_for_user(self, lid, eid, dense=False):
        sql = self._build_rank_sql(dense)
        sql += '\nWHERE lid=%s AND eid=%s'
        data = db.query_one(sql, (lid, eid))
        if data:
            return self._load(data)

    def _build_rank_sql(self, dense=False):
        if dense:
            sql = self.RANK_SQL % (('', '', '(ei.score, eo.eid) >= (eo.score, ei.eid)')  
        else:
            sql = self.RANK_SQL %('DISTINCT ', ' + 1', 'ei.score > eo.score'))
        return sql

 

核心一條低效的sql統計出當前用戶的排名,代碼中dense為True是使用復合屬性,就是用戶排名將不會重復。

  • rank

隨着offset增大,查詢效率會越來越低,返回的數據真實性也會降低。

   def rank(self, leaderboard_id, limit=1000, offset=0, dense=False):
        sql = 'SELECT * FROM entries WHERE lid=%s '
        if dense:
            sql += 'ORDER BY score DESC, eid ASC'
        else:
            sql += 'GROUP BY score, eid ORDER BY score DESC'

        sql += ' LIMIT %s OFFSET %s'
        res = db.query(sql, (leaderboard_id, limit, offset))
        res = [self._load(data) for data in res]
        if res:
            if not dense:
                entry = self.rank_for_user(leaderboard_id, res[0].entry_id, dense)
                offset = entry.rank
            else:
                offset += 1
            self._rank_entries(res, dense, offset)
        return res

    def _rank_entries(self, entries, dense=False, rank=0):
        prev_entry = entries[0]
        prev_entry.rank = rank
        for e in entries[1:]:
            if dense:
                rank += 1
            elif e.score != prev_entry.score:
                rank += 1
            e.rank = rank
            prev_entry = e

 

同樣通過低效的order group選出用戶后,然后獲取到第一個用戶排名,然后簡單的在程序中做排名。

  • 直排的性能

對於100萬數據,如果使用數據直排,取某個用戶平均需要5s,所以這種算法的排名,基本適數據量小於10w數據量的排名。

  • 桶排

桶排是使用桶排序結合數據庫特性優化的一種排行榜算法,在使用不同數據庫實現時,有必要了解數據庫的特性,才能設計好的系統。

桶排適合周期性排行,桶排在用戶更新積分時會改變影響整個排行,整體來說就是個近似排名。 桶排的優化原則是保證區間桶的用戶數量在適合范圍,保證用戶可接受的響應時間。

  • 積分桶 (計數排序)

對於簽到系統,簽到天數在 [0, 5000] 范圍絕對是夠用的(有游戲能做到13年一直保持維護更新?)。那么以簽到天數作為桶號,桶統計當前簽到天數為當前桶號用戶數量,於是最多可能有5001桶,每個桶統計當前得分用戶的數量。這樣可以用簡單的sql:

SELECT SUM(uid) FROM entries GROUP BY score

來獲取桶信息,然后計算出各個積分的排名區間比如得當前簽到天數為5000且有1000個用戶。 如果使用復合uid來排名那么桶號為5000的排名區間為[1-1000] ,如果僅僅使用積分作為排名那么桶5000的排名為1。

因為桶排需要記錄額外的桶信息,所以需要額外的表來保存桶信息。

積分桶表如下:

    CREATE TABLE score_buckets (
      lid MEDIUMINT(8) unsigned NOT NULL,
      score INT(11) unsigned NOT NULL,
      size INT(11) unsigned NOT NULL,
      from_dense INT(11) unsigned NOT NULL,
      to_dense INT(11) unsigned NOT NULL,
      rank INT(11) unsigned NOT NULL,

      PRIMARY KEY leaderboard_score (lid, score),
      KEY dense (from_dense, to_dense)
    ) ENGINE=InnoDB CHARSET=utf8;

 

lid: 排行榜唯一標識
score: 積分桶當前桶號,也就是積分
size: 用於記錄當前桶的用戶數量
from_dense: 記錄復合屬性時桶中用戶的最高排名(起始排名)
to_dense: 記錄復合屬性時桶中用戶的最低排名(終止排名)
rank: 記錄唯一屬性時當前桶的排名
  • 桶統計流程

    def sort(self, leaderboard_id, chunk_block=CHUNK_BLOCK):

        # 獲取當前排行榜的最高分與最低分
        res = db.query_one('SELECT max(score) as max_score, min(score) as min_score \
            FROM entries WHERE lid=%s', (leaderboard_id,))

        max_score, min_score = res
        rank, dense = 0, 0
        from_score = max_score
        #清空可能比現在最高分更高的桶
        self.clear_buckets_by_score_range(leaderboard_id, from_score + 1, None)

        # 因為一次統計所有桶過於費時,所以切割分桶,並清空以前的桶數據,寫入新的的桶數據
        while from_score >= min_score:
            buckets, rank, dense = self._get_buckets(leaderboard_id, from_score - chunk_block, from_score, rank, dense)
            self.clear_buckets_by_score_range(leaderboard_id, from_score - chunk_block, from_score)
            self.save_buckets(buckets)
            from_score -= chunk_block
        # 清空比當前排行榜最低積分低的桶數據
        self.clear_buckets_by_score_range(leaderboard_id, None, min_score -1)

    def _get_buckets(self, leaderboard_id, from_score, to_score, rank, dense):
        """獲取新的桶區間數據"""
        res = db.query('SELECT score, COUNT(score) size FROM entries WHERE lid=%s AND %s<score AND score<=%s GROUP BY score ORDER BY score DESC',
            (leaderboard_id, from_score, to_score))
        buckets = []
        for data in res:
            buckets.append(ScoreBucket(leaderboard_id, data[0], data[1], dense + 1, dense + data[1], rank + 1))
            dense += data[1]
            rank += 1
        return buckets, rank, dense

    def clear_buckets_by_score_range(self, leaderboard_id, from_score, to_score):
        """清空桶區間"""
        if to_score is None:
            return db.execute('DELETE FROM score_buckets WHERE lid=%s AND %s<score', (leaderboard_id, from_score))
        if from_score is None:
            return db.execute('DELETE FROM score_buckets WHERE lid=%s AND score<=%s', (leaderboard_id, to_score))
        return db.execute('DELETE FROM score_buckets WHERE lid=%s AND %s<score AND score<=%s', (leaderboard_id, from_score, to_score))

    def save_buckets(self, buckets):
        """寫入桶數據"""
        if not buckets:
            return
        sql = 'INSERT INTO score_buckets(score, size, lid, from_dense, to_dense, rank) VALUES '
        rows = []
        for bucket in buckets:
            rows.append('(%d, %d, %d, %d, %d, %d)' % (bucket.score, bucket.size,
                bucket.leaderboard_id, bucket.from_dense, bucket.to_dense, bucket.rank))
        db.execute(sql + ','.join(rows))

 

  • 因為不可能一次用使用group by統計出所有桶,因為這樣可能太耗費內存和時間,所以先選出最高積分(max)和最低積分(min):
  • 利用獲取的最高和最低積分,使用一個閾值分割桶, 比如閾值為500,那么分割后為[max, max - 500], [max - 501, max - 1000],..[?, min]直到最小積分。
  • 如sort方法中先清空相關區間的桶數據然后查詢寫入新的桶數據。
  • rank_for_user

可以輕松根據用戶id獲取到score后使用如下api能獲取到當前用戶的排名。

    def rank_for_user(self, leaderboard_id, entry_id, dense=False):
        entry = self.find(leaderboard_id, entry_id)
        if entry:
            if dense:
                data  = db.query_one('SELECT from_dense FROM score_buckets WHERE lid=%s AND score=%s', (leaderboard_id, entry.score))
                from_rank = data[0] 
                rank = db.query_one('SELECT COUNT(eid) as rank FROM entries WHERE lid=%s AND eid<%s AND score=%s', 
                    (leaderboard_id, entry_id, entry.score))[0]
                entry.rank = from_rank + rank 
            else:
                data = db.query_one('SELECT rank FROM score_buckets WHERE lid=%s AND score=%s', (leaderboard_id, entry.score))
                entry.rank = data[0]      
        return entry

 

  • rank

使用桶排 rank算法相對復雜些:

    def rank(self, leaderboard_id, limit=1000, offset=0, dense=False):
        to_score,from_rank, to_rank = db.query_one('SELECT score, from_dense, to_dense FROM score_buckets WHERE lid=%s AND from_dense<=%s AND %s<=to_dense', (leaderboard_id, offset+1, offset+1))
        if to_rank >=limit + offset + 1:
            from_score = to_score
        else:
            from_score = db.query_one('SELECT score FROM score_buckets WHERE lid=%s AND from_dense<=%s AND %s<=to_dense', (leaderboard_id, limit+offset+1, limit+offset+1))[0]
        sql = 'SELECT * FROM entries WHERE lid=%s AND %s<=score AND score<=%s '
        if dense:
            sql += 'ORDER BY score DESC, eid ASC'
        else:
            sql += 'GROUP BY score, eid ORDER BY score DESC'
        sql += ' LIMIT %s OFFSET %s'
        
        res = db.query(sql, (leaderboard_id, from_score, to_score, limit, offset - from_rank+1))
        res = [self._load(data) for data in res]
        if res:
            if not dense:
                entry = self.rank_for_user(leaderboard_id, res[0].entry_id, dense)
                offset = entry.rank
            else:
                offset += 1
            self._rank_entries(res, dense, offset)
        return res

    def _rank_entries(self, entries, dense=False, rank=0):
        prev_entry = entries[0]
        prev_entry.rank = rank
        for e in entries[1:]:
            if dense:
                rank += 1
            elif e.score != prev_entry.score:
                rank += 1
            e.rank = rank
            prev_entry = e

 

代碼流程是:

  • 獲取到當前排名范圍的積分分布范圍
  • 通過縮小積分范圍從entries獲取到根據積分排序好的用戶
  • 然后我們只要獲取到第一個用戶的排名,然后在業務代碼中排好其他用戶的名次就行。
  • 積分桶的優點與缺點

這類排行算法,比較適合實體積分范圍比較小。由於二八法則的用戶積分分布,都可造成單通用戶數量過於膨大。積分范圍過廣泛如[0, 1000000000) 這樣桶的數量過於多。算法也不適宜了。

  • 均勻區間桶

對於工會活躍度積分范圍可能在 [0, 1000000000) 積分分布比較分散,如果使用積分桶,需要耗費比較長的計算時間,查詢用戶排名也會變慢。這時可使用均勻區間桶, 我們把積分分為這樣的連續均勻遞增區間[0, 10000), [10001, 20000), .... ,然后桶不再只對應一個積分,而是對應相關的積分區間,比如桶1對應[0, 10000),桶2對應[10000, 20000)。這樣的桶算法也就是區間桶,其實是最為常見的桶排序。

  • 區間桶存儲表

    CREATE TABLE block_buckets  (
      lid MEDIUMINT(8) unsigned NOT NULL,
      from_score INT(11) unsigned NOT NULL,
      to_score INT(11) unsigned NOT NULL,
      from_rank INT(11) unsigned NOT NULL,
      to_rank INT(11) unsigned NOT NULL,
      from_dense INT(11) unsigned NOT NULL,
      to_dense INT(11) unsigned NOT NULL,

      PRIMARY KEY leaderboard_score (lid,from_score, to_score)
    ) ENGINE=InnoDB CHARSET=utf8;

 

lid: 排行榜唯一標識
from_score: 記錄區間桶的低端
to_score: 記錄區間桶的高端
from_rank: 記錄當前桶唯一屬性排名時的中用戶最高排名
to_rank: 記錄當前桶唯一屬性排名時的中用戶最低排名
from_dense: 記錄復合屬性時桶中用戶的最高排名(起始排名)
to_dense: 記錄復合屬性時桶中用戶的最低排名(終止排名)

桶排算法如下:

 1     def sort(self, leaderboard_id, chunk_block=BUCKET_BLOCK):
 2         """計算刷新保存桶信息"""
 3 
 4         # 獲取當前排行榜的最高分與最低分
 5         res = db.query_one('SELECT max(score) as max_score, min(score) as min_score FROM entries WHERE lid=%s', (leaderboard_id,))
 6         if not res: return
 7 
 8         max_score, min_score = res
 9         if chunk_block is None and max_score > min_score:
10             chunk_block = (max_score - min_score) / (self.total(leaderboard_id)/ (max_score - min_score))
11         elif max_score == min_score:
12             chunk_block = BUCKET_BLOCK
13 
14         rank, dense = 1, 1
15         buckets = []
16         self.clear_buckets(leaderboard_id)
17         to_score = max_score
18         from_score = to_score - chunk_block
19         from_score = max(min_score, from_score)
20 
21         # 切割區間保存並保存桶信息
22         while to_score >= min_score:
23             dense_size = self._get_dense_size(leaderboard_id, from_score, to_score)
24             rank_size = self._get_rank_size(leaderboard_id, from_score,  to_score)
25             buckets.append(BlockBucket(leaderboard_id, from_score, to_score, rank, rank + rank_size - 1, dense, dense + dense_size - 1))
26             if len(buckets) == 500:
27                 self.save_buckets(buckets)
28                 buckets = []
29             to_score = from_score - 1
30             from_score = to_score - chunk_block
31             from_score = max(min_score, from_score)
32             dense += dense_size
33             rank += rank_size
34 
35         self.save_buckets(buckets)
36 
37     def _get_dense_size(self, leaderboard_id, from_score, to_score):
38         """獲取當前區間的復合屬性時的用戶數量"""
39         return db.query_one('SELECT COUNT(score) size FROM entries WHERE lid=%s AND %s<=score AND score<=%s',
40             (leaderboard_id, from_score, to_score))[0]
41 
42     def _get_rank_size(self, leaderboard_id, from_score, to_score):
43         """獲取當前區間的唯一屬性時的用戶數量""""""
44         return db.query_one('SELECT COUNT(DISTINCT(score)) size FROM entries WHERE lid=%s AND %s<=score AND score<=%s',
45             (leaderboard_id, from_score, to_score))[0]
46 
47     def save_buckets(self, buckets):
48         """保存桶數據"""
49         if not buckets: return
50 
51         sql = 'INSERT INTO block_buckets(lid, from_score, to_score, from_rank, to_rank, from_dense, to_dense) VALUES '
52         rows = []
53         for bucket in buckets:
54             rows.append('(%d, %d, %d, %d, %d, %d, %d)' % (bucket.leaderboard_id, bucket.from_score,
55                bucket.to_score, bucket.from_rank, bucket.to_rank, bucket.from_dense, bucket.to_dense))
56         db.execute(sql + ','.join(rows))
57 
58     def clear_buckets(self, leaderboard_id):
59         """清空排行榜桶數據"""
60         return db.execute('DELETE FROM block_buckets WHERE lid=%s', (leaderboard_id,))
61 
62     BlockBucket = namedtuple('BlockBucket', ['leaderboard_id', 'from_score',
63      'to_score', 'from_rank', 'to_rank', 'from_dense', 'to_dense'])
View Code

 

流程是:

  • 獲取當前排行榜的最高和最低積分
  • 利用最高和最低積分,使用一個閾值分割出區間桶, 比如閾值為500,那么分割后為[max, max - 500], [max - 501, max - 1000],..[?, min]直到最小積分。
  • 獲取出當前桶的排名范圍,保存刷新
  • rank_for_user

通過entry_id 獲取到用戶后使用用戶的積分獲取到積分所在桶,然后利用桶的排名范圍和積分范圍縮小sql排序的范圍,統計出用戶的排名

    def rank_for_user(self, leaderboard_id, entry_id, dense=False):
        entry = self.find(leaderboard_id, entry_id)
        if entry:
            if dense:
                data = db.query_one('SELECT from_dense, to_score FROM chunk_buckets WHERE lid=%s AND from_score<=%s AND %s<=to_score', (leaderboard_id, entry.score, entry.score))
                from_dense, to_score = data
                rank = db.query_one('SELECT COUNT(eid) AS rank FROM entries WHERE lid=%s AND eid<%s AND %s<=score AND score<=%s',
                                   (leaderboard_id, entry.entry_id,  entry.score, to_score))
                entry.rank = from_dense + rank[0]
            else:
                data = db.query_one('SELECT from_rank, to_score FROM chunk_buckets WHERE lid=%s AND from_score<=%s AND %s<=to_score', (leaderboard_id, entry.score, entry.score))
                from_rank, to_score = data
                rank = db.query_one('SELECT COUNT(DISTINCT(score)) AS rank FROM entries WHERE lid=%s AND  %s<score AND score<=%s',
                                   (leaderboard_id, entry.score, to_score))[0]
                entry.rank = from_rank + rank
        return entry

 

  • rank

rank算法相對復雜:

    def rank(self, leaderboard_id, limit=1000, offset=0, dense=False):
        from_score, to_score, from_rank, to_rank = db.query_one('SELECT from_score, to_score, from_rank, to_rank FROM chunk_buckets WHERE lid=%s AND from_rank<=%s AND %s<=to_rank', (leaderboard_id, offset+1, offset+1))
        if to_rank < limit + offset + 1:
            from_score = db.query_one('SELECT from_score FROM chunk_buckets WHERE lid=%s AND from_rank<=%s AND %s<=to_rank', (leaderboard_id, limit+offset+1, limit+offset+1))[0]
            
        sql = 'SELECT * FROM entries WHERE lid=%s AND %s<=score AND score<=%s '
        if dense:
            sql += 'ORDER BY score DESC, eid ASC'
        else:
            sql += 'GROUP BY score, eid ORDER BY score DESC'
        sql += ' LIMIT %s OFFSET %s'
        
        res = db.query(sql, (leaderboard_id, from_score, to_score, limit, offset - from_rank+1))
        res = [self._load(data) for data in res]
        if res:
            if not dense:
                entry = self.rank_for_user(leaderboard_id, res[0].entry_id, dense)
                offset = entry.rank
            else:
                offset += 1
            self._rank_entries(res, dense, offset)
        return res

 

流程與積分桶排差不多:

  • 獲取到當前排名范圍的積分分布范圍
  • 通過縮小積分范圍從entries獲取到根據積分排序好的用戶
  • 然后我們只要獲取到第一個用戶的排名,然后在業務代碼中排好其他用戶的名次就行。
  • 均勻區間桶的優點與缺點

區間桶非常適合那些分用戶積分布均勻的排行榜,但要求區間用戶數量比較適合比如保證在5000到10000之間排序都是比較高效的。刷新排名時,算法不一定比積分桶慢,但獲取用戶排名會更慢些。

  • 自適應區間桶

然后我們考慮下用戶的活躍度吧,用戶活躍可能非常符合二八法則,或者在某個積分區間的用戶量特別大,積分桶和均勻區間桶就都不合適。這時可以考慮使用自適應桶,相對前兩者。對於自適應區間的算法就是取出當前最高積分然后使用一個合理閾值得到一個區間,計算該區間的用戶數量,如果當前用戶數量符合排序的比較快的范圍比如[5000, 10000]之間那么,就使用,如果小於5000就增加區間范圍,如果大於10000就減少區間范圍。區間范圍的自適應可以使用指數遞半。比如第一次使用[high, low]發現用戶量過大,使用low = low + (high - low) / 2 將范圍縮小,但這個范圍必須保證 high - low 大於等於零,因為等於零時就是退化為積分桶排了,已經不能再小了。反之使用 low = low - (high-low) /2 計算出一個區間,直到找當合適的區間。對於區間多大合適取決於server的硬件性能。

Note

因為自適應區間桶的數據存儲結構與均勻區間桶是一樣的不再表述。

在算法的實現上,如果不做修改,除了sort排序多了自適應區間算法,其他都是一樣。這里只稍稍描述下如何做到自適應區間,其他接口請參考均勻區間桶實現。

  • 如何做到自適應區間

    def sort(self, leaderboard_id, chunk_block=CHUNK_BLOCK):
        res = db.query_one('SELECT max(score) as max_score, min(score) as min_score FROM entries WHERE lid=%s', (leaderboard_id,))
        if not res: return
        
        max_score, min_score = res
        rank, dense = 1, 1
        buckets = []
        self.clear_buckets(leaderboard_id)
        to_score = max_score
        chunk = DEFAULT_SCORE_CHUNK
        from_score = to_score - chunk
        from_score = max(min_score, from_score)
        while to_score >= min_score:
            
            # 通過不斷獲取當前區間的用戶數量,找到適合的閾值為止
            while True:
                dense_size = self._get_dense_size(leaderboard_id, from_score, to_score)

                if from_score == 0 or (chunk_block / 2) < dense_size <= chunk_block or chunk == 1:
                    break
                chunk += (chunk / 2) if chunk_block / 2 > dense_size else -(chunk / 2)
                from_score = to_score - chunk

            rank_size = self._get_rank_size(leaderboard_id, from_score,  to_score)
            buckets.append(ChunkBucket(leaderboard_id, from_score, to_score, rank, rank + rank_size - 1, dense, dense + dense_size - 1))
            if len(buckets) == 500:
                self.save_buckets(buckets)
                buckets = []
            to_score = from_score - 1
            from_score = to_score - chunk
            from_score = max(min_score, from_score)
            dense += dense_size
            rank += rank_size

        self.save_buckets(buckets)

 

  • 均勻區間桶的優點與缺點

對於自適應區間桶,在排序時將會花費更多時間,如果用戶的排名實在過於集中,最后局部區間也會退化為積分桶。如果排行規則設計的好,使用戶分布比較均勻,那么自適應區間應該是最好的算法。

  • 排行榜刷新重排時需要注意的問題

因為桶排需要額外的調用sort方法刷新排行榜,所以需要實現刷新機制,在Nagi中使用的mysql做的刷新機制,基本實現了定時刷新,和周期性刷新,以及crontab規則刷新。實現比較簡單,可以稍稍看看cron.py中的實現。

細心的會注意到均勻區間桶和自適應桶都是一次性清排行榜的桶數據,而積分桶使用分段先清理老的桶分段數據,然后更新桶信息,確實有必要優化成分段更新,這樣能夠避免排行榜重排時,一段時間排行榜不可用,或者造成誤差很大。在用戶更新積分時,排行榜即使沒有及時的重排(如果使用其他的排序方法把排名寫死,是沒法做到這樣的變化效果),也能反映出用戶的一些排名變化,但積分桶可能不能反映出這種變化。

  • 內存緩存技術

在使用rank api時,很多游戲都更關心top的排行,比如最活躍的一百個工會。這樣,可能希望能夠保證top排行能夠做到實時性。對於桶排來說近似排行會造成不盡人意,這時可以使用內存緩存技術來輔助完成及時排行榜。比如使用Redis來保存排行榜前5000名的活躍用戶,這樣只要稍稍在用戶更新數據時,檢查下是否需要更新。但也不一定要使用內存數據庫,比如運行的服務不需要考慮分布式集群,那么使用大堆(heap),或者紅黑樹這些數據結構做個實現,或者集成網絡接口作為top排行榜服務,另外使用數據庫直排頂部數據有時也是可行的。需要注意的是,在使用mysql這類關聯數據庫時,rank api會隨着offset的增大,拉取數據會變慢,真實性也會降低。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM