翻译- Discord CTO 分享 Discord 的数据库发展过程

2,403 阅读13分钟

原文来自 Discord官网 ,作者 CTO Stanislav Vishnevskiy Discord ,侵权删。

Discord 持续以超出我们预期的速度增长,用户生成内容也是如此。用户越多,聊天信息就越多,每天的消息已经远远超过了上亿条。我们很早就决定永久保存所有的聊天记录,这样用户就可以随时回来,并在任何设备上获取他们的数据。这些海量数据在速度、规模上不断增长,并且必须保持可获取的状态。我们怎么做呢?

Discord一直在做什么


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 的大型公共服务器会发送大量消息。他们有成千上万每天发送数以千计信息的成员,一年下来很容易就积累了数百万条信息。他们几乎总是在请求上一小时发送的信息,而且请求频率很高。因此,数据通常在磁盘缓存中。

接下来定义一下我们的需求:

  • 线性可扩展性:我们不希望没多久就重新考虑解决方案或手动重新分拣数据。
  • 自动故障转移:我们希望建立 Discord 的自修复能力。
  • 低维护成本:它应该在我们设置好后就开始工作。我们只需要随着数据的增长添加更多节点。
  • 经过验证的技术:我们喜欢尝试新技术,但不要太新。
  • 可预测的性能:当 API 响应时间的第 95 个百分位数超过 80ms 时,会发出警报。我们也不想在 Redis 或 Memcached 中缓存消息。
  • 非 blob 存储:如果我们必须不断反序列化 blob 并向其添加内容,那么每秒写入数千条消息的效果就不会很好。
  • 开源:我们想自己掌握命运,不想依赖于第三方公司。

Cassandra 是唯一满足我们所有需求的数据库。我们只需添加节点来扩展它,它可以容忍节点故障,而不会对应用程序造成任何影响。Netflix 和苹果等大公司拥有数千个 Cassandra 节点。相关的数据被连续地存储在磁盘上,这样减少了数据访问寻址成本,且数据易于在集群周围分布。它由 DataStax 支持,但仍然是开源和社区驱动的。

在做出选择之后,我们需要证明它是可行的。

数据建模


向新手描述 Cassandra 最好的方式就是将它描述为 KKV 存储,两个 K 构成了主键。第一个 K 是分区键,用于确定数据所在的节点以及在磁盘上的位置。分区中包含多行数据,行由第二个 K,也就是聚类键标识。聚类键既充当分区中的主键,又决定了数据行排序的方式。你可以将分区看作一个有序的字典。这些属性结合起来可以支持非常强大的数据建模。

前面提到消息索引在 MongoDB 使用 channel_id 和 created_at。因为所有查询都在一个频道上进行,Channel_id 成为了分区键,但是 created_at 并不是一个很好的聚类键,因为两条消息可能具有相同的创建时间。幸运的是,Discord 上的每个 ID 实际上都是 Snowflake(可按时间排序),所以我们能够用它们来代替。主键变成了 (channel_id, message_id),其中 message_id 是一个 Snowflake。这意味着,当加载一个频道时,我们可以确切地告诉 Cassandra 扫描的范围。

下面是我们的消息表的简化模式:

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);

Discord 的数据库发展过程

虽然 Cassandra 的模式与关系型数据库并不一样,但修改它们的成本很低,而且不会对性能造成任何临时性影响。我们充分利用了 blob 存储和关系型存储的优点。

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

我们决定按时间对信息进行分类。我们查看了 Discord 上最大的频道,并确定我们是否可以将 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 的储存空间范围。然后,我们依次查询分区,直到收集到足够的消息。这种方法的缺点是,对于活动很少的 discord,我们将不得不查询多个储存空间来收集足够的消息。在实践中,这被证明是可行的,因为对于活跃的 discord 来说,通常在第一个分区中就可以发现足够的消息。

将消息导入 Cassandra 没有任何问题,我们已经准备好在生产环境下进行尝试。

摸黑启动


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

在启动之后,我们开始在 bug 跟踪器中收到报错,告诉我们 author_id 为空。它怎么可能是空的? 这是一个必填字段!

最终一致性


Cassandra 是一个 AP 数据库,这意味着它以强大的一致性换取了可用性,这也正是我们想要的东西。在 Cassandra 中,这是一种 “先读后写” 的反模式 (读取成本更高),因此即使你只访问某些列,其本质上也会变成更新插入。你还可以写入任何节点,它将使用“last write wins” 自动解决冲突。那么,这对我们有什么影响呢?

在一个用户编辑消息的同时,另一个用户删除相同消息的场景中,由于 Cassandra 的写入都是更新插入,我们最终得到了一个缺少除主键和文本之外的所有数据的行。处理这一问题有两种可能的解决办法:

  1. 在编辑消息时将整个消息写回。这样就有可能恢复已删除的消息,并增加并发写入其他列的冲突机会。
  2. 确定消息已损坏并将其从数据库中删除。

