SpringBoot實戰應用之如何藉助Redis實現排行榜功能

在一些遊戲和活動中,當涉及到社交元素的時候,排行榜可以說是一個很常見的需求場景了,就我們通常見到的排行榜而言,會提供以下基本功能

全球榜單,對所有用戶根據積分進行排名,並在榜單上展示前多少
個人排名,用戶查詢自己所在榜單的位置,並獲知周邊小夥伴的積分,方便自己比較和超越
實時更新,用戶的積分實時更改,榜單也需要實時更新
上面可以說是一個排行榜需要實現的幾個基本要素了,正好我們剛講到了redis這一節,本篇則開始實戰,詳細描述如何藉助redis來實現一份全球排行榜

I. 方案設計
在進行方案設計之前,先模擬一個真實的應用場景,然後進行輔助設計與實現

  1. 業務場景說明
    以前一段時間特別的跳一跳這個小遊戲進行說明,假設我們這個遊戲用戶遍佈全球,因此我們要設計一個全球的榜單,每個玩家都會根據自己的戰績在排行榜中獲取一個排名,我們需要支持全球榜單的查詢,自己排位的查詢這兩種最基本的查詢場景;此外當我的分數比上一次的高時,我需要更新我的積分,重新獲得我的排名;

此外也會有一些高級的統計,比如哪個分段的人數最多,什麼分段是瓶頸點,再根據地理位置計算平均分等等

本篇博文主要內容將放在排行榜的設計與實現上;至於高級的功能實現,後續有機會再說

  1. 數據結構
    因爲排行榜的功能比較簡單了,也不需要什麼複雜的結構設計,也沒有什麼複雜的交互,因此我們需要確認的無非就是數據結構 + 存儲單元

存儲單元

表示排行榜中每一位上應該持有的信息,一個最簡單的如下

// 用來表明具體的用戶long userId;// 用戶在排行榜上的排名long rank;// 用戶的歷史最高積分,也就是排行榜上的積分long score;
數據結構

排行榜,一般而言都是連續的,藉此我們可以聯想到一個合適的數據結構LinkedList,好處在於排名變動時,不需要數組的拷貝

SpringBoot實戰應用之如何藉助Redis實現排行榜功能
上圖演示,當一個用戶積分改變時,需要向前遍歷找到合適的位置,插入並獲取新的排名, 在更新和插入時,相比較於ArrayList要好很多,但依然有以下幾個缺陷

問題1:用戶如何獲取自己的排名?

使用LinkedList在更新插入和刪除的帶來優勢之外,在隨機獲取元素的支持會差一點,最差的情況就是從頭到尾進行掃描

問題2:併發支持的問題?

當有多個用戶同時更新score時,併發的更新排名問題就比較突出了,當然可以使用jdk中類似寫時拷貝數組的方案

上面是我們自己來實現這個數據結構時,會遇到的一些問題,當然我們的主題是藉助redis來實現排行榜,下面則來看下,利用redis可以怎麼簡單的支持我們的需求場景

  1. redis使用方案
    這裏主要使用的是redis的ZSET數據結構,帶權重的集合,下面分析一下可能性

set: 集合確保裏面元素的唯一性
權重:這個可以看做我們的score,這樣每個元素都有一個score;
zset:根據score進行排序的集合
從zset的特性來看,我們每個用戶的積分,丟到zset中,就是一個帶權重的元素,而且是已經排好序的了,只需要獲取元素對應的index,就是我們預期的排名

II. 功能實現
再具體的實現之前,可以先查看一下redis中zset的相關方法和操作姿勢:SpringBoot高級篇Redis之ZSet數據結構使用姿勢

我們主要是藉助zset提供的一些方法來實現排行榜的需求,下面的具體方法設計中,也會有相關說明

  1. 前提準備
    首先準備好redis環境,spring項目搭建好,然後配置好redisTemplate

