前言:
之前寫過排行榜的設計和實現, 不同需求其背后的架構和設計模型也不一樣.
平台差異, 有的立足於游戲平台, 為多個應用提供服務, 有的僅限於單個游戲.排名范圍差異, 有的面向全局排名, 有的只做朋友圈排名. 實時性差異, 離線統計有之, 實時排名更常見.
不管如何, 本文將結合之前寫的網頁闖關游戲, 來具體闡述基於redis排行榜的實戰過程.
相關文章系列:
之前寫過兩篇關於排行榜的文章, 不過那是針對游戲平台(類似微信, 手Q等)而言的. 每個用戶都有自己的排行榜, 不是全局性的.
• 社交游戲的排行榜設計和實現(1)
• 社交游戲的排行榜設計和實現(2)
針對游戲全局排行版的文章
• 基於redis的排行榜設計和實現
需求說明:
以闖關游戲為例, 其排行榜是基於玩家的闖關個數來進行排名的, 這是合乎合理. 但是若兩個玩家得分相同, 這種場景又該如何評定呢?
有一種思路是, 當得分相同時, 以玩家最近一關的破解時間來排定, 既鼓勵准確率, 又鼓勵速度. 換句話說, score(得分)為第一排序因素, time(破解時間)為第二排序因素.
然而, 如果采用redis的sorted set去實現, 只能設定單一的排序分值score. 這樣的話, 二級排序想借助redis, 似乎這條路行不通.
不要灰心, 夢想是有的, 萬一實現了呢? ^_^.
是的, 解決方案是有的, 先賣個關子, 且看下面分解. 同時也來分析下, 使用redis較之mysql的優勢在哪?
mysql方案:
玩家每闖過一關, 需要記錄其在該關的得分記錄. 另一方面玩家是存在重復闖關的行為, 因此在設計得分模型中, 該得分記錄也幫助去重.
• 闖關記錄數據模型
CREATE TABLE IF NOT EXISTS `tb_game_record` ( `id` int(11) NOT NULL AUTO_INCREMENT, `userid` varchar(32) NOT NULL COMMENT '用戶標識', `gateid` int(11) NOT NULL COMMENT '關卡編號', `slove_time` bigint(20) NOT NULL COMMENT '解決時間點', PRIMARY KEY (`id`), UNIQUE KEY `userid` (`userid`,`gateid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
注: userid表示用戶, gateid為關卡編號, slove_time為解決的時間點. userid+gateid是聯合唯一索引, 用於去重.
根據單純的依賴這個數據表的設計, top-n查詢會如何演化.
排行榜查詢類似於Top-N, 其SQL表達有些復雜, 為一個嵌套的子查詢.
1). 統計闖關數和最晚破關時間點
SELECT userid, COUNT(gateid) AS score, MAX(slove_time) AS last_slove_time FROM tb_game_record GROUP BY userid
2). 進行排序(按得分降序, 時間升序)
SELECT useid, score, last_slove_time FROM (...) ORDER BY score DESC, last_slove_time ASC
3). 整合的SQL+區間段
SELECT userid, score, last_slove_time FROM ( SELECT userid, COUNT(gateid) AS score, MAX(slove_time) AS last_slove_time FROM tb_game_record GROUP BY userid ) t ORDER BY score DESC, last_slove_time ASC LIMIT ?, ?
總的來說, 還是比較順利的, 但是性能如何呢? 我們來做一下explain評估.
子SQL使用到filesort, 這個是很耗性能, 但確實也無可奈何.
那有沒有改進的方案呢? 當然有, 為何不單獨引入一個得分表呢?
• 總得分記錄數據模型
CREATE TABLE IF NOT EXISTS `tb_game_score` ( `id` int(11) NOT NULL AUTO_INCREMENT, `userid` varchar(32) NOT NULL, `score` int(11) NOT NULL, `last_slove_time` bigint(20) NOT NULL, UNIQUE KEY `id` (`id`), UNIQUE KEY `userid` (`userid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
注: score為記錄的userid總得分.
每當玩家破解一關的時候, 就自動添加一, 雖然有所寫消耗, 但對於查詢top-n, 則幫了很大的忙.
TOP-N的查詢SQL演變為:
SELECT userid, score, last_slove_time FROM tb_game_score ORDER BY score DESC, last_slove_time ASC
如果使用explain進行sql分析:
雖然也使用到了filesort, 但其數據規模卻比tb_game_record少了一個數量級.
當然它也引入了數據一致性的風險, 因此更新的時候需要做事務上的保護.
redis+mysql方案:
引入總得分記錄表, 在查詢上還是有一定性能損失的. redis被譽為內存數據結構服務器, 能否代替mysql+cache的功能呢?
至少在排行榜的功能上, 其數據結構sorted set是完全可以滿足要求的. 其可以代替得分記錄表, ^_^.
當然其難點在於二級排序的模型抽象, sorted set只支持一級排序(sorted set的score域為double類型), 所以問題就演變為能否構建一個映射函數, 把二級排序映射為一級排序(double域).
幸好在排行榜的需求上, 二級排序(score, time)是可以映射為一級排序的(sorted set的score)域.
可以簡單設定:
score(得分)+time(9999999999-unix的紀元秒, 且固定長度)
注: unix的紀元秒, 在可預見的將來, 時間長度都是固定長度的, 且取負. score在前, time在后.
比如玩家A的得分為10, 最后闖關的關卡時間為2016/3/30 17:35:47, 則時間戳為:1459330547. 最終為:8540669452=9999999999 - 1459330547.
最后的sorted set的score得分值為: 108540669452.
這樣就能完美的到達初期設定的二級排序的排行榜需求了.
• 映射函數設計注意點
這個其實很重要, 因為sorted set的score是double域, 其表達的精度其實是有所限制的. 如果超過這個精度限度, 那么無論幾級排序都是沒有意義的.
Double 域的表示
1bit(符號位) 11bits(指數位) 52bits(尾數位) value of floating-point = significand x base ^ exponent , with sign (浮點) 數值 = 尾數 × 底數 ^ 指數,(附加正負號)
而2^52, 2^52 = 4503599627370496,一共16位,理論上, double的絕對精度為15位.
在映射函數中, 切記15位的上限限定. 之前的設定排行榜的排序映射, 總共為12位(2位游戲得分值, 10位unix紀元秒數), 這是滿足要求的.
總結:
網上對redis sorted set用於排行榜的文章很多, 但真正的案列解說並不多. 可能這種多級排序在應用中, 更常見.
公眾號&游戲站點:
個人微信公眾號: 木目的H5游戲世界
個人游戲作品集站點(尚在建設中...): www.mmxfgame.com, 也可直接ip訪問: http://120.26.221.54/.