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扫描的范围。
下面是我们的消息表的简化模式:
虽然Cassandra的模式与关系型数据库并不一样,但修改它们的成本很低,而且不会对性能造成任何临时性影响。我们充分利用了blob存储和关系型存储的优点。
当我们开始将现有消息导入Cassandra时,立即会在日志中看到警告,告诉我们分区的大小超过100MB。到底发生了什么事? !Cassandra宣称它可以支持2GB的分区!显然,仅仅因为它可以做到,并不意味着它应该做到。在压缩、集群扩展等过程中,大的分区会给Cassandra带来很大的GC压力。拥有一个大分区也意味着其中的数据不能分布在集群周围。很明显,我们必须以某种方式限制分区的大小,因为一个Discord频道可以存在多年,而且其大小会一直增长。
我们决定按时间对信息进行分类。我们查看了Discord上最大的频道,并确定我们是否可以将10天的消息存储在100MB以下的储存空间里。储存空间必须可以从message_id或时间戳导出。
Cassandra分区键可以复合,因此我们的新主键变为((channel_id, bucket),message_id)。
为了查询频道中最近的消息,我们生成了从当前时间到channel_id的储存空间范围。然后,我们依次查询分区,直到收集到足够的消息。这种方法的缺点是,对于活动很少的discord,我们将不得不查询多个储存空间来收集足够的消息。在实践中,这被证明是可行的,因为对于活跃的discord来说,通常在第一个分区中就可以发现足够的消息。
将消息导入Cassandra没有任何问题,我们已经准备好在生产环境下进行尝试。
摸黑启动
将一个新系统引入生产环境总是很可怕的,所以最好可以在不影响用户的情况下对其进行测试。我们把我们的代码设置成对MongoDB和Cassandra进行双重读/写。
在启动之后,我们开始在bug跟踪器中收到报错,告诉我们author_id为空。它怎么可能是空的?这是一个必填字段!
最终一致性
Cassandra是一个AP数据库,这意味着它以强大的一致性换取了可用性,这也正是我们想要的东西。在Cassandra中,这是一种“先读后写”的反模式(读取成本更高),因此即使你只访问某些列,其本质上也会变成更新插入。你还可以写入任何节点,它将使用“last write wins”自动解决冲突。那么,这对我们有什么影响呢?
在一个用户编辑消息的同时,另一个用户删除相同消息的场景中,由于Cassandra的写入都是更新插入,我们最终得到了一个缺少除主键和文本之外的所有数据的行。处理这一问题有两种可能的解决办法:
在编辑消息时将整个消息写回。这样就有可能恢复已删除的消息,并增加并发写入其他列的冲突机会。
确定消息已损坏并将其从数据库中删除。
我们使用了第二个选项,按要求选择了一列(在本例中是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上,这也是一个成功。
Prev Chapter:8个非常棒的NFT分析平台
Next Chapter:全球实体加码NFT数字藏品领域,生成数字艺术是元宇宙基建还是未来场景新财富航标?