跳至主要內容

25. 设计实时游戏排行榜


25. 设计实时游戏排行榜

我们将为一款在线手机游戏设计一个排行榜:

leaderboard
leaderboard

第一步:理解问题并确定设计范围

  • 候选人: 排行榜的得分是如何计算的?
  • 面试官: 用户每赢得一场比赛就获得 1 分。
  • 候选人: 排行榜中是否包括所有玩家?
  • 面试官: 是的。
  • 候选人: 排行榜是否与时间段相关?
  • 面试官: 每个月都会启动一个新的锦标赛,从而启动一个新的排行榜。
  • 候选人: 我们可以假设只关心排名前 10 的用户吗?
  • 面试官: 我们希望展示前 10 名用户,并显示特定用户的位置。如果时间允许,我们可以讨论如何展示特定用户周围的其他用户。
  • 候选人: 每个锦标赛有多少玩家?
  • 面试官: 500 万日活跃用户(DAU)和 2500 万月活跃用户(MAU)。
  • 候选人: 每个锦标赛期间平均有多少场比赛?
  • 面试官: 每个玩家每天平均玩 10 场比赛。
  • 候选人: 如果两名玩家得分相同,如何确定排名?
  • 面试官: 在这种情况下,他们的排名是相同的。如果时间允许,我们可以讨论如何打破平局。
  • 候选人: 排行榜需要实时更新吗?
  • 面试官: 是的,我们希望呈现实时结果,或者尽可能接近实时。展示批量结果历史是不允许的。

功能需求

  • 显示排行榜前 10 名玩家
  • 显示用户的具体排名
  • 显示给定用户上下四名的玩家(附加功能)

非功能需求

  • 实时更新得分
  • 得分更新实时反映在排行榜上
  • 一般的可扩展性、可用性和可靠性

粗略估算

假设游戏的日活跃用户数为 5000 万,如果玩家在 24 小时内的分布均匀,那么每秒约有 50 个用户在线。 然而,由于分布通常不均匀,我们可以估算峰值在线用户为每秒 250 个用户。

每秒查询量(QPS)对于用户得分的请求:假设每个玩家每天玩 10 场比赛,那么每秒得分请求数为 50 用户/s * 10 = 500 QPS。峰值 QPS = 2500。

每秒查询量(QPS)用于获取前 10 名排行榜:假设用户平均每天查看一次排行榜,则 QPS 为 50。

第二步:提出高层设计并获得认可

API 设计

我们需要的第一个 API 是更新用户得分的接口:

POST /v1/scores

该 API 接受两个参数:user_id 和用户通过赢得比赛获得的 points

此 API 仅对游戏服务器可访问,而非最终用户客户端。

接下来的 API 用于获取排行榜前 10 名玩家:

GET /v1/scores

示例响应:

{
  "data": [
    {
      "user_id": "user_id1",
      "user_name": "alice",
      "rank": 1,
      "score": 12543
    },
    {
      "user_id": "user_id2",
      "user_name": "bob",
      "rank": 2,
      "score": 11500
    }
  ],
  ...
  "total": 10
}

你也可以获取特定用户的得分:

GET /v1/scores/{:user_id}

示例响应:

{
    "user_info": {
        "user_id": "user5",
        "score": 1000,
        "rank": 6,
    }
}

高层架构

high-level-architecture
high-level-architecture
  • 当玩家赢得比赛时,客户端向游戏服务发送请求
  • 游戏服务验证胜利是否有效,并调用排行榜服务更新玩家的得分
  • 排行榜服务更新用户在排行榜存储中的得分
  • 玩家调用排行榜服务获取排行榜数据,例如前 10 名玩家和特定玩家的排名

考虑的另一种设计是客户端直接在排行榜服务中更新他们的得分:

alternative-design
alternative-design

这种方式不安全,因为它容易受到中间人攻击。玩家可以通过设置代理来修改他们的得分。

另一个需要注意的点是,在服务器管理游戏逻辑的游戏中,客户端不需要显式调用服务器来记录他们的胜利。 服务器会基于游戏逻辑自动为他们处理这一过程。

另一个需要考虑的问题是,是否应在游戏服务器和排行榜服务之间添加消息队列。如果其他服务也对游戏结果感兴趣,消息队列会很有用,但在面试中并没有明确要求,因此不在设计中包含此部分:

message-queue-based-comm
message-queue-based-comm

数据模型

我们来讨论一下存储排行榜数据的选项——关系型数据库、Redis 和 NoSQL。

NoSQL 解决方案将在深入探讨部分讨论。

关系型数据库解决方案

如果规模不大,且用户不多,关系型数据库可以很好地满足需求。

我们可以从一个简单的排行榜表开始,为每个月创建一个表(个人备注:这种做法不太合理。你可以只添加一个 month 列,避免每个月都需要维护新表的麻烦):

leaderboard-table
leaderboard-table

这里有一些额外的数据可以包含,但与我们要执行的查询无关,因此省略了。

当用户赢得 1 分时会发生什么?

