在2017年,我们写了一篇博客文章,讲述了我们如何存储数十亿条消息的。我们分享了我们的历程,最初我们使用MongoDB,但后来我们将数据迁移到Cassandra,因为我们当时正在寻找一种可扩展、容错且相对低维护的数据库。因为我们知道我们储存的数据会持续增长,目前来看也确实如此。
我们希望数据库的容量能自然的增长,我们也不用付出更多精力去进行维护。但不幸的是,我们的Cassandra集群出现了严重的性能问题,我们需要投入更多的精力仅仅用在维护上九很多,而不是优化上。
将近六年之后,我们的业务发生了很大的变化,我们存储消息的方式也随之改变。
使用Cassandra遇到的问题
我们将消息存储在一个称为cassandra-messages的系统中。顾名思义,它是基于Cassandra,并用于存储消息的系统。在2017年,我们运行了12个Cassandra节点,存储了数十亿条消息。
到了2022年初,该系统已扩展至177个节点,存储的消息数量达到了数万亿条。令我们难受的是,这是一个高运维负担的系统——我们的值班团队因数据库问题而频繁oncall,经常出现异常的数据延迟(消息落库和消息查询cost太长),我们不得不减少成本太高而无法运行的维护操作。(砍了部分功能)
是什么导致了这些问题?首先,让我们来看一条消息。
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都是Snowflake(雪花算法)生成的,这使得它们在时间上是有序的。我们按照message_id和bucket(bucket 是一个时间范围,比如10天)对消息进行分区。这种分区方式意味着,在Cassandra中,所有针对特定频道和桶的消息都将被一起存储并跨三个节点(或者你设定的复制因子所决定的数量)进行复制。
在这种分区策略下隐藏着一个潜在的性能陷阱:一个只有用户的服务器发送的消息量通常会比有成千上万用户的大服务器少几个数量级。
在Cassandra中,读取操作比写入消耗更多。写入操作被append到commit log中,并写入到一个称为memtable的内存结构中,该结构最终会被刷新到磁盘上。然而,读取操作可能需要查询内存中的memtable以及多个SSTable(没有命中memtable的场景)(SSTable是直接存在磁盘上文件),这是一个更耗时的操作。随着用户与服务器的交互,大量的并发读取可能会使一个分区成为热点,我们形象地称这种情况为““hot partition”。因为我们万亿级别的数据量,以及特殊的查询写入方式,导致我们的集群遇到了困难。
当我们遇到热点分区时,它经常会影响到整个数据库集群的延迟。一个频道和桶的组合会接收到大量的流量,随着节点努力处理越来越多的流量而逐渐落后,节点的延迟也会增加。(这是因为discord的场景,越热门的频道,访问的人越多,发言越多,可以类比成微博的热门话题,访问的人多,讨论的也多,查询和写入的需求很大,导致某个话题切进去就白屏,发言要等一段时间才发送成功)
由于节点无法跟上处理速度,对这个节点的其他查询也会受到影响。由于我们使用Quorum(Quorum可以理解成写入操作,大于一半的节点回复ok了)一致性级别 进行读写操作,所有服务于热点分区的节点的查询都会遭受延迟增加,导致更多用户影响。
集群维护任务也经常带来麻烦。我们容易在合并操作上滞后,这是Cassandra为了提高读取性能而将磁盘上的SSTable进行压缩的过程。不仅我们的读取操作因此变得更加昂贵,当节点试图进行压缩时,我们还会看到延迟的连锁反应。
我们经常执行一种我们称之为“gossip dance”的操作,我们将一个节点从轮换中取出,让它在不接收流量的情况下进行压缩,然后将其带回以从Cassandra的提示移交中获取提示,然后重复直到压缩积压为空。我们还花费大量时间调整JVM的垃圾收集器和堆设置,因为GC暂停会导致显著的延迟峰值。
改变我们的架构
我们的消息集群并不是我们唯一的Cassandra数据库。我们还有其他几个Cassandra集群,每个集群都表现出了类似的(尽管可能没有那么严重)故障。
在我们之前版本的这篇文章中,我们提到了对ScyllaDB感兴趣,这是一个用C++编写的与Cassandra兼容的数据库。它承诺提供更好的性能、更快的bugfix、通过其每个核心一个分片的架构实现更强的工作负载隔离,以及0 GC。
尽管ScyllaDB不是绝对没有问题的,但它没有GC,因为它是用C++而不是Java编写的。从历史上看,我们的团队在Cassandra的GC遇到了许多问题,从GC暂停影响延迟,一直到连续超长的GC暂停,严重到需要运维人员手动重启并观察受影响的节点恢复。这些问题成为了巨大oncall压力,也是我们消息集群中许多稳定性问题的根源。
在试验了ScyllaDB并观察到测试中的改进后,我们做出了迁移所有数据库的决定。虽然这个决策本身可以单独写成一篇文章,简而言之,到2020年,除了一个之外,我们所有的数据库都已经迁移到了ScyllaDB。
最后一个呢?就是我们的老朋友,cassandra-messages。(影响太大了,直接迁移怕出问题,不要尝试一步到位做完某个事,不然出了问题就难排查了)
为什么我们还没有迁移它?首先,这是一个庞大的集群。拥有数以万亿计的消息和近200个节点,任何迁移都需要付出巨大的努力。此外,我们想确保在我们致力于优化其性能的同时,我们的新数据库能够尽可能地优秀。我们还想在生产环境中获得更多使用ScyllaDB的经验,将其置于实际压力下运行,并学习它的不足之处。
我们还致力于为我们的用例提升ScyllaDB的性能。在我们的测试中,我们发现反向查询的性能对于我们的需求来说是不够的。当我们尝试按照与表排序相反的顺序进行数据库扫描时,例如按升序扫描消息,我们会执行反向查询。ScyllaDB团队优先考虑了改进并实现了高效的反向查询,消除了我们迁移计划中最后一个数据库障碍。
我们怀疑仅仅在系统上套用一个新的数据库并不能让一切都神奇地变得更好。在ScyllaDB中,热点分区仍然可能是个问题,所以我们还希望投资于改进数据库上游的系统,以帮助屏蔽和促进更好的数据库性能。
数据服务:提供数据
在使用Cassandra的过程中,我们遇到了热点分区的问题。对特定分区的高流量导致了大量的并发,进而引发了连锁延迟效应,即后续查询的响应时间持续变长。如果我们能够控制对热点分区的并发流量,就能够防止数据库被过载。
为了解决这一任务,我们开发了一种称为data services的中间层——它位于我们的APIServer与数据库集群之间。在编写data services时,我们选择了一门在Discord内部越来越受到青睐的语言:Rust!我们之前已用Rust完成了一些项目,它确实如传闻中那样,提供了媲美C/C++的速度,同时不牺牲安全性。(Rust 确实牛的,性能和C++一样,安全性更是超过C++,或许这就是Next C++)
Rust语言的一大优势是所谓的“无畏并发”——它应该能轻松编写安全且并发的代码。其库也与我们想要达成的目标完美契合。TokioTokio - An asynchronous Rust runtime 为构建基于异步I/O的系统提供了坚实的基石,并且该语言支持Cassandra和ScyllaDB的驱动。
此外,在编译器的辅助下,清晰的错误信息、语言结构以及对安全性的强调,使得使用Rust编程成为一种享受。一旦代码通过编译,一般就能正常运行。然而,最重要的是,它让我们可以说我们用Rust重写了这部分代码(在互联网文化中,这可是相当重要的)。
我们的数据服务位于API与ScyllaDB集群之间。它们为每个数据库查询提供了一个gRPC的接口,并且不包含任何业务逻辑。数据服务提供的主要特性是请求合并。如果多个用户同时请求同一行数据,我们只会向数据库发起一次查询。首次发出请求的用户将触发服务内的一个工作线程启动。随后的请求会检查该线程的存在,并订阅它。这个工作线程将从数据库获取数据,并将结果返回给所有订阅者。(想同参数的并发查询请求,只有一个会真正打到数据库上,其他的并发请求会作为消费者等待打到数据库上的那个请求的返回结果,简而言之就是合并查询)
这就是Rust发挥作用的力量:它让编写安全的并发代码变得容易。
让我们设想一下,在拥有非常多用户的服务器上发布通知并@所有人的场景:超级多的用户将会打开应用并阅读最近的消息,这将向数据库发送大量相同的查询(大家都在查询从现在开始的latest的N条数据)。在过去,这种情况可能会导致热点分区的出现,甚至可能需要值班人员介入来帮助系统恢复。但是有了我们的数据服务,我们能够显著减少针对数据库的流量峰值。
这里厉害的第二部分发生在数据服务的上游。我们实现了基于一致性哈希的路由机制到我们的数据服务,以便实现更有效的请求合并。对于每一个到达数据服务的请求,我们都会提供一个路由键。例如,对于消息请求,这个键是一个频道ID,这意味着所有对同一个频道的请求都将被导向服务的同一实例。这种路由方式进一步帮助减轻了数据库的负载。
(这里是提前对请求进行hash了,对相同的频道查询的会直接打到某个数据库上,不用做二次hash)
简而言之,当大型服务器广播重要通知并提及所有用户时,用户们会蜂拥而至,打开应用阅读消息,从而向数据库发送大量的请求。过去,这样的情况可能导致热点分区现象,系统可能因此过载,甚至需要紧急联系值班工程师进行干预。但现在,借助我们的数据服务,我们有能力大幅削减数据库面临的瞬时高流量压力。
这里的另一大妙处在于数据服务的前端。我们引入了一致性哈希算法为基础的路由策略,以此增强请求合并的效果。对于每次数据服务的请求,我们都会为其指定一个路由键值。就消息请求而言,这个键就是频道ID,由此确保所有指向同一频道的请求都能汇聚到服务的同一实例上。这种路由策略,有效地降低了数据库的负担,提升了系统的整体性能。
这些改进确实帮助很大,但它们并不能解决我们所有的问题。我们仍然在Cassandra集群上观察到热点分区和延迟增加的情况,只不过没有以前那么频繁了。这为我们争取到了一些时间,使我们能够准备新的优化过的ScyllaDB集群,并执行迁移计划。
尽管上述的解决方案带来了显著的改善,我们依旧面临热点分区和延迟上升的挑战,只是现在这些问题发生的频次有所降低。这短暂的喘息时机对我们至关重要,它让我们有机会去构建和优化新的ScyllaDB集群,为即将到来的数据库迁移做好充分准备。在这个过渡期间,我们得以细致规划,确保新集群的性能和稳定性达到最优,从而平滑过渡到ScyllaDB,最终彻底解决热点问题,提升用户体验。(别想着一步优化到位,这是不现实的,得有个plan逐步迭代)
一场宏大的迁移(万亿级别的数据库迁移)
我们的迁移需求非常明确:我们需要在不停机的情况下迁移数万亿条消息,并且需要快速完成,因为尽管Cassandra的情况有所改善,但我们仍然经常需要处理突发问题。
第一步很简单:我们使用超级磁盘存储拓扑来配置一个新的ScyllaDB集群。通过使用本地SSD来提升速度,并利用RAID镜像数据到持久化磁盘上,我们得到了本地磁盘的速度与持久化磁盘的耐用性。随着集群的搭建完成,我们可以开始向其中迁移数据了。
我们迁移计划的第一个草稿旨在迅速获得价值。我们将开始使用我们闪亮一新的ScyllaDB集群来处理新数据,采用一个切换时间点,然后将历史数据迁移到它后面。这增加了复杂性,但每个大型项目不都需要额外的复杂性吗?
我们开始双写新数据到Cassandra和ScyllaDB,并同时开始配置ScyllaDB的Spark迁移器。它需要大量的调整,一旦我们设置完毕,我们得到一个预计完成时间:三个月。(已经满头大汗了,数据迁移要3个月)
这个时间框架并没有让我们感到温暖和舒适,我们更倾向于更快地获得价值。我们作为一个团队坐下来,集思广益寻找加速的方法,直到我们想起我们编写了一个快速而高效的数据库组件,可以潜在地扩展它。我们决定进行一些以梗为驱动的工程实践,用Rust重写了数据迁移器。
在一个下午的时间里,我们扩展了我们的数据服务库来执行大规模的数据迁移。它从数据库读取令牌范围,通过SQLite本地检查点它们,然后像消防水龙一样将它们灌入ScyllaDB。我们连接上我们新的改进版迁移器并获得了新的估计时间:九天!如果我们能如此快速地迁移数据,那么我们就可以忘记复杂的基于时间的方法,而是直接一次性切换所有内容。
我们开启它并让它持续运行,以每秒高达320万条消息的速度迁移。几天后,我们聚集在一起观看它达到100%,然后我们意识到它卡在了99.9999%完成(真的)。我们的迁移器在读取最后几个令牌范围的数据时超时了,因为它们包含着庞大的墓碑范围,这些范围在Cassandra中从未被压缩掉。我们对那个令牌范围进行了压缩,几秒钟后,迁移就完成了!
我们通过向两个数据库发送一小部分读取请求并比较结果的方式进行了自动化数据验证,一切看起来都很完美。集群在满负荷生产流量下表现良好,而Cassandra却遭受越来越频繁的延迟问题。我们在团队现场集合,切换开关使ScyllaDB成为主要数据库,并享用了庆祝蛋糕!
几个月后
我们在2022年5月将消息数据库切换了过来,但从那以后它的表现如何呢?
它一直是一个安静且行为良好的数据库(这么说没问题,因为我这周并不值班)。我们不再经历整个周末的紧急应对,也不再在集群中来回挪动节点试图保持正常运行时间。这是一个更加高效的数据库——我们从运行177个Cassandra节点减少到了仅需72个ScyllaDB节点。每个ScyllaDB节点有9TB的磁盘空间,相比每个Cassandra节点平均4TB有了显著提升。
我们的尾部延迟也得到了显著改善。例如,在Cassandra上获取历史消息的p99延迟在40-125毫秒之间,而在ScyllaDB上,p99延迟则愉快地保持在15毫秒。消息插入性能也从Cassandra上的5-70毫秒p99延迟,稳定到了ScyllaDB上的5毫秒p99。得益于上述性能提升,我们现在对于消息数据库有了信心,解锁了新的产品应用场景。
2022年末,全球各地的人们都在关注世界杯。我们很快发现的一个现象是进球会出现在我们的监控图表中。这非常酷,因为不仅能看到现实世界的事件反映在你的系统中很有趣,这也给了我们团队在会议期间看足球的理由。我们并不是“在会议期间看足球”,我们是在“主动监控系统的性能”。
由于性能的显著提升以及ScyllaDB的稳定表现,我们能够更加自信地探索和实现新的产品功能,同时也保证了用户体验的流畅性和响应性。这种实时监控能力不仅提升了我们对系统健康状况的洞察,还让工作变得稍微轻松一些,偶尔在紧张的工作之余也能享受一些体育赛事带来的乐趣。
我们实际上可以通过我们的消息发送图表来讲述世界杯决赛的故事。这场比赛精彩绝伦。莱昂内尔·梅西正试图完成职业生涯中的最后一项成就,巩固他作为史上最伟大球员的地位,并带领阿根廷队夺冠,但横在他面前的是天赋异禀的基利安·姆巴佩和法国队。
图表中的每一次尖峰都代表着比赛中的一次事件。
1、梅西罚进点球,阿根廷1-0领先。
2、 阿根廷再次得分,扩大比分至2-0。
3、上半场结束。用户们在讨论比赛,形成了一段持续十五分钟的平台期。
4、这里的大尖峰是因为姆巴佩为法国队得分,并且在90秒后再次得分,将比分扳平!
5、常规时间结束,这场激烈的比赛进入了加时赛。
6、加时赛的前半段没有太多事情发生,但当我们进入中场休息时,用户们又开始聊天。
7、梅西再次得分,阿根廷重新取得领先!
8、 姆巴佩反击,再次扳平比分!
9、加时赛结束,比赛将进入点球大战!
10紧张和兴奋在整个点球大战中不断升级,直到法国队失手,而阿根廷队没有!阿根廷赢了!
通过监控图表中的数据波动,我们不仅能够看到用户活动与真实世界事件之间的直接关联,还能感受到世界杯决赛那跌宕起伏、充满戏剧性的氛围。每一道尖峰背后都是球迷们的热情与激动,是对比赛进程的实时反馈。
(我在想直播弹幕系统应该也会有类似的波动图,弹幕和IM区别并不大)
全世界的人们都在紧张地观看这场令人难以置信的比赛,但与此同时,Discord及其消息数据库却轻松应对,毫不费力。我们的消息发送量激增,但我们处理得恰到好处。凭借基于Rust的数据服务和ScyllaDB,我们能够承担起这股流量,为用户提供了一个交流的平台。
我们构建了一个能够处理数万亿条消息的系统,如果这项工作让你感到兴奋,不妨访问我们的招聘页面看看。我们正在招聘!
这段描述不仅展现了Discord在技术上的实力和稳定性,即使在面对全球大型赛事引发的高流量冲击时也能保持优异的表现,同时也传达了公司对人才的渴望,邀请那些对处理大规模数据和构建高性能系统感兴趣的开发者加入团队,共同创造更多可能。
来源 How Discord Stores Trillions of Messages