前言

前面几篇文章给大家聊了下目前的常用的排行榜做法。

​关于游戏排行榜设计开发的一些总结​

​游戏排行榜-跳表实现原理分析​

那么这篇文章将给大家带来如何使用redis来实现常见的游戏排行榜功能。

为什么使用redis

如果你已经是redis的高级玩家可以跳过这段介绍。下面这段redis的优势介绍仅对新手。

当然你如果想要了解更多的关于redis的使用你可以查看往期文章:

​Redis :01---Redis简介和安装​​​  ->​​Redis:23---info命令总结​


Redis的特点:

  • 内存数据库,速度快,也支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。
  • Redis不仅仅支持简单的key-value类型的数据,同时还提供list,set,zset,hash等数据结构的存储。
  • Redis支持数据的备份,即master-slave模式的数据备份。
  • 支持事务

Redis的优势:

  • 性能极高 – Redis能读的速度是110000次/s,写的速度是81000次/s 。
  • 丰富的数据类型 – Redis支持二进制案例的 Strings, Lists, Hashes, Sets 及 Ordered Sets 数据类型操作。
  • 原子 – Redis的所有操作都是原子性的,同时Redis还支持对几个操作合并后的原子性执行。(事务)
  • 丰富的特性 – Redis还支持 publish/subscribe, 通知, key 过期等等特性。

Redis与其他key-value存储有什么不同?

  • Redis有着更为复杂的数据结构并且提供对他们的原子性操作,这是一个不同于其他数据库的进化路径。Redis的数据类型都是基于基本数据结构的同时对程序员透明,无需进行额外的抽象。
  • Redis运行在内存中但是可以持久化到磁盘,所以在对不同数据集进行高速读写时需要权衡内存,因为数据量不能大于硬件内存。在内存数据库方面的另一个优点是,相比在磁盘上相同的复杂的数据结构,在内存中操作起来非常简单,这样Redis可以做很多内部复杂性很强的事情。同时,在磁盘格式方面他们是紧凑的以追加的方式产生的,因为他们并不需要进行随机访问。


单一维度的排行榜

在游戏中,我们经常会要对玩家的水晶,金币等资源数量进行做排行榜,数量较多的排行在前列。

这类的排行榜影响排名的因素单一,不会涉及到时间等其他维度的影响,当玩家的水晶,金币等资源增加的时候,我们希望能实时更新排行,这类排序相对来说比较简单, 所以采用redis的有序集合sorted set排序,将变动分值zadd到集合。

比如redis这个用户增加了1个金币,mongodb在打副本的时候增加了2个金币,渣渣mysql比较勤劳,分别增加了3,3,4个金币,最后排序的时候你可以通过ZRANGE 或者ZREVRANGE选择递增递减排序方式输出结果

redis 127.0.0.1:6379> ZADD coinres 1 redis
(integer) 1
redis 127.0.0.1:6379> ZADD coinres 2 mongodb
(integer) 1
redis 127.0.0.1:6379> ZADD coinres 3 mysql
(integer) 1
redis 127.0.0.1:6379> ZADD coinres 3 mysql
(integer) 0
redis 127.0.0.1:6379> ZADD coinres 4 mysql
(integer) 0
redis 127.0.0.1:6379> ZRANGE coinres 0 10 WITHSCORES
1) "redis"
2) "1"
3) "mongodb"
4) "2"
5) "mysql"
6) "4"

多维度的复合排行榜

顾名思义,影响排名的因素是多个的,比如有这么一战力排行榜,先按照战力大小递减排序,战力大的排名在前,战力较小的排名靠后,如果战力相同,谁先到达此积分者排名靠前。

这里我们仍然使用sorted set,这里使用一个小技巧 

score为long类型,但是我们的分值用不到这么大的类型,所以将score的高位表示分值,低位表示更新时间

具体公式如下:

score = ((value & 0xfffff) << 32) | (createTime & 0xffffffff)

1.战力排行榜

首先以等级排行榜(1. 等级 2.战力)为例, 该排行榜要求同等级的玩家, 战斗力大的排在前. 因此分数可以定为:
分数 = 等级*10000000000 + 战斗力

游戏中玩家等级范围是1~100, 战力范围0~100000000.

此处设计中为战斗力保留的值范围是 10位数值, 等级是 3位数值, 因此最大数值为 13位.
有序集合的score取值是是64位整数值或双精度浮点数, 最大表示值是 9223372036854775807, 即能完整表示18位数值, 因此用于此处的 13位score 绰绰有余.

2.爬塔排行榜