user-wins-point
user-wins-point

如果用户尚未在表中存在,我们需要先插入他们:

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

在后续调用中,我们只需更新他们的得分:

UPDATE leaderboard set score=score + 1 where user_id='mary1934';

如何查找排行榜中的前几名玩家?

find-leaderboard-position
find-leaderboard-position

我们可以运行以下查询:

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

不过,这种做法性能较差,因为它会进行全表扫描以按得分排序所有记录。

我们可以通过在 score 上添加索引,并使用 LIMIT 操作来避免扫描所有记录,从而进行优化:

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

然而,如果用户不在排行榜的顶部,你想要找到他们的排名时,这种方法就不太适用了,扩展性较差。

Redis 解决方案

我们希望找到一种解决方案,即使在数百万玩家的情况下也能良好运行,而不必依赖复杂的数据库查询。

Redis 是一个内存数据存储,它的速度非常快,因为它在内存中工作,并且具有适合我们需求的数据结构——有序集合(Sorted Set)。

有序集合是一种类似于编程语言中的集合的数据结构,允许你根据给定的标准保持数据结构的排序。 内部,它使用哈希表来维护键(user_id)和值(score)之间的映射,并使用跳表(skip list)将分数映射到按顺序排列的用户:

sorted-set
sorted-set

跳表是如何工作的?

  • 它是一个链表,允许快速查找
  • 它由一个排序的链表和多级索引组成
skip-list
skip-list

这种结构使我们能够在数据集足够大时快速搜索特定值。在下面的示例中(64 个节点),它需要遍历基本链表中的 62 个节点才能找到给定的值,而在跳表的情况下,只需要遍历 11 个节点:

skip-list-performance
skip-list-performance

有序集合比关系型数据库更高效,因为数据始终保持排序,代价是 O(logN)的添加和查找操作。

相比之下,以下是我们需要执行的嵌套查询,来查找给定用户在关系型数据库中的排名:

SELECT *,(SELECT COUNT(*) FROM leaderboard lb2
WHERE lb2.score >= lb1.score) RANK
FROM leaderboard lb1
WHERE lb1.user_id = {:user_id};

在 Redis 中操作我们的排行榜需要哪些操作?

  • ZADD - 如果用户不存在,则将其插入集合。否则,更新分数。时间复杂度为 O(logN)。
  • ZINCRBY - 按给定的增量增加用户的分数。如果用户不存在,分数从零开始。时间复杂度为 O(logN)。
  • ZRANGE/ZREVRANGE - 获取一范围内按分数排序的用户。可以指定排序顺序(ASC/DESC)、偏移量和结果大小。时间复杂度为 O(logN+M),其中 M 是结果大小。
  • ZRANK/ZREVRANK - 获取给定用户的排名(按升序/降序)。时间复杂度为 O(logN)。

用户得分时会发生什么?

ZINCRBY leaderboard_feb_2021 1 'mary1934'

每个月都会创建一个新的排行榜,而旧的排行榜会被移到历史存储中。

用户查看前 10 名玩家时会发生什么?

ZREVRANGE leaderboard_feb_2021 0 9 WITHSCORES

示例结果:

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

用户查看自己在排行榜中的位置时呢?

leaderboard-position-of-user
leaderboard-position-of-user

可以通过以下查询轻松实现,假设我们知道用户的排行榜位置:

ZREVRANGE leaderboard_feb_2021 357 365

用户的位置可以使用 ZREVRANK <user-id> 获取。

存储需求

让我们探讨一下存储需求:

  • 假设最坏情况是所有 2500 万 MAU 在给定月份都参与了游戏
  • 用户 ID 是 24 个字符的字符串,分数是 16 位整数,我们需要 26 字节 * 2500 万 = ~650MB 的存储空间
  • 即使由于跳表的开销我们将存储成本加倍,这仍然可以轻松适应现代 Redis 集群

另一个非功能性需求是支持每秒 2500 次更新。这完全在单个 Redis 服务器的能力范围内。

其他注意事项:

  • 我们可以启动 Redis 副本,以防 Redis 服务器崩溃时丢失数据
  • 我们仍然可以利用 Redis 的持久化机制,在发生崩溃时不丢失数据
  • 我们需要在 MySQL 中使用两个支持表,以获取用户详细信息(如用户名、显示名称等)以及记录何时某个用户赢得了比赛
  • MySQL 中的第二个表可以用于在发生基础设施故障时重建排行榜
  • 作为小的性能优化,我们可以缓存前 10 名玩家的用户详细信息,因为这些信息将被频繁访问

第三步:设计深入探讨

使用云服务提供商与否

我们可以选择自己部署和管理服务,或者使用云服务提供商来管理这些服务。

如果我们选择自己管理服务,我们将使用 Redis 存储排行榜数据,使用 MySQL 存储用户资料,并且如果希望扩展数据库,可能还需要为用户资料设置缓存:

manage-services-ourselves
manage-services-ourselves

另外,我们可以使用云服务来管理许多服务。例如,我们可以使用 AWS API Gateway 将 API 请求路由到 AWS Lambda 函数:

