2016 年 7 月,Discord 宣布日消息量达到 4000 万一天,12 月达到一亿条
为了应对如此巨量的数据,技术团队在 2015 年 11 月时达到 100 万条消息时,一改年初的单副本 MongoDB 架构,最终迁移到 Cassandra 数据库。
当前的问题
采用单副本的 MongoDB 满足 CAP 理论的 CA,即保证一致性和可用性,但无法容忍网络分区。当出现网络分区问题时,服务将不可用。
随着数据量增长,内存不足以支撑对应量级的索引和数据,出现无法预测的延时。
业务情况
- Discord 随机读多,重度写,读写比例接近 1:1。
- 不同频道数据倾斜明显,技术团队构建 MongoDB 索引时用的是 channel_id + create_at(渠道ID+创建时间)。
其中语音频道每日消息个位数,私有频道每年消息在万级别,而公有频道则读写均很多,主要读近期数据,每年消息量超过百万。
MongoDB 能否适应这些问题
MongoDB 将活跃的数据和索引尽可能加载到内存中,以提高读写性能。
当访问语音频道或私人频道,大概率数据不在缓存中,需要从磁盘获取,产生磁盘 IO 延迟增大。
同时驱逐了其他公有频道的缓存,造成其他频道的访问延迟也增大。
而大量写入操作需要更新数据和索引,同样要先读数据,因 MongoDB 强调一致性,还会碰到写竞争问题,进一步拖慢性能。
不迁移架构,最简单的是服务器升配,无限制增加单机磁盘和内存是不可能的。
从单副本到分片是很自然的选择,Discord 却放弃了,为什么?
分片复杂且不够稳定
- 分片迁移复杂性
从单副本到多分片,涉及对数据的一致性、可用性以及性能的管理,可能会引发大量写入导致服务不可用
- 全局一致性难以保证
MongoDB 默认为 CA 保证一致性,涉及多个用户同时修改、查询时,会有写入竞争和锁定。
- 再分片复杂性
不同数据类别(文字、语音)和不同公开程度(私有、公开)的频道存在明显的数据倾斜问题。
此时可能需要重新划分分片,又会出现分片迁移类似的问题。
新数据库满足什么
从上面的分析看,数据库需支持重度写、同时易于分片(扩展)。
技术团队还提出这些要求,最终 Cassandra 胜出了。
- 线性扩展性:不需要在数据增长时重新分片或手动管理节点。
- 自动故障切换:确保高可用性,降低运维成本。
- 低维护成本:一旦部署,只需随着数据增长添加新节点。
- 性能可预测:能满足重度写和随机读。不需要额外引入缓存层。
- 开源: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 的时间。
原来这个频道的消息被大量删除了,有别于不活跃频道大部分时间是在读空桶,这里的数据虽然被标记删除,但进入这个频道依旧需要扫描上百万的消息。
最终,技术团队找到了两个点:
- 降低删除数据的生命周期,从 10 天改到 2 天
- 跟踪
(channel, bucket)
中无任何数据的 Bucket 分片,查询时跳过这些分片,不读取。
结论
数据存储架构的迁移不是对新技术的盲目追求,是否到了非改不可的地步,需要给出明确的原因。
迁移的选型离不开对业务的读写模式、数据分布做分析,同时对扩展性留有余地。
例如本文中涉及了对分区键的扩展修改,如果选择 ClickHouse(截止到 2024)则又涉及到对数据表的重建。
如同 CAP 是无法达成的完美三角,选型也需要我们根据业务做出合理取舍,技术就是这样。
参考文章
- How Discord Stores Billions of Messages (https://discord.com/blog/how-discord-stores-billions-of-messages)
- Cassandra Basics (https://cassandra.apache.org/_/cassandra-basics.html)