另一个典型排行榜是塔排行榜(1.层数 2.通关时间), 该排行榜要求通过层数相同的, 通关时间较早的优先.

由于要求的是通关时间较早的优先, 因此不能像之前那样直接 分数=层数*10^N+通关时间.

我们可以将通关时间转换为一个相对时间, 即 分数=层数*10^N + (基准时间 - 通关时间)
很明显的, 通关时间越近(大), 则 基准时间 - 通关时间

基准时间的选择则随意选择了较远的一个时间 2050-01-01 00:00:00, 对应时间戳2524579200

最终, *分数 = 层数10^N + (2524579200 - 通过时间戳)
上述分数公式中, N取10, 即保留10位数的相对时间.

日周月等周期性排行榜

首先,来个“今日积分榜”吧,排序规则是今日用户新增积分从多到少。

那么用户增加积分时,都操作一下记录当天积分增加的有序集合。假设今天是 2015 年 04 月 01 日,UID 为 1 的用户因为某个操作,增加了 5 个积分。Redis 命令如下:

bashZINCRBY rank:20150401 5 1

假设还有其他几个用户也增加了积分:


ZINCRBY rank:20150401 1 2
ZINCRBY rank:20150401 10 3

看看现在有序集合 rank:20150401 中的数据(withscores 参数可以附带获取元素的 score):

ZRANGE rank:20150401 0 -1 withscores
1)"2"
2)"1"
3)"1"
4)"5"
5)"3"
6)"10"

按照分数从高到低,获取 top10:

ZREVRANGE rank:20150401 0 9 withscores
1)"3"
2)"10"
3)"1"
4)"5"
5)"2"
6)"1"

因为只有三个元素,所以就查询出了这些数据。

如果每天记录当天的积分排行榜,那么其他花样百出的榜单也就简单了。比如“昨日积分榜”:

ZREVRANGE rank:20150331 0 9 withscores

利用并集实现多天的积分总和,实现“上周积分榜”:

ZUNIONSTORE rank:last_week 7 rank:20150323 rank:20150324 rank:20150325 rank:20150326 rank:20150327 rank:20150328 rank:20150329 WEIGHTS 1 1 1 1 1 1 1

这样就将 7 天的积分记录合并到有序集合 rank:last_week 中了。权重因子 WEIGHTS 如果不给,默认就是 1。为了不隐藏细节,特意写出。那么查询上周积分榜 Top10 的信息就是:

ZREVRANGE rank:last_week 0 9 withscores

“月度榜”、“季度榜”、“年度榜”等等就以此类推。

排行榜数据更新

还是以等级排行榜为例

游戏中展示的等级排行榜所需的数据包括(但不限于):

  • 角色名
  • Uid
  • 战斗力
  • 头像
  • 所属公会名
  • VIP等级

由于这些数据在游戏过程中是会动态变更的, 因此此处不考虑将这些数据直接作为 member 存储在有序集合中.
用于存储玩家等级排行榜有序集合如下

-- s1:rank:user:lv ---------- zset --
| 玩家id1 | score1
| ...
| 玩家idN | scoreN
-------------------------------------

member为角色uid, score为复合积分

使用hash存储玩家的动态数据(json)

-- s1:rank:user:lv:item ------- string --
| 玩家id1 | 玩家数据的json串
| ...
| 玩家idN |
-----------------------------------------

使用这种方案, 只需要在玩家创建角色时, 将该角色添加到等级排行榜中, 后续则是当玩家 等级战斗力 发生变化时需实时更新​​s1:rank:user:lv​​​该玩家的复合积分即可. 若玩家其他数据(用于排行榜显示)有变化, 则也相应地修改其在 ​​s1:rank:user:lv:item​​ 中的数据json串.

注意事项

如果你的redis没有开启持久化,所以需要将排行榜数据定时备份到数据库。由于是分布式服务,所以需要决定由哪个服务来保存数据,并且需要在redis里记录上次保存时间

这里使用了一个分布式锁,每个服务定时拉取排行榜数据到内存,然后竞争分布式锁,如果获取到锁并且判断上次保存的时间,大于保存间隔时间就执行保存操作。

每次保存数据,不是全保存,只保存变化的名次数据(或者有'脏数据'标记的记录),降低写入条数

停服的时候做一次全保存

起服的时候检测redis里是否有排行榜数据,如果没有就需要竞争一个分布式锁,从数据库加载数据到redis

拉取到内存的排行榜数据立马做好序列化,减少序列化次数

限制同一玩家同一榜单数据请求频率(30s),可以减少发送数据次数,降低流量。

排行榜数据的拉取

依旧以等级排行榜为例.

目的

