Discord怎样存储数十亿条消息?

Discord简单介绍就是国外的YY,游戏玩家间可以用其聊天、文字、语音都可以,并支持游戏时实时语音。 目前Discord支持所有聊天记录的永久存储,目前Discord每天的消息数已经达到了100 亿。Discord是怎么做到的呢?

Discord的CTO分享了他们的经验,他提到了他们想要达到一个什么样的效果,又是如何围绕要达到的目标来进行技术选型和解决遇到的问题的。

Discord的增长速度及用户生成的内容超出了我们的预期。有了更多的用户,更多的聊天信息就会出现。7月,我们计划每天有4000万条消息,12月我们每天达到1亿条消息,截至2017年,我们每天数据量已经超过了1.2亿条。我们很早就决定永远保存所有聊天记录,这样用户就可以随时回来,在任何设备上都可以使用他们的数据。这大量的数据,其速度、大小都在不断增加,我们必须保证他们的可用性。我们该怎么做呢?答案就是Cassandra!

我们在做什么

Discord的原始版本是在2015年初的不到两个月内构建的。可以说,最快的迭代数据库之一是MongoDB。 Discord上的所有内容都存储在一个MongoDB副本集中,这是有意的,但我们还计划了一切,以便轻松迁移到新数据库(我们知道我们不会使用MongoDB分片,因为它使用起来很复杂而且不知道其稳定性)。这实际上是我们公司文化的一部分:快速实现产品功能去对产品进行试错,但始终通向更强大的解决方案。

这些消息存储在MongoDB集合中,在channel_id和created_at字段上具有单个复合索引。 2015年11月左右,我们存储了1亿条消息,此时我们开始看到预期出现的问题:数据和索引不再适合RAM,延迟开始变得不可预测。是时候迁移到更适合该任务的数据库了。

选择正确的合适数据库

在选择新数据库之前,我们必须了解读/写模式以及我们当前的解决方案会导致出现问题的原因。

接着下来是我们系统的要求:

Cassandra是唯一满足我们所有要求的数据库。我们可以添加节点来扩展节点,它可以容忍节点丢失而不会对应用程序产生任何影响。 Netflix和Apple等大公司拥有数千个Cassandra节点。相关数据连续存储在磁盘上,提供最小的搜索并在群集周围轻松分发。它得到了DataStax的支持,但仍然是开源和社区驱动的。

做出选择后,我们需要证明我们选择的确是对的。

数据模型

将Cassandra为新手描述的最佳方式是它是KKV存储。两个K包括主键。第一个K是分区键,用于确定数据所在的节点以及磁盘上的位置。分区中包含多个行,分区中的行由第二个K标识,第二个K是集群key。群集key既充当分区中的主键,又充当行的排序方式。您可以将分区视为有序字典。结合这些属性可实现非常强大的数据建模。

你还记得我们之前使用channel_id和created_at在MongoDB中索引消息? channel_id成为分区键,因为所有查询都在一个通道上运行,但created_at没有成为一个很好的集群键,因为两个消息可以具有相同的创建时间。幸运的是,Discord上的每个ID实际上都是Snowflake算法生成的(按时间顺序排序),所以我们可以使用它们。主键变为(channel_id,message_id),其中message_id是Snowflake。这意味着在加载channel 时,我们可以准确地告诉Cassandra扫描消息的范围。

这是我们的消息表的简化模式(这省略了大约10列)

