使用 Firebase 进行排行榜排名

Leaderboard ranking with Firebase

我有一个项目,我需要显示前 20 名的排行榜,如果用户不在排行榜中,他们将以当前排名显示在第 21 位。

有什么有效的方法吗?

我正在使用 Cloud Firestore 作为数据库。我认为选择它而不是 MongoDB 是错误的,但我正处于项目的中间,所以我必须使用 Cloud Firestore。

该应用程序将有 30,000 名用户使用。有没有什么办法可以在不吸引所有 30k 用户的情况下做到这一点?

 this.authProvider.afs.collection('profiles', ref => ref.where('status', '==', 1)
        .where('point', '>', 0)
        .orderBy('point', 'desc').limit(20))

这是我为获得前 20 名所做的代码,但是如果他们不在前 20 名中,获得当前登录用户排名的最佳做法是什么?

以可缩放的方式在排行榜中查找任意玩家的排名是数据库的常见难题。

有几个因素会影响您需要选择的解决方案,例如:

  • 玩家总数
  • 个人玩家加分率
  • 添加新分数的比率(并发玩家 * 以上)
  • 分数范围:有界或无界
  • 分数分布(均匀,还是他们'hot scores')

简单方法

典型的简单方法是计算所有得分较高的玩家,例如SELECT count(id) FROM players WHERE score > {playerScore}

此方法适用于低规模,但随着玩家群的增长,它很快就会变得缓慢且资源昂贵(在 MongoDB 和 Cloud Firestore 中)。

Cloud Firestore 本身不支持 count,因为它是不可扩展的操作。您需要通过简单地计算返回的文档来在客户端实现它。或者,您可以使用 Cloud Functions for Firebase 在服务器端进行聚合以避免返回文档的额外带宽。

定期更新

与其提供实时排名,不如将其改为每隔一段时间更新一次,例如每小时更新一次。例如,如果您查看 Stack Overflow 的排名,它们只会每天更新。

对于这种方法,如果 运行 花费的时间超过 540 秒,您可以 schedule a function, or schedule App Engine。该函数将写出玩家列表,就像在 ladder 集合中一样,新的 rank 字段填充了玩家排名。玩家现在查看天梯时,O(X)时间即可轻松获得前X+玩家自身段位

更好的是,您还可以进一步优化并显式地将前 X 写为单个文档,因此要检索天梯您只需要阅读 2 个文档,前 X 和玩家,既省钱又赚钱它更快。

这种方法真正适用于任何数量的播放器和任何写入速率,因为它是带外完成的。随着您的成长,您可能需要根据您的支付意愿调整频率。每小时 3 万名玩家将是每小时 0.072 美元(每天 1.73 美元),除非您进行了优化(例如,忽略所有得分为 0 的玩家,因为您知道他们排在最后)。

倒排索引

在这个方法中,我们将创建一些倒排索引。如果有一个分数范围明显小于玩家数量(例如,0-999 分数与 30K 玩家),则此方法有效。它也适用于无限分数范围,其中唯一分数的数量仍然明显小于玩家数量。

使用一个名为 'scores' 的单独集合,您有一个包含每个单独分数的文档(如果没有人拥有该分数,则不存在)和一个名为 player_count.[=30= 的字段]

当玩家获得新的总分时,您将在 scores 集合中进行 1-2 次写入。一次写入是将他们的新分数 +1 到 player_count,如果这不是他们第一次写 -1 到他们的旧分数。这种方法适用于“你的最新分数是你的当前分数”和“你的最高分数是你的当前分数”风格的阶梯。

找出玩家的确切排名就像 SELECT sum(player_count)+1 FROM scores WHERE score > {playerScore} 一样简单。

由于 Cloud Firestore 不支持 sum(),您可以执行上述操作,但在客户端求和。 +1 是因为总和是比你高的玩家数,所以加 1 就是那个玩家的等级。

使用这种方法,您最多需要阅读 999 份文件,平均 500 左右才能获得球员排名,但实际上,如果您删除零球员的分数,这会更少。

理解新分数的写入率很重要,因为您平均每 2 秒*只能更新一次个人分数,对于从 0-999 的完美分布分数范围来说,这意味着 500 个新 scores/second**。您可以通过对每个分数使用 distributed counters 来增加它。

* 每 2 秒只有 1 个新分数,因为每个分数生成 2 次写入
** 假设平均游戏时间为 2 分钟,500 个新 scores/second 可以支持 60000 个并发玩家,而无需分布式计数器。如果您使用的是“最高分是您当前的分数”,这在实践中会高得多。

分片 N 叉树

这是迄今为止最难的方法,但可以让您获得所有玩家的更快和实时排名位置。它可以被认为是上面倒排索引方法的读优化版本,而上面的倒排索引方法是写优化版本。

您可以按照这篇相关文章 'Fast and Reliable Ranking in Datastore' 了解适用的一般方法。对于这种方法,您需要有一个有界分数(无界分数是可能的,但需要从下面进行更改)。

我不推荐这种方法,因为您需要为任何具有半频繁更新的阶梯的顶级节点执行分布式计数器,这可能会抵消读取时间的好处。

最后的想法

根据您为玩家显示排行榜的频率,您可以结合多种方法来进一步优化它。

