Discord 是如何存储数十亿条消息

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

我们在做什么

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

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

选择正确的合适数据库

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

  • 很快就发现我们的读取非常随机,我们的读/写比率约为50/50。
  • Discord 的语音聊天服务几乎不发送任何消息。这意味着他们每隔几天发一两条信息。在一年之内,这种服务器消息量在1000条以内。问题是,尽管这是一小部分消息,但它使向用户提供这些数据变得更加困难。比如只向用户返回50条消息会导致在磁盘上进行许多随机查找,从而导致磁盘缓存收回。
  • Discord的私有文本聊天服务发送了相当多的消息,很容易达到每年10万到100万条消息。他们请求的数据通常只是最近产生的。问题是,由于这些服务器的成员通常不到100个,因此请求此数据的速度很低,不太可能位于磁盘缓存中。
  • 大型公共Discord服务器发送大量消息。他们有成千上万的会员每天发送数千封邮件,每年轻松收集数百万封邮件。他们几乎总是在获取过去一小时内发送的消息,并且他们经常获取这些消息。因此,这些数据通常位于磁盘缓存中。
  • 我们知道,在接下来的一年里,我们将为用户提供更多的随机阅读方式:查看过去30天内您提到的内容,然后跳到历史上的那个点,查看并跳到固定邮件,以及全文搜索。所有这些拼写都更随意!!
接着下来是我们系统的要求
  • 线性的可扩展性 - 我们不希望稍后重新考虑解决方案或手动重新分片数据。
  • 故障自动转移 - 我们喜欢在晚上睡觉,并尽可能地建立Discord自我修复功能。
  • 维护成本低 - 一旦我们设置它就应该工作。我们只需要在数据增长时添加更多节点。
  • 事实证明(Proven to work) - 我们喜欢尝试新技术,但不是太新。
    -可预测的性能 - 当我们的API响应时间第95百分位数超过80毫秒时,我们会发出警报。我们也不想在Redis或Memcached中缓存消息。
  • 存储方式不是blob(Not a blob store ) - 如果我们必须不断地反序列化blob并将其附加到Blob存储区中,那么每秒写入数千条消息将不会很好。
  • 开源 - 我们相信控制自己的命运,不想依赖第三方公司。

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并且必须比第一条消息更旧)。然后我们依次查询分区,直到收集到足够的消息。这种方法的缺点是很少有活动的Discords必须查询多个桶以随时间收集足够的消息。在实践中,这被证明是好的,因为对于活动的Discords,通常在第一个分区中找到足够的消息,并且它们占大多数。

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

Dark Launch(注: 是Fackbook使用的一种测试产品新功能的测试方法)

将新系统引入生产总是很可怕,所以尝试在不影响用户的情况下进行测试是个好主意。我们设置代码以对MongoDB和Cassandra进行双重 读/写操作。

启动后我们立即开始在错误跟踪器中收到错误,告诉我们author_id为空。怎么会是null?这是一个必填字段!

最终一致性

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


1 t7VkLRKZVeHb_c6Tl1heew.gif

在用户同时编辑消息而另一个用户删除相同消息的情况下,我们最终得到的行缺少除主键和文本之外的所有数据,因为所有Cassandra写入都是upserts。处理此问题有两种可能的解决方案:
1.编辑消息时写回整个消息。这有可能恢复被删除的消息,并为并发写入其他列的冲突增加更多机会。
2.弄清楚该消息是否是脏数据并从数据库中删除它。

我们选择了第二个选项,我们选择了一个必需的列(在本例中为author_id)并删除了该消息(如果它为null)。

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

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

性能

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


Read/Write Latency via Datadog.png

In line with fast,一致的读性能,这里是一个一年前在包含数百万条消息的频道中跳转到消息的示例:


bb.gif

一个大惊喜

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

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

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

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

  • 我们将墓碑的寿命从10天缩短到2天,因为我们每天晚上在我们的消息集群上进行Cassandra修复(repair操作,他会进行压缩操作)。
  • 我们更改了查询代码以跟踪空桶,并在将来避免使用它们作为通道。这意味着如果用户再次引发此查询,那么在最糟糕的情况下,Cassandra将仅在最近的桶中进行扫描。

结论

自从我们进行转换以来已经过去了一年多,尽管“出人意料”,但它一帆风顺。我们从处理总量为1亿条消息到每天超过1.2亿条消息,性能和稳定性保持很好。

由于该项目的成功,我们已将线上的数据移至Cassandra,这也取得了成功。
在本文的后续内容中,我们将探讨如何使数十亿条消息可搜索。

我们还没有专门的DevOps工程师(只有4个后端工程师),因此拥有一个我们不必担心的系统非常棒。我们正在招聘,所以如果这种类型的东西刺激你的想法加入我们。

原文:https://blog.discordapp.com/how-discord-stores-billions-of-messages-7fa6ec7ee4c7

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容

  • ORA-00001: 违反唯一约束条件 (.) 错误说明:当在唯一索引所对应的列上键入重复值时,会触发此异常。 O...
    我想起个好名字阅读 5,169评论 0 9
  • Apache Cassandra 是一个开源的、分布式、去中心化、弹性可扩展、高可用性、容错、一致性可调、面向行的...
    梁睿坤阅读 13,995评论 2 25
  • 一、MySQL优化 MySQL优化从哪些方面入手: (1)存储层(数据) 构建良好的数据结构。可以大大的提升我们S...
    宠辱不惊丶岁月静好阅读 2,415评论 1 8
  • 本书一开始用几个案例说明组织内部管理变得越来越复杂。原因是因为组织里的人际关系变得复杂。因为组织的成员变得更加的专...
    刘敏捷阅读 539评论 1 0
  • “房子是租来的,但生活不是”,这是我特别喜欢的一句话。谁说不是呢,谁规定咋们背井离乡在外工作就非得住在潮湿阴冷的地...
    没有故事的袁同学阅读 294评论 1 1