CREATE TABLE messages (
  channel_id bigint,
  message_id bigint,
  author_id bigint,
  content text,
  PRIMARY KEY (channel_id, message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

Cassandra的schemas 与关系数据库不同,它们的改变成本很低廉,并且不会对临时性能产生任何影响。我们得到了最好的blob存储和关系存储。

当我们开始将现有消息导入Cassandra时,我们立即在日志中看到警告,告诉我们分区的大小超过100MB。发生什么事了?! Cassandra宣称它可以支持2GB分区!显然,仅仅因为它可以完成,它并不意味着它应该。在压缩,集群扩展等过程中,大型分区会对Cassandra造成很大的GC压力。拥有大分区也意味着其中的数据无法在群集中分布。很明显,我们必须以某种方式限制分区的大小,因为单个Discord通道可以存在多年并且不断增大。

我们决定按时间存储我们的消息。我们查看了Discord上最大的channel(频道),并确定我们是否在一个存储桶中存储了大约10天的消息,我们可以轻松地保持在100MB以下。存储桶必须可以从message_id或时间戳中导出。

DISCORD_EPOCH = 1420070400000
BUCKET_SIZE = 1000 * 60 * 60 * 24 * 10

def make_bucket(snowflake):
   if snowflake is None:
       timestamp = int(time.time() * 1000) - DISCORD_EPOCH
   else:
       # When a Snowflake is created it contains the number of
       # seconds since the DISCORD_EPOCH.
       timestamp = snowflake_id >> 22
   return int(timestamp / BUCKET_SIZE)
  
def make_buckets(start_id, end_id=None):
   return range(make_bucket(start_id), make_bucket(end_id) + 1)

Cassandra分区键可以组合,因此我们的新主键变为((channel_id,bucket),message_id)。

CREATE TABLE messages (
   channel_id bigint,
   bucket int,
   message_id bigint,
   author_id bigint,
   content text,
   PRIMARY KEY ((channel_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

为了查询频道中的最近消息,我们生成从当前时间到channel_id的桶范围(它也是Snowflake ID并且必须比第一条消息更旧)。然后我们依次查询分区,直到收集到足够的消息。这种方法的缺点是很少有活动的Discords必须查询多个桶以随时间收集足够的消息。在实践中,这被证明是好的,因为对于活动的Discords,通常在第一个分区中找到足够的消息,并且它们占大多数。

将消息导入Cassandra顺利进行,我们已准备好投入正式使用。

Dark Launch

Dark Launch是Fackbook使用的一种测试产品新功能的测试方法。

将新系统引入生产总是很可怕,所以尝试在不影响用户的情况下进行测试是个好主意。我们设置代码以对MongoDB和Cassandra进行双重读/写操作。 启动后我们立即开始在错误跟踪器中收到错误,告诉我们author_id为空。怎么会是null?这是一个必填字段!

最终一致性

Cassandra是一个AP数据库,这意味着它可以提供强大的可用性一致性,这是我们想要的。它是Cassandra中的read-before-write(读取更昂贵)的反模式,因此即使你只提供某些列,Cassandra所做的一切本质上都是一个upsert。您还可以写入任何节点,它将使用每列的“last write wins”语义来自动解决冲突。那我们会遇到坑么?

Example of edit/delete race condition

在用户同时编辑消息而另一个用户删除相同消息的情况下,我们最终得到的行缺少除主键和文本之外的所有数据,因为所有Cassandra写入都是upserts。处理此问题有两种可能的解决方案:

  1. 编辑消息时写回整个消息。这有可能恢复被删除的消息,并为并发写入其他列的冲突增加更多机会。
  2. 弄清楚该消息是否是脏数据并从数据库中删除它。

我们选择了第二个选项,通过选择所需的列(在本例中为author_id)并删除消息(如果该列为NULL)来实现。

在解决这个问题时,我们注意到我们的写入效率非常低。由于Cassandra最终是一致的,它不能立即删除数据。它必须将删除复制到其他节点,即使其他节点暂时不可用也要执行此操作。 Cassandra通过将删除视为一种称为“墓碑”的写入形式来实现这一点。在阅读时,它只是跳过它遇到的墓碑。'墓碑' 在配置的时间内(默认为10天)存在,并且在该时间到期时会在执行压缩操作后被永久删除。

删除列或将NULL写入列里他们效果完全相同,他们都会生成墓碑。由于Cassandra中的所有写入都是upserts,这意味着即使在第一次写入NULL时,您也会生成一个逻辑删除。实际上,我们的整个消息Schema包含16列,但平均消息只设置了4个值。这样我们大部分时间都无缘无故地向Cassandra写了12个墓碑。解决方案很简单:只向Cassandra写入非空值。

性能

众所周知,Cassandra的写入速度比读取速度快,我们确实观察到了这一点。写入是亚毫秒,读取时间不到5毫秒。我们观察到这一点,无论访问什么数据,并且在一周的测试期间性能保持一致。没有什么令人惊讶的,和我们预期效果差不多。

Read/Write Latency via Datadog

为了实现快速、一致的读取性能,以下是在包含数百万条消息的通道中跳转到一年多前的消息的示例:

Jumping back one full year of chat

一个大惊喜

一切顺利,所以我们将其作为主要数据库推出,并在一周内逐步淘汰MongoDB。它继续完美地工作......大约6个月,直到有一天Cassandra反应变得很迟钝。

我们注意到Cassandra经常运行10秒“stop-the-world” GC,但我们不知道为什么。我们开始研究并找到一个耗时20秒的Discord频道。由于它是公开的,我们加入它看看情况。令我们惊讶的是,该频道只有一条消息。就在那一刻,很明显他们使用我们的API删除了数百万条消息,在频道中只留下了一条消息。

如果您一直在关注,您可能还记得Cassandra如何使用墓碑处理删除(在最终一致性中提到)。当用户加载此频道时,即使只有一条消息,Cassandra也必须有效地扫描数百万条消息(注:Cassandra读取到消息到再和墓碑里的Delete消息进行合并操作,如果墓碑里的Delete操作有成千上万那么他合并操作就会有点费时)逻辑删除(生成垃圾的速度比JVM可以收集的速度快)。

我们通过以下方式解决了这个问题:

未来

我们目前正在运行一个副本因子为 3 的 12 节点集群,并将根据需要继续添加新的 Cassandra 节点。 我们相信这将持续很长一段时间,但随着 Discord 的不断增长,在遥远的未来我们每天会存储数十亿条消息。 Netflix 和 Apple 运行着由数百个节点组成的集群,因此我们知道我们可以在一段时间内对此进行过多思考。 然而,我们希望对未来有一些想法。

短期:

长期:

结论

自从我们进行转换以来已经过去了一年多,尽管“出人意料”,但它一帆风顺。我们从处理总量为1亿条消息到每天超过1.2亿条消息,性能和稳定性保持很好。由于该项目的成功,我们已将线上的数据移至Cassandra,这也取得了成功。后续我们将探讨如何使数十亿条消息可搜索。

来源:https://discord.com/blog/how-discord-stores-billions-of-messages

展开阅读全文

页面更新:2024-03-08

标签:消息   节点   墓碑   集群   磁盘   分区   操作   数据库   数据   用户

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号

Top