SpringBoot系列教程-應用篇之藉助Redis實現排行榜功能

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

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

  • 全球榜單,對全部用戶根據積分進行排名,並在榜單上展現前多少
  • 我的排名,用戶查詢本身所在榜單的位置,並獲知周邊小夥伴的積分,方便本身比較和超越
  • 實時更新,用戶的積分實時更改,榜單也須要實時更新

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

<!-- more -->github

I. 方案設計

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

1. 業務場景說明

之前一段時間特別🔥的跳一跳這個小遊戲進行說明,假設咱們這個遊戲用戶遍及全球,所以咱們要設計一個全球的榜單,每一個玩家都會根據本身的戰績在排行榜中獲取一個排名,咱們須要支持全球榜單的查詢,本身排位的查詢這兩種最基本的查詢場景;此外當個人分數比上一次的高時,我須要更新個人積分,從新得到個人排名;spring

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

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

2. 數據結構

由於排行榜的功能比較簡單了,也不須要什麼複雜的結構設計,也沒有什麼複雜的交互,所以咱們須要確認的無非就是數據結構 + 存儲單元數據結構

存儲單元併發

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

// 用來代表具體的用戶
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

相關文章
相關標籤/搜索