【问题标题】:Huge Leaderboard ranking with filtering带有过滤功能的巨大排行榜排名
【发布时间】:2015-02-26 16:25:30
【问题描述】:

我们正在打造一款大型多人教育游戏,排行榜中有数百万个条目(基于获得的 XP 总和)。游戏结束后,我们需要显示排行榜以及该玩家/学生的排名情况。 但是这个排行榜有几个过滤器(全球/按国家、按月/年/今天、按年龄等)可以混合在一起,例如'给我排行榜for my Countryfor the last month'。组合数约为 20。

我的问题是如何存储这样一个定期更新的结构;每场比赛后必须重新计算排名。目前,一个典型的完整排行榜有大约 500 万个来自超过 150 个国家/地区的玩家参赛。

  1. 我曾经有一个带有 3 个节点的 MySQL 集群表(userid、xps、countryid),但按 XP 排序(在 DBMS 或需要来自 DB 的所有数据的应用程序中)被证明太慢了更大(> 20K 的用户)。这是一个有趣的post,但对于每个查询来说,半秒的时间太长了。

  2. 然后我们使用了 REDIS(参见post),但过滤是这里的问题。我们对 TOP 5 和其他人使用了单独的列表。 TOP 5 立即更新,其余部分延迟 20-30 分钟。事实上,我们根据排行榜的缓存实例对这个用户进行排名(虽然使用的是真实的 XP,而不是缓存的),所以这是可以接受的。非 Top5 上的实时不是先决条件。 这对于一个全球排名来说很好,但是如何根据月份和/或国家和/或年龄过滤结果。我们是否需要为每个过滤组合保留一个列表?

  3. 我们还在 Java 中测试了自定义结构(将其用作 Java 缓存服务器,在功能上与 REDIS 类似),仍在试验中。哪个是实现我们目标的最佳结构组合?我们最终为每个过滤组合使用了一个列表,例如Map<FilteringCombination, SortedList<User>> 然后对特定键的列表进行二进制搜索。这样,一个完成的游戏需要几次插入,比如 X,但它需要 X*NumOfPlayers 空间,这是保留单个列表的 X 倍(不确定这是否适合内存,但我们总是可以在这里创建一个集群将组合拆分到不同的服务器)。这里有一个问题是如何在失败的情况下重建缓存,但这是我们可以处理的另一个问题。

  4. 扩展上述方法,如果我们在每个列表中定义评分桶(例如,一个用于 0-100xp 的桶,另一个用于 101 - 1000xp,另一个用于 1001 - 10000xp 等),我们可能会稍微提高性能。分桶策略将基于我们游戏中玩家的 xp 分布。确实,这种分布在现实世界中是动态的,但我们已经看到几个月后变化很小,记住 XP 总是在增加,但新用户也在不断涌现。

  5. 我们还在利用集群键和白行功能测试 Cassandra 的自然排序,尽管我们知道拥有数百万行可能并不容易处理。

总而言之,这就是我们需要实现的目标。如果某个用户(我们将其命名为 UserX)不在 Top5 列表中,我们需要显示该用户的排名以及周围的一些玩家(例如上面 2 和下面 2),如下例所示:

    Global TOP 5        My Global Ranking (425)   My Country Ranking     Other Rankings      
1. karen (12000xp)          423. george              1. david    
2. greg (11280xp)           424. nancy               2. donald 
3. philips (10293xp)      **425. UserX**             3. susan
4. jason (9800xp)           426. rebecca           **4. UserX** 
5. barbara (8000xp)         427. james               5. teresa

我研究了许多 SO 或其他帖子,但仍然找不到有效更新和过滤大型排行榜表格的解决方案。您会选择哪一种候选解决方案以及可能的性能改进(空间 + 内存 +(插入/搜索 CPU 成本))?

【问题讨论】:

  • 您能分享一下您使用的解决方案吗?

标签: java database caching redis cassandra


【解决方案1】:

这是一个非常有趣的问题 - 感谢您的发帖。一般来说,数据库擅长处理需要过滤和搜索大量数据的这类问题。我的第一个猜测是您没有正确使用 MySQL 索引。话虽如此,您显然需要定期在有序列表中找到第 n 行,这是 SQL 根本不擅长的。

如果您正在寻找某种形式的内存数据库,那么您将需要比 REDIS 更复杂的东西。我建议你看看 VoltDB,它非常快但并不便宜。

如果您想建立自己的内存存储,那么您需要计算内存使用情况,看看是否可行。对于要搜索或过滤的每一行以及每个用户的记录,您都需要一个索引(稍后在此答案中讨论)。然而,即使对于 1000 万行和 20 个字段,它仍然会小于 1Gb RAM,这在现代计算机上应该没问题。

现在是数据结构。我相信您在使用地图到列表时走在正确的轨道上。我认为不需要对列表进行排序-您只需要能够获得具有特定价值的用户集。事实上,集合可能更合适(再次值得测试性能)。这是我尝试的建议(我刚刚添加了国家和年龄字段 - 我假设您需要其他字段,但这是一个合理的示例):

enum Country {
    ...
}

class User {
    String givenName;
    String familyName;
    int xp;
    Country country;
    int age;
}

class LeaderBoard {
    Set<User> users;
    Map<Integer, Set<User>> xpIndex;
    Map<Country, Set<User>> countryIndex;
    Map<Integer, Set<User>> ageIndex;
}

当字段更改时,每个索引都需要更新。例如:

private setUserAge(User user, int age) {
    assert users.contains(user);
    assert ageIndex.get(user.getAge()).contains(user);
    ageIndex.get(user.getAge()).remove(user);
    if (!ageIndex.containsKey(age)) {
        ageIndex.put(age, new TreeSet<>());
    }
    ageIndex.get(age).add(user);
    user.setAge(age);
}

可以通过多种方式按等级获取满足给定组合的所有用户:

countryIndex.get(Country.Germany).stream()
    .filter(ageIndex.get(20)::contains)
    .sorted(User::compareRank)
    ...

或

SortedSet<User> germanUsers = new TreeSet<>(User::compareRank);
germanUsers.addAll(countryIndex.get(Country.Germany));
germanUsers.retainAll(ageIndex.get(20));

您需要检查哪些更有效 - 我猜流实现会是。它也可以很容易地转换为paralellStream。

您提到了对更新效率的担忧。如果这是一个问题,我会感到非常惊讶,除非一秒钟有很多更新。一般来说,使用这些类型的应用程序,您将获得比写入更多的读取。

我认为没有理由按照您的建议手动分区索引,除非您将拥有数亿个条目。更好的是尝试使用 HashMap 与 TreeMap 来具体实例化索引。

如果您需要更好的性能,下一个明显的增强是应用程序的多线程。这不应该太复杂,因为您要同步的数据结构相对简单。在搜索中使用并行流当然会有所帮助(您可以在 Java 8 中免费获得它们)。

所以我的建议是使用这些简单的数据结构,并在尝试更复杂的方法之前使用多线程和调整具体实现(例如哈希函数)来获取性能。

【讨论】:

  • 谢谢你的回答。我会尝试一下您的建议并上传任何有趣的结果。关于读取 VS 写入,请记住,它们很可能几乎是相等的。假设一个 20 人的游戏,当一个游戏结束时,所有用户都可以看到当前的排行榜。因此,每个用户 a) 更新她的 XP 但她也 b) 查看更新的排名。也有一些浏览也只是看排名,但浏览排名表并不像玩游戏那么普遍,因此最终阅读的频率略高于写作。
  • ...目前,每天大约有 100 万场比赛 ~= 每秒 11 场比赛。另外,由于每秒已经有很多来自不同线程(池中的线程)的排序请求,我不确定使用多线程排序是否会极大地提高性能,但我也会对其进行测试。
  • 我期待听到您的发现。有趣的是,读取和写入的频率可能相同:这肯定会影响要选择的结构。要回答您的问题,可能不需要 xpIndex - 它旨在简化排名,但在获得所需子集后按索引排序可能更容易。
  • 我为 VoltDB 工作。谢谢你提到我们。我们确实有许多客户在 VoltDB 中拥有大量的排行榜。 Xin Jia 的这篇博客展示了一个优化的关键特性,它可以快速查找大于或小于玩家分数的记录计数,前提是对分数列进行索引。 voltdb.com/blog/…
