2017年,Discord 在技术博客中提到,由于 RAM 中无法再容纳数据和索引,延迟开始变得不可预测,急速增长的数据存储亟待迁移。他们希冀找到一款可扩展、容错且维护成本相对较低的数据库,以实现存储数十亿条消息的目标,最终完成了从 MongoDB 到 Cassandra 的迁移。
技术人员都希望,现行数据库能够满足不断增长的存储需求,同时保持较低的维护需求。可惜现实往往事与愿违—— Discord 使用的 Cassandra 集群出现严重的性能问题,技术人员耗费越来越多的精力,致力于维护数据库,而非改进性能。
时隔六年,Discord 消息存储再面临性能挑战,于是将数据库迁移至 ScyllaDB。
这两次数据库迁移原因几何?Discord 如何选型?迁移过程中问题又将如何解决?
让我们一同在下文中寻找答案。
2017:从MongoDB到Cassandra
Discord 的增长速度和用户生成的内容数量超出了我们的预期,用户越多,聊天信息也就越多。2016年7月,平台消息量已达4000万条,12月增至1亿条,而截至这篇文章发布(2017年1月)时,信息量已超过1.2亿条。
我们很早就决定永久存储所有聊天记录,以便用户随时返回查看,并在任何设备上使用他们的数据。而这些庞大数据的增长速度及规模都在持续攀升,并且需要随时保持可用。
这一点如何做到?我们的回答是迁移至 Cassandra 数据库。
1.Discord 最初版本
2015年初,Discord 的最初版本在不到两个月的时间成功创建。在当时情况下,MongoDB 是快速迭代的最佳数据库之一。Discord 的所有内容都存储在单个 MongoDB 集群中,这是有意为之,但同时我们也做好一切后续规划,以便将所有数据轻松地迁移到新数据库(我们确信不会使用 MongoDB 分片,因为它使用复杂,且稳定性不佳)。
实际上,这是我们公司文化的一部分:快速构建以验证产品功能,但始终为更强大的解决方案留有后路。
消息存储在 MongoDB 集合中,使用 channel_id 和 created_at 的单一复合索引。2015 年 11 月左右,存储消息数量达到 1 亿条,此时,预期问题发生了:RAM 无法再容纳数据和索引,延迟开始变得不可控,是时候迁移到更合适的数据库了。
2.选择合适的数据库
选择新数据库之前,必须了解我们的读/写模式,以及我们目前解决方案出现问题的原因。
随后,我们来定义下需求:
Cassandra 是唯一能满足所有要求的数据库。添加节点即可扩展,同时添加过程中可以容忍节点丢失,不会对应用程序产生任何影响。Netflix 和苹果等大公司已部署使用了数千个 Cassandra 节点。相关数据连续存储在磁盘上,这样减少了数据访问寻址次数,并且数据便于在集群中分布。Cassandra 由 DataStax 支持,但仍然是开源的,由社区驱动。
既然做出了选择,我们就需要证明它确实可行。
3.数据建模
如何向新手介绍 Cassandra?最好的方式就是将其描述为一个 KKV 存储器,它的主键由两个 K 组成。
第一个 K 是分区键,用于确定数据所在的节点以及在磁盘上的位置。分区中包含多行记录,每行记录由第二个 K(即聚类键)确定。聚类键既是分区内的主键,也是行的排序方式,可以将分区看作有序字典。这些属性结合在一起,即可实现非常强大的数据建模。
前文提到, MongoDB 使用 channel_id 和 created_at 索引信息, 因为所有查询都在频道(channel)中进行,所以channel_id 被设为分区键,但 created_at 并不是一个很好的聚类键,因为不同消息的创建时间可能相同。
好在Discord 的所有 ID都是雪花算法(可按时间排序),因此我们可以用它们来代替created_at。由此主键变成了(channel_id,message_id),其中 message_id 是雪花算法。这意味着加载频道时,可以告知 Cassandra 扫描消息的准确范围。
下面是消息表的简化模式(省略了约 10 列)。
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);
Cassandra 的 schema 与关系数据库的模式有很大差别,Cassandra 的 schema 更改成本相较更低,而且不会对性能造成任何临时性影响,因此,我们获得了 blob 存储和关系型存储的最佳效果。
当我们开始将现有信息导入 Cassandra 时,日志立刻出现告警,提醒分区的大小超过了 100MB。这是怎么回事?Cassandra 宣称可以单个分区可支持 2GB!显然,理论性能并不意味着实际应用效果。
在压缩、集群扩展等操作过程中,大分区会给 Cassandra 带来很大的 GC 压力。同时,大分区还意味着其中的数据无法分布在集群中。由于 Discord 频道(channel)将存在数年,且持续增长扩大,所以必须限制分区的大小。
我们决定按时间分类信息,参考Discord 上最大频道,确定如果在一个桶中存储约 10 天的消息,就可以轻松地将容量控制在 100MB 以下。桶必须从消息 ID 或时间戳中归并。
DISCORD_EPOCH = 1420070400000BUCKET_SIZE = 1000 * 60 * 60 * 24 * 10def 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 的过程非常顺利,我们已做好了迁移到生产环境的准备。
4.惊险的启动
将一个新系统引入生产环境总是令人恐惧的,因此最好在不影响用户的情况下进行测试。我们将代码设置为 MongoDB 和 Cassandra 的双重读/写。
启动后,我们的错误(bug)跟踪器立即收到错误信息,提示称 author_id 为空(null)。怎么回事?这是一个必填字段!
让我们先一起回顾下问题产生的背景。
5.最终一致性
Cassandra 是 AP 数据库,这意味着它牺牲了强一致性以换取可用性,而这正是我们想要的。在 Cassandra 中,“先读后写”是一种反模式(读取比写入成本更高),因此,即使只访问某些列,在 Cassandra 上本质也会成为一个更新插入操作。你也可以向任何节点写入数据,它将根据每一列的情况,使用“last write wins”的策略自动解决冲突。这对我们有什么影响?
以上动图例子中,在一个用户编辑消息的同时,另一个用户删除了同一条消息。由于 Cassandra 写入时执行更新插入操作,因此我们最终发现记录中只有主键和文本外,缺少其余数据。
有两种可能解决方案处理这个问题:
我们采用了第二种方法,即选择一个必填列(本例中为 author_id),如果该列为空,则删除该消息。
解决这个问题时,我们注意到我们的写入效率非常低。由于 Cassandra 被设计为最终一致性,因此它执行删除操作时不会立即删除数据,必须将删除复制到其他节点。即使其他节点暂时不可用,也要执行删除操作。
Cassandra 将删除作为一种名为“墓碑”的写入形式来处理。在读取时,它会跳过遇到的墓碑。墓碑的存活时间可配置(默认为 10 天),过期后会在压缩过程中被永久删除。
删除列和将空值(null)写入列完全是一回事。它们都会生成墓碑。由于 Cassandra 中的所有写入都是更新插入,这意味着即使第一次写入空值也会生成墓碑。实际上,我们的整个消息模式包含 16 个列,但平均每条消息长度仅有 4 个值。这导致插入新一行数据时,大部分时间都在无缘无故地向 Cassandra 写入 12 个墓碑。
解决这个问题的办法很简单:只向 Cassandra 写入非空值。
6.性能
众所周知,Cassandra 的写入速度比读取速度快,我们的观察结果也是如此:写入速度低于1毫秒,读取速度低于 5 毫秒。无论访问什么数据,观察结果都一致,并且性能在一周的测试中始终如一。意料之中,我们得到了所期望的数据库。
快速、一致的读取性能可以通过以下例子表现:在数百万条信息的频道中跳转到一年前的某条消息。
7.巨大的意外
一切都很顺利,我们将 Cassandra 切换为主数据库,并在一周内淘汰掉 MongoDB 。Cassandra 完美地运行了约 6 个月,直到有一天变得反应迟钝。
我们注意到 Cassandra 持续出现 10 秒钟的 GC 全停顿("stop-the-world "GC),但原因未知。我们开始定位问题,发现加载 Discord 频道需要 20 秒。一个名为“Puzzles & Dragons Subreddit”的公共 Discord 服务器是罪魁祸首。由于它是一个开放的服务器,我们加入进去一探原因。
令人惊讶的是,频道里只有一条信息。同时我们发现,他们使用我们的 API 删除了数百万条信息,只在频道中留下了 1 条信息。
上文(谈及最终一致性时)提到过, Cassandra 是如何使用墓碑处理删除操作。当用户加载该频道时,即使只有一条消息,Cassandra 也必须扫描数以百万计的消息墓碑(产生垃圾的速度比 JVM 收集垃圾的速度更快)。
我们通过以下方法解决了这个问题:
8.未来发展规划
目前正在运行一个复制系数为 3 的 12 节点集群,并将根据意外所需继续添加新的 Cassandra 节点,我们相信在很长一段时间内这个集群可以持续高效运行。
但随着 Discord 的不断发展,在遥远的未来,有可能每天需要存储数十亿条消息。Netflix 和苹果公司运行着数百个节点的集群,因此我们知道这个阶段不用过多顾虑。不过,我们还是准备了一些未雨绸缪的计划。
1)近期计划
2)长期计划
9.结论
尽管经历“巨大的意外”,但我们的切换过程一直很顺利。每日信息总量从 1 亿多条增加到 1.2 亿多条,也一直保持着良好的性能与稳定性。
由于这个项目的成功,我们已经将其余的实时生产数据转移到 Cassandra,并且也取得了成功。
2023:从Cassandra到 ScyllaDB
2023年,Discord 使用的 Cassandra 集群出现严重的性能问题,技术人员耗费越来越多的精力,致力于维护数据库,而非改进性能。
时隔六年,Discord 消息存储再面临性能挑战,存储迁移刻不容缓。
1.Cassandra的存储痛点
Discord 将信息存储在名为 cassandra-messages 的数据库中。顾名思义,它运行 Cassandra 以存储信息。2017年,Discord 运行12个 Cassandra 节点,存储数十亿条信息。
截至2022年初,以上系统拥有177个节点和数万亿条消息,Cassandra 出现了严重的性能问题。由于不可预测的数据库延迟等问题,技术团队必须随时保持联系,减少运维操作,避免增加系统运行成本。
痛点从何产生?让我们来看以下这条消息。
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 语句是消息模式的最小版本。Discord 使用的每个 ID 都通过Snowflake 生成,因此可按时间排序。根据消息发送的通道以及一个存储桶(一个静态时间窗口)来划分消息。这种分区意味着,在 Cassandra 中,给定通道和存储桶的所有消息都将存储在一起,并跨三个节点(或设置的任何复制系数)进行复制。
在 Cassandra 中,读取比写入的成本高。写入操作被附加到提交日志中,并写入名为内存表(memtable)的内存结构中,最终被刷新到磁盘。然而,读取需要查询内存表(memtable)和可能的多个磁盘文件(SSTable),操作成本相当高。
用户与服务器交互时,大量并发读取使分区出现热点,一般将其称为“热分区”。一旦数据集的规模与这些访问模式结合,导致 Cassandra 集群出现问题。
热分区经常导致整个数据库集群延迟。通道与存储桶组合接收大量流量,随着节点服务流量越发吃力,节点延迟就越发严重。
由于节点速度无法跟上,对该节点的其他查询受到影响。由于我们使用仲裁一致性级别执行读写操作,因此服务于热分区的节点的所有查询都会影响,延迟增加,从而对最终用户产生更广泛的影响。
集群维护任务也经常引起麻烦。我们很容易在压缩上落后,Cassandra 会压缩磁盘上的SSTable以获得更高的性能读取。这样一来,我们的读取成本不仅会更高,并且由于节点试图压缩,还会产生级联延迟。
我们经常执行一种被称为“八卦舞蹈”的操作,即停止一个节点的轮换,让它在不占用流量的情况下进行压缩,将其重新加入轮换,从 Cassandra 获取切换提示,然后重复这个操作,直至压缩积压的信息被清空。由于 GC 暂停会导致显著的延迟峰值,我们还花了大量时间调优 JVM 的垃圾收集器和堆设置。
2.架构迁移
消息集群并非唯一的 Cassandra 数据库,我们还具备其他几个集群,每个集群都表现出类似的缺点(虽然可能没有那么严重)。
ScyllaDB 引起了我们的兴趣,这是一个用 C++ 编写的与 Cassandra 兼容的数据库。它承诺提供更好的性能、更快的修复、通过核分片架构实现更强的工作负载隔离,以及无垃圾回收,听起来相当吸引人。
当然ScyllaDB也存在不足之处,由于采用 C++ 编译而不是 Java,所以它没有垃圾收集器。过去,我们的团队在 Cassandra 上的垃圾收集器上遇到许多问题,包括影响延迟的 GC 暂停、一直到超长的连续 GC 暂停,以至于操作员必须手动重新启动,问题节点才能恢复健康状态。这些问题导致技术团队必须随叫随到,同时,这些问题也是影响消息集群稳定性问题的根源。
在对 ScyllaDB 进行试验,并观察到测试中的改进成效后,我们决定迁移所有数据库。虽然这项决定本身可以用一篇博客来介绍,但简而言之,截至2020年,我们已经将所有数据库迁移到 ScyllaDB,除了一个数据库—— cassandra-messages 。
为什么我们还未开始迁移?首先,集群规模巨大,包括数以万亿计的消息和近200个节点,任何迁移操作都非常复杂。此外,我们希望调整新数据库时,其性能达到最佳状态。我们还想在生产环境中积累使用 ScyllaDB 的更多经验,了解其缺陷。
针对我们的用例,还改进了 ScyllaDB 的性能。我们在测试中发现,反向查询的性能不足以满足需求。以与表排序相反的顺序进行数据库扫描时,例如按升序扫描消息时,则执行反向查询。ScyllaDB 团队优先对其进行改进,实现了高性能的反向查询,清除了我们迁移计划中的“最后一个数据库”的障碍。
我们担心,在系统上添加新数据库不太可能有翻天覆地的性能改进,“热分区”的问题依然存在于 ScyllaDB ,因此,我们寄希望于投资改进数据库上游系统,以助于数据库屏蔽和提升数据库性能。
3.使用数据服务提供数据
针对 Cassandra ,我们遇到了热分区的难题,分给特定分区的高流量会引起无限并发,进而导致级联延迟,延长后续查询时间。如果能够控制热分区的并发流量,就可保护数据库免于重负。
为了完成这项任务,我们编写了所谓的数据服务——位于 API 单体和数据库集群之间的中介服务。编写数据服务时,我们选择了一种在 Discord 应用越发广泛的语言:Rust!它能在保障安全性的前提下,提供可与 C/ C++ 媲美的高速度。
Rust 的主要优势之一是无惧并发——该语言使编写安全并发代码变得容易。它的库也非常适合完成我们的其他工作,Tokio生态系统是构建异步I/O系统的监视基础,并且该语言也对 Cassandra 和 ScyllaDB 提供驱动程序支持。
此外,Rust 编译器提供的帮助、清晰的错误消息、语言结构以及对安全性的重视,编写代码变得很有趣。Rust 程序一旦通过编译,就可以运行,这让我们很满意。最重要的是,我们用Rust 进行重写(meme/模因信誉非常重要)。
我们的数据服务位于 API 和 ScyllaDB 集群之间。每个数据库查询大约包含一个gRPC 端点,并且故意不包含业务逻辑。数据服务一大特点是请求合并,如果多个用户同时请求同一行,我们将只查询一次数据库。首个发出请求的用户会触发数据服务中的工作任务,后续请求将检查该任务是否存在并订阅它,该工作任务将查询数据库并将该行返回给所有订阅者。
这就是 Rust 的强大之处:它使编写安全的并发代码变得轻松。
让我们想象一下,大型服务器有一条@所有人的重要公告:用户将打开应用程序并阅读消息,向数据库发送大量流量。以往,这可能会导致热分区,并且可能需要工程师随时保持待机,以帮助系统恢复。数据服务能够显著减少数据库的流量峰值。
第二个神奇之处在于数据服务的上游。为实现更有效的合并,我们实现了一致的基于哈希的数据服务路由,为每个数据服务请求提供路由键。对消息来说,这是一个通道ID,因此对同一通道的所有请求都将转到服务的同一实例,这种路由方式有助于进一步减少数据库的负载。
这些改进颇有助益,但并未解决所有问题。,但它们并不能解决我们所有的问题。Cassandra集群上仍然存在热分区和延迟增加,只是不那么频繁了,这为我们赢得了一些时间,以便准备最优ScyllaDB集群并执行迁移。
4.大规模数据迁移
我们的迁移需求非常简单:在不停机的情况下迁移数万亿条消息,并快速完成。正如上文所述,虽然 Cassandra 的情况有所改善,但还常常出现问题。
第一步很简单:使用超级磁盘存储拓扑配置一个新的 ScyllaDB 集群。
使用本地 SSD 提高速度,并利用 RAID 将数据镜像到持久磁盘,由此我们同时获得了连接本地磁盘的速度和持久磁盘的持久性。集群建立后,就可以开始向其迁移数据了。
我们的初版迁移技术旨在快速获取价值。我们将开始使用全新的 ScyllaDB 集群来处理新数据,在切换时间内迁移历史数据。这项操作增加了复杂性,但每个大型项目都无法避免额外的复杂性,对吧?
然后,向 Cassandra 和 ScyllaDB 双重写入新数据,并同时开始准备 ScyllaDB 的 Spark 迁移器。这需要大量调整,一旦设置完成,我们预计三个月能够完成迁移。
这个时间期限让我们并不满意,因为我们希望更快获取价值。所以,我们组织了一场团队会议,集思广益,思考如何增速——我们已经编写了一个快速和高性能的数据库,可以对其进行拓展。因而我们选择参与了一些模因驱动工程,用Rust重写数据迁移器。
某一天的下午,我们扩展了数据服务库以便执行大规模数据迁移。它从数据库读取令牌范围,通过 SQLite 在本地检查,然后将它们送入 ScyllaDB 。连接改进后的新迁移器后,我们重新预估工期:9天!如果可以这么迅速地迁移数据,就可以放弃基于时间的复杂方式,一次性切换所有数据。
启动迁移器并让其保持运行,以每秒320万的速度迁移信息。几天后,迁移进度达到100%,但我们意识到它停留在99.9999%的完成度。迁移器在读取数据的最后几个令牌范围时超时了,因为它们包含了 Cassandra 中未压缩过的巨大墓碑范围。我们将这个令牌范围压实,几秒钟后,迁移完成!
执行自动数据验证,即通过向两个数据库发送一小部分读取数据并比较结果,一切看起来都很好。在全生产流量的情况下,集群表现良好,而 Cassandra 却遭受越来越频繁的延迟问题。我们团队聚集在现场,打开开关使 ScyllaDB 成为主数据库。
5.数据库迁移效果
Discord 在2022年5月切换了消息数据库,迁移效果如何?
运行的177个 Cassandra 节点减少到72个 ScyllaDB 节点,每个 ScyllaDB 节点有9tb的磁盘空间,高于每个 Cassandra 节点平均 4tb 的磁盘空间。
我们的尾部延迟也大大改善了。例如,在 Cassandra 上获取历史消息的p99在40-125ms之间,ScyllaDB 的p99延迟在15ms之间,消息插入性能从 Cassandra 上的5-70ms p99上升到 ScyllaDB 上稳定的5ms p99。
2022年底,全球观众都在收看世界杯。技术人员发现,Discord 的监控图表可以展示决赛的进球情况。这为技术团队提供了一个在会议期间观看足球比赛的借口——不是“在会议期间看足球比赛”,而是“主动监控系统性能”。
以上的信息发送数量图,描绘了世界杯决赛的精彩发展,图中的每个峰值分别代表比赛中的重要节点。
1. 梅西罚进点球,阿根廷1比0领先。
2. 阿根廷再次得分,以2比0领先。
3. 中场休息。当用户谈论比赛时,会持续15分钟。
4. 姆巴佩为法国队进球,90秒后再次进球将比分扳平!
5. 规则结束了,这场重要的比赛将进入加时赛。
6. 在加时赛的前半段没有太多的事情发生,但我们到了中场休息,用户开始聊天。
7. 梅西再次得分,阿根廷队领先!
8. 姆巴佩反击将比分扳平!
9. 加时赛结束了,我们要进点球了!
10. 在整个点球大战中,兴奋和压力不断增加,直到法国队失误,而阿根廷队没有!阿根廷赢了!
世界各地的人们都在观看这场激动人心的比赛,与此同时,Discord 使用基于 rust 的数据服务和 ScyllaDB ,不费吹灰之力便承担了比赛产生的巨大流量,同时为用户提供交流平台。
>>>>参考资料
作者丨Stanislav Vishnevskiy & Bo Ingram
编译丨onehunnit
*本文为dbaplus社群编译整理,如需转载请取得授权并标明出处!欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn