更多Spring文章,歡迎點選 一灰灰Blog-Spring專題
在一些遊戲和活動中,當涉及到社交元素的時候,排行榜可以說是一個很常見的需求場景了,就我們通常見到的排行榜而言,會提供以下基本功能
- 全球榜單,對所有使用者根據積分進行排名,並在榜單上展示前多少
- 個人排名,使用者查詢自己所在榜單的位置,並獲知周邊小夥伴的積分,方便自己比較和超越
- 實時更新,使用者的積分實時更改,榜單也需要實時更新
上面可以說是一個排行榜需要實現的幾個基本要素了,正好我們剛講到了redis這一節,本篇則開始實戰,詳細描述如何藉助redis來實現一份全球排行榜
I. 方案設計
在進行方案設計之前,先模擬一個真實的應用場景,然後進行輔助設計與實現
1. 業務場景說明
以前一段時間特別?的跳一跳這個小遊戲進行說明,假設我們這個遊戲使用者遍佈全球,因此我們要設計一個全球的榜單,每個玩家都會根據自己的戰績在排行榜中獲取一個排名,我們需要支援全球榜單的查詢,自己排位的查詢這兩種最基本的查詢場景;此外當我的分數比上一次的高時,我需要更新我的積分,重新獲得我的排名;
此外也會有一些高階的統計,比如哪個分段的人數最多,什麼分段是瓶頸點,再根據地理位置計算平均分等等
本篇博文主要內容將放在排行榜的設計與實現上;至於高階的功能實現,後續有機會再說
2. 資料結構
因為排行榜的功能比較簡單了,也不需要什麼複雜的結構設計,也沒有什麼複雜的互動,因此我們需要確認的無非就是資料結構 + 儲存單元
儲存單元
表示排行榜中每一位上應該持有的資訊,一個最簡單的如下
// 用來表明具體的使用者
long userId;
// 使用者在排行榜上的排名
long rank;
// 使用者的歷史最高積分,也就是排行榜上的積分
long score;
複製程式碼
資料結構
排行榜,一般而言都是連續的,藉此我們可以聯想到一個合適的資料結構LinkedList,好處在於排名變動時,不需要陣列的拷貝
上圖演示,當一個使用者積分改變時,需要向前遍歷找到合適的位置,插入並獲取新的排名, 在更新和插入時,相比較於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);
}
}
複製程式碼
接下來我們挑選第15名,獲取對應的排行榜資訊
@GetMapping(path = "/rank")
public RankDO queryRank(long userId) {
return rankListComponent.getRank(userId);
}
複製程式碼
首先我們從redis中獲取第15名的userId,然後再來查詢
然後嘗試修改下他的積分,改大一點,將score改成80分,則會排到第五名
@GetMapping(path = "/update")
public RankDO updateScore(long userId, float score) {
return rankListComponent.updateRank(userId, score);
}
複製程式碼
最後我們查詢下這個使用者周邊2個的排名資訊
@GetMapping(path = "/around")
public List<RankDO> around(long userId, int n) {
return rankListComponent.getRankAroundUser(userId, n);
}
複製程式碼
2. 小結
上面利用redis的zset實現了排行榜的基本功能,主要藉助下面三個方法
- range 獲取範圍排行資訊
- score 獲取對應的score
- range 獲取對應的排名
雖然實現了基本功能,但是問題還是有不少的
- 上面的實現,redis的複合操作,原子性問題
- 由原子性問題導致併發安全問題
- 效能怎麼樣需要測試
III. 其他
0. 專案
- 工程:spring-boot-demo
- module原始碼: spring-case/120-redis-ranklist
1. 一灰灰Blog
- 一灰灰Blog個人部落格 blog.hhui.top
- 一灰灰Blog-Spring專題部落格 spring.hhui.top
一灰灰的個人部落格,記錄所有學習和工作中的博文,歡迎大家前去逛逛
2. 宣告
盡信書則不如,以上內容,純屬一家之言,因個人能力有限,難免有疏漏和錯誤之處,如發現bug或者有更好的建議,歡迎批評指正,不吝感激
- 微博地址: 小灰灰Blog
- QQ: 一灰灰/3302797840
3. 掃描關注
一灰灰blog
知識星球