巨大的排行榜排名与过滤

时间:2014-12-30 13:42:37

标签: java database caching redis cassandra

我们正在构建一个庞大的多玩家教育游戏,在排行榜中有数百万条目(基于所获得的聚合XP)。游戏结束后,我们需要显示排行榜以及该玩家/学生的排名。 但是这个排行榜有几个过滤器(全球/按国家/地区,按月/年/今天,按年龄等)可以混合在一起,例如: '让我获得排行榜for my Country for the last month'。组合数量约为20。

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

  1. 我以前有一个拥有3个节点的MySQL集群表(userid,xps,countryid),但XPs(在DBMS或应用程序中需要来自数据库的所有数据)的排序被证明太慢,因为数字得到了更大(> 20K的用户)。这是一个有趣的post,但每个查询再过半秒太多了。

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

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

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

  5. 我们也通过利用聚类键和白行功能来测试Cassandra的自然顺序,尽管我们知道有数百万行可能不容易处理。

  6. 总而言之,这就是我们需要实现的目标。如果用户(让她的名字命名为她的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成本))?

3 个答案:

答案 0 :(得分:0)

这是一个非常有趣的问题 - 感谢发布。通常,数据库在这类问题中表现优异,其中存在大量需要过滤和搜索的数据。我的第一个猜测是你没有正确使用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中免费获得它们)。

所以我的建议是使用这些简单的数据结构,并在尝试更复杂的事情之前使用多线程并调整具体实现(例如散列函数)来提高性能。

答案 1 :(得分:0)

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

Map<Country, Map<Age, Map <TimingIdentifier, List<User>>>> (列表已分类)

关于密钥的一些注释:我添加了一个名为World的国家,以便拥有一个完全领导者国家独立的实例(就像没有选择国家过滤器一样)。我为Age(All-Ages)和TimeIdentifier(All-Time)做了同样的事情。 TimeIdentifier键值为[All-Time,Month,Week,Day]

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

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

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

然后我们定义Map<FilterCombination, List<User>>(排序列表)

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

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

是否有其他结构表现得更好,O(logn + n)for insert + get rank?

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

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

1st:XP积分

如果是平局 - 第二个标准:上次XP更新的时间戳

因此,排序列表中的等值很可能非常少。更重要的是,如果两个拥有相同XP的用户按相反的顺序排列,我不会介意(即使我们的数百万游戏的样本数据,我发现很少有关系,不包括我不喜欢的零XP和# 39;一点都不关心。)

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

答案 2 :(得分:0)

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