SpringBoot應用篇之藉助Redis實現排行榜功能

一灰灰發表於2018-12-25

更多Spring文章,歡迎點選 一灰灰Blog-Spring專題

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

  • 全球榜單,對所有使用者根據積分進行排名,並在榜單上展示前多少
  • 個人排名,使用者查詢自己所在榜單的位置,並獲知周邊小夥伴的積分,方便自己比較和超越
  • 實時更新,使用者的積分實時更改,榜單也需要實時更新

上面可以說是一個排行榜需要實現的幾個基本要素了,正好我們剛講到了redis這一節,本篇則開始實戰,詳細描述如何藉助redis來實現一份全球排行榜

I. 方案設計

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

1. 業務場景說明

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

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

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

2. 資料結構

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

儲存單元

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

// 用來表明具體的使用者
long userId;
// 使用者在排行榜上的排名
long rank;
// 使用者的歷史最高積分,也就是排行榜上的積分
long score;
複製程式碼

資料結構

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

arch

上圖演示,當一個使用者積分改變時,需要向前遍歷找到合適的位置,插入並獲取新的排名, 在更新和插入時,相比較於ArrayList要好很多,但依然有以下幾個缺陷

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

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

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

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

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

3. redis使用方案

這裡主要使用的是redis的ZSET資料結構,帶權重的集合,下面分析一下可能性

  • set: 集合確保裡面元素的唯一性
  • 權重:這個可以看做我們的score,這樣每個元素都有一個score;
  • zset:根據score進行排序的集合

從zset的特性來看,我們每個使用者的積分,丟到zset中,就是一個帶權重的元素,而且是已經排好序的了,只需要獲取元素對應的index,就是我們預期的排名

II. 功能實現

再具體的實現之前,可以先檢視一下redis中zset的相關方法和操作姿勢:SpringBoot高階篇Redis之ZSet資料結構使用姿勢

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

0. 前提準備

首先準備好redis環境,spring專案搭建好,然後配置好redisTemplate

/**
 * Created by @author yihui in 15:05 18/11/8.
 */
public class DefaultSerializer implements RedisSerializer<Object> {
    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);
    }
}


@Configuration
public 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<RankDO> buildRedisRankToBizDO(Set<ZSetOperations.TypedTuple<String>> result, long offset) {
    List<RankDO> rankList = new ArrayList<>(result.size());
    long rank = offset;
    for (ZSetOperations.TypedTuple<String> 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<RankDO> getRankAroundUser(Long userId, int n) {
    // 首先是獲取使用者對應的排名
    RankDO rank = getRank(userId);
    if (rank.getRank() <= 0) {
        // fixme 使用者沒有上榜時,不返回
        return Collections.emptyList();
    }

    // 因為實際的排名是從0開始的,所以查詢周邊排名時,需要將n-1
    Set<ZSetOperations.TypedTuple<String>> 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]

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

4. 獲取topn排行榜

上面的理解之後,這個就很簡答了

/**
 * 獲取前n名的排行榜資料
 *
 * @param n
 * @return
 */
public List<RankDO> getTopNRanks(int n) {
    Set<ZSetOperations.TypedTuple<String>> 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介面如下

@RestController
public class RankAction {
    @Autowired
    private RankListComponent rankListComponent;

    @GetMapping(path = "/topn")
    public List<RankDO> showTopN(int n) {
        return rankListComponent.getTopNRanks(n);
    }
}
複製程式碼

topn

接下來我們挑選第15名,獲取對應的排行榜資訊

@GetMapping(path = "/rank")
public RankDO queryRank(long userId) {
    return rankListComponent.getRank(userId);
}
複製程式碼

首先我們從redis中獲取第15名的userId,然後再來查詢

rank

然後嘗試修改下他的積分,改大一點,將score改成80分,則會排到第五名

@GetMapping(path = "/update")
public RankDO updateScore(long userId, float score) {
    return rankListComponent.updateRank(userId, score);
}
複製程式碼

update

最後我們查詢下這個使用者周邊2個的排名資訊

@GetMapping(path = "/around")
public List<RankDO> around(long userId, int n) {
    return rankListComponent.getRankAroundUser(userId, n);
}
複製程式碼

around

2. 小結

上面利用redis的zset實現了排行榜的基本功能,主要藉助下面三個方法

  • range 獲取範圍排行資訊
  • score 獲取對應的score
  • range 獲取對應的排名

雖然實現了基本功能,但是問題還是有不少的

  • 上面的實現,redis的複合操作,原子性問題
  • 由原子性問題導致併發安全問題
  • 效能怎麼樣需要測試

III. 其他

0. 專案

1. 一灰灰Blog

一灰灰的個人部落格,記錄所有學習和工作中的博文,歡迎大家前去逛逛

2. 宣告

盡信書則不如,以上內容,純屬一家之言,因個人能力有限,難免有疏漏和錯誤之處,如發現bug或者有更好的建議,歡迎批評指正,不吝感激

3. 掃描關注

一灰灰blog

QrCode

知識星球

goals

相關文章