自学内容网 自学内容网

频道聊天顶流 Discord,如何实现亿级消息架构迁移

在这里插入图片描述
2016 年 7 月,Discord 宣布日消息量达到 4000 万一天,12 月达到一亿条

为了应对如此巨量的数据,技术团队在 2015 年 11 月时达到 100 万条消息时,一改年初的单副本 MongoDB 架构,最终迁移到 Cassandra 数据库。

当前的问题

采用单副本的 MongoDB 满足 CAP 理论的 CA,即保证一致性和可用性,但无法容忍网络分区。当出现网络分区问题时,服务将不可用。

随着数据量增长,内存不足以支撑对应量级的索引和数据,出现无法预测的延时。

业务情况

  1. Discord 随机读多,重度写,读写比例接近 1:1。
  2. 不同频道数据倾斜明显,技术团队构建 MongoDB 索引时用的是 channel_id + create_at(渠道ID+创建时间)。

其中语音频道每日消息个位数,私有频道每年消息在万级别,而公有频道则读写均很多,主要读近期数据,每年消息量超过百万。

MongoDB 能否适应这些问题

MongoDB 将活跃的数据和索引尽可能加载到内存中,以提高读写性能。

当访问语音频道或私人频道,大概率数据不在缓存中,需要从磁盘获取,产生磁盘 IO 延迟增大。

同时驱逐了其他公有频道的缓存,造成其他频道的访问延迟也增大

而大量写入操作需要更新数据和索引,同样要先读数据,因 MongoDB 强调一致性,还会碰到写竞争问题,进一步拖慢性能。

不迁移架构,最简单的是服务器升配,无限制增加单机磁盘和内存是不可能的。

从单副本到分片是很自然的选择,Discord 却放弃了,为什么?

分片复杂且不够稳定

  1. 分片迁移复杂性

从单副本到多分片,涉及对数据的一致性、可用性以及性能的管理,可能会引发大量写入导致服务不可用

  1. 全局一致性难以保证

MongoDB 默认为 CA 保证一致性,涉及多个用户同时修改、查询时,会有写入竞争和锁定。

  1. 再分片复杂性

不同数据类别(文字、语音)和不同公开程度(私有、公开)的频道存在明显的数据倾斜问题。

此时可能需要重新划分分片,又会出现分片迁移类似的问题。

新数据库满足什么

从上面的分析看,数据库需支持重度写、同时易于分片(扩展)。

技术团队还提出这些要求,最终 Cassandra 胜出了。

  1. 线性扩展性:不需要在数据增长时重新分片或手动管理节点。
  2. 自动故障切换:确保高可用性,降低运维成本。
  3. 低维护成本:一旦部署,只需随着数据增长添加新节点。
  4. 性能可预测:能满足重度写和随机读。不需要额外引入缓存层。
  5. 开源:Discord 希望自己控制,不依赖第三方公司。

迁移-数据建模优化

数据库选型要考虑数据迁移和新数据的写入。

Cassandra 的数据是自动分片,集群是线性可扩展的。这一切来自于 KKV(Key-Key-Value)的设计,比我们熟知的 K-V 多了一个 K,第一个 K 是分区键(Partition Key),定义数据的逻辑分区,是实现水平扩展和负载均衡的关键。第二个键是找到对应记录的 Clustering Key,用于分区内部对数据排序。

分区键需要唯一定位到节点,Clustering 键需要单调递增。

创建时间不能作为分区键,因为无法唯一定位到节点,无效查询多个节点。

Discord 选择用 ChannelID - MessageID - Mesaage 作为 KKV,MessageID 为可排序的雪花算法生成的ID,单调递增。

再分区-解决数据倾斜

但数据倾斜问题还是存在,大分区情况下,Cassandra 会在压缩时引发 GC。擒贼先擒王,技术团队分析了最大的频道的数据分布,发现 10 天作为切割可以将分区控制在 100M 以内。


DISCORD_EPOCH = 1420070400000
BUCKET_SIZE = 1000 * 60 * 60 * 24 * 10


def 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)

于是新的 KKV 变成 ((channel_id, bucket), message_id),对于某个大频道 Pin 的数据,要回到当时的聊天时间点,只需根据消息 ID 计算对应的 Bucket 范围,就可以直接定位到节点,无需盲扫。

对于大频道,大概率只需要扫描最近的一个桶就可以满足一次数据拉取的量。

缺点是对于不活跃的频道、或语音频道等,这类时间分布稀疏的,查看数据必须跨多个 Bucket 才可以收集足够的数据范围。

你可能会问,那要是频道的数据停在几年前,岂不是全表扫描了。

答案是一般以月或年为单位,限制一次请求最多可以跨的桶数,因此这个性能是可预见的

最终一致性

MongoDB 的一致性是 Read Before Write,写之前必须查同时要加锁。

Cassandra 是一个 AP 数据库,是最终一致性,以最后一次写入为准,不存在写入竞争。在写入性能上天然比强一致性的数据库有优势。

编辑、删除竞争问题

创建消息时,Discord 严格限制了必须有作者等额外信息字段,但却出现了作者信息为空的数据!

Discord 允许同时多个用户同时修改一条数据,当有用户编辑数据时,另一个用户可以删除。

创建虽然要求必须有作者等额外的字段,但编辑却没有,如编辑请求晚于删除,就会存在空字段

两个方案,直接以全字段上报编辑请求,另一个是当检测到字段不全时标记为删除,Discord 选择了后者。

标记删除的墓碑问题

Cassandra 的删除也是一种 upsert 操作,数据像墓碑一样标标记出来,当读到该块数据时,这些墓碑数据就会被跳过,过了一段时间才被数据库压缩删除。

在迁移后的半年,Discord 碰到了一个诡异的情况:

访问一个叫 PuzzleAndDragon 频道,里面只有一条数据,却会触发 10s 的 GC 且加载要花费长达 20s 的时间。

原来这个频道的消息被大量删除了,有别于不活跃频道大部分时间是在读空桶,这里的数据虽然被标记删除,但进入这个频道依旧需要扫描上百万的消息。

最终,技术团队找到了两个点:

  1. 降低删除数据的生命周期,从 10 天改到 2 天
  2. 跟踪 (channel, bucket) 中无任何数据的 Bucket 分片,查询时跳过这些分片,不读取。

结论

数据存储架构的迁移不是对新技术的盲目追求,是否到了非改不可的地步,需要给出明确的原因。

迁移的选型离不开对业务的读写模式、数据分布做分析,同时对扩展性留有余地

例如本文中涉及了对分区键的扩展修改,如果选择 ClickHouse(截止到 2024)则又涉及到对数据表的重建。

如同 CAP 是无法达成的完美三角,选型也需要我们根据业务做出合理取舍,技术就是这样。

参考文章

  1. How Discord Stores Billions of Messages (https://discord.com/blog/how-discord-stores-billions-of-messages)
  2. Cassandra Basics (https://cassandra.apache.org/_/cassandra-basics.html)

原文地址:https://blog.csdn.net/FesonX/article/details/142703129

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!