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

作者:Discord CTO & 联合创始人 Stanislav Vishnevskiy
2017.01.13

Discord 的增长速度继续快于我们的预期,我们的用户生成内容也是如此。用户越多,聊天消息就越多。7 月,我们宣布每天 4000 万条消息,12 月我们宣布 1 亿条消息,截至本博文,我们早已超过 1.2 亿条。我们很早就决定永久存储所有聊天记录,以便用户可以随时返回并在任何设备上提供他们的数据。这是大量数据,其速度和大小不断增加,并且必须保持高可用性。我们是怎么做的?Cassandra!
我们做了什么
Discord 的原始版本在 2015 年初不到两个月的时间里就做好了。毫无疑问,快速迭代的最佳数据库之一是MongoDB。Discord 上的所有内容都存储在单个 MongoDB 副本集 (replica set) 中,这是有意为之,但我们也计划了一切,以便轻松迁移到新数据库(我们知道我们不会使用 MongoDB 分片,因为它使用起来很复杂并且不以稳定性而闻名)。这实际上是我们公司文化的一部分:快速构建以证明产品功能,但始终提供通往更强大解决方案的途径。
这些消息存储在MongoDB集合 (Collection) 中,只有一个建立在channel_id和created_at上的复合索引。大约在 2015 年 11 月,我们累计存储了 1 亿条消息,此时我们开始看到预期的问题出现:数据和索引无法完全容纳在 RAM 中,延迟开始变得不可预测。是时候迁移到更适合该任务的数据库了。
选择正确的数据库
在选择新数据库之前,我们必须了解我们的读/写模式以及当前方案出现问题的原因。
我们很快就发现,我们的读取非常随机,读/写比率约为50/50。
语音聊天繁重的 Discord 服务器几乎不发送任何消息。这意味着他们每隔几天发送一两条消息。在一年内,这种服务器不太可能达到 1000 条消息。问题是,即使这是少量的消息,它也使得向用户提供这些数据变得更加困难。仅向用户返回 50 条消息就可能导致磁盘上的许多随机查找,从而导致磁盘缓存逐出。
文本聊天繁重的 Discord 服务器发送大量消息,每年轻松达到 10 万到 100 万条消息。他们请求的数据通常只是最近的。问题是,由于这些服务器通常具有少于 100 个成员,因此请求此数据的频率较低,并且不太可能在磁盘缓存中。
大型公共 Discord 服务器发送大量消息。他们有成千上万的成员每天发送数千条消息,每年轻松收集数百万条消息。他们几乎总是请求在最后一小时内发送的消息,并且经常请求它们。因此,数据通常位于磁盘缓存中。
我们知道,在未来一年,我们将为用户添加更多随机读取的方式:能够查看过去 30 天的提及,然后跳转到历史记录中的该点,查看和跳转到固定消息,以及全文搜索。所有这些都意味着更多的随机读取!!
接下来,我们定义了我们的需求:
线性可扩展性 — 我们不希望以后重新考虑解决方案或手动重新分片数据。
自动故障转移 — 我们喜欢在晚上睡觉,并构建 Discord 以尽可能多地自我修复。
低维护 — 一旦我们设置好它,它应该就可以工作了。随着数据的增长,我们只需要添加更多的节点。
被证明是有效的 — 我们喜欢尝试新技术,但不是太新。
可预测的性能 — 当我们有超过5%的 API 响应时间超过 80 ms时,会发出警报。我们也不希望在 Redis 或 Memcached 中缓存消息。
不要 blob 存储 — 如果我们必须不断反序列化 blob 同时追加消息,那么每秒写入数千条消息的效果并不好。
开源 — 我们希望控制自己的命运,不想依赖第三方公司。
Cassandra是唯一满足我们所有要求的数据库。我们只需添加节点来扩展它,它可以容忍节点丢失而不会对应用程序产生任何影响。像Netflix和Apple这样的大公司拥有数千个Cassandra节点。相关数据连续存储在磁盘上,提供最少的寻道和围绕集群的轻松分布。它由DataStax支持,但仍然是开源和社区驱动的。
做出选择后,我们需要证明它确实有效。
数据建模
向新人描述Cassandra的最好方法是它是一个KKV存储数据库。两个 K 构成主键。第一个 K 是分区键,用于确定数据位于哪个节点上以及数据在磁盘上的位置。分区中包含多行,分区中的一行由第二个 K(即集群键)标识。集群键既充当分区中的主键,也充当行的排序标识。您可以将一个分区视为有序字典。这些属性相结合,可以实现非常强大的数据建模。
还记得消息在MongoDB中使用channel_id和created_at建立索引吗?channel_id可以作为分区键,因为所有查询都在一个通道上运行,但created_at并不能作为一个很好的集群键,因为两条消息可以具有相同的创建时间。幸运的是,Discord 上的每个 ID 实际上都是一片雪花(按时间顺序排序),所以我们能够使用它们来代替。主键变为 (channel_id, message_id),其中message_id是雪花 (Snowflake) 。这意味着在加载Discord频道时,我们可以准确地告诉 Cassandra 在哪里扫描消息。
下面是消息表的简化架构(这省略了大约 10 列)。
虽然 Cassandra 的模式与关系数据库没有什么不同,但它们的更改成本很低,并且不会对性能造成任何临时影响。我们充分利用了 blob 存储和关系型存储。
当我们开始将现有消息导入 Cassandra 时,我们立即开始在日志中看到警告,告诉我们检测到分区大小超过 100MB。什么?!Cassandra宣传它可以支持2GB分区!显然,仅仅因为它可以做到,并不意味着它应该做到。大型分区在压缩、集群扩展等过程中给 Cassandra 带来了很大的垃圾回收压力。拥有较大的分区也意味着其中的数据不能分布在集群周围。很明显,我们必须以某种方式限制分区的大小,因为单个 Discord 频道可以存在多年并且大小不断增长。
我们决定按时间存储我们的消息。我们查看了 Discord 上最大的频道,并确定我们是否可以在一个存储桶中存储大约 10 天的消息并舒适地保持在 100MB 以下。存储桶必须可从message_id或时间戳派生。
Cassandra 分区键可以复合,所以我们的新主键变成了((channel_id,存储桶id),message_id)
为了查询频道中最近的消息,我们生成一个从当前时间到channel_id的存储桶范围(它也是雪花,必须比第一条消息早)。然后,我们按顺序查询分区,直到收集到足够的消息。这种方法的缺点是,很少活跃的 Discords 将不得不查询多个存储桶才能随着时间的推移收集足够的消息。在实践中,这被证明影响不大,因为对于活跃的 Discord频道,通常在第一个分区中就能找到足够的消息,并且这些频道占绝大多数。
将消息导入 Cassandra 很顺利,我们已经准备好尝试在生产环境中部署。
暗发布
将新系统引入生产环境总是很可怕,因此尝试在不影响用户的情况下对其进行测试是个好主意。我们将代码设置为对MongoDB和Cassandra进行双重读/写。
启动后,我们立即开始在错误跟踪器中收到错误,告诉我们author_id为null。怎么可能为null???这是必填字段!
最终一致性
Cassandra 是一个 AP 数据库,这意味着它以强一致性换取可用性,这正是我们想要的。 这是 Cassandra 中先读后写的反模式(读取成本更高),因此即使您只提供某些列,Cassandra 所做的一切本质上都是更新插入 (upsert)。 您还可以写入任何节点,它将在每列的基础上使用“最后写入获胜”语义自动解决冲突。 那么它是如何反咬我们的呢?