/** * Created by @author yihui in 15:05 18/11/8. */public class DefaultSerializer implements RedisSerializer { private final Charset charset; public DefaultSerializer() { this(Charset.forName(“UTF8”)); } public DefaultSerializer(Charset charset) { Assert.notNull(charset, “Charset must not be null!”); this.charset = charset; } @Override public byte[] serialize(Object o) throws SerializationException { return o == null ? null : String.valueOf(o).getBytes(charset); } @Override public Object deserialize(byte[] bytes) throws SerializationException { return bytes == null ? null : new String(bytes, charset); }}@Configurationpublic class AutoConfig { @Bean(value = “selfRedisTemplate”) public RedisTemplate<String, String> stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { StringRedisTemplate redis = new StringRedisTemplate(); redis.setConnectionFactory(redisConnectionFactory); // 設置redis的String/Value的默認序列化方式 DefaultSerializer stringRedisSerializer = new DefaultSerializer(); redis.setKeySerializer(stringRedisSerializer); redis.setValueSerializer(stringRedisSerializer); redis.setHashKeySerializer(stringRedisSerializer); redis.setHashValueSerializer(stringRedisSerializer); redis.afterPropertiesSet(); return redis; }}

  1. 用戶上傳積分
    上傳用戶積分,然而zset中有一點需要注意的是其排行是根據score進行升序排列,這個就和我們實際的情況不太一樣了;爲了和實際情況一致,可以將score取反;另外一個就是排行默認是從0開始的,這個與我們的實際也不太一樣,需要+1

/** * 更新用戶積分,並獲取最新的個人所在排行榜信息 * * @param userId * @param score * @return */public RankDO updateRank(Long userId, Float score) { // 因爲zset默認積分小的在前面,所以我們對score進行取反,這樣用戶的積分越大,對應的score越小,排名越高 redisComponent.add(RANK_PREFIX, String.valueOf(userId), -score); Long rank = redisComponent.rank(RANK_PREFIX, String.valueOf(userId)); return new RankDO(rank + 1, score, userId);}
上面的實現,主要利用了zset的兩個方法,一個是添加元素,一個是查詢排名,對應的redis操作方法如下,

@Resource(name = “selfRedisTemplate”)private StringRedisTemplate redisTemplate; /** * 添加一個元素, zset與set最大的區別就是每個元素都有一個score,因此有個排序的輔助功能; zadd * * @param key * @param value * @param score /public void add(String key, String value, double score) { redisTemplate.opsForZSet().add(key, value, score);} /* * 判斷value在zset中的排名 zrank * * 積分小的在前面 * * @param key * @param value * @return */public Long rank(String key, String value) { return redisTemplate.opsForZSet().rank(key, value);}
2. 獲取個人排名
獲取個人排行信息,主要就是兩個一個是排名一個是積分;需要注意的是當用戶沒有積分時(即沒有上榜時),需要額外處理

/** * 獲取用戶的排行榜位置 * * @param userId * @return */public RankDO getRank(Long userId) { // 獲取排行, 因爲默認是0爲開頭,因此實際的排名需要+1 Long rank = redisComponent.rank(RANK_PREFIX, String.valueOf(userId)); if (rank == null) { // 沒有排行時,直接返回一個默認的 return new RankDO(-1L, 0F, userId); } // 獲取積分 Double score = redisComponent.score(RANK_PREFIX, String.valueOf(userId)); return new RankDO(rank + 1, Math.abs(score.floatValue()), userId);}
上面的封裝中,除了使用前面的獲取用戶排名之外,還有獲取用戶積分

/** * 查詢value對應的score zscore * * @param key * @param value * @return */public Double score(String key, String value) { return redisTemplate.opsForZSet().score(key, value);}
3. 獲取個人周邊用戶積分及排行信息
有了前面的基礎之後,這個就比較簡單了,首先獲取用戶的個人排名,然後查詢固定排名段的數據即可

private List buildRedisRankToBizDO(Set<ZSetOperations.TypedTuple> result, long offset) { List rankList = new ArrayList<>(result.size()); long rank = offset; for (ZSetOperations.TypedTuple sub : result) { rankList.add(new RankDO(rank++, Math.abs(sub.getScore().floatValue()), Long.parseLong(sub.getValue()))); } return rankList;}/** * 獲取用戶所在排行榜的位置,以及排行榜中其前後n個用戶的排行信息 * * @param userId * @param n * @return */public List getRankAroundUser(Long userId, int n) { // 首先是獲取用戶對應的排名 RankDO rank = getRank(userId); if (rank.getRank() <= 0) { // fixme 用戶沒有上榜時,不返回 return Collections.emptyList(); } // 因爲實際的排名是從0開始的,所以查詢周邊排名時,需要將n-1 Set<ZSetOperations.TypedTuple> result = redisComponent.rangeWithScore(RANK_PREFIX, Math.max(0, rank.getRank() - n - 1), rank.getRank() + n - 1); return buildRedisRankToBizDO(result, rank.getRank() - n);}
看下上面的實現,獲取用戶排名之後,就可以計算要查詢的排名範圍[Math.max(0, rank.getRank() - n - 1), rank.getRank() + n - 1]

其次需要注意的如何將返回的結果進行封裝,上面寫了個轉換類,主要起始排行榜信息

  1. 獲取topn排行榜
    上面的理解之後,這個就很簡答了

/** * 獲取前n名的排行榜數據 * * @param n * @return */public List getTopNRanks(int n) { Set<ZSetOperations.TypedTuple> result = redisComponent.rangeWithScore(RANK_PREFIX, 0, n - 1); return buildRedisRankToBizDO(result, 1);}
III. 測試小結
首先準備一個測試腳本,批量的插入一下積分,用於後續的查詢更新使用

public class RankInitTest { private Random random; private RestTemplate restTemplate; @Before public void init() { random = new Random(); restTemplate = new RestTemplate(); } private int genUserId() { return random.nextInt(1024); } private double genScore() { return random.nextDouble() * 100; } @Test public void testInitRank() { for (int i = 0; i < 30; i++) { restTemplate.getForObject(“http://localhost:8080/update?userId=” + genUserId() + “&score=” + genScore(), String.class); } }}

  1. 測試
    上面執行完畢之後,排行榜中應該就有三十條數據,接下來我們開始逐個接口測試,首先獲取top10排行

對應的rest接口如下

@RestControllerpublic class RankAction { @Autowired private RankListComponent rankListComponent; @GetMapping(path = “/topn”) public List showTopN(int n) { return rankListComponent.getTopNRanks(n); }}
SpringBoot實戰應用之如何藉助Redis實現排行榜功能
接下來我們挑選第15名,獲取對應的排行榜信息

@GetMapping(path = “/rank”)public RankDO queryRank(long userId) { return rankListComponent.getRank(userId);}
首先我們從redis中獲取第15名的userId,然後再來查詢

SpringBoot實戰應用之如何藉助Redis實現排行榜功能
然後嘗試修改下他的積分,改大一點,將score改成80分,則會排到第五名

@GetMapping(path = “/update”)public RankDO updateScore(long userId, float score) { return rankListComponent.updateRank(userId, score);}

SpringBoot實戰應用之如何藉助Redis實現排行榜功能

最後我們查詢下這個用戶周邊2個的排名信息

@GetMapping(path = “/around”)public List around(long userId, int n) { return rankListComponent.getRankAroundUser(userId, n);}
SpringBoot實戰應用之如何藉助Redis實現排行榜功能
2. 小結
上面利用redis的zset實現了排行榜的基本功能,主要藉助下面三個方法

range 獲取範圍排行信息
score 獲取對應的score
range 獲取對應的排名
雖然實現了基本功能,但是問題還是有不少的

上面的實現,redis的複合操作,原子性問題
由原子性問題導致併發安全問題
性能怎麼樣需要測試
最後,如果覺得本文不錯,那就關注轉發一下吧!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章