Discord是如何存储亿万条消息的 — 深挖系统设计

Discord的持续增长速度超出了他们的预期。随着用户数量的增加,聊天消息也在增多。2017年1月,他们宣布每天有1.2亿条消息。他们早早决定永久存储所有的聊天历史,以便用户可以随时回来,并在任何设备上使用其数据。这是相当一大批数据,并且其速度和规模不断增长,保证高可用性势在必行。他们是如何做到的呢?Cassandra!

他们在做什么?

Discord上的数据都存储在一个MongoDB副本集中,这是有意为之的,但他们也有了提前计划,以便轻松迁移到新的数据库(他们知道他们不打算使用MongoDB分片,因为它使用复杂,而且不以稳定性而闻名)。

消息被存储在MongoDB的Collection中,并且带有一个关于channel_id和created_at的复合索引。到了2015年11月左右,他们已经存储了1亿条消息,这时他们开始看到出现了一些预期的问题。内存容量不再足以放进全部的数据和索引,延迟开始变得不可控。

选择合适的数据库

在选择新的数据库之前,Discord团队必须了解他们的读写模式以及当前解决方案中的问题。

读操作是极其随机的,而且他们的读/写比大约是50/50。

同时,他们知道在未来的一年里,他们将为用户提供更多发出随机读取请求的操作,例如查看并跳转到置顶消息以及全文搜索等。这些都意味着更多的随机读取操作!

接下来,他们明确了他们的需求:

因此最终,Cassandra是唯一满足以上要求的数据库。他们只需添加节点以进行扩展,它可以容忍节点的丢失而不会影响应用程序。相关联的数据会顺序存储在磁盘上以最小化I/O开销,同时提供更简单的数据分布模式。

构建数据模型

Cassandra是一个KKV存储系统。两个K组成了主键,第一个K是分区键,用于确定数据存储在哪个节点以及在磁盘上的位置。分区中包含多个行,而分区内的行由第二个K标识,即聚集键。聚集键既充当分区内的主键,又决定了行的排序方式。

MongoDB中的消息是使用channel_id和created_at进行索引的,因此channel_id变为分区键,因为所有查询都在一个Discord channel上操作(一个channel相当于群组,是用户主要交互的地方),但created_at并不适合作为聚集键,因为两条消息可能具有相同的创建时间。

幸运的是,Discord上的每个ID实际上都是一个按时间排序的雪花ID,因此可以使用它们作为聚集键。主键变成了(channel_id,message_id),其中message_id就是雪花ID。

这是他们消息表简化后的schema(省略了大约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拥有与关系型数据库类似的模式,但它们很容易进行修改,并且不会对性能造成临时影响。尽管Cassandra宣传可以支持2GB分区,但当他们开始将现有消息导入Cassandra时,日志中立即开始报警,告诉他们发现了超过100MB大小的分区。怎么回事?!

显然,可以做到并不代表应该这样做。Cassandra中的大分区在压缩、集群扩展等操作中会给GC带来很大的压力。很明显,他们必须以某种方式限制分区的大小,因为单个Discord channel可能存在多年并不断增长。

他们决定按时间对消息进行分桶。他们查看了Discord上最大的channel,并确定如果在一个分桶中存储约10天的消息,它们可以轻松地保持在100MB以下。

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中的最新消息,他们从当前时间到channel_id(它也是一个雪花ID,必须早于第一条消息)生成一个分桶范围。然后顺序查询分区,直到查询到足够的消息。这种方法的不足之处是很少活跃的Discord channel将不得不查询多个分桶以收集足够长时间范围内的消息。但实际上,这已经被证明是可以接受的。因为对于活跃的Discord,通常在第一个分区中可以找到足够多的消息,它们占大多数。

灰度上线

将新系统引入生产环境总是令人担忧的,因此一个好的方法是尝试在不影响用户的情况下进行测试。他们设置了他们的代码进行双读双写,将读/写操作同时发送到MongoDB和Cassandra。在启动之后,系统立即开始报错,告诉他们author_id为null。它怎么可能为null呢?这可是一个必填字段!

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

他们选择了第二个选项,他们通过选择一个必需的列(在这种情况下是author_id)来删除消息,如果它为null的话。在解决这个问题的过程中,他们注意到他们的写入效率非常低下。由于Cassandra是最终一致性的,它不能立即删除数据。它必须将删除操作复制到其他节点,即使其他节点暂时不可用也必须这样做。

Cassandra通过将删除视为一种称为“墓碑(tombstone)”的写入形式来执行此操作。在读取时,它只是在遇到墓碑的时候跳过该键。墓碑会在可配置的时间段内保留(默认为10天),并在过期时在compaction期间永久删除。

删除列和将列写入null是完全相同的。它们都生成墓碑。由于Cassandra中的所有写入操作都是upserts,这意味着即使首次将null写入,也会生成一个墓碑。在实际环境中,他们的整个消息schema包含16个列,但平均每条消息只设置了4个值。他们大多数时候都会平白无故将12个墓碑值写入到Cassandra。那么解决此问题的方法很简单:只将非null值写入Cassandra即可。

性能

Cassandra以其写入速度快于读取而闻名,他们也观察到了这一点。写入操作在亚毫秒级别,读取操作在5毫秒以下。无论访问什么数据,他们都观察到了这一点,并且性能在一周的测试期间保持一致。

额外惊喜

迁移过程进行得很顺利,所以他们将其作为主要数据库推出,并在一周内逐步淘汰了MongoDB。它继续无故障地工作了大约6个月,直到某一天Cassandra变得无响应。

他们注意到Cassandra不断运行约10秒的垃圾回收(GC),但他们不知道原因。他们开始深入研究,并发现一个Discord频道需要20秒才能加载。后来发现,原来是一个名为“Puzzles & Dragons Subreddit”的公共Discord服务器惹的祸。

因为它是公开的,他们加入了它以查看情况。令他们惊讶的是,这个频道里只有1条消息。正是在那一刻,他们明白该服务器通过Cassandra删除了数百万条消息,只留下了1条消息。

也许你还记得Cassandra如何使用墓碑处理删除操作(在上文中提到)。当用户加载这个频道时,即使只有1条消息,Cassandra也必须扫描数百万个消息墓碑(因为生成无用数据的速度比JVM能够收集的更快)。

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

他们将墓碑的生存期从10天降低到2天,因为每天晚上会在消息集群上运行Cassandra修复脚本。其次,他们更改了查询代码以跟踪空分桶,并在未来尽量避免扫描到它们。这意味着如果用户再次发起这个查询,那么在最坏的情况下,Cassandra将仅扫描最近的分桶。

结论

一年内,Discord的消息存量从超过1亿增长到每天产生超过1.2亿条,性能和稳定性仍旧保持一致。

展开阅读全文

页面更新:2024-03-01

标签:消息   节点   墓碑   分区   性能   操作   服务器   数据库   数据   用户   系统

1 2 3 4 5

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

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

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

Top