dot 您所在城市的盛会,高速的未来即将到来。

加入 Redis 发布会

Redis 游戏机制:计分

首先,我来警示大家我不是游戏开发人员。因此,我这篇酝酿了很多年的文章,既部分探讨了一场理论游戏使用 Redis 时的工作原理,又是一种隐喻,旨在帮助读者理解 Redis 的工作原理。 

呼! 

扫清了所有这些障碍,让我们讨论如何实现由 Redis 提供支持的游戏。我们将把这款游戏命名为 超级 Redis 兄弟——它是一款与中央服务器相连的动作平台游戏。游戏主角是 Redisman,他需要收集金币,使用那些金币进行升级,并避免被辐射杀死。Redisman 最早是意大利软件开发者萨尔,他被一个 Redis 模块咬伤,变成了棍状人 Redisman,并传送到了这个颠三倒四的世界中。今天,我们将介绍 超级 Redis 兄弟(又名收集金币)的得分方面。

撇开游戏的所有其他机制,你如何管理人物的金币数?你可以使用像字符串这样的简单事物来存储和控制金币数。得到金币可以像这样

> INCR pID123

每次得到多个金币点将采用 INCRBY 命令,并通过 DECRBY 或带有负增量的 INCRBY 来丢失金币点。这很好,但如果你想实现某种排行榜或玩家匹配系统会怎样?在 Redis 中使用简单字符串不会非常有效。我们需要一个有序集合:这将允许我们非常有效地拉取排行榜,但是我们能用它来 同时 跟踪各个玩家的得分吗?

让我们看看!

使用有序集合收集金币

有序集合由各成员组成,每个成员在 Redis 中的一个键下都带有数字分数。像普通集合一样,成员不能重复,但可能有多个成员具有相同的分数。在 Redis 中使用字符串/计数器操作是一个 O(1) 操作,而在有序集合上进行的写入操作是 O(log(n)) 操作(其中 n 与集合中的成员相关),所以仍然不错。Redis 中的所有有序集合命令都使用前缀“Z”。

为收集金币的游戏机制建模时,我们将为所有玩家的分数使用单一密钥,成员是玩家标识符,而此成员的分数是玩家拥有的金币数。当玩家收集金币时,我们将使用 ZINCRBY 根据收集的点数增加分数。如果玩家尚未收集金币,那么 Redis 将从零开始并相应增加。请注意,有序集合允许零分(与不在集合中的成员明显不同),也允许负数。我们来直观地看看这个过程。 

(注意:很明显,我不是游戏艺术家!)

因此,当雷迪斯曼接触到 10 个金币时,我们使用 ZINRCBY 将成员的分数增加 10。当雷迪斯曼接触到 20 个金币时也会发生同样的事情,只是值不同。 

在上面的图形中,你还可以看到平视金币显示。可以通过使用 ZSCORE 来检索初始值,提供密钥和成员名称时,它将返回分数。收集积分时,ZINCRBY 将返回成员的新分数,因此可以将结果用于更新金币平视显示。

我们再看看一些操作。假设我们想要实现一个能力提升,用 10 个金币换取冰冻敌人。可以使用相同的 ZINCRBY 命令来实现,只不过要使用负增量参数。另一种情况可能是将你的金币数减少到零(就像索尼克失去他的光环)。在这种情况下,我们将使用 ZADD,分数为 0。ZADD在这种情况下可能看似违反直觉,但可以将 ZADD 视为 upsert — 如果成员已存在,此操作会更新分数;如果成员不存在,则会将其添加新分数。如同 ZINCRBYZADD 将返回分数,因此我们也可以用它来更新平视显示。 

请注意,上图中有一个明显的过度简化:在当前设置中,如果雷迪斯曼有 0 个金币并与敌人冰冻能力提升相撞,那么他的金币将降至 -10,这可能并不是我们想要的结果。如果用伪代码表示,我们将执行以下操作

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

 //用 -1 乘以负数以转换它们
如果 (currentScore >= increment*-1) {
则返回 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,其中包含该玩家上方和下方所需的结果数。

伸缩和限制 

您可能已经注意到,本文中的示例始终引用相同的键 分数。这突出显示了该方法在实际游戏中大规模使用时的缺陷。由于您不停地触碰同一键,因此可能会创建一个非常 热点键。在集群 Redis 中,键决定了将数据存储在哪个分片或节点上。在单个键上的活动最终会出现在同一台机器上,直到重新分片。在这种情况下,即使您的集群由许多节点和分片组成,所有评分活动都将在单个分片上进行,因此您最终会受到此分片资源的限制。这个数字会很高,但如果在多名玩家之间频繁收集或花钱,在用户数量众多的游戏中可能会达到此数字。

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

另一种选择是以稍微不寻常的方式使用 Redis Enterprise 主动-主动部署。Redis Enterprise 中的主动-主动主要旨在通过将多个副本对等集群分布在广阔的地理区域内来促进地理分布,以减少固有延迟:向附近的副本写入和读取数据,而 Redis Enterprise 使用 无冲突复制数据类型 (CRDT)的概念来处理任何冲突。使用此方法,您可以同等轻松地设置多个对等集群,然后对这些不同的集群进行循环写入。写入将以各个集群的速度被接受,然后解析到不同的集群。尽管这不会 完全实时,但可以提供一种无缝的方式来扩展单个键的写入次数,尤其是在工作负载具有突发流量模式时。

现在您已上手,使用 Redis 可以轻松地进行远程分数记录。有序集合创建了一个非常重要的数据结构,用于记录和显示分数,无论是对于单个用户还是排行榜都是如此。只需记住管理潜在热点键的策略。然后去收集那些硬币吧!