【解决方案2】:

虽然我还在基准测试中,但我正在更新当前开发的状态。 使用时可获得最佳性能:

Map&lt;Country, Map&lt;Age, Map &lt;TimingIdentifier, List&lt;User&gt;&gt;&gt;&gt; (列表已排序)

关于键的一些注释:我添加了一个名为 World 的 Country,以便拥有一个独立于国家/地区的完整排行榜的实例(就像未选择 Country 过滤器一样)。我对 Age (All-Ages) 和 TimeIdentifier (All-Time) 做了同样的事情。 TimeIdentifier 键值为 [All-Time, Month, Week, Day]

以上内容可以扩展到其他过滤器,因此也可以应用于其他场景。 Map&lt;Filter1,Map&lt;Filter2,Map&lt;Filter3,Map&lt;Filter4 ..other Map Keys here..,List&lt;User&gt;&gt;&gt;&gt;

更新: 与使用多个 Map 包装器不同,在具有上述字段的单个 Map 中用作键的类稍微快一些。当然,我们需要一个类似于 multiton 的模式来创建所有可用的 FilterCombination 对象:

class FilterCombination {
    private int CountryId;
    private int AgeId;
    private int TimeId;
    ...
}

然后我们定义Map&lt;FilterCombination, List&lt;User&gt;&gt;(排序列表)

我可以使用 TreeSet,但我没有。为什么? 基本上,我一直在寻找订单统计树(参见here),但似乎没有官方的Java 实现(参见here)。由于List.add(index, Object) 的效率低下,这可能是 VS 排序列表的方式,即 O(n)。对于.add(index, Object),LinkedList 会更好,但不幸的是,获取第 k 个元素的速度很慢(排名为 O(n))。因此,对于这样的任务,每个结构都有其优点和缺点。

目前,我最终使用了排序列表。原因是在向排序列表添加元素时,我使用了稍微修改的二进制搜索算法(参见here)。上面的方法给了我当前用户在插入阶段的排名(所以不需要额外的搜索查询),它是 O(logn + n) (二进制搜索索引 + List.add(index, Object))。

还有没有其他结构比 O(logn + n) for insert + get rank 的性能更好?

*当然,如果我以后需要询问用户的排名,我会再次根据用户的 XP(+ 时间戳,如下所示)而不是 Id 进行二分搜索,因为现在我无法通过用户搜索-Id 在列表中)。

**作为比较器,我使用以下标准

第一:经验值

在平局的情况下 - 第二个标准:上次 XP 更新的时间戳

因此,排序列表中的等式很可能很少。更重要的是,我不介意两个拥有相同 XP 的用户的排名倒序(即使我们有数百万游戏的样本数据,我也发现很少有平局,不包括我不关心的零 XP完全)。

XP 更新需要一些工作和资源。幸运的是,第二个比较标准显着改善了此列表中的用户搜索(再次进行二进制搜索),因为在更新用户的 XP 之前,我必须删除列表中该用户的先前条目......但我正在查看她以前的 XP和时间戳,所以它是 log(n)。

【讨论】:

    【解决方案3】:

    最简单的选择是选择 Redis 的排序集,并使用主从进行复制。在每个从站上打开 RDB 并将 RDB 文件备份到 S3。在写入到 Redis 之前,使用 Kafka 持久化所有写入。所以我们可以稍后重放丢失的交易。

    【讨论】:

      猜你喜欢
      • 2020-06-15
      • 1970-01-01
      • 2013-04-01
      • 2018-01-31
      • 2021-09-21
      • 2021-10-07
      • 2014-03-21
      • 1970-01-01
      • 2019-03-22
      相关资源
      最近更新 更多