设计百万日活用户手游实时排行榜

本文将为您介绍如何设计在线手机游戏排行榜。

什么是排行榜?在游戏或其他地方,排行榜是非常常见的,用于显示哪些玩家在比赛中处于领先地位。用户完成任务或挑战后被分配积分,谁的积分最多谁就在排行榜的顶部。下图显示了一个示例。

候选人:排行榜的得分是如何计算的?

面试官:用户在赢得比赛时获得积分。我们可以采用一个简单的积分系统,每次用户赢得比赛时,我们将相应的积分加到他们的总得分中。

候选人:排行榜中是否包括所有玩家?

面试官:是的。

候选人:排行榜是否与时间段相关联?

面试官:每个月都会开始一个新的锦标赛,启动一个新的排行榜。

候选人:在排行榜中,可以假设只关心前10名用户吗?

面试官:我们希望显示前10名用户以及特定用户在排行榜上的位置。另外,我们还可以讨论一下如何返回距离特定用户上下几名的用户。

候选人:一个锦标赛中有多少用户?

面试官:平均每天有超过500万的日活用户(DAU)和2500万的月活跃用户(MAU)。

候选人:在一个锦标赛期间平均进行多少场比赛?

面试官:每个玩家平均每天进行10场比赛。

候选人:如果两名玩家得分相同,我们如何确定排名?

面试官:在这种情况下,他们的排名是相同的。

候选人:排行榜是否需要实时更新?

面试官:是的,我们希望呈现实时结果。

总结一下,列出功能性需求:

  1. 显示排行榜上的前10名玩家。
  2. 显示用户的具体排名。
  3. 显示距离所需用户上下四名的玩家。

除了功能性需求外,以下非功能性需求也很重要。

  1. 分数的实时更新。
  2. 可伸缩性、可用性和可靠性要求。

系统QPS估算

有500万日活跃用户(DAU),如果在24小时内玩家均匀分布,我们将每秒平均有50名用户(5,000,000 / 24 / 60 / 60 = 50)。然而,我们知道实际情况不是均匀分布的,可能在晚上有高峰期。考虑到这一点,我们可以假设峰值负载是平均负载的5倍。因此,我们希望允许每秒最多250名用户的峰值负载。

对于获得积分的用户:如果用户平均每天玩10场比赛,每秒获得积分的QPS是:50(平均值) x 10 = 500。峰值QPS是平均值的5倍:500 x 5 = 2500。

获取前10名排行榜的QPS:假设用户每天只打开游戏一次,并且仅在用户首次打开游戏时加载前10名排行榜。这个QPS大约是50。

总体设计

在这一部分,我们将讨论API设计、整体架构和数据模型。

API设计

总的来说,我们需要以下三个API:

POST /v1/scores

当用户赢得一场比赛时,更新用户在排行榜上的位置。这应该是一个内部API,在游戏服务器间调用。客户端不应该直接更新排行榜分数。

请求参数:

  1. user_id:赢得比赛的用户ID。
  2. points:用户通过赢得比赛获得的积分。

响应:

  1. 200 OK:成功更新用户的分数。
  2. 400 Bad Request:无法更新用户的分数。

GET /v1/scores

从排行榜中获取前10名玩家。

响应示例:

复制代码
{
  "data": [
    {
      "user_id": 111,
      "user_name": "alice",
      "rank": 1,
      "score": 976
    },
    {
      "user_id": 112,
      "user_name": "bob",
      "rank": 2,
      "score": 965
    },
    // ... (可能还有其他用户数据)
  ],
  "total": 10
}

GET /v1/scores/{:user_id}

获取特定用户的排名。

响应示例:

复制代码
{
  "user_info": {
    "user_id": 5,
    "score": 940,
    "rank": 6
  }
}
复制代码
 

总体设计图如下图所示。在这个设计中有两个服务。游戏服务允许用户玩游戏,而排行榜服务则创建并显示排行榜。

  1. 当玩家赢得一场游戏时,游戏客户端发送请求到游戏服务。
  2. 游戏服务调用排行榜服务更新分数。
  3. 排行榜服务持久化存储分数。
  4. 玩家调用排行榜服务以获取排行榜数据,包括:

(a) 前10名的排行榜。

(b) 玩家在排行榜上的排名。

游戏客户端直接调用排行榜服务?