需要从 `s1:rank:user:lv` 中取出前100名玩家, 及其数据.

用到的Redis命令

[`ZRANGE key start stop [WITHSCORES]`](http://redisdoc.com/sorted_set/zrange.html)
时间复杂度: O(log(N)+M), N 为有序集的基数,而 M 为结果集的基数。

步骤

  1. ​zRange("s1:rank:user:lv", 0, 99)​​ 获取前100个玩家的uid
  2. ​hGet("s1:rank:user:lv:item", $uid)​​ 逐个获取前100个玩家的具体信息

具体实现时, 上面的步骤2是可以优化的.

分析

  • zRange时间复杂度是O(log(N)+M) , N 为有序集的基数,而 M 为结果集的基数
  • hGet时间复杂度是 O(1)
  • 步骤2由于最多需要获取100个玩家数据, 因此需要执行100次, 此处的执行时间还得加上与redis通信的时间, 即使单次只要1MS, 最多也需要100MS.

解决

  • 借助Redis的Pipeline, 整个过程可以降低到只与redis通信2次, 大大降低了所耗时间.

以下示例为php代码

// $redis
$redis->multi(Redis::PIPELINE);
foreach ($uids as $uid) {
$redis->hGet($userDataKey, $uid);
}
$resp = $redis->exec(); // 结果会一次性以数组形式返回

Tip: Pipeline 与 Multi 模式的区别

  • Pipeline 管线化, 是在客户端将命令缓冲, 因此可以将多条请求合并为一条发送给服务端. 但是不保证原子性!!!
  • Multi 事务, 是在服务端将命令缓冲, 每个命令都会发起一次请求, 保证原子性, 同时可配合 WATCH 实现事务, 用途是不一样的.

这里我提供部分排行的php代码,仅供参考

<?php
class RankList
{
protected $rankKey;
protected $rankItemKey;
protected $sortFlag;
protected $redis;


public function __construct($redis, $rankKey, $rankItemKey, $sortFlag=SORT_DESC)
{
$this->redis = $redis;
$this->rankKey = $rankKey;
$this->rankItemKey = $rankItemKey;
$this->sortFlag = SORT_DESC;
}


/**
* @return Redis
*/
public function getRedis()
{
return $this->redis;
}


/**
* @param Redis $redis
*/
public function setRedis($redis)
{
$this->redis = $redis;
}


/**
* 新增/更新单人排行数据
* @param string|int $uid
* @param null|double $score
* @param null|string $rankItem
*/
public function updateScore($uid, $score=null, $rankItem=null)
{
if (is_null($score) && is_null($rankItem)) {
return;
}


$redis = $this->getRedis()->multi(Redis::PIPELINE);
if (!is_null($score)) {
$redis->zAdd($this->rankKey, $score, $uid);
}
if (!is_null($rankItem)) {
$redis->hSet($this->rankItemKey, $uid, $rankItem);
}
$redis->exec();
}


/**
* 获取单人排行
* @param string|int $uid
* @return array
*/
public function getRank($uid)
{
$redis = $this->getRedis()->multi(Redis::PIPELINE);
if ($this->sortFlag == SORT_DESC) {
$redis->zRevRank($this->rankKey, $uid);
} else {
$redis->zRank($this->rankKey, $uid);
}
$redis->hGet($this->rankItemKey, $uid);
list($rank, $rankItem) = $redis->exec();
return [$rank===false ? -1 : $rank+1, $rankItem];
}


/**
* 移除单人
* @param $uid
*/
public function del($uid)
{
$redis = $this->getRedis()->multi(Redis::PIPELINE);
$redis->zRem($this->rankKey, $uid);
$redis->hDel($this->rankItemKey, $uid);
$redis->exec();
}


/**
* 获取排行榜前N个
* @param $topN
* @param bool $withRankItem
* @return array
*/
public function getList($topN, $withRankItem=false)
{
$redis = $this->getRedis();
if ($this->sortFlag === SORT_DESC) {
$list = $redis->zRevRange($this->rankKey, 0, $topN);
} else {
$list = $redis->zRange($this->rankKey, 0, $topN);
}


$rankItems = [];
if (!empty($list) && $withRankItem) {
$redis->multi(Redis::PIPELINE);
foreach ($list as $uid) {
$redis->hGet($this->rankItemKey, $uid);
}
$rankItems = $redis->exec();
}
return [$list, $rankItems];
}


/**
* 清除排行榜
*/
public function flush()
{
$redis = $this->getRedis();
$redis->del($this->rankKey, $this->rankItemKey);
}
}

这就是一个排行榜最简单的实现了, 排行项的积分计算由外部自行处理.