Discord 公司如何使用 Cassandra 存储上亿条线上数据

Discord 是一款国外的相似 YY 的语音聊天软件。Discord 语音聊天软件及咱们的 UGC 内容的增加速度比想象中要快得多。随着愈来愈多用户的加入,带来了更多聊天消息。2016 年 7 月,天天大约有 4 千万条消息;2016 年 12 月,天天超过亿条。当写这篇文章时(2017 年 1 月),天天已经超过 1.2 亿条了。数据库

咱们早期决定永久保存全部用户的聊天历史记录,这样用户能够随时在任何设备查找他们的数据。这是一个持续增加的高并发访问的海量数据,并且须要保持高可用。如何才能搞定这一切?咱们的经验是选择 Cassandra 做为数据库!缓存

咱们在作什么

Discord 语音聊天软件的最第一版本在 2015 年只用了两个月就开发出来。在那个阶段,MongoDB 是支持快速迭代最好的数据库之一。全部 Discord 数据都保存在同一个 MongoDB 集群中,但在设计上咱们也支持将全部数据很容易地迁移到一种新的数据库(咱们不打算使用 MongoDB 数据库的分片,由于它使用起来复杂以及稳定性很差)。安全

实际上这是咱们企业文化的一部分:快速搭建来验证产品的特性,但也预留方法来支持将它升级到一个更强大的版本。服务器

消息保存在 MongoDB 中,使用 channel_id 和 created_at 的单一复合索引。到 2015 年 11 月,存储的消息达到了 1 亿条,这时,原来预期的问题开始出现:内存中再也放不下全部索引及数据,延迟开始变得不可控,是时候迁移到一个更适合这个项目的数据库了。并发

选择正确的数据库

在选择一个新的数据库以前,咱们必须了解当前的读/写模式,以及咱们目前的解决方案为何会出现问题。高并发

  • 很显然,咱们的读取是很是随机的,咱们的读/写比为 50 / 50。
  • 语音聊天服务器:它只处理不多的消息,每隔几天才发几条信息。在一年内,这种服务器不太可能达到 1000 条消息。它面临的问题是,即便请求量很小,它也很难高效,单返回 50 条消息给一个用户,就会致使磁盘中的许屡次随机查找,并致使磁盘缓存淘汰。
  • 私信聊天服务器:发送至关数量的消息,一年下来很容易达到 10 万到 100 万条消息。他们请求的数据一般只是最近的。它们的问题是,数据因为访问得很少且分散,所以不太可能被缓存在磁盘中。
  • 大型公共聊天服务器:发送大量的消息。他们天天有成千上万的成员发送数以千计的消息,每一年能够轻松地发送数以百万计的消息。他们几乎老是在频繁请求最近一小时的消息,所以数据能够很容易地被磁盘缓存命中。
  • 咱们预计在将来的一年,将会给用户提供更多随机读取数据的功能:查看 30 天内别人说起到你的消息,而后点击到某条历史记录消息,查阅标记(pinned)的消息以及全文搜索等功能。这一切致使更多的随机读取!!

接下来咱们来定义一下需求:性能

  • 线性可扩展性  -  咱们不想等几个月又要从新考虑新的扩展方案,或者是从新拆分数据。
  • 自动故障转移 (failover) -  咱们不但愿晚上的休息被打扰,当系统出现问题咱们但愿它尽量的能自动修复。
  • 低维护成本  -  一配置完它就能开始工做,随着数据的增加时,咱们要须要简单增长机器就能解决。
  • 已经被验证过的技术  -  咱们喜欢尝试新的技术,但不要太新。
  • 可预测的性能  -  当 API 的响应时间 95% 超过 80ms 时也无需警示。咱们也不想重复在 Redis 或 Memcached 增长缓存机制。
  • 非二进制存储  - 因为数据量大,咱们不太但愿写数据以前作一些读出二进制并反序列化的工做。
  • 开源  -  咱们但愿能掌控本身的命运,不想依靠第三方公司。

Cassandra 是惟一能知足咱们上述全部需求的数据库。咱们能够添加节点来扩展它,添加过程不会对应用程序产生任何影响,也能够容忍节点的故障。一些大公司如 Netflix 和苹果,已经部署有数千个 Cassandra 节点。数据连续存储在磁盘上,这样减小了数据访问寻址成本,且数据能够很方便地分布在集群上。它依赖 DataStax,但依旧是开源和社区驱动的。测试

作出选择后,咱们须要证实它其实是可行的。动画

