dot Redis 8 已发布——且它是开源的

了解更多

Redis 游戏机制:得分

首先,让我声明我不是一个游戏开发者。所以这篇文章,在我脑子里萦绕多年,部分是关于理论上游戏如何与 Redis 一起工作,但也是一个比喻,帮助读者理解 Redis 的工作原理。 

呼! 

撇开这些,我们来谈谈如何实现一个由 Redis 驱动的游戏。我们将这款游戏命名为 《超级 Redis 兄弟》——这是一款连接到中央服务器的动作平台游戏。主角是 Redisman,他需要收集金币,用金币换取能力提升,并避免被辐射杀死。Redisman 最初是 Sal,一位意大利软件开发者,他被一个 Redis 模块咬伤,变成了火柴人 Redisman,并被传送到了这个颠倒的世界。今天我们将讨论《超级 Redis 兄弟》(即收集金币)中的得分方面。

抛开游戏的所有其他机制不谈,你如何管理角色的金币数量?你可以使用像字符串一样简单的东西来存储和操作金币数量。获得一枚金币的操作可能像这样:

> INCR pID123

一次获得多个分数将使用 INCRBY 命令,通过 DECRBY 或使用负增量的 INCRBY 命令来失去分数。这都很好,但如果你想实现某种排行榜或玩家匹配系统怎么办?在 Redis 中使用简单的字符串效率不会很高。我们需要的是一个有序集合(Sorted Set):这能让我们高效地获取排行榜,但我们能用它来同时跟踪每个玩家的分数吗?

让我们来看看!

使用有序集合收集金币

有序集合由成员组成,每个成员都有一个数值分数,都存储在 Redis 的一个键下。像普通集合一样,成员不能重复,但可以有多个成员拥有相同的分数。虽然在 Redis 中使用字符串/计数器操作是 O(1) 操作,但有序集合的写操作是 O(log(n)) 操作(其中 n 是集合中成员数量的相对值),所以仍然相当高效。Redis 中所有有序集合命令都使用前缀“Z”。

建模收集金币的游戏机制,我们将使用一个键来存储所有玩家的分数,成员是玩家标识符,该成员的分数是该玩家拥有的金币数量。当玩家收集金币时,我们将使用 ZINCRBY 根据收集的点数增加分数。如果一个玩家还没有收集金币,那么 Redis 将从零开始并相应地增加。请注意,有序集合允许零分(这与不在集合中的成员有明显区别)以及负数。让我们直观地看一下。 

注意:显然,我不是游戏艺术家!)

所以,当 Redisman 碰到 10 个金币时,我们使用 ZINCRBY 将该成员的分数增加 10。当 Redisman 碰到 20 个金币时也会发生同样的事情,只是数值不同。 

在上图中,您还可以看到抬头显示(HUD)的金币数量。初始值可以使用 ZSCORE 获取,该命令提供键和成员名称后将返回分数。收集分数后,ZINCRBY 将返回该成员的新分数,因此结果可以用于更新金币 HUD。

让我们看一些其他操作。假设我们想实现一个能力提升,用 10 个金币冻结敌人。这可以使用相同的 ZINCRBY 命令来实现,只是增量参数为负数。另一种情况可能是某种东西将您的金币数量降至零(想想《刺猬索尼克》丢失戒指)。在这种情况下,我们将使用 ZADD 并设置分数为 0。在这种情况下使用 ZADD 可能看起来不直观,但可以将 ZADD 视为“存在则更新,不存在则添加”(upsert)操作——如果成员已存在,此操作将更新其分数;如果成员不存在,则添加该成员并设置新分数。像 ZINCRBY 一样,ZADD 将返回分数,因此我们也可以用它来更新抬头显示。 

请注意,上图有一个关键的过度简化:在当前的设置中,如果 Redisman 有 0 个金币并与敌人冻结能力提升碰撞,他的金币会降到 -10,这可能不是我们想要的。在伪代码中,我们将执行以下操作:

currentScore = ZSCORE scores pID1234
increment = -10
member = pID1234

//将负数乘以 -1 反转
If (currentScore >= increment*-1) {
Return ZINCRBY scores increment member
}

所有伪代码脚本所做的就是首先检查金币是否足够,如果足够,则允许负增量通过。这是一个非常适合简单 Lua 脚本的任务: 

> EVAL "if tonumber(redis.call('zscore',KEYS[1],ARGV[2])) >= (ARGV[1]*-1) then return tonumber(redis.call('zincrby',KEYS[1],ARGV[1],ARGV[2])) end" 1 scores -10 pID1234

如果您不熟悉 Lua,这可能有点难以理解,但这已经尽可能简单了:获取分数,确保它不会低于零,如果可以,则进行递减。Lua 的好处在于它是原子执行的。使用此脚本,Redis 在 ZSCOREZINCRBY 之间不可能修改任何内容。在生产环境中,您可能会使用 SCRIPT LOADEVALSHA 代替 EVAL,但原理是一样的。

获取排行榜

有序集合是排行榜的近乎完美的数据结构。在此示例中,我们使用金币作为有序集合中的分数,因此排行榜功能几乎是内置的。排行榜通常从高分到低分呈现,所以我们将使用 ZREVRANGE 命令

> ZREVRANGE scores 0 9 WITHSCORE

这将显示得分最高的十名玩家,得分最高的玩家排在第一位。它也会记录平局,因此前十名玩家可能都拥有相同的分数。 

然而,在所有情况下显示前十名可能不是最佳选择,特别是对于分数较低的新玩家。玩家可能想看到的是那些比他们略好一点以及比他们略差一点的人。要做到这一点,首先您需要获取目标玩家的排名。您可以使用 ZREVRANK,然后将结果馈送到 ZREVRANGE,并指定您想在目标玩家上方和下方显示的結果数量。

扩展和限制 

您可能已经注意到,本文中的示例总是引用同一个键 scores。这突显了此方法在实际大规模游戏中使用时的一个缺陷。由于您反复触碰同一个键,可能会创建一个非常热点键。在 Redis 集群中,键决定了数据存储在哪个分片或节点上。单个键上的活动最终都会在同一台机器上,直到发生再分片。在这种情况下,即使您有一个由许多节点和分片组成的集群,所有得分活动都将在单个分片上,因此最终将受到该分片资源的限制。这个数字会很高,但在玩家频繁收集或花费金币的高负载游戏中,可能会达到这个限制。

有几种解决方案:您可以回到使用 INCR 和每个玩家使用一个键的方式,然后定期使用 SCAN 更新一个有序集合,但这会失去排行榜的实时性,并创建额外的进程来更新有序集合。 

另一种选择是以一种略微不同寻常的方式使用 Redis Enterprise Active-Active(主-主)部署。Redis Enterprise 中的 Active-Active 主要设计用于通过在广阔地理区域分散多个复制对等集群来促进地理分布,从而减少固有延迟:读取和写入附近的实例,Redis Enterprise 使用无冲突复制数据类型(CRDTs)的概念来处理任何冲突。使用此方法,您可以轻松设置几个对等集群,然后轮询写入这些不同的集群。写入操作将以单个集群的速度被接受,然后解析到不同的集群。虽然这不会完全实时,但它提供了一种无缝的方式来扩展单个键上的写入数量,特别是当工作负载具有突发流量模式时。

就是这样,使用 Redis 轻松实现远程得分记录。有序集合是记录和显示分数(无论是个人用户还是排行榜)的非常棒的数据结构。记住管理潜在热点键的策略。然后去收集那些金币吧!