前言:
之前写过排行榜的设计和实现, 不同需求其背后的架构和设计模型也不一样.
平台差异, 有的立足于游戏平台, 为多个应用提供服务, 有的仅限于单个游戏.排名范围差异, 有的面向全局排名, 有的只做朋友圈排名. 实时性差异, 离线统计有之, 实时排名更常见.
不管如何, 本文将结合之前写的网页闯关游戏, 来具体阐述基于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/.