如上图所示设计,分数由游戏客户端设置。这个选项不安全,因为它容易受到中间人攻击(

https://en.wikipedia.org/wiki/Man-in-the-middle_attack )的影响,玩家可以使用代理更改分数。因此,我们需要在服务器端设置分数。

我们是否需要在游戏服务和排行榜服务之间使用消息队列?

这个问题完全取决于游戏积分的使用方式。

如果该数据在其他地方使用或支持多个功能,那么将数据放入Kafka可能是有意义的,如下图所示。这样,同一份数据可以被多个消费者使用,比如排行榜服务、分析服务、通知服务等。

当游戏是一个回合制或多人游戏时,我们需要通知其他玩家有关分数更新的情况,这一点尤为重要。

假设在之前与面试官的对话中没有明确的要求,我们在设计中没有使用消息队列。

数据模型

系统中的关键组件之一是排行榜存储。我们将讨论三种潜在的解决方案:关系数据库、Redis和NoSQL。

基于关系数据库的方案

如果用户规模不是太大,我们很可能选择使用关系数据库系统(RDS)实现一个简单的排行榜解决方案。

每个月的排行榜可以被表示为一个包含用户ID和分数列的数据库表。当用户赢得比赛时,为用户更新积分。为了确定用户在排行榜上的排名,我们将按分数降序对表进行排序。

实际上,排行榜表还包含其他信息,比如game_id、timestamp等。然而,查询和更新排行榜的基本逻辑保持不变。为简单起见,我们假设只有当前月份的排行榜数据存储在排行榜表中。

赢得积分

假设每次胜利赢得1分数。如果在当月的排行榜中为新用户:

INSERT INTO leaderboard (user_id, score) VALUES ('mary1934', 1);

如果是老用户:

UPDATE leaderboard SET score = score + 1 WHERE user_id = 'mary1934';

查找用户在排行榜中的位置

为了获取用户的排名,我们将对排行榜表按照分数排序:

复制代码
SELECT (@rownum := @rownum + 1) AS rank, user_id, score
FROM leaderboard
ORDER BY score DESC;
复制代码
 

SQL查询的结果类似于这样:

这个解决方案在数据集较小时是没有什么问题的,但是当有数百万行数据时,查询可能变得非常慢。让我们看看为什么。

这个SQL查询实际上需要全表扫描。另外请注意,分数可能重复,所以排名可以不是用户在列表中的位置。我们需要基于上面的SQL查询结果进行进一步处理。

当我们需要处理大量不断变化的信息时,SQL数据库性能不佳。由于数据不断更新变化,在这里,使用缓存也不太合适。

我们可以进行优化:添加索引,并使用LIMIT子句限定结果集。查询看起来像这样:

复制代码
SELECT (@rownum := @rownum + 1) AS rank, user_id, score
FROM leaderboard
ORDER BY score DESC
LIMIT 10;
复制代码
 

然而,这种方法的扩展性并不好。首先,它本质上还是需要对表进行扫描以确定排名。其次,这种方法没有获取到不在排行榜顶部的用户的排名情况。

基于Redis的解决方案

我们希望找到一种解决方案,即使对于数百万用户,也能提供可预测的性能,并且允许我们轻松进行常见的排行榜操作,而无需依赖复杂的数据库查询。

Redis为我们的问题提供了一个潜在的解决方案。Redis是一个支持键值对的内存数据存储组件。由于它在内存中工作,它允许快速读写。Redis有一个特定的数据类型称为sorted sets,非常适合解决排行榜系统设计问题。

有序集合是一种类似于集合的数据类型。有序集合的每个成员与一个分数相关联。集合的成员必须是唯一的,但分数可以重复。分数用于按升序排列有序集。

这里,我们的排行榜用例场景完美匹配Redis sorted sets。

Redis sorted sets有序集由两个数据结构实现:哈希表和跳表。哈希表将用户映射到分数,而跳表将分数映射到用户。在有序集中,用户按分数排序。

理解有序集的一种好方法是将其想象成一个包括分数和用户两列的表,如下图所示。表按分数降序排序。

我们从总体上看一下有序集的实现思想。

跳表是一种允许快速搜索的列表结构。它由一个基本排序链表和多级索引组成。

看一个例子。

如下图所示,基本列表是一个有序的单向链表。插入、删除和搜索操作的时间复杂度是O(n)。

如何使得这些操作变得更快呢?一个想法是快速到达中间,就像二分搜索算法一样。

为了实现这一点,我们添加一个索引:每隔一个节点,选取一个节点。类似的方式,再添加一个索引。以此类推,我们不断引入额外的索引。

当节点之间的距离为n/2 - 1 时,停止添加更多的索引。其中n是总节点数。如图所示,当我们有多级索引时,搜索数字45就变得更快了。

有序集比关系数据库性能好,那是因为每个元素在插入或更新时都会自动按正确的顺序定位。

在有序集中进行添加或查找操作的复杂度是对数级别的:O(log(n))。

相比之下,在关系数据库中计算特定用户的排名,我们需要运行嵌套查询:

复制代码
SELECT COUNT(*) FROM leaderboard lb2 WHERE lb2.score >= lb1.score) AS RANK
FROM leaderboard lb1
WHERE lb1.user_id = {:user_id};

