排行榜在游戲中非常常見的功能之一,在游戲中有各種排行榜,如工會活躍度,玩家的英雄戰斗力排行等。當數據上億時,如果使用數據庫直排是致命的慢,遠遠超出用戶接受的響應時間。也對數據庫造成非常大的壓力。本文將會講述千萬用戶級別的用戶排行系統的一些設計理念並講述數據庫直排以及使用桶排和內存數據優化排行榜。
在講述設計前,有必要先了解一些基礎理論,文章將會先講述什么排行榜的類別,排行規則和排名分布,然后進一步結合以往寫的一個簡單的排行系統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: | 這個用來決定使用什么什么算法做排行榜 |
數據庫直排,算法比較低效,但數據少量時,依舊是最高效最簡單的算法。
獲取某個用戶排名核心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是使用復合屬性,就是用戶排名將不會重復。
隨着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選出用戶后,然后獲取到第一個用戶排名,然后簡單的在程序中做排名。
桶排是使用桶排序結合數據庫特性優化的一種排行榜算法,在使用不同數據庫實現時,有必要了解數據庫的特性,才能設計好的系統。
桶排適合周期性排行,桶排在用戶更新積分時會改變影響整個排行,整體來說就是個近似排名。 桶排的優化原則是保證區間桶的用戶數量在適合范圍,保證用戶可接受的響應時間。
對於簽到系統,簽到天數在 [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方法中先清空相關區間的桶數據然后查詢寫入新的桶數據。
可以輕松根據用戶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算法相對復雜些:
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, 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'])
流程是:
- 獲取當前排行榜的最高和最低積分
- 利用最高和最低積分,使用一個閾值分割出區間桶, 比如閾值為500,那么分割后為[max, max - 500], [max - 501, max - 1000],..[?, min]直到最小積分。
- 獲取出當前桶的排名范圍,保存刷新
通過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算法相對復雜:
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就減少區間范圍。區間范圍的自適應可以使用指數遞半。比如第一次使用[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的增大,拉取數據會變慢,真實性也會降低。