数据模型

向一个新手描述 Cassandra 数据库最好的办法,是将它描述为 KKV 存储,两个 K 构成了主键。第一个 K 是分区键(partition key),用于肯定数据存储在哪一个节点上,以及在磁盘上的位置。一个分区包含不少行数据,行的位置由第二个 K 肯定,这是聚类键(clustering key),聚类键充当分区内的主键,以及决定了数据行如何排序。能够将分区视为有序字典。这些属性相结合,能够支持很是强大的数据建模。spa

前面提到过,消息在 MongoDB 中的索引用的是 channel_id 和 created_at,因为常常查询一个 channel 中的消息,所以 channel_id 被设计成为分区键,但 created_at 不做为一个大的聚类键,缘由是系统内多个消息可能具备相同的建立时间。

幸运的是,Discord 系统的 ID 使用了相似 Twitter Snowflake [1] 的发号器(按时间粗略有序),所以咱们可使用这个 ID。主键就变成( channel_id, message_id), message_id 是 Snowflake 发号器产生。当加载一个 channel 时,咱们能够准确地告诉 Cassandra 扫描数据的范围。

下面是咱们的消息表的简化模式。

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 与关系数据库模式有很大区别,调整 schema 很是方便,不会带来任何临时性的性能影响。所以咱们得到了最好的二进制存储和关系型存储。

当咱们开始向 Cassandra 数据库导入现有的消息时,立刻看见出如今日志上的警告,提示分区的大小超过 100MB。发生了什么?!Cassandra 但是宣称单个分区能够支持 2GB!显然,支持那么大并不意味着它应该设成那么大。

大的分区在进行压缩、集群扩容等操做时会对 Cassandra 带来较大的 GC 压力。大分区也意味着它的数据不能分布在集群中。很明显,咱们必须限制分区的大小,由于一个单一的 channel 能够存在多年,且大小不断增加。

咱们决定按时间来归并咱们的消息并放在一个 bucket 中。经过分析最大的 channel,咱们来肯定 10 天的消息放在一个 bucket 中是否会超过 100mb。Bucket 必须从 message_id 或时间戳来归并。

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)

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(也是 Snowflake 发号器生成,要比第一个消息旧)的 bucket。而后咱们依次查询分区直到收集到足够的消息。这种方法的缺点是,不活跃的 channel 须要遍历多个 bucket 从而收集到足够返回的消息。在实践中,这已被证实还行得通,由于对于活跃的 channel,查询第一个 bucket 就能够返回足够多的数据。

将消息导入到 Cassandra 数据库十分顺利,咱们准备尝试迁移到生产环境。

冒烟启动

在生产环境引入新系统老是可怕的,所以最好在不影响用户的前提下先进行测试。咱们将代码设置成双读/写到 MongoDB 和 Cassandra。

一启动系统咱们就收到 bug 追踪器发来的错误信息,错误提示 author_id 为 null。怎么会是 null ?这是一个必需的字段!在解释这个问题以前,先介绍一下问题的背景。

最终一致性

Cassandra 是一个 AP 数据库,这意味着它牺牲了强一致性(C)来换取可用性(A),这也正是咱们所须要的。在 Cassandra 中读写是一个反模式(读比写的代价更昂贵)。你也能够写入任何节点,在 column 的范围,它将使用“last write wins”的策略自动解决写入冲突,这个策略对咱们有何影响?请看下面动画。

1_t7VkLRKZVeHb_c6Tl1heew

在例子中,一个用户编辑消息时,另外一个用户删除相同的消息,当 Cassandra 执行 upsert 以后,咱们只留下了主键和另一个正在更新文本的列。

有两个可能的解决方案来处理这个问题:

  • 编辑消息时,将整个消息写回。这有可能找回被删除的消息,可是也增长了更多数据列冲突的可能。
  • 可以判断消息已经损坏时,将其从数据库中删除。

咱们选择第二个选项,咱们按要求选择一列(在这种状况下, author_id),若是消息是空的就删除。

在解决这个问题时,咱们也注意到咱们的写入效率很低。因为 Cassandra 被设计为最终一致性,所以执行删除操做时不会当即删除数据,它必须复制删除到其余节点,即便其余节点暂时不可用,它也照作。

Cassandra 为了方便处理,将删除处理成一种叫“墓碑”的写入形式。在处理过程当中,它只是简单跳过它遇到的墓碑。墓碑经过一个可配置的时间而存在(默认 10 天),在逾期后,会在压缩过程当中被永久删除。

