我们正在构建一个庞大的多玩家教育游戏,在排行榜中有数百万条目(基于所获得的聚合XP)。游戏结束后,我们需要显示排行榜以及该玩家/学生的排名。
但是这个排行榜有几个过滤器(全球/按国家/地区,按月/年/今天,按年龄等)可以混合在一起,例如: '让我获得排行榜for my Country
for the last month
'。组合数量约为20。
我的问题是如何存储定期更新的结构;必须在每场比赛后重新计算排名。目前,一个典型的完整排行榜有来自> 150个国家/地区的玩家约有5百万条参赛作品。
我以前有一个拥有3个节点的MySQL集群表(userid,xps,countryid),但XPs(在DBMS或应用程序中需要来自数据库的所有数据)的排序被证明太慢,因为数字得到了更大(> 20K的用户)。这是一个有趣的post,但每个查询再过半秒太多了。
然后我们使用REDIS(请参阅此post),但过滤是这里的问题。我们为TOP 5和其他人使用了单独的列表。 TOP 5立即更新,剩下的有20-30分钟的延迟。事实上,我们基于排行榜的缓存实例(使用真实的XP而不是缓存)对此用户进行排名,因此这是可以接受的。非Top5上的实时不是先决条件。 这适用于一个全球排名,但如何根据月份和/或国家和/或年龄过滤结果。我们是否需要为每个过滤组合保留一个列表?
我们还在Java中测试了自定义结构(使用它作为与REDIS功能类似的Java缓存服务器),仍在尝试使用它。哪种结构最佳组合才能实现我们的目标?我们最终每个过滤组合使用一个列表,例如Map<FilteringCombination, SortedList<User>>
然后二进制搜索到特定键列表。这样,一个完成的游戏需要一些插入说X,但它需要X * NumOfPlayers空间,这比保留单个列表多X倍(不确定这是否适合内存,但我们总是可以在这里创建一个集群将组合拆分到不同的服务器)。这里有一个关于如何在发生故障时重建缓存的问题,但这是我们可以处理的另一个问题。
扩展上述方法,如果我们在每个列表中定义评分桶(例如0-100xp的存储桶,101-1000xp的另一个存储桶,1001-10000xp的另一个存储桶等),我们可能会略微提高性能。铲斗拆分策略将基于玩家在游戏中的xp分布。确实,这种分布在现实世界中是动态的,但是我们已经看到,经过几个月的变化是微不足道的,记住XP总是在增加,但新用户也会来。
我们也通过利用聚类键和白行功能来测试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成本))?
答案 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之前保留所有写入。所以我们以后可以重播丢失的交易。