api-gateway-mapping
api-gateway-mapping

AWS Lambda 使我们能够在不管理或配置服务器的情况下运行代码。它只在需要时运行,并且可以自动扩展。

用户得分示例:

user-scoring-point-lambda
user-scoring-point-lambda

用户检索排行榜示例:

user-retrieve-leaderboard
user-retrieve-leaderboard

Lambda 是一种无服务器架构的实现。我们不需要管理扩展和环境设置。

作者建议,如果我们从零开始构建游戏,最好采用这种方法。

扩展 Redis

对于 500 万 DAU 用户,我们从存储和 QPS 角度来看,单个 Redis 实例足以应对。

但是,如果我们假设用户基数增长 10 倍,达到 5 亿 DAU,那么我们将需要 65GB 的存储空间,QPS 则会增长到 25 万。

这种规模需要分片。

一种实现方法是按范围分区数据:

range-partition
range-partition

在这个示例中,我们将根据用户的分数进行分片。我们将在应用程序代码中维护用户 ID 和分片之间的映射。 我们可以通过 MySQL 或其他缓存来管理映射。

为了获取前 10 名玩家,我们可以查询分数最高的分片([900-1000])。

为了获取用户的排名,我们需要计算该用户所在分片内的排名,并加上其他分片中所有分数更高的用户数量。 后者是 O(1) 操作,因为每个分片的总记录可以通过 info keyspace 命令快速访问。

另外,我们可以使用 Redis 集群进行哈希分区。它是一个代理,将数据根据类似一致性哈希的方式分布到 Redis 节点上,但并不完全相同:

hash-partition
hash-partition

这种设置下,计算前 10 名玩家会比较复杂。我们需要获取每个分片的前 10 名玩家,并在应用程序中合并结果:

top-10-players-calculation
top-10-players-calculation

哈希分区有一些限制:

  • 如果我们需要获取前 K 个用户,且 K 很大,延迟可能会增加,因为我们需要从所有分片获取大量数据
  • 随着分区数量的增加,延迟也会增加
  • 确定用户排名没有直接的办法

由于这些原因,作者倾向于使用固定分区来解决这个问题。

其他注意事项:

  • 最佳实践是为写密集型的 Redis 节点分配两倍于所需的内存,以适应快照(如果需要)
  • 我们可以使用 Redis-benchmark 工具来跟踪 Redis 设置的性能,并做出数据驱动的决策

替代方案:NoSQL

另一个需要考虑的替代方案是使用适合的 NoSQL 数据库,优化以下方面:

  • 高写入负载
  • 在同一分区内根据分数有效地排序项

DynamoDB、Cassandra 或 MongoDB 都是不错的选择。

在本章中,作者选择了使用 DynamoDB。它是一个完全托管的 NoSQL 数据库,提供可靠的性能和很好的可扩展性。 它还允许使用全局二级索引,当我们需要查询非主键字段时非常有用。

dynamo-db
dynamo-db

让我们从存储一个棋类游戏排行榜的表开始:

chess-game-leaderboard-table-1
chess-game-leaderboard-table-1

这种设计很好,但如果我们需要按分数查询,它的扩展性就不好。因此,我们可以将分数作为排序键:

chess-game-leaderboard-table-2
chess-game-leaderboard-table-2

这个设计的另一个问题是我们按月份进行分区。由于最新的月份将比其他月份访问频繁,这导致了热点分区。

我们可以使用一种称为写分片(write sharding)的技术,为每个键附加一个分区编号,计算方法为 user_id % num_partitions

![

chess-game-leaderboard-table-3](../image/system-design-388.png)

一个重要的权衡是我们应该使用多少分区:

  • 分区越多,写入扩展性越高
  • 但是,读取扩展性会受到影响,因为我们需要查询更多的分区来汇总结果

使用这种方法需要我们使用之前看到的“scatter-gather”技术,随着分区数量的增加,时间复杂度会增长:

scatter-gather-2
scatter-gather-2

为了做出关于分区数量的良好评估,我们需要进行一些基准测试。

这种 NoSQL 方法仍然有一个主要的缺点——很难计算用户的具体排名。

如果我们有足够的规模需要分片,那么我们可以告诉用户他们的“百分位数”分数。

一个定时任务可以定期运行,分析分数分布,并根据此确定用户的百分位数,例如:

第 10 百分位 = 分数 < 100
第 20 百分位 = 分数 < 500
...
第 90 百分位 = 分数 < 6500

第四步:总结

如果时间允许,还有其他一些讨论:

  • 更快的检索 - 我们可以通过 Redis 哈希缓存用户对象,映射 user_id -> user object,这能加速检索,相比查询数据库更高效。
  • 破除平局 - 当两名玩家的分数相同,我们可以通过他们的最后一场比赛来打破平局。
  • 系统故障恢复 - 在大规模的 Redis 故障发生时,我们可以通过查看 MySQL 的 WAL 记录,并通过一个临时脚本重建排行榜。