本文属于老雅痞原创文章,转载规矩不变,给我们打声招呼~
转载请微信联系:huangdiezi,更多DAO、Web3、NFT、Metaverse资讯请关注老雅痞
今日老雅痞共推送三篇文章。
本文是对数据安全的分析,第5篇。
你对加密货币的原创文章,推荐阅读今天第一条,我们关于ADA的原创分析文章。
如果你对Web3音乐赛道的项目感兴趣,推荐阅读第二条,我们关于Reveel的原创分析文章。
信息来源自Discord,略有修改,作者Stanislav Vishnevskiy
编译:RR
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上,这也是一个成功。
文章转发自FastDaily微信公众号,版权归其所有。文章内容不代表本站立场和任何投资暗示。
Copyright © 2021.Company 元宇宙YITB.COM All rights reserved.元宇宙YITB.COM