存储量扩大千倍,Discord是如何使用Rust语言和ScyllaDB数据库来改进架构的?
作者 BoIngram 译者 平川 策划 Tina
本文最初发布于Discord官方博客。
2017年,我们写了一篇关于我们如何存储数十亿条消息的博文,分享了我们开始时如何使用MongoDB,但又将数据迁移到Cassandra的过程,因为我们正在寻找一个扩展性和容错性比较高而维护成本相对较低的数据库。我们确信自己会发展,而且我们确实做到了!
我们想要一个能随着我们的发展而演进的数据库,但又不希望它的维护需求会随着我们的存储需求而增长。遗憾的是,我们发现事实并非如此——我们的Cassandra集群出现了严重的性能问题,光是维护就需要花费很多的精力,更不用说改进了。
近6年过去了,我们已经改变了很多,我们存储消息的方式也发生了变化。
Cassandra的麻烦
我们把信息存储在一个名为cassandra-messages的数据库中。顾名思义,它运行Cassandra来存储消息。2017年,我们运行了12个Cassandra节点,存储了数十亿条消息。
2022年初,节点数达到177个,而消息有数万亿条。令人懊恼的是,这是一个高强度的系统——我们的随叫随到团队经常接到反馈数据库问题的电话,延迟不可预测,因为成本过高,我们不得不减少维护操作。
是什么导致了这些问题?首先,让我们来看一条消息。
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);
上面的CQL语句是我们最小的消息模式版本。我们使用的每个ID都是用雪花算法生成的,按时间顺序排序。我们根据消息的发送通道以及桶(一个静态时间窗口)进行消息分区。这种分区意味着,在Cassandra中,特定通道和桶的所有消息将存储在一起,并在3个节点(取决于设置的复制因子)上复制。
这种分区有潜在的性能缺陷:只有一小群人使用的服务器发送的消息往往比有数十万人使用的服务器少几个数量级。
在Cassandra中,读的开销比写大。写操作会被追加到提交日志,并写入内存中一个名为memtable的结构,最后再刷写到磁盘。然而,读操作需要查询memtable,还可能要查询多个SSTable(磁盘上的文件),其成本更高。当用户与服务器交互时,大量的并发读取会使一个分区成为热点,我们可以称其为“热分区”。这些访问模式在遇到我们的数据集规模时,导致我们的集群陷入了困境。
当我们遇到热分区时,它经常会影响整个数据库集群的延迟。一个通道-桶对接收了大量的流量,节点为之提供服务会越来越吃力,延迟会越来越大,越落越远。
该节点上的其他查询也会受到影响,因为它的速度跟不上。由于我们的读写操作都是仲裁一致性级别的,所以在为热分区提供服务的节点上,所有查询的延迟都会增加,进而对最终用户产生更广泛的影响。
集群维护任务也经常引起麻烦。我们很容易在压缩上落后,为了获得更高的读性能,Cassandra会压缩磁盘上的SSTable。这样一来,不仅读取的开销增大,而且当节点试图压缩时,还会产生级联延迟。
我们经常执行一种我们称之为“八卦舞”的操作。我们让一个节点退出轮换,让它在停止接收流量的情况下进行压缩,然后让它重新加入轮换,从Cassandra获取暗示切换线索,然后再重复,直到待压缩项为空。我们还花了大量时间对JVM的垃圾收集器和堆设置进行调优,因为GC暂停会导致显著的延迟尖峰。
改进架构
消息集群并不是我们唯一的Cassandra数据库。我们还有其他几个集群,每个集群都展现出类似的缺陷(尽管可能没有那么严重)。
在上文提到的那篇文章中,ScyllaDB引起了我们的兴趣,那是一个用C++编写的数据库,兼容Cassandra。它承诺提供更好的性能、更快的修复、更强的工作负载隔离(通过其按核分片架构),而且无垃圾回收,听起来相当吸引人。
尽管ScyllaDB也不一定没问题,但它没有垃圾收集器,因为它是用C++而不是Java编写的。长期以来,我们的团队在Cassandra的垃圾收集器上遇到过许多问题,从GC暂停影响延迟,到连续超长时间的GC暂停,甚至运维人员必须手动重启问题节点才能将其恢复到健康状态。这些问题导致了大量的随叫随到工作,也是我们消息集群中许多稳定性问题的根源。
在对ScyllaDB进行试验并在测试中观察改进效果之后,我们决定迁移所有的数据库。虽然这个决定本身可以单独写成一篇博文,但简单来说,截止2020年,除一个数据库之外,我们已经将其他所有的数据库都迁移到了ScyllaDB上。
最后剩下的那个是我们的朋友,cassandra-messages。
为什么我们还没有迁移它呢?首先,这是一个很大的集群,有数万亿条消息和近200个节点,任何迁移工作都会很复杂。此外,我们对新数据库进行了性能调优,希望它们能够达到最佳状态。我们还希望能够积累更多在生产环境使用ScyllaDB的经验,了解它的陷阱。
我们还针对我们的用例改进了ScyllaDB的性能。我们在测试中发现,反向查询的性能不足以满足我们的需求。在以与表排序相反的顺序扫描数据库时,例如按升序扫描消息时,将执行反向查询。ScyllaDB团队优先改进并实现了高性能的反向查询,为我们的迁移计划消除了最后的数据库障碍。
我们并没有指望在系统上加一个新数据库就能让一切神奇地变好。热分区在ScyllaDB中仍然存在。因此,我们还希望投资改进数据库上游系统,为数据库增加一道屏障,进一步提升数据库的性能。
用数据服务提供数据
对于Cassandra,我们遇到了热分区的麻烦。到特定分区的高流量会导致无限并发,进而导致级联延迟,后续查询的延迟会继续增加。如果可以控制热分区的并发流量,我们就可以保护数据库不被压垮。
为了完成这项任务,我们编写了所谓的数据服务——介于API单体和数据库集群之间的中介服务。在编写数据服务时,我们选择了一种在Discord中应用越来越多的语言:Rust。我们在之前的几个项目中用过它,它没有辜负我们的期望。它为我们提供了媲美C/C++的速度,而且没有牺牲安全性。
无惧并发是Rust引以为豪的主要优势之一——该语言使编写安全并发代码变得更容易。它提供的库也非常符合我们的预期。Tokio生态系统是构建异步I/O系统的坚实基础,并且该语言提供了Cassandra和ScyllaDB的驱动程序。
此外,我们还发现,Rust编译器提供的帮助、清晰的错误消息、语言结构及其对安全性的重视,让编码变得很有乐趣。我们非常喜欢的一点是,Rust程序一旦通过编译,通常就可以运行。不过,最重要的是,我们可以说我们用Rust进行了重写(模因声誉非常重要)。
我们的数据服务介于API和ScyllaDB集群之间。大致上,它们为每个数据库查询提供一个gRPC端点,并且故意不包含业务逻辑。数据服务的一大特色是请求合并。如果多个用户同时请求同一行,我们将只查询数据库一次。第一个发出请求的用户会触发数据服务中的工作者任务。后续请求将检查该任务是否存在并订阅它。该工作者任务将查询数据库并把行返回给所有订阅者。
这就是Rust的强大之处:它使编写安全并发代码变得更简单。
让我们想象一下,在一个大型服务器上,有一条@所有人的重要公告:用户将打开应用程序并阅读消息,向数据库发送大量流量。以前,这可能会导致热分区,并且可能需要随叫随到工程师帮助恢复系统。通过数据服务,我们能够显著降低数据库的流量峰值。
魔法的第2部分在数据服务的上游。为了实现更有效的合并,我们实现了一致的基于哈希的数据服务路由。我们为每个数据服务请求提供一个路由键。对于消息,这是一个通道ID。这样一来,对同一通道的所有请求都会发送到服务的同一实例。这种路由方式帮助我们进一步减少了数据库的负载。
这些改进对我们帮助很大,但并不能解决所有问题。我们仍然会在Cassandra集群上看到热分区和延迟增加,只是不那么频繁了。那为我们赢得了一些时间,让我们可以准备最优的ScyllaDB集群并执行迁移。
一次规模非常大的迁移
我们的迁移需求非常简单:我们需要在不停机的情况下迁移数万亿条消息,而且需要快速完成,因为虽然Cassandra的情况有所改善,但我们还是经常处于灭火状态。
第一步很简单:使用超级磁盘存储拓扑准备一个新的ScyllaDB集群。借助本地SSD来提高速度,并利用RAID将数据镜像到持久盘。这样,我们既从附加的本地磁盘那里获得了速度,又从持久盘那里获得了持久性。集群启动后,我们就可以开始向其中迁移数据了。
我们第一版的迁移计划旨在快速获取价值。我们开始使用崭新的ScyllaDB集群来处理新数据,然后找一个切换时间迁移历史数据。这带来了更多的复杂性,但每个大型项目都会有额外的复杂性,不是吗?
对于新数据,我们开始执行双重写入,即同时写入Cassandra和ScyllaDB。与此同时,我们还开始准备ScyllaDB的Spark迁移器。那需要大量的调整,设置完成之后,我们估计了完成时间:3个月。
对于这个期限,我们并不满意。我们希望可以更快地获取价值。因此,我们团队组织了一场头脑风暴,看看如何加快速度,直到我们记起来,我们已经编写了一个快速的高性能数据库库,我们可以对它进行扩展。我们选择参与了一些模因驱动工程,并用Rust重写了数据迁移器。
有一天下午,为了执行大规模数据迁移,我们扩展了数据服务库。它从数据库中读取令牌范围,通过SQLite在本地进行检查,然后将它们送入ScyllaDB。我们连接到经过改进的新迁移器,并重新估计了工期:9天!如果能够这么快的迁移数据,我们就可以抛开我们基于时间的复杂方法,一次性地切换所有内容。
我们启动它并让它保持运行,以每秒320万条消息的速度迁移。几天后,我们看到迁移已达100%。不过我们意识到,它被卡在了99.9999%(是真的)。我们的迁移器在读取数据的最后几个令牌范围时超时了,因为它们包含了巨大的墓碑范围,而且从未压实。在我们把那个令牌范围压实几秒钟后,迁移就完成了!
通过向两个数据库发送一小部分读数请求并比较结果,我们完成了自动数据验证,一切看起来都很好。在全生产流量的情况下,集群依然运行良好,而Cassandra却遇到了越来越频繁的延迟问题。我们的团队聚在现场,按下开关,让ScyllaDB成为主数据库,并分享了庆祝蛋糕!
数月之后……
2022年5月,我们切换了消息数据库,但自那以后它的运行状况如何呢?
它是一个安静、乖巧的数据库(这么说没关系,因为这周我不用随叫随到)。我们周末不用长时间救火了,也不用为了保持正常运行时间而同时处理多个集群节点。这个数据库更高效——我们的Cassandra节点有177个,而ScyllaDB节点只有72个。每个ScyllaDB节点有9TB的磁盘空间,而每个Cassandra节点的平均磁盘空间为4TB。
我们的尾部延迟也得到了大幅改善。例如,从Cassandra获取历史消息的p99延迟在40-125毫秒之间,在ScyllaDB上只有15毫秒;向Cassandra插入消息的p99延迟在5-70毫秒之间,而ScyllaDB为稳定的5毫秒。得益于前面提到的性能改进,我们还解锁了新的产品用例。现在,我们对消息数据库很有信心。
2022年底,全世界的人都在收看世界杯。我们很快就发现,监控图上显示了总进球数。这非常酷,因为那不仅让我们可以在系统中看到真实世界的事件,还让我们团队在会议期间观看足球比赛有了正当的理由。我们不是“在会议期间观看足球比赛”,而是“在主动监控系统的性能”。
实际上,我们可以通过消息发送图来讲述世界杯决赛的故事。这场比赛非常精彩。梅西试图完成职业生涯的最后一项成就,带领阿根廷队夺得冠军,但才华横溢的姆巴佩和法国队试图阻挡他的前进之路。
图中的9个尖峰代表比赛中的9个事件:
1.梅西罚进点球,阿根廷1-0领先。
2.阿根廷再次得分,2-0领先。
3.中场休息。用户谈论比赛,有一个持续15分钟的平稳期。
4.这里的大尖峰是因为姆巴佩为法国队进球,90秒后又进球扳平了比分!
5.常规时间的比赛结束了,这场重要的比赛将进入加时赛。
6.加时赛的前半段什么也没发生,但到了中场休息时,用户们在聊天。
7.梅西再次进球,阿根廷取得领先!
8.姆巴佩反击扳平!
9.加时赛结束了,要踢点球了!
10.在点球大战中,兴奋感和压力不断增加,直到法国队罚丢,而阿根廷队命中!阿根廷赢了!
每秒合并消息数
全世界的人们都在观看这场不可思议的比赛,但与此同时,Discord和消息数据库却毫无压力。我们在信息发送和处理方面做得很好。我们基于Rust的数据服务和ScyllaDB能够承受这些流量,并为用户提供一个交流的平台。
声明:本文为InfoQ翻译,未经许可禁止转载。
原文链接
https://discord.com/blog/how-discord-stores-trillions-of-messages