我们使用了第二个选项,按要求选择了一列 (在本例中是 author_id) 并在消息为空时删除了该消息。

在解决这个问题时,我们发现我们的写入效率很低。由于 Cassandra 被设计为最终一致性,它不能立即删除数据。它必须将删除复制到其他节点,即使其他节点暂时不可用,它也要这样做。Cassandra 通过把删除当作一种被称为 “tombstone” 的写入形式来做到这一点。在读取时,它只是跳过它遇到的 tombstone。tombstone 的维持时间是可设置的(默认为 10 天),在逾期后,它会在压缩过程中被永久删除。

删除列和向列写入 null 是完全相同的事情。它们都产生了一个 tombstone。因为在 Cassandra 中所有的写入都是更新插入,这意味着即使在第一次写入 null 时也会生成一个 tombstone。实际上,我们的整个消息模式包含 16 列,但一般的消息只设置了 4 个值。这导致大多数时候,我们都在没原由地向 Cassandra 写入 12 个 tombstone。解决这个问题的方法很简单: 只向 Cassandra 写入非空值。

性能


众所周知,Cassandra 的写入速度比读取速度快。其写入速度低于一毫秒,读取速度低于 5 毫秒。无论访问什么数据,我们都能观察到这一点,且在一周的测试中,其性能保持一致。我们得到的正是我们所期望的。

为了保持快速、一致的读取性能,下面是一个在有数百万条消息的频道中跳转到一年多以前的消息的示例:

出乎意料的事情


一切都很顺利,所以我们将其作为我们的主数据库推出,并在一周内淘汰了 MongoDB。它完美地工作了大约 6 个月,直到有一天 Cassandra 变得毫无反应。

我们注意到 Cassandra 持续了 10 秒的 “stop-the-world”GC,但不知道为什么。我们开始分析,并发现了一个需要 20 秒加载的 Discord 频道:Puzzles & Dragons Subreddit 的公共 Discord 服务器便是罪魁祸首。由于它是公开的,我们就加入进去看了看。令我们惊讶的是,这个频道只有一条信息。很明显,就在那一刻,他们使用我们的 API 删除了数百万条消息,只留下一条消息在频道中。

你可能还记得 Cassandra 是如何使用 tombstone 处理删除的。当用户加载这个频道时,即使只有 1 条消息,Cassandra 也必须有效地扫描数以百万计的消息 tombstone(生成垃圾的速度比 JVM 收集的速度更快)。

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

  • 我们将 tombstone 的维持周期从 10 天减少到 2 天,因为我们每天晚上都在我们的消息集群上运行 Cassandra repair(一种反熵过程)。
  • 我们更改了查询代码,以跟踪空的储存空间,并避免它们会在将来其他频道出现。这意味着如果用户再次触发这个查询,那么最坏情况下 Cassandra 只会扫描最近的储存空间。

未来**


我们目前正在运行一个复制系数是 3 的 12 节点的集群,并将根据需要继续添加新的 Cassandra 节点。我们相信这将持续很长一段时间,但随着 Discord 的不断发展,在遥远的未来,我们将每天存储数十亿条信息。Netflix 和苹果公司运行着数百个节点的集群,所以我们知道我们短时间不需要对此考虑太多。然而,我们希望我们对未来能有一些想法。

近期


  • 将我们的消息集群从 Cassandra 2 升级到 Cassandra 3。Cassandra 3 有一种新的存储格式,可以减少 50% 以上的存储大小。
  • 更新版本的 Cassandra 更擅长在单个节点上处理更多的数据。目前,我们在每个节点上存储了将近 1TB 的压缩数据。我们相信我们可以安全地减少集群中的节点数量,将其提升至 2TB。

长期


  • 探索使用一个用 c++ 编写的与 Cassandra 兼容的数据库 Scylla。在正常运行期间,我们的 Cassandra 节点实际上不会占用太多的 CPU,但是在非高峰时间,当我们运行修复 (一个反熵过程) 时,它们会变得相当受 CPU 限制,并且持续时间会随着自上次修复以来写入的数据量而增加。而 Scylla 宣称将使修复时间大大缩短。
  • 构建一个系统,将未使用的频道备份到谷歌 Cloud Storage,并按需加载它们。我们希望尽量避免这样做,而且不认为我们非得这么做。

结论


距离我们转换已经很久了,尽管发生了 “巨大的意外”,但一切都很顺利。我们的消息从每天超过 1 亿条增长到超过 1.2 亿条,但性能和稳定性一直保持良好。

由于这个项目的成功,我们已经把剩余的现场生产数据迁移到了 Cassandra 上,这也是一个成功。