[英]Leaderboard ranking with Firebase
我有一个项目,我需要显示前 20 名的排行榜,如果用户不在排行榜中,他们将以当前排名出现在第 21 位。
有有效的方法吗?
我正在使用 Cloud Firestore 作为数据库。 我认为选择它而不是 MongoDB 是错误的,但我正处于项目的中间,所以我必须使用 Cloud Firestore 来完成。
该应用程序将由 30K 用户使用。 有没有什么办法可以在不吸引所有 30k 用户的情况下做到这一点?
this.authProvider.afs.collection('profiles', ref => ref.where('status', '==', 1)
.where('point', '>', 0)
.orderBy('point', 'desc').limit(20))
这是我为获得前 20 名所做的代码,但是如果他们不在前 20 名中,获得当前登录用户排名的最佳实践是什么?
以可扩展的方式在排行榜中查找任意玩家的排名是数据库常见的难题。
有几个因素将推动您需要选择的解决方案,例如:
典型的简单方法是计算所有得分较高的玩家,例如SELECT count(id) FROM players WHERE score > {playerScore}
。
这种方法适用于小规模,但随着您的玩家群的增长,它很快变得既缓慢又耗费资源(在 MongoDB 和 Cloud Firestore 中)。
Cloud Firestore 本身不支持count
因为它是不可扩展的操作。 您需要在客户端通过简单地计算返回的文档来实现它。 或者,您可以使用 Cloud Functions for Firebase 在服务器端进行聚合,以避免返回文档的额外带宽。
与其给他们实时排名,不如将其更改为仅每隔一段时间(例如每小时)更新一次。 例如,如果您查看 Stack Overflow 的排名,它们只会每天更新。
对于这种方法,您可以安排一个函数,或者如果运行时间超过 540 秒,则安排 App Engine 。 该函数将在ladder
集合中写出玩家列表,并在新的rank
字段中填充玩家等级。 当玩家现在查看天梯时,您可以在 O(X) 时间内轻松获得顶部 X + 玩家自己的排名。
更好的是,您可以进一步优化并将顶部 X 显式写出作为单个文档,因此要检索梯子,您只需要阅读 2 个文档,top-X 和播放器,既节省资金又加快速度。
这种方法确实适用于任何数量的播放器和任何写入速率,因为它是在带外完成的。 随着您的成长,您可能需要根据支付意愿调整频率。 除非您进行了优化(例如,忽略所有 0 分玩家,因为您知道他们排在最后),否则每小时 30K 玩家将是每小时 0.072 美元(每天 1.73 美元)。
在这种方法中,我们将创建某种倒排索引。 如果存在明显小于想要的玩家数量的有界分数范围(例如,0-999 分数 vs 30K 玩家),则此方法有效。 它也适用于无限的分数范围,其中唯一分数的数量仍然明显小于玩家数量。
使用一个名为“scores”的单独集合,您有一个文档,用于每个单独的分数(如果没有人拥有该分数,则不存在),其中包含一个名为player_count
的字段。
当玩家获得新的总分时,您将在scores
集合中写入 1-2 次。 一次写入是对player_count
的新分数 +1,如果这不是他们的第一次,则对他们的旧分数 -1。 此方法适用于“您的最新分数是您当前的分数”和“您的最高分数是您当前的分数”样式的阶梯。
找出玩家的确切排名就像SELECT sum(player_count)+1 FROM scores WHERE score > {playerScore}
。
由于 Cloud Firestore 不支持sum()
,您可以执行上述操作,但在客户端进行 sum 。 +1 是因为总和是在你之上的玩家数量,所以加 1 给你那个玩家的排名。
使用这种方法,您需要阅读最多 999 个文档,平均 500ish 才能获得玩家排名,但实际上,如果您删除零个玩家的分数,这会更少。
理解新分数的写入率很重要,因为您平均只能每 2 秒* 更新一次单个分数,对于 0-999 的完美分布分数范围,这意味着 500 个新分数/秒**。 您可以通过为每个分数使用分布式计数器来增加这一点。
* 每 2 秒只有 1 个新分数,因为每个分数生成 2 次写入
** 假设平均游戏时间为 2 分钟,500 个新分数/秒可支持 60000 名并发玩家,无需分布式计数器。 如果您使用“最高分数是您当前的分数”,这在实践中会高得多。
这是迄今为止最难的方法,但可以让您为所有玩家提供更快和实时的排名位置。 可以将其视为上述倒排索引方法的读取优化版本,而上面的倒排索引方法是该方法的写入优化版本。
您可以按照此相关文章了解适用的一般方法,了解“数据存储中的快速可靠排名” 。 对于这种方法,您需要有一个有界分数(无界是可能的,但需要从下面进行更改)。
我不会推荐这种方法,因为您需要为具有半频繁更新的任何梯子的顶级节点执行分布式计数器,这可能会抵消读取时间的好处。
根据您为玩家显示排行榜的频率,您可以结合多种方法对其进行更多优化。
在更短的时间范围内将“倒排索引”与“定期更新”相结合,可以为所有玩家提供 O(1) 的排名访问权限。
只要在“定期更新”期间所有玩家的排行榜被查看 > 4 次,您就可以节省资金并拥有更快的排行榜。
基本上每个时期,比如说 5 到 15 分钟,你按降序阅读scores
中的所有文件。 使用这个,保持players_count
的运行总数。 将每个分数重新写入一个名为scores_ranking
的新集合中, scores_ranking
使用一个新字段players_above
。 这个新字段包含不包括当前分数player_count
的运行总数。
要获得玩家的排名,您现在需要做的就是从score_ranking
-> 他们的排名为players_above
+ 1 读取玩家分数的文档。
我将在我的在线游戏中实施并且可能在您的用例中使用的一种此处未提及的解决方案是估计用户的排名,如果他们不在任何可见的排行榜中,因为坦率地说,用户不会知道(或关心?)他们是否排名第 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 构建,因此您可以获得关系数据库。
Dan 没有提到的一个解决方案是将安全规则与 Google Cloud Functions 结合使用。
创建高分地图。 示例:
然后:
这在我看来是最简单的选择。 它也是实时的。
你可以用云存储做点什么。 因此,手动拥有一个包含所有用户分数(按顺序)的文件,然后您只需读取该文件并在该文件中找到分数的 position。
然后写入文件,您可以设置一个 CRON 作业以定期添加所有带有标志 isWrittenToFile false 的文档,将它们全部添加到文件(并将它们标记为 true)。 这样你就不会吃掉你的写作。 每次用户想要查看他们的 position 时读取一个文件可能不是那么密集。 它可以从云 function 完成。
为了解决拥有用户和积分排行榜的问题,并以一种不太成问题的方式了解您在排行榜中的 position,我有以下解决方案:
在我的例子中,我有一个文档perMission
,每个用户都有一个字段,其中 userId 作为属性,相应的排行榜点作为value 。
更新我的 Javascript 代码中的值会更容易。 例如,每当用户完成任务(更新它的分数)时:
import { doc, setDoc, increment } from "firebase/firestore";
const docRef = doc(db, 'leaderboards', 'perMission');
setDoc(docRef, { [userId]: increment(1) }, { merge: true });
increment
值可以是你想要的。 在我的例子中,每次用户完成任务时我都会运行这段代码,从而增加价值。
因此,在您的客户端,要获得您的 position,您必须对这些值进行排序,然后循环遍历它们以使您的 position 进入排行榜。
在这里您还可以使用object获取所有用户及其各自的积分,订购。 但是这里我不是这样做的,我只对我的 position 感兴趣。
代码注释解释了每个块。
// 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);
您现在可以使用您的 position,即使数组非常大,逻辑也在客户端运行。 所以要小心。 您还可以改进客户端代码,减少数组,限制数组等。
但请注意,您应该在客户端执行 rest 代码,而不是 Firebase 端。
本回答主要是告诉大家如何“好办法”存储和使用数据库。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.