删除列以及将 null 写入列是彻底相同的事情。他们都产生墓碑。由于全部在 Cassandra 数据库中的写入都是更新插入(upsert),这意味着哪怕第一次插入 null 都会生成一个墓碑。

实际上,咱们整个消息数据包含 16 个列,但平均消息长度可能只有了 4 个值。这致使新插入一行数据没原因地将 12 个新的墓碑写入至 Cassandra 中。

解决这个问题的方法很简单:只给 Cassandra 数据库写入非空值。

性能

Cassandra 以写入速度比读取速度要快著称,咱们观察的结果也确实如此。写入速度一般低于 1 毫秒而读取低于 5 毫秒。咱们观察了数据访问的状况,性能在测试的一周内保持了良好的稳定性。没什么意外,咱们获得了咱们所指望的数据库。

说到快速、一致的读取性能,这里有一个例子,跳转到某个上百万条消息的 channel 的一年前的某条消息,请看动画

跳转到一年前的聊天记录的性能

巨大的意外

一切都很顺利,所以咱们将它切换成咱们的主数据库,而后在一周内淘汰掉 MongoDB。Cassandra 工做一切正常,直到 6 个月后有一天,Cassandra 忽然变得反应迟钝。咱们注意到 Cassandra 开始出现 10 秒钟的 GC 全停顿(Stop-the-world) ,可是咱们不知道缘由。

咱们开始定位分析,发现加载某个 channel 须要 20 秒。一个叫 “Puzzles & Dragons Subreddit” 的公共 channel 是罪魁祸首。由于它是一个开放的 channel,所以咱们也跑进去探个究竟。

令咱们惊讶的是,channel 里只有 1 条消息。咱们也了解到他们用咱们的 API 删除了数百万条消息,只在 channel 中留下了 1 条消息。

上文提到 Cassandra 是如何用墓碑(在最终一致性中说起过)来处理删除动做的。当一个用户载入这个 channel,虽然只有 1 条的消息,Cassandra 不得不扫描百万条墓碑(产生垃圾的速度比虚拟机收集的速度更快)。

咱们经过以下措施解决:

  • 由于咱们每晚都会运行 Cassandra 数据库修复(一个反熵进程),咱们将墓碑的生命周期从 10 天下降至 2 天。
  • 咱们修改了查询代码,用来跟踪空的 buckets,并避免他们在将来的 channel 中加载。这意味着,若是一个用户再次触发这个查询,最坏的状况,Cassandra 数据库只在最近的 bucket 中进行扫描。

将来

咱们目前在运行着一个复制因子是 3 的 12 节点集群,并根据业务须要持续增长新的节点,我相信这种模式能够支撑很长一段时间。但随着 Discord 软件的发展,相信有一天咱们可能须要天天存储数十亿条消息。

Netflix 和苹果都维护了运行着数千个节点的集群,因此咱们知道目前这个阶段不太须要顾虑太多。固然咱们也但愿有一些点子能够未雨绸缪。

近期工做

将咱们的消息集群从 Cassandra 2 升级到 Cassandra 3。Cassandra 3 有一个新的存储格式,能够将存储大小减小 50% 以上。新版 Cassandra 单节点能够处理更多数据。目前,咱们在每一个节点存储了将近 1TB 的压缩数据。咱们相信咱们能够安全地扩展到 2TB,以减小集群中节点的数量。

长期工做

尝试下 Scylla [4],它是一款用 C++ 编写与 Cassandra 兼容的数据库。在正常操做期间,咱们 Cassandra 节点其实是没有占用太多的 CPU,然而在非高峰时间,当咱们运行修复(一个反熵进程)变得至关占用 CPU,同时,继上次修复后,修复持续时间和写入的数据量也增大了许多。 Scylla 宣称有着极短的修复时间。

将没使用的 Channel 备份成谷歌云存储上的文件,而且在有须要时能够加载回来。咱们其实也不太想作这件事,因此这个计划未必会执行。

结论

切换以后刚刚过去一年,尽管经历过“巨大的意外”,一切仍是一路顺风。从天天 1 亿条消息到目前超过 1.2 亿条,一直保持着良好的性能和稳定性。因为这个项目的成功,所以咱们将生产环境的其余数据也迁移到 Cassandra,而且也取得了成功。


原文连接 本文为云栖社区原创内容,未经容许不得转载。

相关文章
相关标签/搜索