在一个用户编辑消息的同时另一个用户删除同一条消息的情况下,我们最终会得到一行缺少除主键和文本之外的所有数据的情况,因为所有 Cassandra 写入都是更新插入。 处理这个问题有两种可能的解决方案:
编辑消息时写回整个消息。 这有可能复活已删除的消息,并增加对其他列的并发写入发生冲突的机会。
确定消息已损坏并将其从数据库中删除。
我们选择了第二个选项,方法是选择必需的列(在本例中为 author_id)并在它为 null 时将整条消息删除。
在解决这个问题时,我们注意到我们的写入效率非常低。 由于 Cassandra 最终是一致的,它不能立即删除数据。 即使其他节点暂时不可用,它也必须将删除操作复制到其他节点并执行此操作。 Cassandra 通过将删除视为一种称为“墓碑”的写入形式来实现此目的。 在阅读时,它只是跳过它遇到的墓碑。 墓碑的生存时间可配置(默认为 10 天),并在该时间到期时在压缩过程中永久删除。
删除列和向列写入 null 是完全相同的事情。 他们都会生成一个墓碑。 由于 Cassandra 中的所有写入都是更新插入,这意味着即使第一次写入 null 也会生成墓碑。 实际上,我们的整个消息模式 (schema) 包含 16 列,但通常一个消息只有 4 个列有值。 大多数时候我们无缘无故地将 12 个墓碑写入 Cassandra。 解决方案很简单:只将非空值写入 Cassandra。
性能
众所周知,Cassandra 的写入速度比读取速度快,我们也确实观察到了这一点。 写入时间低于1毫秒,读取时间低于 5 毫秒。 无论访问什么数据,我们都观察到这一点,并且在一周的测试期间性能保持一致。 没有什么令人惊讶的,我们得到了我们所期望的效果。

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

大惊喜
一切都很顺利,因此我们将其作为我们的主要数据库上线,并在一周内逐步淘汰了 MongoDB。 它继续完美地工作......大约 6 个月,直到有一天 Cassandra 变得无响应。
我们注意到 Cassandra 不断运行 10 秒的“stop-the-world” GC,但我们不知道为什么。 我们开始探究,发现一个 Discord 频道需要 20 秒才能加载。 罪魁祸首是“智龙迷城Subreddit (Puzzles & Dragons Subreddit) ”公共 Discord 服务器。 既然它是公开的,我们就加入进去看看。 令我们惊讶的是,该频道中只有 1 条消息。 就在那一刻,很明显他们使用我们的 API 删除了数百万条消息,频道中只留下了 1 条消息。
如果您一直在关注,您可能还记得 Cassandra 如何使用逻辑删除来处理删除(在最终一致性中提到)。 当用户加载此通道时,即使只有 1 条消息,Cassandra 也必须有效扫描数百万条消息墓碑(生成垃圾的速度比 JVM 收集垃圾的速度快)。
我们通过执行以下操作解决了这个问题:
我们将墓碑的寿命从 10 天降低到 2 天,因为我们每晚都会在消息集群上运行 Cassandra 修复(一个反熵过程)。
我们更改了查询代码以跟踪空存储桶并在将来的频道中避免使用它们。 这意味着,如果用户再次引发此查询,那么在最坏的情况下,Cassandra 将仅扫描最近的存储桶。
未来
我们目前正在运行一个副本因子为 3 的 12 节点集群,并将根据需要继续添加新的 Cassandra 节点。 我们相信这将持续很长一段时间,但随着 Discord 的不断增长,在遥远的未来我们每天会存储数十亿条消息。 Netflix 和 Apple 运行着由数百个节点组成的集群,因此我们知道我们可以在一段时间内对此进行更多思考。 然而,我们也希望对未来有一些想法。
短期
将我们的消息集群从 Cassandra 2 升级到 Cassandra 3。Cassandra 3 具有新的存储格式,可以将存储大小减少 50% 以上。
新版本的 Cassandra 更擅长在单个节点上处理更多数据。 目前我们在每个节点上存储了近 1TB 的压缩数据。 我们相信,通过将其增加到 2TB,我们可以安全地减少集群中的节点数量。
长期
探索使用 Scylla,这是一个用 C++ 编写的兼容 Cassandra 的数据库。 在正常操作期间,我们的 Cassandra 节点实际上并没有使用太多的 CPU,但是在非高峰时段,当我们运行修复(反熵过程)时,它们会相当受 CPU 限制,并且持续时间随着自上次修复以来写入的数据量而增加。 Scylla 宣称修复时间显著缩短。
构建一个系统,将未使用的频道存档到 Google Cloud Storage 上的平面文件(flat file)中,并按需加载它们。 我们想避免这样做,也不认为我们必须这样做。
结语
自从我们做出转变以来已经过去了一年多的时间,尽管有“巨大的惊喜”,但一切进展顺利。 我们每天的消息总数从超过 1 亿条增加到超过 1.2 亿条,并且性能和稳定性保持一致。
由于该项目的成功,我们已将其余实时生产数据转移到 Cassandra,这也取得了成功。
在这篇文章的后续内容中(未来),我们将探讨如何使数十亿条消息变得可搜索。
我们还没有专门的 DevOps 工程师(只有 4 名后端工程师),因此拥有一个我们不必担心的系统真是太棒了。 我们正在招聘,所以如果您喜欢这种类型的东西,请加入我们。