前言
年前公司有很多活動要進行定制開發,活動中有游戲可以玩,最后對每個人的游戲分數進行排行展示,最終根據排名發放獎品。乍一看需求確實很簡單,直接order by score
一下不就完事了?需求確實簡單,但是有不少小坑,故在此記錄一下。
需求
- 排行榜展示前100名最佳分數排行榜
- 如果當前登錄人在100名之后,則展示內容有兩項
- 前100名最佳分數排行榜
- 當前登錄人排名以及前后兩個用戶的排名
數據庫表設計
- user_id:用戶ID
- user_nickname:用戶昵稱
- score:分數
- avatar_image_path:用戶頭像
- user_type:用戶類型
一、數據庫查詢 - 先分組,后排序
因為用戶可以玩多次游戲,所以表中同一個用戶會有對此游戲分數記錄。
- 查尋當前登錄人的最佳分數
SELECT
user_id,
user_nickname,
score,
avatar_image_path,
user_type
FROM
game_score_record
WHERE
`user_id` = #{userId}
ORDER BY
`score` DESC
id ASC
LIMIT 1
- 查尋當前登錄人的最佳排名
SELECT count(*)
FROM (
SELECT id, user_id, max( score ) AS score
FROM game_score_record
GROUP BY user_id
HAVING score > #{myBestScore}
-- 這里是判斷若有和登錄人相同分數的用戶,前一名是先行記錄的用戶
OR CASE WHEN score = #{myBestScore} THEN (score = #{myBestScore} AND id < #{myBestId}) else '' end
) AS fo
- 查詢前100個用戶最佳分數排行榜
SELECT
user_id,
user_nickname,
max( score ) AS score,
avatar_image_path,
user_type
FROM
game_score_record
GROUP BY
user_id
ORDER BY
score DESC,
id ASC
LIMIT 100;
order by score是由分數從高到低進行排序;order by id是當相同分數時,最先記錄的排在前面
- 獲取前一名以及后一名排名信息
-- 前一名信息
(SELECT
id,
user_id,
max( score ) AS score,
user_nickname,
avatar_image_path,
user_type
FROM
game_score_record
GROUP BY
user_id
HAVING
score > #{myBestScore}
-- 這里是判斷若有和登錄人相同分數的用戶,前一名是先行記錄的用戶
OR CASE WHEN score = #{myBestScore} THEN (score = #{myBestScore} AND id < #{myBestId}) else '' end
ORDER BY
score ASC,
id DESC
LIMIT 1)
UNION ALL
-- 后一名信息
(SELECT
id,
user_id,
max( score ) AS score,
user_nickname,
avatar_image_path,
user_type
FROM
game_score_record
GROUP BY
user_id
HAVING
score < #{myBestScore}
OR CASE WHEN score = #{myBestScore} THEN (score = #{myBestScore} AND id > #{myBestId}) else '' end
ORDER BY
score DESC,
id ASC
LIMIT 1)
數據庫查詢為了滿足需求,sql之復雜,且效率極低。當我跑入百萬級數據量時,上述所有查尋耗時均超過了5s,很明顯不可取的技術方案
二、Redis強勢介入
當發現數據量上去之后,SQL查尋非常之緩慢,便准備轉為將數據存入Redis緩存中查詢
溫習一下Redis五大數據結構
1. string
- 介紹 :string 數據結構是簡單的 key-value 類型。雖然 Redis 是用 C 語言寫的,但是 Redis 並沒有使用 C 的字符串表示,而是自己構建了一種 簡單動態字符串(simple dynamic string,SDS)。相比於 C 的原生字符串,Redis 的 SDS 不光可以保存文本數據還可以保存二進制數據,並且獲取字符串長度復雜度為 O(1)(C 字符串為 O(N)),除此之外,Redis 的 SDS API 是安全的,不會造成緩沖區溢出。
- 常用命令:
set,get,strlen,exists,dect,incr,setex
等等。 - 應用場景 :一般常用在需要計數的場景,比如用戶的訪問次數、熱點文章的點贊轉發數量等等。
2. list
- 介紹 :list 即是 鏈表。鏈表是一種非常常見的數據結構,特點是易於數據元素的插入和刪除並且且可以靈活調整鏈表長度,但是鏈表的隨機訪問困難。許多高級編程語言都內置了鏈表的實現比如 Java 中的 LinkedList,但是 C 語言並沒有實現鏈表,所以 Redis 實現了自己的鏈表數據結構。Redis 的 list 的實現為一個 雙向鏈表,即可以支持反向查找和遍歷,更方便操作,不過帶來了部分額外的內存開銷。
- 常用命令:
rpush,lpop,lpush,rpop,lrange、llen
等。 - 應用場景: 發布與訂閱或者說消息隊列、慢查詢。
3. hash
- 介紹 :hash 類似於 JDK1.8 前的 HashMap,內部實現也差不多(數組 + 鏈表)。不過,Redis 的 hash 做了更多優化。另外,hash 是一個 string 類型的 field 和 value 的映射表,特別適合用於存儲對象,后續操作的時候,你可以直接僅僅修改這個對象中的某個字段的值。 比如我們可以 hash 數據結構來存儲用戶信息,商品信息等等。
- 常用命令:
hset,hmset,hexists,hget,hgetall,hkeys,hvals
等。 - 應用場景: 系統中對象數據的存儲。
4. set
- 介紹 : set 類似於 Java 中的
HashSet
。Redis 中的 set 類型是一種無序集合,集合中的元素沒有先后順序。當你需要存儲一個列表數據,又不希望出現重復數據時,set 是一個很好的選擇,並且 set 提供了判斷某個成員是否在一個 set 集合內的重要接口,這個也是 list 所不能提供的。可以基於 set 輕易實現交集、並集、差集的操作。比如:你可以將一個用戶所有的關注人存在一個集合中,將其所有粉絲存在一個集合。Redis 可以非常方便的實現如共同關注、共同粉絲、共同喜好等功能。這個過程也就是求交集的過程。 - 常用命令:
sadd,spop,smembers,sismember,scard,sinterstore,sunion
等。 - 應用場景: 需要存放的數據不能重復以及需要獲取多個數據源交集和並集等場景
5. sorted set
- 介紹: 和 set 相比,sorted set 增加了一個權重參數 score,使得集合中的元素能夠按 score 進行有序排列,還可以通過 score 的范圍來獲取元素的列表。有點像是 Java 中 HashMap 和 TreeSet 的結合體。
- 常用命令:
zadd,zcard,zscore,zrange,zrevrange,zrem
等。 - 應用場景: 需要對數據根據某個權重進行排序的場景。比如在直播系統中,實時排行信息包含直播間在線用戶列表,各種禮物排行榜,彈幕消息(可以理解為按消息維度的消息排行榜)等信息。
以上介紹信息來源於:https://github.com/captainkun/JavaGuide/blob/master/docs/database/Redis/redis-all.md
- 結構圖
采用Redis的sorted set數據結構存儲排行榜數據
初始化數據到redis中
// 獲取BoundZSetOperations,后續對元素的增刪改查都是操作該對象
String redisKey = "gameRank";
BoundZSetOperations<String, Object> bzo = redisTemplate.boundZSetOps(redisKey);
Set<ZSetOperations.TypedTuple<Object>> tuples = new HashSet<>();
// 這里是有限制的,一次不能寫入太多,我個人測試了一下,如果一次寫入70W以上就會報錯,具體臨界值也不太清楚
for (int i = 0; i < 500000; i++) {
// 構造函數中第一個參數為用戶ID,第二個參數為分數
ZSetOperations.TypedTuple<Object> objectTypedTuple = new DefaultTypedTuple<>(i + 1, (double) i);
tuples.add(objectTypedTuple);
tuples.add(objectTypedTuple);
}
bzo.add(tuples);
即使一次寫入50w條數據到redis,執行耗時也在2s之內,非常之快。這里因為每次寫入數量有限制,所以稍作修改,分兩次執行即可達到百萬級數據
redisTemplate相關API的Demo
Integer userId = 9527;
// 所有用戶,正序排名
System.out.println("==============正序排名==============");
Set rankSet = bzo.range(0, -1);
rankSet.forEach(System.out::println);
// 所有用戶,降序排名
System.out.println("===============降序排名=============");
Set<Object> reverseRankSet = bzo.reverseRange(0, -1);
reverseRankSet.forEach(System.out::println);
// 獲取已有具體元素的降序排名, 如果找不到數據,rank值為null
System.out.println("===============獲取已有具體元素的排名=============");
Long rank = bzo.reverseRank(userId);
System.out.println(userId + " 排名:" + (rank + 1));// rank值是從0起始,所以展示要 +1
// 根據排名獲取具體的元素,注意都是從0開始為第一個
System.out.println("==============獲取名次區間在3, 5的元素集合==============");
Set<Object> reverseRange = bzo.reverseRange(3, 5);
reverseRange.forEach(System.out::println);
System.out.println("==============根據分數區間值排序取值==============");
Set<Object> objects = bzo.rangeByScore(20, 20);
objects.forEach(System.out::println);
System.out.println("==============獲取分數區間的數量==============");
Long count = bzo.count(0, 20);
System.out.println(count);
將每個用戶的最高分數存入Redis
每次游戲結束后,得到用戶當前游戲分數,與redis中該用戶的分數進行比較,若redis中沒有該用戶數據,則直接存入redis;若當前用戶游戲分數 > redis中的用戶分數,則存入redis,此時會自動覆蓋掉歷史記錄。
// 當前登錄用戶
Integer userId = 9527;
// 當前游戲分數
double currentScore = 985.0;
// 1. 將游戲記錄存入數據庫
// 略
// 2. 獲取操作redis sorted set的對象,將用戶游戲最高分存入redis中
String redisKey = "gameRank";
BoundZSetOperations<String, Object> bzo = redisTemplate.boundZSetOps(redisKey);
// 2.1 獲取用戶在redis中存儲的最高分數
Double bestScoreInRedis = bzo.score(userId);
// 2.2 若redis中沒有該用戶數據,則是第一次玩,將當前記錄存入redis;若當前用戶游戲分數 > redis中的用戶分數,覆蓋歷史記錄
if (Objects.isNull(bestScoreInRedis) || currentScore > bestScoreInRedis){
// 2.3 直接添加,自動覆蓋歷史記錄
bzo.add(userId, currentScore);
}
這么寫似乎沒有什么問題,但redis內部機制導致業務上存在一個問題:遇到相同分數的用戶數據,后記錄的數據,在redis排序中卻是排在前面的!
相同分數的用戶數據,后記錄卻排序在前的解決方式
由於我們的游戲分數都是整數的,redis
中的分數是存入的double
類型,所以決定在小數點后面做文章。
整體思想: 數據的時間先后標識值,與一個提前定義的Integer.MAX_VALUE
差值,添加到小數點后面。這樣以來后添加的數據分數值肯定最大,但是與Integer.MAX_VALUE
差值就是最小的,相同分數后添加的這就排在后面啦
兩種方案:
- 分數后面添加時間戳(如果同時在一個時間點操作的,當前運行時間的時間戳會有相同的情況,不如下面的方案)
- 游戲記錄先入庫后,獲取新增記錄的數據庫主鍵ID,在分數后面添加(推薦,先后順序交給數據庫來定奪,肯定不會重復)
修正后:
// 當前登錄用戶
Integer userId = 9527;
// 當前游戲分數
double currentScore = 985.0;
// 1. 將游戲記錄存入數據庫
// 獲取插入到數據庫的主鍵ID,此處簡寫
int id = 980;
// 2. 獲取操作redis sorted set的對象,將用戶游戲最高分存入redis中
String redisKey = "gameRank";
BoundZSetOperations<String, Object> bzo = redisTemplate.boundZSetOps(redisKey);
// 2.1 獲取用戶在redis中存儲的最高分數
Double bestScoreInRedis = bzo.score(userId);
// 2.2 若redis中沒有該用戶數據,則是第一次玩,將當前記錄存入redis;若當前用戶游戲分數 > redis中的用戶分數,覆蓋歷史記錄
if (Objects.isNull(bestScoreInRedis) || currentScore > bestScoreInRedis){
// 2.3 小數點前面為用戶真實分數,后面則為用戶游戲記錄先后值與最大Integer的差值
String redisScore = currentScore + "." + (Integer.MAX_VALUE - id);
bzo.add(userId, redisScore);
}
所有問題已經解決!上線后無問題,查尋效率非常之高!看菜鳥教程中,redis里面存儲的元素可以高到離譜,但沒有真實測試過存儲量,個人感覺隨便滿足日常開發了