这个查询使用了子查询来计算比当前用户分数高的其他用户数量,从而确定当前用户在排行榜上的排名。这样的查询结构在大规模数据集上的性能可能较差。

使用Redis有序集实现

基本操作:

  1. ZADD :如果用户尚不存在,则将用户插入集合中。否则,更新用户的分数。执行时间复杂度为O(log(n))。
  2. ZINCRBY :将用户的分数增加指定的增量。如果用户在集合中不存在,则分数从0开始。执行时间复杂度为O(log(n))。
  3. ZRANGE/ZREVRANGE :按分数排序获取一定范围内的用户。我们可以指定排序顺序(range vs. revrange)、条目数以及要从哪个位置开始。执行时间复杂度为O(log(n) + m),其中m是要获取的条目数(在我们的场景中通常很小),n是有序集中的条目数。
  4. ZRANK/ZREVRANK :以对数时间复杂度获取任何用户在升序/降序排序中的位置。

下面看看具体工作流程。

1,用户赢得积分

每个月我们都会创建一个新的排行榜有序集,之前的排行榜将被作为历史数据存储。

当用户赢得一场比赛时,得到1积分;因此,我们调用ZINCRBY来在当月的排行榜中将用户的分数增加1。ZINCRBY的语法如下:

ZINCRBY <key> <increment> <user>

例如,以下命令为用户mary1934赢得比赛后为其增加1分:

ZINCRBY leaderboard_feb_2021 1 mary1934

2,用户获取全球前10名排行榜

调用ZREVRANGE以降序获取用户列表。

例如,以下命令获取2021年2月排行榜的前10名玩家:

ZREVRANGE leaderboard_feb_2021 0 9 WITHSCORES

将返回一个类似这样的列表:

[(user2, score2), (user1, score1), (user5, score5), ...]

3,用户想要获取他们在排行榜中的位置

要获取用户在排行榜上的位置,我们将调用ZREVRANK来检索他们在排行榜上的排名。

ZREVRANK leaderboard_feb_2021 mary1934

4,获取用户在排行榜中的相对位置

我们可以通过ZREVRANGE轻松获取用户的相对位置。

例如,如果用户'E.Elliott'的排名是47,我们想要获取在他上面和下面的4名玩家,我们将运行以下命令。

ZREVRANGE leaderboard_feb_2021 43 51

存储需求

我们需要存储用户ID和分数。最坏的情况是,所有2500万月活跃用户都赢得了至少一场比赛,他们都在该月的排行榜中有条目。

假设ID是一个24个字符的字符串,分数是一个16位整数(2字节),每个排行榜条目需要26字节的存储空间。

在最坏的情况下,每个MAU一个排行榜条目,我们需要26字节 x 2500万 = 650百万字节,约为650MB。

即使考虑到跳表和有序集的哈希开销,我们将内存使用量翻倍,目前一般的Redis服务器足以容纳这些数据。

关于Redis服务器CPU和I/O。我们根据简易估算得到的峰值是每秒更新2500次。这远远低于单个Redis服务器的性能范围。

关于Redis缓存的一个担忧是数据持久化,因为Redis节点可能会发生故障。幸运的是,Redis是支持数据持久化的。通常,可以为Redis配置读取副本,当主实例宕机时,读取副本会被提升为主实例。

在关系型数据库中,我们需要两个表(用户表和积分表)。用户表将存储用户ID和姓名等。积分表将包含用户ID、分数和时间戳等。这可以用于其他业务功能,比如游戏历史记录,并且在系统故障的情况下也可以用来重新创建Redis缓存中的排行榜。

另外,创建一个额外的缓存存储用户详细信息应该比较有用,比如前10名玩家的详细信息,因为它们频繁访问。

扩展

Redis集群有可能遭遇大规模故障,需要使用一种机制确保用户数据不丢失。

用户每次赢得比赛,获得积分,我们可以使用关系型数据库如MySQL在数据库添加一条记录。这样,在大规模故障的情况下,我们可以为每个用户遍历此表,为每个条目调用一次ZINCRBY。从而,离线重建Redis缓存中的排行榜。


系列文章总目录