在更短的时间范围内将 'Inverted Index' 与 'Periodic Update' 相结合可以为所有玩家提供 O(1) 排名。

只要在 'Periodic Update' 期间所有玩家的排行榜被查看次数 > 4 次,您就可以省钱并拥有更快的排行榜。

基本上每个时间段,比如 5-15 分钟,您按降序阅读 scores 中的所有文档。使用这个,保持 运行ning 总数 players_count。使用新字段 players_above 将每个分数重新写入名为 scores_ranking 的新集合中。此新字段包含 运行ning 总计,不包括当前分数 player_count

要获得玩家的排名,您现在需要做的就是从 score_ranking 中读取玩家得分的文档 -> 他们的排名是 players_above + 1.

Dan 没有提到的一个解决方案是结合使用安全规则和 Google Cloud Functions。

创建高分地图。示例:

  • 高分(top20)

然后:

  1. 授予用户 write/read 获得高分的权限。
  2. 给 document/map highScores 在 属性 中的最小分数。
  3. 让用户只在他的分数 > 最小分数时写入 highScores。
  4. 在 Google Cloud Functions 中创建一个写入触发器,该触发器将在写入新的 highScore 时激活。在该函数中,删除最小的分数。

这在我看来是最简单的选择。也是实时的。

我即将在我的在线游戏中实施并可能在您的用例中使用的一个解决方案是在用户不在任何可见排行榜中时估计用户的排名,因为坦率地说,用户不在任何可见的排行榜中不会知道(或关心?)他们是排名第 22,882 位还是第 22,838 位。

如果第 20 名的得分为 250 分并且共有 32,000 名玩家,那么低于 250 分的每个分值平均为 127 名,尽管您可能想使用某种曲线让他们向上移动一个点在可见排行榜的底部,他们每次都不会准确地跃升 127 位 - 大多数排名跃升应该接近零分。

是否要将此估计排名确定为估计由您决定,您可以在数字中添加一些随机盐以使其看起来真实:

// Real rank: 22,838

// Display to user:
player rank: ~22.8k    // rounded
player rank: 22,882nd  // rounded with random salt of 44

我会做后者。

另一种观点 - NoSQL 和文档存储使此类任务过于复杂。如果您使用的是 Postgres,则使用计数函数非常简单。 Firebase 很诱人,因为它很容易上手,但像这样的用例是关系数据库大放异彩的时候。 Supabase 值得一看 https://supabase.io/ 与 firebase 类似,因此您可以使用后端快速上手,但它是开源的并构建在 Postgres 上,因此您可以获得关系数据库。

你可以用云存储做点什么。因此,手动创建一个包含所有用户分数(按顺序)的文件,然后您只需读取该文件并找到分数在该文件中的位置。

然后要写入文件,您可以设置一个 CRON 作业以定期添加所有带有标志 isWrittenToFile false 的文档,将它们全部添加到文件(并将它们标记为 true)。这样你就不会吃掉你的写作。并且每次用户想要查看他们的位置时读取文件可能不是那么密集。它可以通过云功能来完成。

2022 更新和工作答案

为了解决有用户和积分排行榜的问题,并以一种不太成问题的方式了解你在这个排行榜中的位置,我有这个解决方案:

1) 您应该像这样创建您的 Firestorage 文档

在我的例子中,我有一个文档 perMission,每个用户都有一个字段,用户 ID 为 属性 以及各自的排行榜积分作为 .

更新我的 Javascript 代码中的值会更容易。 例如,每当用户完成任务(更新它的分数)时:

import { doc, setDoc, increment } from "firebase/firestore"; 

const docRef = doc(db, 'leaderboards', 'perMission');
setDoc(docRef, { [userId]: increment(1) }, { merge: true });

increment值可以随意。在我的例子中,我 运行 每次用户完成任务时都会使用此代码,增加价值。

2) 获得排行榜内的位置

所以在您的客户端,为了获得您的位置,您必须对值进行排序,然后循环遍历它们以获得您在排行榜中的位置。

这里也可以使用对象获取所有用户及其各自的积分,进行排序。不过这里我不做这个,我只对我的位置感兴趣。

代码注释解释每个块。


// Values coming from the database.
const leaderboards = {
  userId1: 1,
  userId2: 20,
  userId3: 30,
  userId4: 12,
  userId5: 40,
  userId6: 2
};

// Values coming from your user.
const myUser = "userId4";
const myPoints = leaderboards[myUser];

// Sort the values in decrescent mode.
const sortedLeaderboardsPoints = Object.values(leaderboards).sort(
  (a, b) => b - a
);

// To get your specific position
const myPosition = sortedLeaderboardsPoints.reduce(
  (previous, current, index) => {
    if (myPoints === current) {
      return index + 1 + previous;
    }

    return previous;
  },
  0
);

// Will print: [40, 30, 20, 12, 2, 1]
console.log(sortedLeaderboardsPoints);

// Will print: 4
console.log(myPosition);

您现在可以使用您的位置,即使数组非常大,逻辑在客户端也是 运行ning。所以要小心。您还可以改进客户端代码,减少数组,限制它等

但请注意,您应该在客户端而不是 Firebase 端执行其余代码。

这个回答主要是为了告诉大家如何“好”地存储和使用数据库。